pax_global_header00006660000000000000000000000064151655651550014530gustar00rootroot0000000000000052 comment=e1e16b1a44219003ad666ac436c13bc0732b05b6 python-advanced-alchemy-1.9.3/000077500000000000000000000000001516556515500162465ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/.github/000077500000000000000000000000001516556515500176065ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/.github/CODEOWNERS000066400000000000000000000007651516556515500212110ustar00rootroot00000000000000# Code owner settings for `litestar-org` # @maintainers should be assigned to all reviews. # Most specific assignment takes precedence though, so if you add a more specific thing than the `*` glob, you must also add @maintainers # For more info about code owners see https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners#codeowners-file-example # Global Assignment * @litestar-org/maintainers @litestar-org/members python-advanced-alchemy-1.9.3/.github/dependabot.yaml000066400000000000000000000001651516556515500226010ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: "daily" python-advanced-alchemy-1.9.3/.github/labeler.yml000066400000000000000000000074611516556515500217470ustar00rootroot00000000000000version: v1 labels: # -- types ------------------------------------------------------------------- - label: 'type/feat' sync: true matcher: title: '^feat(\([^)]+\))?!?:' - label: 'type/bug' sync: true matcher: title: '^fix(\([^)]+\))?!?:' - label: 'type/docs' sync: true matcher: title: '^docs(\([^)]+\))?:' - label: 'Breaking ๐Ÿ”จ' sync: true matcher: title: '^(feat|fix)(\([^)]+\))?!:' # -- distinct areas ---------------------------------------------------------- - label: 'area/docs' sync: true matcher: files: any: ['docs/*', 'docs/**/*', '**/*.rst', '**/*.md'] - label: 'area/unit-tests' sync: true matcher: files: any: ['test/unit/*', 'test/unit/**/*', 'tests/*.py', 'tests/fixtures/**/*'] - label: 'area/integration-tests' sync: true matcher: files: any: ['test/integration/*', 'test/integration/**/*'] - label: 'area/example-apps' sync: true matcher: files: any: ['examples/**/*', 'examples/**/*'] - label: 'area/docs' sync: true matcher: files: any: ['docs/*', 'docs/**/*', '**/*.rst', '**/*.md'] - label: 'area/ci' sync: true matcher: files: any: ['.github/**/*', 'codecov.yml', 'pre-commit-config.yaml', 'sonar-project.properties', '*.yaml', '*.yml'] - label: 'area/dependencies' sync: true matcher: files: any: ['pyproject.toml', '*.lock'] - label: 'area/repositories' sync: true matcher: files: ['advanced_alchemy/repository/**/*'] - label: 'area/services' sync: true matcher: files: ['advanced_alchemy/service/**/*'] - label: 'area/base' sync: true matcher: files: ['advanced_alchemy/base.py'] - label: 'area/exceptions' sync: true matcher: files: ['advanced_alchemy/exceptions.py'] - label: 'area/filters' sync: true matcher: files: ['advanced_alchemy/filters.py'] - label: 'area/operations' sync: true matcher: files: ['advanced_alchemy/operations.py'] - label: 'area/mixins' sync: true matcher: files: ['advanced_alchemy/mixins/**/*'] - label: 'area/config' sync: true matcher: files: ['advanced_alchemy/config/**/*'] - label: 'area/alembic' sync: true matcher: files: ['advanced_alchemy/alembic/**/*'] - label: 'area/flask' sync: true matcher: files: ['advanced_alchemy/extensions/flask/**/*','advanced_alchemy/extensions/flask.py'] - label: 'area/sanic' sync: true matcher: files: ['advanced_alchemy/extensions/sanic/**/*','advanced_alchemy/extensions/sanic.py'] - label: 'area/fastapi' sync: true matcher: files: ['advanced_alchemy/extensions/starlette/**/*','advanced_alchemy/extensions/starlette.py'] - label: 'area/litestar' sync: true matcher: files: ['advanced_alchemy/extensions/litestar/**/*'] - label: 'area/private-api' sync: true matcher: files: any: ['advanced_alchemy/_*.py', 'advanced_alchemy/*/_*.py', 'advanced_alchemy/_*/**/*.py'] - label: 'area/tools' sync: true matcher: files: ['tools/**/*'] # -- Size Based Labels ------------------------------------------------------- - label: 'size: small' sync: true matcher: files: count: gte: 1 lte: 10 - label: 'size: medium' sync: true matcher: files: count: gte: 10 lte: 25 - label: 'size: large' sync: true matcher: files: count: gte: 26 # -- Merge Checks -------------------------------------------------------------- checks: - context: 'No Merge check' description: "Disable merging when 'do not merge' label is set" labels: none: ['do not merge'] python-advanced-alchemy-1.9.3/.github/workflows/000077500000000000000000000000001516556515500216435ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/.github/workflows/cd.yml000066400000000000000000000010741516556515500227560ustar00rootroot00000000000000name: Continuous Deployment on: push: tags: - "v*.*.*" jobs: generate-changelog: name: Generate changelog runs-on: ubuntu-22.04 outputs: release_body: ${{ steps.git-cliff.outputs.content }} steps: - name: Checkout uses: actions/checkout@v6 with: fetch-depth: 0 - name: Generate a changelog uses: orhun/git-cliff-action@main id: git-cliff with: config: pyproject.toml args: -vv --latest --strip header env: OUTPUT: docs/CHANGELOG.rst python-advanced-alchemy-1.9.3/.github/workflows/ci.yml000066400000000000000000000123311516556515500227610ustar00rootroot00000000000000name: Tests And Linting on: pull_request: push: branches: - main concurrency: group: test-${{ github.head_ref }} cancel-in-progress: true jobs: validate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Install uv uses: astral-sh/setup-uv@v7 - name: Set up Python run: uv python install 3.12 - name: Create virtual environment run: uv sync --all-extras --dev - name: Install Pre-Commit hooks run: uv run pre-commit install - name: Load cached Pre-Commit Dependencies id: cached-pre-commit-dependencies uses: actions/cache@v5 with: path: ~/.cache/pre-commit/ key: pre-commit|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }} - name: Execute Pre-Commit run: uv run pre-commit run --show-diff-on-failure --color=always --all-files mypy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Install uv uses: astral-sh/setup-uv@v7 - name: Set up Python run: uv python install 3.13 - name: Install dependencies run: uv sync --all-extras --dev - name: Run mypy run: uv run mypy pyright: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Install uv uses: astral-sh/setup-uv@v7 - name: Set up Python run: uv python install 3.13 - name: Install dependencies run: uv sync --all-extras --dev - name: Run pyright run: uv run pyright # # TODO(cofin) # # AttributeError: 'SuiteRequirements' object has no attribute 'computed_reflects_normally' slotscheck: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Install uv uses: astral-sh/setup-uv@v7 - name: Set up Python run: uv python install 3.13 - name: Install dependencies run: uv sync --all-extras --dev - name: Run slotscheck run: uv run slotscheck -m advanced_alchemy.config -m advanced_alchemy.repository -m advanced_alchemy.service -m advanced_alchemy.extensions -m advanced_alchemy.base -m advanced_alchemy.types -m advanced_alchemy.operations test: name: "test (${{ matrix.python-version }}" strategy: fail-fast: true matrix: python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] uses: ./.github/workflows/test.yml with: coverage: ${{ matrix.python-version == '3.13' }} python-version: ${{ matrix.python-version }} sonar: needs: - test - validate if: github.event.pull_request.head.repo.fork == false && github.repository_owner == 'litestar-org' runs-on: ubuntu-latest steps: - name: Check out repository uses: actions/checkout@v6 with: fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis - name: Download Artifacts uses: actions/download-artifact@v8 with: name: coverage-xml - name: SonarCloud Scan uses: SonarSource/sonarqube-scan-action@v7 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} codecov: needs: - test - validate runs-on: ubuntu-latest permissions: security-events: write steps: - uses: actions/checkout@v6 - uses: actions/setup-python@v6 with: python-version: "3.13" - name: Download Artifacts uses: actions/download-artifact@v8 with: name: coverage-xml path: coverage.xml merge-multiple: true # - name: Combine coverage files # run: | # python -Im pip install coverage covdefaults # python -Im coverage combine # python -Im coverage xml -i # - name: Fix coverage file name # run: sed -i "s/home\/runner\/work\/advanced-alchemy\/advanced-alchemy/github\/workspace/g" coverage.xml - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v6 with: files: coverage.xml token: ${{ secrets.CODECOV_TOKEN }} slug: litestar-org/advanced-alchemy build-docs: needs: - validate if: github.event_name == 'pull_request' runs-on: ubuntu-latest steps: - name: Check out repository uses: actions/checkout@v6 - name: Install Microsoft ODBC run: sudo apt-get update && sudo ACCEPT_EULA=Y apt-get install msodbcsql18 -y - name: Install uv uses: astral-sh/setup-uv@v7 - name: Set up Python run: uv python install 3.13 - name: Install dependencies run: uv sync --all-extras --dev - name: Build docs run: uv run make docs - name: Check docs links env: LITESTAR_DOCS_IGNORE_MISSING_EXAMPLE_OUTPUT: 1 run: uv run make docs-linkcheck - name: Save PR number run: | echo "${{ github.event.number }}" > .pr_number - name: Upload artifact uses: actions/upload-artifact@v7 with: name: docs-preview path: | docs/_build/html .pr_number include-hidden-files: true python-advanced-alchemy-1.9.3/.github/workflows/codeql.yml000066400000000000000000000103711516556515500236370ustar00rootroot00000000000000# For most projects, this workflow file will not need changing; you simply need # to commit it to your repository. # # You may wish to alter this file to override the set of languages analyzed, # or to provide custom queries or build logic. # # ******** NOTE ******** # We have attempted to detect the languages in your repository. Please check # the `language` matrix defined below to confirm you have the correct set of # supported CodeQL languages. # name: "CodeQL" on: push: branches: [ "main" ] pull_request: branches: [ "main" ] schedule: - cron: '43 0 * * 0' jobs: analyze: name: Analyze (${{ matrix.language }}) # Runner size impacts CodeQL analysis time. To learn more, please see: # - https://gh.io/recommended-hardware-resources-for-running-codeql # - https://gh.io/supported-runners-and-hardware-resources # - https://gh.io/using-larger-runners (GitHub.com only) # Consider using larger runners or machines with greater resources for possible analysis time improvements. runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} permissions: # required for all workflows security-events: write # required to fetch internal or private CodeQL packs packages: read # only required for workflows in private repositories actions: read contents: read strategy: fail-fast: false matrix: include: - language: python build-mode: none # CodeQL supports the following values keywords for 'language': 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' # Use `c-cpp` to analyze code written in C, C++ or both # Use 'java-kotlin' to analyze code written in Java, Kotlin or both # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages steps: - name: Checkout repository uses: actions/checkout@v6 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} # If you wish to specify custom queries, you can do so here or in a config file. # By default, queries listed here will override any specified in a config file. # Prefix the list here with "+" to use these queries and those in the config file. # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs # queries: security-extended,security-and-quality # If the analyze step fails for one of the languages you are analyzing with # "We were unable to automatically build your code", modify the matrix above # to set the build mode to "manual" for that language. Then modify this step # to build your code. # โ„น๏ธ Command-line programs to run using the OS shell. # ๐Ÿ“š See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun - if: matrix.build-mode == 'manual' run: | echo 'If you are using a "manual" build mode for one or more of the' \ 'languages you are analyzing, replace this with the commands to build' \ 'your code, for example:' echo ' make bootstrap' echo ' make release' exit 1 - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v4 with: category: "/language:${{matrix.language}}" python-advanced-alchemy-1.9.3/.github/workflows/docs-preview.yml000066400000000000000000000044571516556515500250070ustar00rootroot00000000000000name: Deploy Documentation Preview on: workflow_run: workflows: [Tests And Linting] types: [completed] jobs: deploy: if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request' }} runs-on: ubuntu-latest permissions: issues: write pull-requests: write steps: - name: Check out repository uses: actions/checkout@v6 - name: Download artifact uses: dawidd6/action-download-artifact@v20 with: workflow_conclusion: success run_id: ${{ github.event.workflow_run.id }} path: docs-preview name: docs-preview - name: Set PR number run: echo "PR_NUMBER=$(cat docs-preview/.pr_number)" >> $GITHUB_ENV - name: Deploy docs preview uses: JamesIves/github-pages-deploy-action@v4 with: folder: docs-preview/docs/_build/html token: ${{ secrets.DOCS_PREVIEW_DEPLOY_TOKEN }} repository-name: litestar-org/advanced-alchemy-docs-preview clean: false target-folder: ${{ env.PR_NUMBER }} branch: gh-pages - uses: actions/github-script@v8 env: PR_NUMBER: ${{ env.PR_NUMBER }} with: script: | const issue_number = process.env.PR_NUMBER const body = "Documentation preview will be available shortly at https://litestar-org.github.io/advanced-alchemy-docs-preview/" + issue_number const opts = github.rest.issues.listComments.endpoint.merge({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issue_number, }); const comments = await github.paginate(opts) for (const comment of comments) { if (comment.user.id === 41898282 && comment.body === body) { await github.rest.issues.deleteComment({ owner: context.repo.owner, repo: context.repo.repo, comment_id: comment.id }) } } await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issue_number, body: body, }) python-advanced-alchemy-1.9.3/.github/workflows/docs.yml000066400000000000000000000022571516556515500233240ustar00rootroot00000000000000name: Documentation Building on: release: types: [published] push: branches: - main # Allows you to run this workflow manually from the Actions tab workflow_dispatch: jobs: build_and_deploy: permissions: contents: write pages: write id-token: write environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Install uv uses: astral-sh/setup-uv@v7 - name: Set up Python run: uv python install 3.13 - name: Install dependencies run: uv sync --all-extras --dev - name: Build Release Documentation run: uv run python tools/build_docs.py docs-build --version latest if: github.event_name == 'release' - name: Build Documentation run: uv run python tools/build_docs.py docs-build --version latest if: github.event_name != 'release' - name: Upload artifact uses: actions/upload-pages-artifact@v4 with: path: docs-build/ - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v5 python-advanced-alchemy-1.9.3/.github/workflows/pr-labeler.yml000066400000000000000000000025531516556515500244200ustar00rootroot00000000000000name: "Pull Request Labeler" on: pull_request_target: jobs: apply-labels: permissions: contents: read pull-requests: write checks: write statuses: write runs-on: ubuntu-latest steps: - uses: fuxingloh/multi-labeler@v4 with: github-token: "${{ secrets.GITHUB_TOKEN }}" distinguish-pr-origin: needs: apply-labels if: ${{ always() }} permissions: pull-requests: write runs-on: ubuntu-latest steps: - uses: actions/github-script@v8 with: github-token: ${{secrets.GITHUB_TOKEN}} script: | const maintainers = [ 'JacobCoffee', 'provinzkraut', 'cofin','Alc-Alc', 'dependabot[bot]', 'all-contributors[bot]' ] if (maintainers.includes(context.payload.sender.login)) { github.rest.issues.addLabels({ issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, labels: ['pr/internal'] }) } else { github.rest.issues.addLabels({ issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, labels: ['pr/external', 'Triage Required :hospital:'] }) } python-advanced-alchemy-1.9.3/.github/workflows/pr-title.yml000066400000000000000000000005321516556515500241260ustar00rootroot00000000000000name: "Lint PR Title" on: pull_request_target: types: - opened - edited - synchronize permissions: pull-requests: read jobs: main: name: Validate PR title runs-on: ubuntu-latest steps: - uses: amannn/action-semantic-pull-request@v6 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} python-advanced-alchemy-1.9.3/.github/workflows/publish.yml000066400000000000000000000011701516556515500240330ustar00rootroot00000000000000name: Latest Release on: release: types: [published] workflow_dispatch: jobs: publish-release: runs-on: ubuntu-latest permissions: id-token: write environment: release steps: - name: Check out repository uses: actions/checkout@v6 - name: Install uv uses: astral-sh/setup-uv@v7 - name: Set up Python run: uv python install 3.13 - name: Install dependencies run: uv sync --all-extras - name: Build package run: uv build - name: Publish package distributions to PyPI uses: pypa/gh-action-pypi-publish@release/v1 python-advanced-alchemy-1.9.3/.github/workflows/test.yml000066400000000000000000000040371516556515500233510ustar00rootroot00000000000000name: Test on: workflow_call: inputs: python-version: required: true type: string coverage: required: false type: boolean default: false os: required: false type: string default: "ubuntu-latest" timeout: required: false type: number default: 60 jobs: test: runs-on: ${{ inputs.os }} timeout-minutes: ${{ inputs.timeout }} defaults: run: shell: bash steps: - name: Check out repository uses: actions/checkout@v6 - name: Install Microsoft ODBC Drivers run: sudo apt-get update && sudo ACCEPT_EULA=Y apt-get install msodbcsql18 -y - name: Free additional space run: | sudo docker rmi $(docker image ls -aq) >/dev/null 2>&1 || true sudo rm -rf \ /usr/share/dotnet /usr/local/lib/android /opt/ghc \ /usr/local/share/powershell /usr/share/swift /usr/local/.ghcup \ /usr/lib/jvm || true sudo apt-get autoremove -y \ && sudo apt-get clean -y \ && sudo rm -rf /root/.cache \ && sudo rm -rf /var/apt/lists/* \ && sudo rm -rf /var/cache/apt/* \ && sudo apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false - name: Install uv uses: astral-sh/setup-uv@v7 - name: Set up Python run: uv python install ${{ inputs.python-version }} - name: Install dependencies run: uv sync --all-extras --dev - name: Set PYTHONPATH run: echo "PYTHONPATH=$PWD" >> $GITHUB_ENV - name: Test if: ${{ !inputs.coverage }} run: uv run pytest --dist "loadgroup" -m "" tests -n 2 - name: Test with coverage if: ${{ inputs.coverage }} run: uv run pytest tests --dist "loadgroup" -m "" --cov=advanced_alchemy --cov-report=xml -n 2 - uses: actions/upload-artifact@v7 if: ${{ inputs.coverage }} with: name: coverage-xml path: coverage.xml python-advanced-alchemy-1.9.3/.gitignore000066400000000000000000000065341516556515500202460ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python .python-version build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST tmp/ # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ cover/ test.sqlite *.db # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ docs-build/ # PyBuilder .pybuilder/ target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: # .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # poetry # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control #poetry.lock # pdm # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. #pdm.lock # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it # in version control. # https://pdm.fming.dev/#use-with-ide .pdm.toml .pdm-python .pdm-build/ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ envs/ .envs/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # pytype static type analyzer .pytype/ # Cython debug symbols cython_debug/ # PyCharm # JetBrains specific template is maintained in a separate JetBrains.gitignore that can # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ /.idea # vscode .vscode/ # generated changelog /docs/changelog.md .cursorrules .cursorignore .zed .cursor .todos .tmp .envs/ .claude .gemini specs requirements AGENTS.md CLAUDE.md GEMINI.md .agent/ tools/scripts/detect_mcp_tools.py .geminiignore .agents/ python-advanced-alchemy-1.9.3/.pre-commit-config.yaml000066400000000000000000000022001516556515500225210ustar00rootroot00000000000000default_language_version: python: "3" repos: - repo: https://github.com/compilerla/conventional-pre-commit rev: v4.4.0 hooks: - id: conventional-pre-commit stages: [commit-msg] - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: - id: check-ast - id: check-case-conflict - id: check-toml - id: debug-statements - id: end-of-file-fixer - id: mixed-line-ending - id: trailing-whitespace - repo: https://github.com/provinzkraut/unasyncd rev: "v0.10.0" hooks: - id: unasyncd additional_dependencies: ["ruff"] - repo: https://github.com/charliermarsh/ruff-pre-commit rev: "v0.15.9" hooks: # Run the linter. - id: ruff types_or: [python, pyi] args: [--fix] # Run the formatter. - id: ruff-format types_or: [python, pyi] - repo: https://github.com/codespell-project/codespell rev: v2.4.2 hooks: - id: codespell additional_dependencies: [tomli] - repo: https://github.com/sphinx-contrib/sphinx-lint rev: "v1.0.2" hooks: - id: sphinx-lint python-advanced-alchemy-1.9.3/.sourcery.yaml000066400000000000000000000011561516556515500210660ustar00rootroot00000000000000ignore: - .tox/ - .venv/ - dist/ - docs/_build/ - docs/_static/ - node_modules/ - vendor/ - venv/ rule_settings: enable: [default] disable: [dont-import-test-modules] rule_types: - refactoring - suggestion - comment python_version: "3.8" rules: [] metrics: quality_threshold: 25.0 github: ignore_labels: - sourcery-ignore - docs labels: - build-ignore request_review: origin: owner forked: author sourcery_branch: sourcery/{base_branch} clone_detection: min_lines: 3 min_duplicates: 2 identical_clones_only: false proxy: no_ssl_verify: false python-advanced-alchemy-1.9.3/CONTRIBUTING.rst000066400000000000000000000150361516556515500207140ustar00rootroot00000000000000Contribution guide ================== Setting up the environment -------------------------- 1. Run ``make install-uv`` to install `uv `_ if not already installed 1. Run ``make install`` to install all dependencies and pre-commit hooks Code contributions ------------------ Workflow ++++++++ 1. `Fork `_ the `Advanced Alchemy repository `_ 2. Clone your fork locally with git 3. `Set up the environment <#setting-up-the-environment>`_ 4. Make your changes 5. Run ``make lint`` to run linters and formatters. This step is optional and will be executed automatically by git before you make a commit, but you may want to run it manually in order to apply fixes automatically by git before you make a commit, but you may want to run it manually in order to apply fixes 6. Commit your changes to git 7. Push the changes to your fork 8. Open a `pull request `_. Give the pull request a descriptive title indicating what it changes. If it has a corresponding open issue, the issue number should be included in the title as well. For example a pull request that fixes issue ``bug: Increased stack size making it impossible to find needle #100`` could be titled ``fix(#100): Make needles easier to find by applying fire to haystack`` .. tip:: Pull requests and commits all need to follow the `Conventional Commit format `_ .. note:: To run the integration tests locally, you will need the `ODBC Driver for SQL Server `_, one option is using `unixODBC `_. Guidelines for writing code ---------------------------- - All code should be fully `typed `_. This is enforced via `mypy `_. - All code should be tested. This is enforced via `pytest `_. - All code should be properly formatted. This is enforced via `Ruff `_. Writing and running tests +++++++++++++++++++++++++ .. todo:: Write this section Project documentation --------------------- The documentation is located in the ``/docs`` directory and is `ReST `_ and `Sphinx `_. If you're unfamiliar with any of those, `ReStructuredText primer `_ and `Sphinx quickstart `_ are recommended reads. Running the docs locally ++++++++++++++++++++++++ To run or build the docs locally, you need to first install the required dependencies: ``make install`` Then you can serve the documentation with ``make docs-serve``, or build them with ``make docs``. Creating a new release ---------------------- 1. **Set up your environment** - Ensure you have the ``gh`` CLI installed and logged in to GitHub. - Switch to the ``main`` branch. 2. **Install and update dependencies** - Run: .. code-block:: bash make install # Install all dependencies make upgrade # Update dependencies to the latest versions make docs # Verify documentation builds 3. **Bump the version** - Run: .. code-block:: bash make release bump=patch - Use ``bump=minor`` or ``bump=major`` if you need to bump the minor or major version instead. 4. **Prepare the release** - Run: .. code-block:: bash uv run tools/prepare_release.py -c -i --base v{current_version} {new_version} - Replace ``{current_version}`` with the current version (e.g., ``1.2.3``). - Replace ``{new_version}`` with the new version (e.g., ``1.2.4``). - Example: ``uv run tools/prepare_release.py -c -i --base v1.4.4 1.4.5`` 5. **Run linters and formatters** - Ensure code style compliance: .. code-block:: bash make lint 6. **Clean up the changelog** - Open ``docs/changelog.rst`` and remove any placeholder comments, such as: .. code-block:: rst 7. **Commit the release** - Create a new branch: .. code-block:: bash git checkout -b v{new_version} - Commit the changes: .. code-block:: bash git commit -am "chore(release): bump to v{new_version}" 8. **Open a pull request** - Push the branch and create a PR into ``main``. - Merge once CI checks pass. 9. **Verify the release draft** - Once merged, a draft release will be created under ``Releases`` on GitHub. - Edit and publish it. 10. **Publish to PyPI** - Approve the ``Latest Release`` workflow under ``Actions`` to publish the package to PyPI. Creating a pre-release ---------------------- Use pre-releases to publish alpha, beta, or release candidate versions. These follow `PEP 440 `_ pre-release format (e.g., ``1.10.0a1``, ``1.10.0b1``, ``1.10.0rc1``). 1. **Bump to a pre-release version** .. code-block:: bash make pre-release version=1.10.0a1 # First alpha make pre-release version=1.10.0a2 # Second alpha make pre-release version=1.10.0b1 # First beta make pre-release version=1.10.0rc1 # First release candidate 2. **Commit and push** .. code-block:: bash git add -A && git commit -m "chore(release): bump to v1.10.0a1" git push origin HEAD 3. **Create a GitHub pre-release** .. code-block:: bash gh release create v1.10.0a1 --prerelease --title "v1.10.0a1" 4. **PyPI behavior** PyPI automatically marks PEP 440 pre-release versions: - Users **won't** get pre-releases via ``pip install advanced-alchemy`` - Users can opt-in via ``pip install --pre advanced-alchemy`` - Or pin explicitly: ``pip install advanced-alchemy==1.10.0a1`` Graduating from pre-release to stable ++++++++++++++++++++++++++++++++++++++ From the last release candidate, bump the ``pre`` part to move past ``rc`` to ``stable``: .. code-block:: bash make release bump=pre # e.g. 1.10.0rc1 โ†’ 1.10.0 Or skip to the next stable version directly: .. code-block:: bash make release bump=patch # From any version โ†’ next patch make release bump=minor # From any version โ†’ next minor Then follow the standard `Creating a new release`_ steps above. python-advanced-alchemy-1.9.3/LICENSE000066400000000000000000000020661516556515500172570ustar00rootroot00000000000000MIT License Copyright (c) 2024 Litestar Organization Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. python-advanced-alchemy-1.9.3/Makefile000066400000000000000000000252651516556515500177200ustar00rootroot00000000000000SHELL := /bin/bash .SHELLFLAGS := -eu -o pipefail -c # ============================================================================= # Configuration and Environment Variables # ============================================================================= .DEFAULT_GOAL:=help .ONESHELL: .EXPORT_ALL_VARIABLES: MAKEFLAGS += --no-print-directory DOC_TEST_FILES := \ docs/usage/modeling/basics.rst \ docs/usage/modeling/inheritance.rst \ docs/usage/modeling/sqlmodel.rst \ docs/usage/modeling/types.rst \ docs/usage/repositories/advanced.rst \ docs/usage/repositories/basics.rst \ docs/usage/repositories/filtering.rst \ docs/usage/database_seeding.rst \ docs/usage/services.rst # ----------------------------------------------------------------------------- # Display Formatting and Colors # ----------------------------------------------------------------------------- BLUE := $(shell printf "\033[1;34m") GREEN := $(shell printf "\033[1;32m") RED := $(shell printf "\033[1;31m") YELLOW := $(shell printf "\033[1;33m") NC := $(shell printf "\033[0m") INFO := $(shell printf "$(BLUE)โ„น$(NC)") OK := $(shell printf "$(GREEN)โœ“$(NC)") WARN := $(shell printf "$(YELLOW)โš $(NC)") ERROR := $(shell printf "$(RED)โœ–$(NC)") # ============================================================================= # Help and Documentation # ============================================================================= .PHONY: help help: ## Display this help text for Makefile @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z0-9_-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) # ============================================================================= # Installation and Environment Setup # ============================================================================= .PHONY: install-uv install-uv: ## Install latest version of uv @echo "${INFO} Installing uv..." @curl -LsSf https://astral.sh/uv/install.sh | sh >/dev/null 2>&1 @uv tool install nodeenv >/dev/null 2>&1 @echo "${OK} UV installed successfully" .PHONY: install install: destroy clean ## Install the project, dependencies, and pre-commit @echo "${INFO} Starting fresh installation..." @uv python pin 3.10 >/dev/null 2>&1 @uv venv >/dev/null 2>&1 @uv sync --all-extras --dev @echo "${OK} Installation complete! ๐ŸŽ‰" .PHONY: destroy destroy: ## Destroy the virtual environment @echo "${INFO} Destroying virtual environment... ๐Ÿ—‘๏ธ" @rm -rf .venv @echo "${OK} Virtual environment destroyed ๐Ÿ—‘๏ธ" # ============================================================================= # Dependency Management # ============================================================================= .PHONY: upgrade upgrade: ## Upgrade all dependencies to latest stable versions @echo "${INFO} Updating all dependencies... ๐Ÿ”„" @uv lock --upgrade @echo "${OK} Dependencies updated ๐Ÿ”„" @uv run pre-commit autoupdate @echo "${OK} Updated Pre-commit hooks ๐Ÿ”„" .PHONY: lock lock: ## Rebuild lockfiles from scratch @echo "${INFO} Rebuilding lockfiles... ๐Ÿ”„" @uv lock --upgrade >/dev/null 2>&1 @echo "${OK} Lockfiles updated" # ============================================================================= # Build and Release # ============================================================================= .PHONY: build build: ## Build the package @echo "${INFO} Building package... ๐Ÿ“ฆ" @uv build >/dev/null 2>&1 @echo "${OK} Package build complete" .PHONY: release release: ## Bump version and create release tag @echo "${INFO} Preparing for release... ๐Ÿ“ฆ" @make docs @make clean @uv run python tools/pypi_readme.py @make build @uv run bump-my-version bump $(bump) @uv lock --upgrade-package advanced-alchemy @echo "${OK} Release complete ๐ŸŽ‰" .PHONY: pre-release pre-release: ## Start a pre-release: make pre-release version=1.10.0a1 @if [ -z "$(version)" ]; then \ echo "${ERROR} Usage: make pre-release version=X.Y.ZaN"; \ echo ""; \ echo "Pre-release workflow:"; \ echo " 1. Start alpha: make pre-release version=1.10.0a1"; \ echo " 2. Next alpha: make pre-release version=1.10.0a2"; \ echo " 3. Move to beta: make pre-release version=1.10.0b1"; \ echo " 4. Move to rc: make pre-release version=1.10.0rc1"; \ echo " 5. Final release: make release bump=pre (from rc) OR bump=patch/minor (from stable)"; \ exit 1; \ fi @echo "${INFO} Preparing pre-release $(version)... ๐Ÿงช" @make clean @make build @uv run bump-my-version bump --new-version $(version) pre @uv lock --upgrade-package advanced-alchemy @echo "${OK} Pre-release $(version) complete ๐Ÿงช" @echo "" @echo "${INFO} Next steps:" @echo " 1. Push: git push origin HEAD" @echo " 2. Create a GitHub pre-release: gh release create v$(version) --prerelease --title 'v$(version)'" @echo " 3. This will publish to PyPI with pre-release tags" # ============================================================================= # Cleaning and Maintenance # ============================================================================= .PHONY: clean clean: ## Cleanup temporary build artifacts @echo "${INFO} Cleaning working directory... ๐Ÿงน" @rm -rf .pytest_cache .ruff_cache .hypothesis build/ dist/ .eggs/ .coverage coverage.xml coverage.json htmlcov/ .pytest_cache tests/.pytest_cache tests/**/.pytest_cache .mypy_cache .unasyncd_cache/ .auto_pytabs_cache node_modules >/dev/null 2>&1 @find . -name '*.egg-info' -exec rm -rf {} + >/dev/null 2>&1 @find . -type f -name '*.egg' -exec rm -f {} + >/dev/null 2>&1 @find . -name '*.pyc' -exec rm -f {} + >/dev/null 2>&1 @find . -name '*.pyo' -exec rm -f {} + >/dev/null 2>&1 @find . -name '*~' -exec rm -f {} + >/dev/null 2>&1 @find . -name '__pycache__' -exec rm -rf {} + >/dev/null 2>&1 @find . -name '.ipynb_checkpoints' -exec rm -rf {} + >/dev/null 2>&1 @echo "${OK} Working directory cleaned" $(MAKE) docs-clean # ============================================================================= # Testing and Quality Checks # ============================================================================= .PHONY: docs-test docs-test: ## Run executable documentation examples @echo "${INFO} Running executable documentation examples... ๐Ÿ“š" @uv run pytest $(DOC_TEST_FILES) --quiet @echo "${OK} Documentation examples passed โœจ" .PHONY: test test: ## Run the tests @echo "${INFO} Running test cases... ๐Ÿงช" @uv run pytest --dist "loadgroup" -m "" -n 2 --quiet @echo "${OK} Tests passed โœจ" .PHONY: coverage coverage: ## Run tests with coverage report @echo "${INFO} Running tests with coverage... ๐Ÿ“Š" @uv run pytest --dist "loadgroup" -m "" --cov=advanced_alchemy --cov-report=xml -n 2 --quiet @uv run coverage html >/dev/null 2>&1 @echo "${OK} Coverage report generated โœจ" # ----------------------------------------------------------------------------- # Type Checking # ----------------------------------------------------------------------------- .PHONY: mypy mypy: ## Run mypy @echo "${INFO} Running mypy... ๐Ÿ”" @uv run dmypy run @echo "${OK} Mypy checks passed โœจ" .PHONY: mypy-nocache mypy-nocache: ## Run Mypy without cache @echo "${INFO} Running mypy without cache... ๐Ÿ”" @uv run mypy @echo "${OK} Mypy checks passed โœจ" .PHONY: pyright pyright: ## Run pyright @echo "${INFO} Running pyright... ๐Ÿ”" @uv run pyright @echo "${OK} Pyright checks passed โœจ" .PHONY: type-check type-check: mypy pyright ## Run all type checking # ----------------------------------------------------------------------------- # Linting and Formatting # ----------------------------------------------------------------------------- .PHONY: pre-commit pre-commit: ## Run pre-commit hooks @echo "${INFO} Running pre-commit checks... ๐Ÿ”Ž" @NODE_OPTIONS="--no-deprecation --disable-warning=ExperimentalWarning" uv run pre-commit run --color=always --all-files @echo "${OK} Pre-commit checks passed โœจ" .PHONY: slotscheck slotscheck: ## Run slotscheck @echo "${INFO} Running slots check... ๐Ÿ”" @uv run slotscheck @echo "${OK} Slots check passed โœจ" .PHONY: fix fix: ## Run code formatters @echo "${INFO} Running code formatters... ๐Ÿ”ง" @uv run ruff check --fix --unsafe-fixes @echo "${OK} Code formatting complete โœจ" .PHONY: lint lint: pre-commit type-check slotscheck ## Run all linting checks .PHONY: check-all check-all: lint test coverage ## Run all checks (lint, test, coverage) # ============================================================================= # Documentation # ============================================================================= .PHONY: docs-clean docs-clean: ## Clean documentation build @echo "${INFO} Cleaning documentation build assets... ๐Ÿงน" @rm -rf docs/_build >/dev/null 2>&1 @echo "${OK} Documentation assets cleaned" .PHONY: docs-serve docs-serve: ## Serve documentation locally @echo "${INFO} Starting documentation server... ๐Ÿ“š" @uv run sphinx-autobuild docs docs/_build/ -j auto --watch advanced_alchemy --watch docs --watch tests --watch CONTRIBUTING.rst --open-browser .PHONY: docs docs: docs-clean ## Build documentation @echo "${INFO} Building documentation... ๐Ÿ“" @uv run sphinx-build -M html docs docs/_build/ -E -a -j auto -W --keep-going @echo "${OK} Documentation built successfully" .PHONY: docs-linkcheck docs-linkcheck: ## Check documentation links @echo "${INFO} Checking documentation links... ๐Ÿ”—" @uv run sphinx-build -b linkcheck ./docs ./docs/_build -D linkcheck_ignore='http://.*','https://.*' @echo "${OK} Link check complete" .PHONY: docs-linkcheck-full docs-linkcheck-full: ## Run full documentation link check @echo "${INFO} Running full link check... ๐Ÿ”—" @uv run sphinx-build -b linkcheck ./docs ./docs/_build -D linkcheck_anchors=0 @echo "${OK} Full link check complete" python-advanced-alchemy-1.9.3/README.md000066400000000000000000000512321516556515500175300ustar00rootroot00000000000000

Advanced Alchemy Logo

| Project | Status | |-----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | CI/CD | [![Latest Release](https://github.com/litestar-org/advanced-alchemy/actions/workflows/publish.yml/badge.svg)](https://github.com/litestar-org/advanced-alchemy/actions/workflows/publish.yml) [![ci](https://github.com/litestar-org/advanced-alchemy/actions/workflows/ci.yml/badge.svg)](https://github.com/litestar-org/advanced-alchemy/actions/workflows/ci.yml) [![Documentation Building](https://github.com/litestar-org/advanced-alchemy/actions/workflows/docs.yml/badge.svg?branch=main)](https://github.com/litestar-org/advanced-alchemy/actions/workflows/docs.yml) | | Quality | [![Coverage](https://codecov.io/github/litestar-org/advanced-alchemy/graph/badge.svg?token=vKez4Pycrc)](https://codecov.io/github/litestar-org/advanced-alchemy) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=litestar-org_advanced-alchemy&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=litestar-org_advanced-alchemy) [![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=litestar-org_advanced-alchemy&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=litestar-org_advanced-alchemy) [![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=litestar-org_advanced-alchemy&metric=reliability_rating)](https://sonarcloud.io/summary/new_code?id=litestar-org_advanced-alchemy) [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=litestar-org_advanced-alchemy&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=litestar-org_advanced-alchemy) | | Package | [![PyPI - Version](https://img.shields.io/pypi/v/advanced-alchemy?labelColor=202235&color=edb641&logo=python&logoColor=edb641)](https://badge.fury.io/py/advanced-alchemy) ![PyPI - Support Python Versions](https://img.shields.io/pypi/pyversions/advanced-alchemy?labelColor=202235&color=edb641&logo=python&logoColor=edb641) ![Advanced Alchemy PyPI - Downloads](https://img.shields.io/pypi/dm/advanced-alchemy?logo=python&label=package%20downloads&labelColor=202235&color=edb641&logoColor=edb641) | | Community | [![Discord](https://img.shields.io/discord/919193495116337154?labelColor=202235&color=edb641&label=chat%20on%20discord&logo=discord&logoColor=edb641)](https://discord.gg/litestar) [![Matrix](https://img.shields.io/badge/chat%20on%20Matrix-bridged-202235?labelColor=202235&color=edb641&logo=matrix&logoColor=edb641)](https://matrix.to/#/#litestar:matrix.org) | | Meta | [![Litestar Project](https://img.shields.io/badge/Litestar%20Org-%E2%AD%90%20Advanced%20Alchemy-202235.svg?logo=python&labelColor=202235&color=edb641&logoColor=edb641)](https://github.com/litestar-org/advanced-alchemy) [![types - Mypy](https://img.shields.io/badge/types-Mypy-202235.svg?logo=python&labelColor=202235&color=edb641&logoColor=edb641)](https://github.com/python/mypy) [![License - MIT](https://img.shields.io/badge/license-MIT-202235.svg?logo=python&labelColor=202235&color=edb641&logoColor=edb641)](https://spdx.org/licenses/) [![linting - Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/charliermarsh/ruff/main/assets/badge/v2.json&labelColor=202235)](https://github.com/astral-sh/ruff) |
# Advanced Alchemy Check out the [project documentation][project-docs] ๐Ÿ“š for more information. ## About A carefully crafted, thoroughly tested, optimized companion library for SQLAlchemy, offering: - Sync and async repositories, featuring common CRUD and highly optimized bulk operations - Integration with major web frameworks including Litestar, Starlette, FastAPI, Sanic - Custom-built alembic configuration and CLI with optional framework integration - Utility base classes with audit columns, primary keys and utility functions - [SQLModel](https://sqlmodel.tiangolo.com/) compatibility โ€” use `SQLModel` `table=True` models directly with repositories and services - Composite primary key support โ€” work with multi-column primary keys across repositories, services, and bulk operations - Read/write replica routing with automatic query routing, round-robin/random replica selection, and sticky-primary mode - Dogpile caching integration for query result caching - Built in `File Object` data type for storing objects: - Unified interface for various storage backends ([`fsspec`](https://filesystem-spec.readthedocs.io/en/latest/) and [`obstore`](https://developmentseed.org/obstore/latest/)) - Optional lifecycle event hooks integrated with SQLAlchemy's event system to automatically save and delete files as records are inserted, updated, or deleted. - Optimized JSON types including a custom JSON type for Oracle - Integrated support for UUID6 and UUID7 using [`uuid-utils`](https://github.com/aminalaee/uuid-utils) (install with the `uuid` extra) - Integrated support for Nano ID using [`fastnanoid`](https://github.com/oliverlambson/fastnanoid) (install with the `nanoid` extra) - Custom encrypted text type with multiple backend support including [`pgcrypto`](https://www.postgresql.org/docs/current/pgcrypto.html) for PostgreSQL and the Fernet implementation from [`cryptography`](https://cryptography.io/en/latest/) for other databases - Custom password hashing type with multiple backend support including [`Argon2`](https://github.com/P-H-C/phc-winner-argon2), [`Passlib`](https://passlib.readthedocs.io/en/stable/), and [`Pwdlib`](https://pwdlib.readthedocs.io/en/stable/) with automatic salt generation - Pre-configured base classes with audit columns UUID or Big Integer primary keys and a [sentinel column](https://docs.sqlalchemy.org/en/20/core/connections.html#configuring-sentinel-columns). - Synchronous and asynchronous repositories featuring: - Common CRUD operations for SQLAlchemy models - Bulk inserts, updates, upserts, and deletes with dialect-specific enhancements - Integrated counts, pagination, sorting, filtering with `LIKE`, `IN`, `IS NULL`/`IS NOT NULL`, and dates before and/or after. - Tested support for multiple database backends including: - SQLite via [aiosqlite](https://aiosqlite.omnilib.dev/en/stable/) or [sqlite](https://docs.python.org/3/library/sqlite3.html) - Postgres via [asyncpg](https://magicstack.github.io/asyncpg/current/) or [psycopg3 (async or sync)](https://www.psycopg.org/psycopg3/) - MySQL via [asyncmy](https://github.com/long2ice/asyncmy) - Oracle via [oracledb (async or sync)](https://oracle.github.io/python-oracledb/) (tested on 18c and 23c) - Google Spanner via [spanner-sqlalchemy](https://github.com/googleapis/python-spanner-sqlalchemy/) - DuckDB via [duckdb_engine](https://github.com/Mause/duckdb_engine) - Microsoft SQL Server via [pyodbc](https://github.com/mkleehammer/pyodbc) or [aioodbc](https://github.com/aio-libs/aioodbc) - CockroachDB via [sqlalchemy-cockroachdb (async or sync)](https://github.com/cockroachdb/sqlalchemy-cockroachdb) - ...and much more ## Usage ### Installation ```shell pip install advanced-alchemy ``` > [!IMPORTANT]\ > Check out [the installation guide][install-guide] in our official documentation! ### Repositories Advanced Alchemy includes a set of asynchronous and synchronous repository classes for easy CRUD operations on your SQLAlchemy models.
Click to expand the example ```python from advanced_alchemy import base, repository, config from sqlalchemy import create_engine from sqlalchemy.orm import Mapped, sessionmaker class User(base.UUIDBase): # you can optionally override the generated table name by manually setting it. __tablename__ = "user_account" # type: ignore[assignment] email: Mapped[str] name: Mapped[str] class UserRepository(repository.SQLAlchemySyncRepository[User]): """User repository.""" model_type = User db = config.SQLAlchemySyncConfig(connection_string="duckdb:///:memory:", session_config=config.SyncSessionConfig(expire_on_commit=False)) # Initializes the database. with db.get_engine().begin() as conn: User.metadata.create_all(conn) with db.get_session() as db_session: repo = UserRepository(session=db_session) # 1) Create multiple users with `add_many` bulk_users = [ {"email": 'cody@litestar.dev', 'name': 'Cody'}, {"email": 'janek@litestar.dev', 'name': 'Janek'}, {"email": 'peter@litestar.dev', 'name': 'Peter'}, {"email": 'jacob@litestar.dev', 'name': 'Jacob'} ] objs = repo.add_many([User(**raw_user) for raw_user in bulk_users]) db_session.commit() print(f"Created {len(objs)} new objects.") # 2) Select paginated data and total row count. Pass additional filters as kwargs created_objs, total_objs = repo.list_and_count(LimitOffset(limit=10, offset=0), name="Cody") print(f"Selected {len(created_objs)} records out of a total of {total_objs}.") # 3) Let's remove the batch of records selected. deleted_objs = repo.delete_many([new_obj.id for new_obj in created_objs]) print(f"Removed {len(deleted_objs)} records out of a total of {total_objs}.") # 4) Let's count the remaining rows remaining_count = repo.count() print(f"Found {remaining_count} remaining records after delete.") ```
For a full standalone example, see the sample [here][standalone-example] ### Services Advanced Alchemy includes an additional service class to make working with a repository easier. This class is designed to accept data as a dictionary or SQLAlchemy model, and it will handle the type conversions for you.
Here's the same example from above but using a service to create the data: ```python from advanced_alchemy import base, repository, filters, service, config from sqlalchemy import create_engine from sqlalchemy.orm import Mapped, sessionmaker class User(base.UUIDBase): # you can optionally override the generated table name by manually setting it. __tablename__ = "user_account" # type: ignore[assignment] email: Mapped[str] name: Mapped[str] class UserService(service.SQLAlchemySyncRepositoryService[User]): """User repository.""" class Repo(repository.SQLAlchemySyncRepository[User]): """User repository.""" model_type = User repository_type = Repo db = config.SQLAlchemySyncConfig(connection_string="duckdb:///:memory:", session_config=config.SyncSessionConfig(expire_on_commit=False)) # Initializes the database. with db.get_engine().begin() as conn: User.metadata.create_all(conn) with db.get_session() as db_session: service = UserService(session=db_session) # 1) Create multiple users with `add_many` objs = service.create_many([ {"email": 'cody@litestar.dev', 'name': 'Cody'}, {"email": 'janek@litestar.dev', 'name': 'Janek'}, {"email": 'peter@litestar.dev', 'name': 'Peter'}, {"email": 'jacob@litestar.dev', 'name': 'Jacob'} ]) print(objs) print(f"Created {len(objs)} new objects.") # 2) Select paginated data and total row count. Pass additional filters as kwargs created_objs, total_objs = service.list_and_count(LimitOffset(limit=10, offset=0), name="Cody") print(f"Selected {len(created_objs)} records out of a total of {total_objs}.") # 3) Let's remove the batch of records selected. deleted_objs = service.delete_many([new_obj.id for new_obj in created_objs]) print(f"Removed {len(deleted_objs)} records out of a total of {total_objs}.") # 4) Let's count the remaining rows remaining_count = service.count() print(f"Found {remaining_count} remaining records after delete.") ```
### Web Frameworks Advanced Alchemy works with nearly all Python web frameworks. Several helpers for popular libraries are included, and additional PRs to support others are welcomed. #### Litestar Advanced Alchemy is the official SQLAlchemy integration for Litestar. In addition to installing with `pip install advanced-alchemy`, it can also be installed as a Litestar extra with `pip install litestar[sqlalchemy]`.
Litestar Example ```python from litestar import Litestar from litestar.plugins.sqlalchemy import SQLAlchemyPlugin, SQLAlchemyAsyncConfig # alternately... # from advanced_alchemy.extensions.litestar import SQLAlchemyAsyncConfig, SQLAlchemyPlugin alchemy = SQLAlchemyPlugin( config=SQLAlchemyAsyncConfig(connection_string="sqlite+aiosqlite:///test.sqlite"), ) app = Litestar(plugins=[alchemy]) ```
For a full Litestar example, check [here][litestar-example] #### Flask
Flask Example ```python from flask import Flask from advanced_alchemy.extensions.flask import AdvancedAlchemy, SQLAlchemySyncConfig app = Flask(__name__) alchemy = AdvancedAlchemy( config=SQLAlchemySyncConfig(connection_string="duckdb:///:memory:"), app=app, ) ```
For a full Flask example, see [here][flask-example] #### FastAPI
FastAPI Example ```python from advanced_alchemy.extensions.fastapi import AdvancedAlchemy, SQLAlchemyAsyncConfig from fastapi import FastAPI app = FastAPI() alchemy = AdvancedAlchemy( config=SQLAlchemyAsyncConfig(connection_string="sqlite+aiosqlite:///test.sqlite"), app=app, ) ```
For a full FastAPI example with optional CLI integration, see [here][fastapi-example] #### Starlette
Pre-built Example Apps ```python from advanced_alchemy.extensions.starlette import AdvancedAlchemy, SQLAlchemyAsyncConfig from starlette.applications import Starlette app = Starlette() alchemy = AdvancedAlchemy( config=SQLAlchemyAsyncConfig(connection_string="sqlite+aiosqlite:///test.sqlite"), app=app, ) ```
#### Sanic
Pre-built Example Apps ```python from sanic import Sanic from sanic_ext import Extend from advanced_alchemy.extensions.sanic import AdvancedAlchemy, SQLAlchemyAsyncConfig app = Sanic("AlchemySanicApp") alchemy = AdvancedAlchemy( sqlalchemy_config=SQLAlchemyAsyncConfig(connection_string="sqlite+aiosqlite:///test.sqlite"), ) Extend.register(alchemy) ```
## Contributing All [Litestar Organization][litestar-org] projects will always be a community-centered, available for contributions of any size. Before contributing, please review the [contribution guide][contributing]. If you have any questions, reach out to us on [Discord][discord], our org-wide [GitHub discussions][litestar-discussions] page, or the [project-specific GitHub discussions page][project-discussions].

Litestar Organization Project
An official Litestar Organization Project

[litestar-org]: https://github.com/litestar-org [contributing]: https://advanced-alchemy.litestar.dev/latest/contribution-guide.html [discord]: https://discord.gg/litestar [litestar-discussions]: https://github.com/orgs/litestar-org/discussions [project-discussions]: https://github.com/litestar-org/advanced-alchemy/discussions [project-docs]: https://advanced-alchemy.litestar.dev [install-guide]: https://advanced-alchemy.litestar.dev/latest/#installation [fastapi-example]: https://github.com/litestar-org/advanced-alchemy/blob/main/examples/fastapi/fastapi_service.py [flask-example]: https://github.com/litestar-org/advanced-alchemy/blob/main/examples/flask/flask_services.py [litestar-example]: https://github.com/litestar-org/advanced-alchemy/blob/main/examples/litestar/litestar_service.py [standalone-example]: https://github.com/litestar-org/advanced-alchemy/blob/main/examples/standalone.py python-advanced-alchemy-1.9.3/advanced_alchemy/000077500000000000000000000000001516556515500215155ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/advanced_alchemy/__init__.py000066400000000000000000000006241516556515500236300ustar00rootroot00000000000000from advanced_alchemy import ( alembic, base, cli, config, exceptions, extensions, filters, mixins, operations, routing, service, types, utils, ) __all__ = ( "alembic", "base", "cli", "config", "exceptions", "extensions", "filters", "mixins", "operations", "routing", "service", "types", "utils", ) python-advanced-alchemy-1.9.3/advanced_alchemy/__main__.py000066400000000000000000000003661516556515500236140ustar00rootroot00000000000000from advanced_alchemy.cli import add_migration_commands as build_cli_interface def run_cli() -> None: # pragma: no cover """Advanced Alchemy CLI""" build_cli_interface()() if __name__ == "__main__": # pragma: no cover run_cli() python-advanced-alchemy-1.9.3/advanced_alchemy/__metadata__.py000066400000000000000000000010671516556515500244470ustar00rootroot00000000000000"""Metadata for the Project.""" from importlib.metadata import PackageNotFoundError, metadata, version # pragma: no cover __all__ = ("__project__", "__version__") # pragma: no cover try: # pragma: no cover __version__ = version("advanced_alchemy") """Version of the project.""" __project__ = metadata("advanced_alchemy")["Name"] """Name of the project.""" except PackageNotFoundError: # pragma: no cover __version__ = "0.0.1" __project__ = "Advanced Alchemy" finally: # pragma: no cover del version, PackageNotFoundError, metadata python-advanced-alchemy-1.9.3/advanced_alchemy/_listeners.py000066400000000000000000000724471516556515500242540ustar00rootroot00000000000000# ruff: noqa: BLE001, C901, PLR0915 """Application ORM configuration.""" import asyncio import datetime import logging from typing import TYPE_CHECKING, Any, Callable, Optional, Union, cast from sqlalchemy import event from sqlalchemy.inspection import inspect from advanced_alchemy.utils.deprecation import warn_deprecation from advanced_alchemy.utils.sync_tools import is_async_context as _is_async_context_util if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncSession, async_scoped_session from sqlalchemy.orm import Session, UOWTransaction, scoped_session from sqlalchemy.orm.state import InstanceState from advanced_alchemy.cache import CacheManager from advanced_alchemy.types.file_object import FileObject, FileObjectSessionTracker, StorageRegistry _active_file_operations: set[asyncio.Task[Any]] = set() """Stores active file operations to prevent them from being garbage collected.""" _active_cache_operations: set[asyncio.Task[Any]] = set() """Stores active cache invalidation operations to prevent them from being garbage collected.""" _FILE_TRACKER_KEY = "_aa_file_tracker" _CACHE_TRACKER_KEY = "_aa_cache_tracker" def get_file_tracker( session: "Session", create: bool = True, ) -> Optional["FileObjectSessionTracker"]: """Get or create a file session tracker for the session. The tracker is stored on session.info to ensure proper scoping per session instance and avoid ContextVar collisions. Args: session: The SQLAlchemy session instance. create: Whether to create a new tracker if one doesn't exist. Returns: The file tracker or None if not available. """ from advanced_alchemy.types.file_object import FileObjectSessionTracker tracker: Optional[FileObjectSessionTracker] = session.info.get(_FILE_TRACKER_KEY) if tracker is None and create: raise_on_error = session.info.get("file_object_raise_on_error", True) tracker = FileObjectSessionTracker(raise_on_error=raise_on_error) session.info[_FILE_TRACKER_KEY] = tracker return tracker logger = logging.getLogger("advanced_alchemy") def _is_listener_enabled(session: "Session", key: str) -> bool: """Check if a listener is enabled for this session. Checks session.info, engine execution_options (including sync_engine), and session execution_options, in that priority order. Args: session: The SQLAlchemy session instance. key: The key to check (e.g. ``"enable_file_object_listener"`` or ``"enable_cache_listener"``). Returns: Whether the listener is enabled. """ enable_listener = True # Enabled by default session_info = getattr(session, "info", {}) if key in session_info: return bool(session_info[key]) options_sources: list[Optional[Union[Callable[[], dict[str, Any]], dict[str, Any]]]] = [] if session.bind: options_sources.append(getattr(session.bind, "execution_options", None)) sync_engine = getattr(session.bind, "sync_engine", None) if sync_engine: options_sources.append(getattr(sync_engine, "execution_options", None)) options_sources.append(getattr(session, "execution_options", None)) for options_source in options_sources: if options_source is None: continue options: Optional[dict[str, Any]] = None if callable(options_source): try: result = options_source() if isinstance(result, dict): # pyright: ignore options = result except Exception as e: logger.debug("Error calling execution_options source: %s", e) else: options = options_source if options is not None and key in options: enable_listener = bool(options[key]) break return enable_listener def set_async_context(is_async: bool = True) -> None: # noqa: ARG001 """Set the async context flag. .. deprecated:: 1.9.0 This function is no longer needed as listeners are now explicitly sync or async. """ warn_deprecation( version="1.9.0", deprecated_name="set_async_context", kind="function", removal_in="2.0.0", info="Listeners are now explicitly sync or async, so this context flag is no longer needed.", ) def reset_async_context(token: Any) -> None: # noqa: ARG001 """Reset the async context flag using the provided token. .. deprecated:: 1.9.0 This function is no longer needed as listeners are now explicitly sync or async. """ warn_deprecation( version="1.9.0", deprecated_name="reset_async_context", kind="function", removal_in="2.0.0", info="Listeners are now explicitly sync or async, so this context flag is no longer needed.", ) def is_async_context() -> bool: """Check if we're in an async context. .. deprecated:: 1.9.0 This function is no longer needed as listeners are now explicitly sync or async. """ warn_deprecation( version="1.9.0", deprecated_name="is_async_context", kind="function", removal_in="2.0.0", alternative="advanced_alchemy.utils.sync_tools.is_async_context", info="This function in `_listeners` is deprecated. Use the utility in `sync_tools` or relying on explicit listener classes.", ) return _is_async_context_util() class FileObjectInspector: """Utilities for inspecting FileObject attribute changes.""" @staticmethod def inspect_instance(instance: Any, tracker: "FileObjectSessionTracker") -> None: """Inspect an instance for changes in FileObject attributes.""" from advanced_alchemy.types.file_object import StoredObject state = inspect(instance) if not state: return mapper = state.mapper if not mapper: return for attr_name, attr in mapper.column_attrs.items(): if not isinstance(attr.expression.type, StoredObject): continue try: attr_state = state.attrs[attr_name] except KeyError: continue is_multiple = getattr(attr.expression.type, "multiple", False) if not is_multiple: FileObjectInspector.handle_single_attribute(attr_state, tracker) else: FileObjectInspector.handle_multiple_attribute(instance, attr_name, attr_state, tracker) @staticmethod def handle_single_attribute(attr_state: Any, tracker: "FileObjectSessionTracker") -> None: """Handle inspection of a single FileObject attribute.""" history = attr_state.history current_value: Optional[FileObject] = history.added[0] if history.added else None original_value: Optional[FileObject] = history.deleted[0] if history.deleted else None if current_value: pending_content = getattr(current_value, "_pending_source_content", None) pending_source_path = getattr(current_value, "_pending_source_path", None) if pending_content is not None: tracker.add_pending_save(current_value, pending_content) elif pending_source_path is not None: tracker.add_pending_save(current_value, pending_source_path) if original_value and original_value.path: tracker.add_pending_delete(original_value) @staticmethod def handle_multiple_attribute( instance: Any, attr_name: str, attr_state: Any, tracker: "FileObjectSessionTracker", ) -> None: """Handle inspection of multiple FileObject attributes (MutableList).""" from advanced_alchemy.types.file_object import FileObject from advanced_alchemy.types.mutables import MutableList history = attr_state.history items_to_delete: set[FileObject] = set() items_to_save: dict[FileObject, Any] = {} current_list_instance: Optional[MutableList[FileObject]] = getattr(instance, attr_name, None) original_list_from_history: Optional[MutableList[FileObject]] = history.deleted[0] if history.deleted else None current_list_from_history: Optional[MutableList[FileObject]] = history.added[0] if history.added else None # 1. Deletions from Mutations (Primary source: _pending_removed set) if isinstance(current_list_instance, MutableList): removed_items_internal: set[FileObject] = getattr( current_list_instance, "_pending_removed", set[FileObject](), ) valid_removed_internal = {item for item in removed_items_internal if item and item.path} if valid_removed_internal: logger.debug( "[Multiple-Mutation] Found %d valid items in internal _pending_removed set.", len(valid_removed_internal), ) items_to_delete.update(valid_removed_internal) # 2. Deletions from Replacements (Secondary source: history) if original_list_from_history: # Indicates list replacement logger.debug("[Multiple-Replacement] Processing list replacement via history.") original_items_set = {item for item in original_list_from_history if item.path} current_items_set = ( {item for item in current_list_from_history if item.path} if current_list_from_history else set[FileObject]() ) removed_due_to_replacement = original_items_set - current_items_set if removed_due_to_replacement: logger.debug( "[Multiple-Replacement] Found %d items removed via replacement.", len(removed_due_to_replacement), ) items_to_delete.update(removed_due_to_replacement) # 3. Determine items to save # Saves from pending appends (Mutation or New) if isinstance(current_list_instance, MutableList): pending_append = getattr(current_list_instance, "_pending_append", []) if pending_append: logger.debug("[Multiple-Mutation] Found %d items in _pending_append list.", len(pending_append)) for item in pending_append: pending_content = getattr(item, "_pending_content", None) pending_source_path = getattr(item, "_pending_source_path", None) if pending_content is not None: items_to_save[item] = pending_content elif pending_source_path is not None: items_to_save[item] = pending_source_path # Saves from newly added list items (New Instance or Replacement) if current_list_from_history: log_prefix = "[Multiple-New]" if not original_list_from_history else "[Multiple-Replacement]" logger.debug( "%s Checking current list from history (%d items) for pending saves.", log_prefix, len(current_list_from_history), ) for item in current_list_from_history: pending_content = getattr(item, "_pending_source_content", None) pending_source_path = getattr(item, "_pending_source_path", None) if pending_content is not None and item not in items_to_save: logger.debug("%s Found pending content for %r", log_prefix, item.filename) items_to_save[item] = pending_content elif pending_source_path is not None and item not in items_to_save: logger.debug("%s Found pending source path for %r", log_prefix, item.filename) items_to_save[item] = pending_source_path # 4. Finalize MutableList state (if applicable) if isinstance(current_list_instance, MutableList): finalize_method = getattr(current_list_instance, "_finalize_pending", None) if finalize_method: logger.debug("[Multiple] Calling _finalize_pending on list instance.") finalize_method() # 5. Schedule all collected operations if items_to_delete: logger.debug("[Multiple] Scheduling %d items for deletion.", len(items_to_delete)) for item_to_delete in items_to_delete: tracker.add_pending_delete(item_to_delete) if items_to_save: logger.debug("[Multiple] Scheduling %d items for saving.", len(items_to_save)) for item_to_save, data in items_to_save.items(): tracker.add_pending_save(item_to_save, data) @staticmethod def process_deleted_instance( instance: Any, mapper: Any, tracker: "FileObjectSessionTracker", ) -> None: """Process an instance that is being deleted from the session.""" from advanced_alchemy.types.file_object import StoredObject from advanced_alchemy.types.mutables import MutableList for attr_name, attr in mapper.column_attrs.items(): if isinstance(attr.expression.type, StoredObject): is_multiple = getattr(attr.expression.type, "multiple", False) original_value: Any = getattr(instance, attr_name, None) if original_value is None: continue if not is_multiple: tracker.add_pending_delete(original_value) elif isinstance(original_value, (list, MutableList)): for item in original_value: # pyright: ignore tracker.add_pending_delete(cast("FileObject", item)) class BaseFileObjectListener: """Base class for FileObject event listeners.""" @classmethod def _is_listener_enabled(cls, session: "Session") -> bool: return _is_listener_enabled(session, "enable_file_object_listener") @classmethod def before_flush(cls, session: "Session", flush_context: "UOWTransaction", instances: Optional[object]) -> None: """Track FileObject changes before a flush.""" from advanced_alchemy.types.file_object import StoredObject if not cls._is_listener_enabled(session): return tracker = get_file_tracker(session, create=True) if not tracker: return for instance in session.new: FileObjectInspector.inspect_instance(instance, tracker) for instance in session.dirty: FileObjectInspector.inspect_instance(instance, tracker) for instance in session.deleted: state = inspect(instance) if not state: continue mapper = state.mapper if not mapper: continue # Avoid inspecting if no StoredObject columns exist has_stored_object = any( isinstance(attr.expression.type, StoredObject) for attr in mapper.column_attrs.values() ) if not has_stored_object: continue FileObjectInspector.process_deleted_instance(instance, mapper, tracker) class SyncFileObjectListener(BaseFileObjectListener): """Synchronous FileObject listener.""" @classmethod def after_commit(cls, session: "Session") -> None: """Process file operations after a successful commit.""" tracker = get_file_tracker(session, create=False) if tracker: tracker.commit() session.info.pop(_FILE_TRACKER_KEY, None) @classmethod def after_rollback(cls, session: "Session") -> None: """Clean up pending file operations after a rollback.""" tracker = get_file_tracker(session, create=False) if tracker: tracker.rollback() session.info.pop(_FILE_TRACKER_KEY, None) class AsyncFileObjectListener(BaseFileObjectListener): """Asynchronous FileObject listener.""" @classmethod def after_commit(cls, session: "Session") -> None: """Process file operations after a successful commit.""" tracker = get_file_tracker(session, create=False) if not tracker: return async def _do_async_commit() -> None: try: await tracker.commit_async() except Exception as e: # Using %s for cleaner logging of exception causes logger.debug("An error occurred while committing a file object: %s", e.__cause__) finally: session.info.pop(_FILE_TRACKER_KEY, None) # Store the task reference, even if not awaited here t = asyncio.create_task(_do_async_commit()) _active_file_operations.add(t) t.add_done_callback(lambda _: _active_file_operations.remove(t)) @classmethod def after_rollback(cls, session: "Session") -> None: """Clean up pending file operations after a rollback.""" tracker = get_file_tracker(session, create=False) if not tracker: return async def _do_async_rollback() -> None: try: await tracker.rollback_async() except Exception as e: logger.debug("An error occurred during async FileObject rollback: %s", e.__cause__) finally: session.info.pop(_FILE_TRACKER_KEY, None) # Store the task reference, even if not awaited here t = asyncio.create_task(_do_async_rollback()) _active_file_operations.add(t) t.add_done_callback(lambda _: _active_file_operations.remove(t)) class FileObjectListener(SyncFileObjectListener, AsyncFileObjectListener): """Legacy FileObject listener that handles both sync and async via runtime checks. .. deprecated:: 1.9.0 Use :class:`SyncFileObjectListener` or :class:`AsyncFileObjectListener` instead. """ @classmethod def after_commit(cls, session: "Session") -> None: if is_async_context(): AsyncFileObjectListener.after_commit(session) else: SyncFileObjectListener.after_commit(session) @classmethod def after_rollback(cls, session: "Session") -> None: if is_async_context(): AsyncFileObjectListener.after_rollback(session) else: SyncFileObjectListener.after_rollback(session) def setup_file_object_listeners(registry: Optional["StorageRegistry"] = None) -> None: # noqa: ARG001 """Registers the FileObject event listeners globally. .. deprecated:: 1.9.0 This function registers listeners globally on the Session class. Prefer using scoped listeners via SQLAlchemyConfig. """ from sqlalchemy.event import contains from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session listeners = { "before_flush": FileObjectListener.before_flush, "after_commit": FileObjectListener.after_commit, "after_rollback": FileObjectListener.after_rollback, } # Register for sync Session for event_name, listener_func in listeners.items(): if not contains(Session, event_name, listener_func): # type: ignore[arg-type] event.listen(Session, event_name, listener_func) # type: ignore[arg-type] async_listeners_to_register = { "after_commit": FileObjectListener.after_commit, "after_rollback": FileObjectListener.after_rollback, } for event_name, listener_func in async_listeners_to_register.items(): if hasattr(AsyncSession, event_name) and not contains(AsyncSession, event_name, listener_func): event.listen(AsyncSession, event_name, listener_func) class CacheInvalidationTracker: """Tracks pending cache invalidations for a session transaction. This tracker collects entity invalidations during a transaction and processes them only after a successful commit. On rollback, the pending invalidations are discarded. Note: Model version bumps are also deferred to commit to ensure rollbacks don't invalidate list caches when no DB change occurred. """ __slots__ = ("_cache_manager", "_pending_invalidations", "_pending_model_bumps") def __init__(self, cache_manager: "CacheManager") -> None: self._cache_manager = cache_manager self._pending_invalidations: list[tuple[str, Any, Optional[str]]] = [] self._pending_model_bumps: set[str] = set() def add_invalidation(self, model_name: str, entity_id: Any, bind_group: Optional[str] = None) -> None: """Queue an entity for cache invalidation. The actual invalidation and model version bump are deferred until commit() is called, ensuring rollbacks don't affect the cache. Args: model_name: The model/table name. entity_id: The entity's primary key value. bind_group: Optional routing group for multi-master configurations. When provided, only the cache entry for that bind_group is invalidated. """ self._pending_invalidations.append((model_name, entity_id, bind_group)) # Queue model version bump for list query invalidation (deferred to commit) self._pending_model_bumps.add(model_name) def commit(self) -> None: """Process all pending invalidations after successful commit.""" # First bump model versions for list query invalidation for model_name in self._pending_model_bumps: self._cache_manager.bump_model_version_sync(model_name) self._pending_model_bumps.clear() # Then invalidate individual entities for model_name, entity_id, bind_group in self._pending_invalidations: self._cache_manager.invalidate_entity_sync(model_name, entity_id, bind_group) self._pending_invalidations.clear() def rollback(self) -> None: """Discard pending invalidations on rollback.""" self._pending_invalidations.clear() self._pending_model_bumps.clear() async def commit_async(self) -> None: """Process all pending invalidations after successful commit (async-safe). This method performs cache I/O using the CacheManager async APIs so that dogpile backends (often sync network clients) never block the event loop. """ # First bump model versions for list query invalidation for model_name in self._pending_model_bumps: await self._cache_manager.bump_model_version_async(model_name) self._pending_model_bumps.clear() # Then invalidate individual entities for model_name, entity_id, bind_group in self._pending_invalidations: await self._cache_manager.invalidate_entity_async(model_name, entity_id, bind_group) self._pending_invalidations.clear() def get_cache_tracker( session: "Union[Session, AsyncSession, scoped_session[Session], async_scoped_session[AsyncSession]]", cache_manager: Optional["CacheManager"] = None, create: bool = True, ) -> Optional["CacheInvalidationTracker"]: """Get or create a cache invalidation tracker for the session. The tracker is stored on session.info to ensure proper scoping per session instance and avoid ContextVar collisions. Args: session: The SQLAlchemy session instance (sync or async, including scoped sessions). cache_manager: The CacheManager instance (required if create=True). create: Whether to create a new tracker if one doesn't exist. Returns: The cache tracker or None if not available. """ tracker: Optional[CacheInvalidationTracker] = session.info.get(_CACHE_TRACKER_KEY) if tracker is None and create and cache_manager is not None: tracker = CacheInvalidationTracker(cache_manager) session.info[_CACHE_TRACKER_KEY] = tracker return tracker class BaseCacheListener: """Base class for cache invalidation event listeners.""" @classmethod def _is_listener_enabled(cls, session: "Session") -> bool: """Check if cache listener is enabled for this session.""" return _is_listener_enabled(session, "enable_cache_listener") class SyncCacheListener(BaseCacheListener): """Synchronous cache invalidation listener.""" @classmethod def after_commit(cls, session: "Session") -> None: """Process cache invalidations after a successful commit.""" if not cls._is_listener_enabled(session): return tracker = get_cache_tracker(session, create=False) if tracker: tracker.commit() session.info.pop(_CACHE_TRACKER_KEY, None) @classmethod def after_rollback(cls, session: "Session") -> None: """Discard pending cache invalidations after a rollback.""" tracker = get_cache_tracker(session, create=False) if tracker: tracker.rollback() session.info.pop(_CACHE_TRACKER_KEY, None) class AsyncCacheListener(BaseCacheListener): """Asynchronous cache invalidation listener.""" @classmethod def after_commit(cls, session: "Session") -> None: """Process cache invalidations after a successful commit.""" if not cls._is_listener_enabled(session): return tracker = get_cache_tracker(session, create=False) if tracker: task = asyncio.create_task(tracker.commit_async()) _active_cache_operations.add(task) task.add_done_callback(_active_cache_operations.discard) session.info.pop(_CACHE_TRACKER_KEY, None) @classmethod def after_rollback(cls, session: "Session") -> None: """Discard pending cache invalidations after a rollback.""" tracker = get_cache_tracker(session, create=False) if tracker: tracker.rollback() session.info.pop(_CACHE_TRACKER_KEY, None) class CacheInvalidationListener(BaseCacheListener): """Unified cache invalidation listener for sync and async contexts. This class preserves the historical behavior of choosing sync vs async invalidation based on whether a running event loop is detected. """ @classmethod def after_commit(cls, session: "Session") -> None: """Process cache invalidations after a successful commit.""" if not cls._is_listener_enabled(session): return tracker = get_cache_tracker(session, create=False) if tracker: try: asyncio.get_running_loop() except RuntimeError: # No running loop: sync usage, perform invalidation inline. tracker.commit() else: # Running loop: schedule async invalidation so commit doesn't block. task = asyncio.create_task(tracker.commit_async()) _active_cache_operations.add(task) task.add_done_callback(_active_cache_operations.discard) session.info.pop(_CACHE_TRACKER_KEY, None) @classmethod def after_rollback(cls, session: "Session") -> None: """Discard pending cache invalidations after a rollback.""" tracker = get_cache_tracker(session, create=False) if tracker: tracker.rollback() session.info.pop(_CACHE_TRACKER_KEY, None) def setup_cache_listeners() -> None: """Register cache invalidation event listeners globally. This registers the unified listener on Session, which handles both sync and async contexts by detecting a running event loop at commit time. For more control, prefer using scoped listeners via SQLAlchemyConfig. """ from sqlalchemy.event import contains from sqlalchemy.orm import Session # Use the unified listener that handles both sync and async contexts listeners = { "after_commit": CacheInvalidationListener.after_commit, "after_rollback": CacheInvalidationListener.after_rollback, } for event_name, listener_func in listeners.items(): if not contains(Session, event_name, listener_func): event.listen(Session, event_name, listener_func) logger.debug("Cache invalidation listeners registered") # Existing listener (keep it) def touch_updated_timestamp(session: "Session", *_: Any) -> None: """Set timestamp on update. Called from SQLAlchemy's :meth:`before_flush ` event to bump the ``updated`` timestamp on modified instances. Args: session: The sync :class:`Session ` instance that underlies the async session. """ for instance in session.dirty: state = inspect(instance) if not state or not hasattr(state.mapper.class_, "updated_at") or state.deleted or instance in session.new: continue updated_at_attr = state.attrs.get("updated_at") if not updated_at_attr or updated_at_attr.history.added: # Respect explicit user assignments such as manual overrides or import routines continue if _has_persistent_column_changes(state, skip_keys={"updated_at"}): instance.updated_at = datetime.datetime.now(datetime.timezone.utc) def _has_persistent_column_changes( state: "InstanceState[Any]", *, skip_keys: "Optional[set[str]]" = None, ) -> bool: """Check if any mapped column (excluding ``skip_keys``) has modifications pending flush.""" if skip_keys is None: skip_keys = set() mapper = state.mapper for attr in mapper.column_attrs: if attr.key in skip_keys: continue attr_state = state.attrs.get(attr.key) if attr_state is not None and attr_state.history.has_changes(): return True return False python-advanced-alchemy-1.9.3/advanced_alchemy/_serialization.py000066400000000000000000000150141516556515500251040ustar00rootroot00000000000000# ruff: noqa: PLR0911 import datetime import decimal import enum import uuid from typing import Any, ClassVar, Protocol, Union, cast from typing_extensions import runtime_checkable from advanced_alchemy.exceptions import MissingDependencyError try: from pydantic import BaseModel # type: ignore PYDANTIC_INSTALLED = True except ImportError: @runtime_checkable class BaseModel(Protocol): # type: ignore[no-redef] """Placeholder Implementation""" model_fields: ClassVar[dict[str, Any]] def model_dump_json(self, *args: Any, **kwargs: Any) -> str: """Placeholder for pydantic.BaseModel.model_dump_json Returns: The JSON representation of the model. """ msg = "pydantic" raise MissingDependencyError(msg) PYDANTIC_INSTALLED = False # pyright: ignore[reportConstantRedefinition] def _type_to_string(value: Any) -> str: # pragma: no cover if isinstance(value, datetime.datetime): return convert_datetime_to_gmt_iso(value) if isinstance(value, datetime.date): return convert_date_to_iso(value) if isinstance(value, enum.Enum): return str(value.value) if PYDANTIC_INSTALLED and isinstance(value, BaseModel): return value.model_dump_json() try: val = str(value) except Exception as exc: raise TypeError from exc return val try: from msgspec.json import Decoder, Encoder encoder, decoder = Encoder(enc_hook=_type_to_string), Decoder() decode_json = decoder.decode def encode_json(data: Any) -> str: # pragma: no cover return encoder.encode(data).decode("utf-8") except ImportError: try: from orjson import ( # type: ignore[import-not-found] # pyright: ignore[reportMissingImports] OPT_NAIVE_UTC, # pyright: ignore[reportUnknownVariableType] OPT_SERIALIZE_NUMPY, # pyright: ignore[reportUnknownVariableType] OPT_SERIALIZE_UUID, # pyright: ignore[reportUnknownVariableType] ) from orjson import ( # type: ignore[import-not-found] # pyright: ignore[reportMissingImports] dumps as _encode_json, # pyright: ignore[reportUnknownVariableType] ) from orjson import ( # type: ignore[no-redef,assignment,import-not-found] # pyright: ignore[reportMissingImports] loads as decode_json, # pyright: ignore[reportUnknownVariableType,reportUnusedImport] ) def encode_json(data: Any) -> str: # pragma: no cover return _encode_json( # type: ignore[no-any-return] data, default=_type_to_string, option=OPT_SERIALIZE_NUMPY | OPT_NAIVE_UTC | OPT_SERIALIZE_UUID ).decode("utf-8") except ImportError: from json import dumps as encode_json # type: ignore[assignment] # noqa: F401 from json import loads as decode_json # type: ignore[assignment] # noqa: F401 def convert_datetime_to_gmt_iso(dt: datetime.datetime) -> str: # pragma: no cover """Handle datetime serialization for nested timestamps. Returns: str: The ISO 8601 formatted datetime string. """ if not dt.tzinfo: dt = dt.replace(tzinfo=datetime.timezone.utc) return dt.isoformat().replace("+00:00", "Z") def convert_date_to_iso(dt: datetime.date) -> str: # pragma: no cover """Handle datetime serialization for nested timestamps. Returns: str: The ISO 8601 formatted date string. """ return dt.isoformat() def encode_complex_type(obj: Any) -> Any: """Convert an object to a JSON-serializable format if possible. Handles types that are not natively JSON serializable: - datetime, date, time: ISO format strings - timedelta: total seconds as float - Decimal: string representation - bytes: hex string - UUID: string representation - set, frozenset: list Args: obj: The object to encode. Returns: A JSON-serializable representation of the object, or None if the type is not supported. """ if isinstance(obj, datetime.datetime): return {"__type__": "datetime", "value": obj.isoformat()} if isinstance(obj, datetime.date): return {"__type__": "date", "value": obj.isoformat()} if isinstance(obj, datetime.time): return {"__type__": "time", "value": obj.isoformat()} if isinstance(obj, datetime.timedelta): return {"__type__": "timedelta", "value": obj.total_seconds()} if isinstance(obj, decimal.Decimal): return {"__type__": "decimal", "value": str(obj)} if isinstance(obj, bytes): return {"__type__": "bytes", "value": obj.hex()} if isinstance(obj, uuid.UUID): return {"__type__": "uuid", "value": str(obj)} if isinstance(obj, (set, frozenset)): items: list[Any] = list(cast("Union[set[Any], frozenset[Any]]", obj)) # type: ignore[redundant-cast] return {"__type__": "set", "value": items} return None def decode_complex_type(value: Any) -> Any: """Recursively decode special type markers. Decodes the special ``{"__type__": ..., "value": ...}`` structures. """ if isinstance(value, list): value_list = cast("list[Any]", value) # type: ignore[redundant-cast] return [decode_complex_type(v) for v in value_list] if not isinstance(value, dict): return value # Decode any nested values first value_dict = cast("dict[Any, Any]", value) # type: ignore[redundant-cast] decoded: dict[str, Any] = {str(k): decode_complex_type(v) for k, v in value_dict.items()} # Then decode "typed" marker dicts if "__type__" in decoded and "value" in decoded: return _decode_typed_marker(decoded) return decoded def _decode_typed_marker(obj: dict[str, Any]) -> Any: """Custom JSON decoder for special types. Args: obj: The dictionary to decode. Returns: The decoded object, or the original dict if not a special type. """ type_name = obj["__type__"] value = obj["value"] if type_name == "datetime": return datetime.datetime.fromisoformat(value) if type_name == "date": return datetime.date.fromisoformat(value) if type_name == "time": return datetime.time.fromisoformat(value) if type_name == "timedelta": return datetime.timedelta(seconds=value) if type_name == "decimal": return decimal.Decimal(value) if type_name == "bytes": return bytes.fromhex(value) if type_name == "uuid": return uuid.UUID(value) if type_name == "set": return set(value) return obj python-advanced-alchemy-1.9.3/advanced_alchemy/_typing.py000066400000000000000000000025321516556515500235420ustar00rootroot00000000000000# ruff: noqa: RUF100 """Foundational type shims for optional dependencies. Provides stub types used across the package when optional libraries (e.g. SQLModel) are not installed. This module is intentionally kept minimal and free of internal imports so that low-level modules like ``base`` can use it without reaching into higher-level packages. """ from typing import TYPE_CHECKING, Any, ClassVar if TYPE_CHECKING: from sqlalchemy.orm import Mapper from sqlalchemy.sql import FromClause class SQLModelBaseLike: """Placeholder for sqlmodel.SQLModel when the package is not installed. Declares the same structural attributes as :class:`ModelProtocol` so that type checkers can see SQLModel ``table=True`` models as protocol-compatible without requiring the real SQLModel package. """ if TYPE_CHECKING: __table__: "FromClause" __mapper__: "Mapper[Any]" __name__: str model_fields: ClassVar[dict[str, Any]] = {} try: from sqlmodel import SQLModel as SQLModelBase SQLMODEL_INSTALLED: bool = True # pyright: ignore[reportConstantRedefinition] except ImportError: SQLModelBase = SQLModelBaseLike # type: ignore[assignment,misc] SQLMODEL_INSTALLED = False # pyright: ignore[reportConstantRedefinition] __all__ = ( "SQLMODEL_INSTALLED", "SQLModelBase", "SQLModelBaseLike", ) python-advanced-alchemy-1.9.3/advanced_alchemy/alembic/000077500000000000000000000000001516556515500231115ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/advanced_alchemy/alembic/__init__.py000066400000000000000000000000001516556515500252100ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/advanced_alchemy/alembic/commands.py000066400000000000000000000355341516556515500252760ustar00rootroot00000000000000import inspect # Added import import sys from typing import TYPE_CHECKING, Any, Optional, TextIO, Union from alembic.config import Config as _AlembicCommandConfig from alembic.ddl.impl import DefaultImpl from advanced_alchemy.config.asyncio import SQLAlchemyAsyncConfig from advanced_alchemy.exceptions import ImproperConfigurationError from alembic import command as migration_command if TYPE_CHECKING: import os from argparse import Namespace from collections.abc import Mapping from pathlib import Path from alembic.runtime.environment import ProcessRevisionDirectiveFn from alembic.script.base import Script from sqlalchemy import Engine from sqlalchemy.ext.asyncio import AsyncEngine from advanced_alchemy.config.sync import SQLAlchemySyncConfig class AlembicSpannerImpl(DefaultImpl): """Alembic implementation for Spanner.""" __dialect__ = "spanner+spanner" class AlembicDuckDBImpl(DefaultImpl): """Alembic implementation for DuckDB.""" __dialect__ = "duckdb" class AlembicCommandConfig(_AlembicCommandConfig): def __init__( self, engine: "Union[Engine, AsyncEngine]", version_table_name: str, bind_key: "Optional[str]" = None, file_: "Union[str, os.PathLike[str], None]" = None, toml_file: "Union[str, os.PathLike[str], None]" = None, ini_section: str = "alembic", output_buffer: "Optional[TextIO]" = None, stdout: "TextIO" = sys.stdout, cmd_opts: "Optional[Namespace]" = None, config_args: "Optional[Mapping[str, Any]]" = None, attributes: "Optional[dict[str, Any]]" = None, template_directory: "Optional[Path]" = None, version_table_schema: "Optional[str]" = None, render_as_batch: bool = True, compare_type: bool = False, user_module_prefix: "Optional[str]" = "sa.", ) -> None: """Initialize the AlembicCommandConfig. Args: engine (sqlalchemy.engine.Engine | sqlalchemy.ext.asyncio.AsyncEngine): The SQLAlchemy engine instance. version_table_name (str): The name of the version table. bind_key (str | None): The bind key for the metadata. file_ (str | os.PathLike[str] | None): The file path for the alembic .ini configuration. toml_file (str | os.PathLike[str] | None): The file path for the alembic pyproject.toml configuration. ini_section (str): The ini section name. output_buffer (typing.TextIO | None): The output buffer for alembic commands. stdout (typing.TextIO): The standard output stream. cmd_opts (argparse.Namespace | None): Command line options. config_args (typing.Mapping[str, typing.Any] | None): Additional configuration arguments. attributes (dict[str, typing.Any] | None): Additional attributes for the configuration. template_directory (pathlib.Path | None): The directory for alembic templates. version_table_schema (str | None): The schema for the version table. render_as_batch (bool): Whether to render migrations as batch. compare_type (bool): Whether to compare types during migrations. user_module_prefix (str | None): The prefix for user modules. """ self.template_directory = template_directory self.bind_key = bind_key self.version_table_name = version_table_name self.version_table_pk = engine.dialect.name != "spanner+spanner" self.version_table_schema = version_table_schema self.render_as_batch = render_as_batch self.user_module_prefix = user_module_prefix self.compare_type = compare_type self.engine = engine self.db_url = engine.url.render_as_string(hide_password=False) _config_args = {} if config_args is None else dict(config_args) # Prepare kwargs for super().__init__ super_init_kwargs: dict[str, Any] = { "file_": file_, "ini_section": ini_section, "output_buffer": output_buffer, "stdout": stdout, "cmd_opts": cmd_opts, "config_args": _config_args, # Pass the mutable copy "attributes": attributes, } # Inspect the parent class __init__ for toml_file parameter parent_init_sig = inspect.signature(super().__init__) if "toml_file" in parent_init_sig.parameters: super_init_kwargs["toml_file"] = toml_file elif toml_file is not None: msg = ( "The 'toml_file' parameter is not supported by your current Alembic version. " "Please upgrade Alembic to 1.16.0 or later to use this feature, " "or remove the 'toml_file' argument from AlembicCommandConfig." ) raise ImproperConfigurationError(msg) super().__init__(**super_init_kwargs) def get_template_directory(self) -> str: """Return the directory where Alembic setup templates are found. This method is used by the alembic ``init`` and ``list_templates`` commands. """ if self.template_directory is not None: return str(self.template_directory) return super().get_template_directory() class AlembicCommands: def __init__(self, sqlalchemy_config: "Union[SQLAlchemyAsyncConfig, SQLAlchemySyncConfig]") -> None: """Initialize the AlembicCommands. Args: sqlalchemy_config (SQLAlchemyAsyncConfig | SQLAlchemySyncConfig): The SQLAlchemy configuration. """ self.sqlalchemy_config = sqlalchemy_config self.config = self._get_alembic_command_config() def upgrade( self, revision: str = "head", sql: bool = False, tag: "Optional[str]" = None, ) -> None: """Upgrade the database to a specified revision. Args: revision (str): The target revision to upgrade to. sql (bool): If True, generate SQL script instead of applying changes. tag (str | None): An optional tag to apply to the migration. """ return migration_command.upgrade(config=self.config, revision=revision, tag=tag, sql=sql) def downgrade( self, revision: str = "head", sql: bool = False, tag: "Optional[str]" = None, ) -> None: """Downgrade the database to a specified revision. Args: revision (str): The target revision to downgrade to. sql (bool): If True, generate SQL script instead of applying changes. tag (str | None): An optional tag to apply to the migration. """ return migration_command.downgrade(config=self.config, revision=revision, tag=tag, sql=sql) def branches(self, verbose: bool = False) -> None: """Show current branch points in the script directory. Args: verbose (bool): If True, display detailed information. """ return migration_command.branches(config=self.config, verbose=verbose) def check(self) -> None: """Check for pending upgrade operations. This method checks if there are any pending upgrade operations that need to be applied to the database. """ return migration_command.check(config=self.config) def current(self, verbose: bool = False) -> None: """Display the current revision of the database. Args: verbose (bool): If True, display detailed information. """ return migration_command.current(self.config, verbose=verbose) def edit(self, revision: str) -> None: """Edit the revision script using the system editor. Args: revision (str): The revision identifier to edit. """ return migration_command.edit(config=self.config, rev=revision) def ensure_version(self, sql: bool = False) -> None: """Ensure the alembic version table exists. Args: sql (bool): If True, generate SQL script instead of applying changes. """ return migration_command.ensure_version(config=self.config, sql=sql) def heads(self, verbose: bool = False, resolve_dependencies: bool = False) -> None: """Show current available heads in the script directory. Args: verbose (bool): If True, display detailed information. resolve_dependencies (bool): If True, resolve dependencies between heads. """ return migration_command.heads(config=self.config, verbose=verbose, resolve_dependencies=resolve_dependencies) def history( self, rev_range: "Optional[str]" = None, verbose: bool = False, indicate_current: bool = False, ) -> None: """List changeset scripts in chronological order. Args: rev_range (str | None): The revision range to display. verbose (bool): If True, display detailed information. indicate_current (bool): If True, indicate the current revision. """ return migration_command.history( config=self.config, rev_range=rev_range, verbose=verbose, indicate_current=indicate_current, ) def merge( self, revisions: str, message: "Optional[str]" = None, branch_label: "Optional[str]" = None, rev_id: "Optional[str]" = None, ) -> "Union[Script, None]": """Merge two revisions together. Args: revisions (str): The revisions to merge. message (str | None): The commit message for the merge. branch_label (str | None): The branch label for the merge. rev_id (str | None): The revision ID for the merge. Returns: Script | None: The resulting script from the merge. """ return migration_command.merge( config=self.config, revisions=revisions, message=message, branch_label=branch_label, rev_id=rev_id, ) def revision( self, message: "Optional[str]" = None, autogenerate: bool = False, sql: bool = False, head: str = "head", splice: bool = False, branch_label: "Optional[str]" = None, version_path: "Optional[str]" = None, rev_id: "Optional[str]" = None, depends_on: "Optional[str]" = None, process_revision_directives: "Optional[ProcessRevisionDirectiveFn]" = None, ) -> "Union[Script, list[Optional[Script]], None]": """Create a new revision file. Args: message (str | None): The commit message for the revision. autogenerate (bool): If True, autogenerate the revision script. sql (bool): If True, generate SQL script instead of applying changes. head (str): The head revision to base the new revision on. splice (bool): If True, create a splice revision. branch_label (str | None): The branch label for the revision. version_path (str | None): The path for the version file. rev_id (str | None): The revision ID for the new revision. depends_on (str | None): The revisions this revision depends on. process_revision_directives (ProcessRevisionDirectiveFn | None): A function to process revision directives. Returns: Script | List[Script | None] | None: The resulting script(s) from the revision. """ return migration_command.revision( config=self.config, message=message, autogenerate=autogenerate, sql=sql, head=head, splice=splice, branch_label=branch_label, version_path=version_path, rev_id=rev_id, depends_on=depends_on, process_revision_directives=process_revision_directives, ) def show( self, rev: Any, ) -> None: """Show the revision(s) denoted by the given symbol. Args: rev (Any): The revision symbol to display. """ return migration_command.show(config=self.config, rev=rev) def init( self, directory: str, package: bool = False, multidb: bool = False, ) -> None: """Initialize a new scripts directory. Args: directory (str): The directory to initialize. package (bool): If True, create a package. multidb (bool): If True, initialize for multiple databases. """ template = "sync" if isinstance(self.sqlalchemy_config, SQLAlchemyAsyncConfig): template = "asyncio" if multidb: template = f"{template}-multidb" msg = "Multi database Alembic configurations are not currently supported." raise NotImplementedError(msg) return migration_command.init( config=self.config, directory=directory, template=template, package=package, ) def list_templates(self) -> None: """List available templates. This method lists all available templates for alembic initialization. """ return migration_command.list_templates(config=self.config) def stamp( self, revision: str, sql: bool = False, tag: "Optional[str]" = None, purge: bool = False, ) -> None: """Stamp the revision table with the given revision. Args: revision (str): The revision to stamp. sql (bool): If True, generate SQL script instead of applying changes. tag (str | None): An optional tag to apply to the migration. purge (bool): If True, purge the revision history. """ return migration_command.stamp(config=self.config, revision=revision, sql=sql, tag=tag, purge=purge) def _get_alembic_command_config(self) -> "AlembicCommandConfig": """Get the Alembic command configuration. Returns: AlembicCommandConfig: The configuration for Alembic commands. """ kwargs: dict[str, Any] = {} if self.sqlalchemy_config.alembic_config.toml_file: kwargs["toml_file"] = self.sqlalchemy_config.alembic_config.toml_file if self.sqlalchemy_config.alembic_config.script_config: kwargs["file_"] = self.sqlalchemy_config.alembic_config.script_config if self.sqlalchemy_config.alembic_config.template_path: kwargs["template_directory"] = self.sqlalchemy_config.alembic_config.template_path kwargs.update( { "engine": self.sqlalchemy_config.get_engine(), "version_table_name": self.sqlalchemy_config.alembic_config.version_table_name, }, ) self.config = AlembicCommandConfig(**kwargs) self.config.set_main_option("script_location", self.sqlalchemy_config.alembic_config.script_location) return self.config python-advanced-alchemy-1.9.3/advanced_alchemy/alembic/templates/000077500000000000000000000000001516556515500251075ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/advanced_alchemy/alembic/templates/asyncio/000077500000000000000000000000001516556515500265545ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/advanced_alchemy/alembic/templates/asyncio/README000066400000000000000000000000751516556515500274360ustar00rootroot00000000000000Asynchronous SQLAlchemy configuration with Advanced Alchemy. python-advanced-alchemy-1.9.3/advanced_alchemy/alembic/templates/asyncio/alembic.ini.mako000066400000000000000000000050011516556515500315730ustar00rootroot00000000000000# Advanced Alchemy Alembic Asyncio Config [alembic] prepend_sys_path = src:. # path to migration scripts script_location = migrations # template used to generate migration files file_template = %%(year)d-%%(month).2d-%%(day).2d_%%(slug)s_%%(rev)s # This is not required to be set when running through `advanced_alchemy` # sqlalchemy.url = driver://user:pass@localhost/dbname # timezone to use when rendering the date # within the migration file as well as the filename. # string value is passed to dateutil.tz.gettz() # leave blank for localtime # timezone = UTC # max length of characters to apply to the # "slug" field truncate_slug_length = 40 # set to 'true' to run the environment during # the 'revision' command, regardless of autogenerate # revision_environment = false # set to 'true' to allow .pyc and .pyo files without # a source .py file to be detected as revisions in the # versions/ directory # sourceless = false # version location specification; this defaults # to alembic/versions. When using multiple version # directories, initial revisions must be specified with --version-path # version_locations = %(here)s/bar %(here)s/bat alembic/versions # version path separator; As mentioned above, this is the character used to split # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. # Valid values for version_path_separator are: # # version_path_separator = : # version_path_separator = ; # version_path_separator = space version_path_separator = os # Use os.pathsep. Default configuration used for new projects. # set to 'true' to search source files recursively # in each "version_locations" directory # new in Alembic version 1.10 # recursive_version_locations = false # the output encoding used when revision files # are written from script.py.mako output_encoding = utf-8 # [post_write_hooks] # This section defines scripts or Python functions that are run # on newly generated revision scripts. See the documentation for further # detail and examples # format using "black" - use the console_scripts runner, # against the "black" entrypoint # hooks = black # black.type = console_scripts # black.entrypoint = black # black.options = -l 79 REVISION_SCRIPT_FILENAME # lint with attempts to fix using "ruff" - use the exec runner, execute a binary # hooks = ruff # ruff.type = exec # ruff.executable = %(here)s/.venv/bin/ruff # ruff.options = --fix REVISION_SCRIPT_FILENAME python-advanced-alchemy-1.9.3/advanced_alchemy/alembic/templates/asyncio/env.py000066400000000000000000000064311516556515500277220ustar00rootroot00000000000000import asyncio from typing import TYPE_CHECKING, cast from alembic.autogenerate import rewriter from sqlalchemy import pool from sqlalchemy.ext.asyncio import AsyncEngine, async_engine_from_config from advanced_alchemy.base import metadata_registry from alembic import context if TYPE_CHECKING: from sqlalchemy.engine import Connection from advanced_alchemy.alembic.commands import AlembicCommandConfig __all__ = ("do_run_migrations", "run_migrations_offline", "run_migrations_online") # this is the Alembic Config object, which provides # access to the values within the .ini file in use. config: "AlembicCommandConfig" = context.config # type: ignore writer = rewriter.Rewriter() def run_migrations_offline() -> None: """Run migrations in 'offline' mode. This configures the context with just a URL and not an Engine, though an Engine is acceptable here as well. By skipping the Engine creation we don't even need a DBAPI to be available. Calls to context.execute() here emit the given string to the script output. """ context.configure( url=config.db_url, target_metadata=metadata_registry.get(config.bind_key), literal_binds=True, dialect_opts={"paramstyle": "named"}, compare_type=config.compare_type, version_table=config.version_table_name, version_table_pk=config.version_table_pk, user_module_prefix=config.user_module_prefix, render_as_batch=config.render_as_batch, process_revision_directives=writer, ) with context.begin_transaction(): context.run_migrations() def do_run_migrations(connection: "Connection") -> None: """Run migrations.""" context.configure( connection=connection, target_metadata=metadata_registry.get(config.bind_key), compare_type=config.compare_type, version_table=config.version_table_name, version_table_pk=config.version_table_pk, user_module_prefix=config.user_module_prefix, render_as_batch=config.render_as_batch, process_revision_directives=writer, ) with context.begin_transaction(): context.run_migrations() async def run_migrations_online() -> None: """Run migrations in 'online' mode. In this scenario we need to create an Engine and associate a connection with the context. Raises: RuntimeError: If the engine cannot be created from the config. """ configuration = config.get_section(config.config_ini_section) or {} configuration["sqlalchemy.url"] = config.db_url connectable = cast( "AsyncEngine", config.engine or async_engine_from_config( configuration, prefix="sqlalchemy.", poolclass=pool.NullPool, future=True, ), ) if connectable is None: # pyright: ignore[reportUnnecessaryComparison] msg = "Could not get engine from config. Please ensure your `alembic.ini` according to the official Alembic documentation." raise RuntimeError( msg, ) async with connectable.connect() as connection: await connection.run_sync(do_run_migrations) await connectable.dispose() if context.is_offline_mode(): run_migrations_offline() else: asyncio.run(run_migrations_online()) python-advanced-alchemy-1.9.3/advanced_alchemy/alembic/templates/asyncio/script.py.mako000066400000000000000000000047331516556515500313670ustar00rootroot00000000000000"""${message} Revision ID: ${up_revision} Revises: ${down_revision | comma,n} Create Date: ${create_date} """ import warnings from typing import TYPE_CHECKING, Any import sqlalchemy as sa from alembic import op from advanced_alchemy.types import EncryptedString, EncryptedText, GUID, ORA_JSONB, DateTimeUTC, StoredObject, PasswordHash, FernetBackend from advanced_alchemy.types.encrypted_string import PGCryptoBackend from sqlalchemy import Text # noqa: F401 ${imports if imports else ""} try: from advanced_alchemy.types.password_hash.argon2 import Argon2Hasher except ImportError: Argon2Hasher = Any # type: ignore try: from advanced_alchemy.types.password_hash.passlib import PasslibHasher except ImportError: PasslibHasher = Any # type: ignore try: from advanced_alchemy.types.password_hash.pwdlib import PwdlibHasher except ImportError: PwdlibHasher = Any # type: ignore if TYPE_CHECKING: from collections.abc import Sequence __all__ = ["downgrade", "upgrade", "schema_upgrades", "schema_downgrades", "data_upgrades", "data_downgrades"] sa.GUID = GUID sa.DateTimeUTC = DateTimeUTC sa.ORA_JSONB = ORA_JSONB sa.EncryptedString = EncryptedString sa.EncryptedText = EncryptedText sa.StoredObject = StoredObject sa.PasswordHash = PasswordHash sa.Argon2Hasher = Argon2Hasher sa.PasslibHasher = PasslibHasher sa.PwdlibHasher = PwdlibHasher sa.FernetBackend = FernetBackend sa.PGCryptoBackend = PGCryptoBackend # revision identifiers, used by Alembic. revision = ${repr(up_revision)} down_revision = ${repr(down_revision)} branch_labels = ${repr(branch_labels)} depends_on = ${repr(depends_on)} def upgrade() -> None: with warnings.catch_warnings(): warnings.filterwarnings("ignore", category=UserWarning) with op.get_context().autocommit_block(): schema_upgrades() data_upgrades() def downgrade() -> None: with warnings.catch_warnings(): warnings.filterwarnings("ignore", category=UserWarning) with op.get_context().autocommit_block(): data_downgrades() schema_downgrades() def schema_upgrades() -> None: """schema upgrade migrations go here.""" ${upgrades if upgrades else "pass"} def schema_downgrades() -> None: """schema downgrade migrations go here.""" ${downgrades if downgrades else "pass"} def data_upgrades() -> None: """Add any optional data upgrade migrations here!""" def data_downgrades() -> None: """Add any optional data downgrade migrations here!""" python-advanced-alchemy-1.9.3/advanced_alchemy/alembic/templates/sync/000077500000000000000000000000001516556515500260635ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/advanced_alchemy/alembic/templates/sync/README000066400000000000000000000000741516556515500267440ustar00rootroot00000000000000Synchronous SQLAlchemy configuration with Advanced Alchemy. python-advanced-alchemy-1.9.3/advanced_alchemy/alembic/templates/sync/alembic.ini.mako000066400000000000000000000050021516556515500311030ustar00rootroot00000000000000# Advanced Alchemy Alembic Sync Config [alembic] prepend_sys_path = src:. # path to migration scripts script_location = migrations # template used to generate migration files file_template = %%(year)d-%%(month).2d-%%(day).2d_%%(slug)s_%%(rev)s # This is not required to be set when running through the `advanced_alchemy` # sqlalchemy.url = driver://user:pass@localhost/dbname # timezone to use when rendering the date # within the migration file as well as the filename. # string value is passed to dateutil.tz.gettz() # leave blank for localtime # timezone = UTC # max length of characters to apply to the # "slug" field truncate_slug_length = 40 # set to 'true' to run the environment during # the 'revision' command, regardless of autogenerate # revision_environment = false # set to 'true' to allow .pyc and .pyo files without # a source .py file to be detected as revisions in the # versions/ directory # sourceless = false # version location specification; this defaults # to alembic/versions. When using multiple version # directories, initial revisions must be specified with --version-path # version_locations = %(here)s/bar %(here)s/bat alembic/versions # version path separator; As mentioned above, this is the character used to split # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. # Valid values for version_path_separator are: # # version_path_separator = : # version_path_separator = ; # version_path_separator = space version_path_separator = os # Use os.pathsep. Default configuration used for new projects. # set to 'true' to search source files recursively # in each "version_locations" directory # new in Alembic version 1.10 # recursive_version_locations = false # the output encoding used when revision files # are written from script.py.mako output_encoding = utf-8 # [post_write_hooks] # This section defines scripts or Python functions that are run # on newly generated revision scripts. See the documentation for further # detail and examples # format using "black" - use the console_scripts runner, # against the "black" entrypoint # hooks = black # black.type = console_scripts # black.entrypoint = black # black.options = -l 79 REVISION_SCRIPT_FILENAME # lint with attempts to fix using "ruff" - use the exec runner, execute a binary # hooks = ruff # ruff.type = exec # ruff.executable = %(here)s/.venv/bin/ruff # ruff.options = --fix REVISION_SCRIPT_FILENAME python-advanced-alchemy-1.9.3/advanced_alchemy/alembic/templates/sync/env.py000066400000000000000000000062551516556515500272350ustar00rootroot00000000000000from typing import TYPE_CHECKING, cast from alembic.autogenerate import rewriter from sqlalchemy import Engine, engine_from_config, pool from advanced_alchemy.base import metadata_registry from alembic import context if TYPE_CHECKING: from sqlalchemy.engine import Connection from advanced_alchemy.alembic.commands import AlembicCommandConfig __all__ = ("do_run_migrations", "run_migrations_offline", "run_migrations_online") # this is the Alembic Config object, which provides # access to the values within the .ini file in use. config: "AlembicCommandConfig" = context.config # type: ignore writer = rewriter.Rewriter() def run_migrations_offline() -> None: """Run migrations in 'offline' mode. This configures the context with just a URL and not an Engine, though an Engine is acceptable here as well. By skipping the Engine creation we don't even need a DBAPI to be available. Calls to context.execute() here emit the given string to the script output. """ context.configure( url=config.db_url, target_metadata=metadata_registry.get(config.bind_key), literal_binds=True, dialect_opts={"paramstyle": "named"}, compare_type=config.compare_type, version_table=config.version_table_name, version_table_pk=config.version_table_pk, user_module_prefix=config.user_module_prefix, render_as_batch=config.render_as_batch, process_revision_directives=writer, ) with context.begin_transaction(): context.run_migrations() def do_run_migrations(connection: "Connection") -> None: """Run migrations.""" context.configure( connection=connection, target_metadata=metadata_registry.get(config.bind_key), compare_type=config.compare_type, version_table=config.version_table_name, version_table_pk=config.version_table_pk, user_module_prefix=config.user_module_prefix, render_as_batch=config.render_as_batch, process_revision_directives=writer, ) with context.begin_transaction(): context.run_migrations() def run_migrations_online() -> None: """Run migrations in 'online' mode. In this scenario we need to create an Engine and associate a connection with the context. Raises: RuntimeError: If the engine cannot be created from the config. """ configuration = config.get_section(config.config_ini_section) or {} configuration["sqlalchemy.url"] = config.db_url connectable = cast( "Engine", config.engine or engine_from_config( configuration, prefix="sqlalchemy.", poolclass=pool.NullPool, future=True, ), ) if connectable is None: # pyright: ignore[reportUnnecessaryComparison] msg = "Could not get engine from config. Please ensure your `alembic.ini` according to the official Alembic documentation." raise RuntimeError( msg, ) with connectable.connect() as connection: do_run_migrations(connection=connection) connectable.dispose() if context.is_offline_mode(): run_migrations_offline() else: run_migrations_online() python-advanced-alchemy-1.9.3/advanced_alchemy/alembic/templates/sync/script.py.mako000066400000000000000000000047331516556515500306760ustar00rootroot00000000000000"""${message} Revision ID: ${up_revision} Revises: ${down_revision | comma,n} Create Date: ${create_date} """ import warnings from typing import TYPE_CHECKING, Any import sqlalchemy as sa from alembic import op from advanced_alchemy.types import EncryptedString, EncryptedText, GUID, ORA_JSONB, DateTimeUTC, StoredObject, PasswordHash, FernetBackend from advanced_alchemy.types.encrypted_string import PGCryptoBackend from sqlalchemy import Text # noqa: F401 ${imports if imports else ""} try: from advanced_alchemy.types.password_hash.argon2 import Argon2Hasher except ImportError: Argon2Hasher = Any # type: ignore try: from advanced_alchemy.types.password_hash.passlib import PasslibHasher except ImportError: PasslibHasher = Any # type: ignore try: from advanced_alchemy.types.password_hash.pwdlib import PwdlibHasher except ImportError: PwdlibHasher = Any # type: ignore if TYPE_CHECKING: from collections.abc import Sequence __all__ = ["downgrade", "upgrade", "schema_upgrades", "schema_downgrades", "data_upgrades", "data_downgrades"] sa.GUID = GUID sa.DateTimeUTC = DateTimeUTC sa.ORA_JSONB = ORA_JSONB sa.EncryptedString = EncryptedString sa.EncryptedText = EncryptedText sa.StoredObject = StoredObject sa.PasswordHash = PasswordHash sa.Argon2Hasher = Argon2Hasher sa.PasslibHasher = PasslibHasher sa.PwdlibHasher = PwdlibHasher sa.FernetBackend = FernetBackend sa.PGCryptoBackend = PGCryptoBackend # revision identifiers, used by Alembic. revision = ${repr(up_revision)} down_revision = ${repr(down_revision)} branch_labels = ${repr(branch_labels)} depends_on = ${repr(depends_on)} def upgrade() -> None: with warnings.catch_warnings(): warnings.filterwarnings("ignore", category=UserWarning) with op.get_context().autocommit_block(): schema_upgrades() data_upgrades() def downgrade() -> None: with warnings.catch_warnings(): warnings.filterwarnings("ignore", category=UserWarning) with op.get_context().autocommit_block(): data_downgrades() schema_downgrades() def schema_upgrades() -> None: """schema upgrade migrations go here.""" ${upgrades if upgrades else "pass"} def schema_downgrades() -> None: """schema downgrade migrations go here.""" ${downgrades if downgrades else "pass"} def data_upgrades() -> None: """Add any optional data upgrade migrations here!""" def data_downgrades() -> None: """Add any optional data downgrade migrations here!""" python-advanced-alchemy-1.9.3/advanced_alchemy/alembic/utils.py000066400000000000000000000135461516556515500246340ustar00rootroot00000000000000from contextlib import AbstractAsyncContextManager, AbstractContextManager from pathlib import Path from typing import TYPE_CHECKING, Union from sqlalchemy import Column, Engine, MetaData, String, Table from typing_extensions import TypeIs from advanced_alchemy.base import model_to_dict from advanced_alchemy.exceptions import MissingDependencyError from advanced_alchemy.utils.sync_tools import async_ if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession from sqlalchemy.orm import DeclarativeBase, Session __all__ = ("drop_all", "dump_tables") async def drop_all(engine: "Union[AsyncEngine, Engine]", version_table_name: str, metadata: MetaData) -> None: """Drop all tables in the database. Args: engine: The database engine. version_table_name: The name of the version table. metadata: The metadata object containing the tables to drop. Raises: MissingDependencyError: If the `rich` package is not installed. """ try: from rich import get_console except ImportError as e: # pragma: no cover msg = "rich" raise MissingDependencyError(msg, install_package="cli") from e console = get_console() def _is_sync(engine: "Union[Engine, AsyncEngine]") -> "TypeIs[Engine]": return isinstance(engine, Engine) def _drop_tables_sync(engine: Engine) -> None: console.rule("[bold red]Connecting to database backend.") with engine.begin() as db: console.rule("[bold red]Dropping the db", align="left") metadata.drop_all(db) console.rule("[bold red]Dropping the version table", align="left") # Create a properly defined Alembic version table with required column structure # Using a temporary metadata instance prevents modifying the shared metadata object temp_metadata = MetaData() alembic_version_table = Table( version_table_name, temp_metadata, Column("version_num", String(32), primary_key=True, nullable=False), ) alembic_version_table.drop(db, checkfirst=True) console.rule("[bold yellow]Successfully dropped all objects", align="left") async def _drop_tables_async(engine: "AsyncEngine") -> None: console.rule("[bold red]Connecting to database backend.", align="left") async with engine.begin() as db: console.rule("[bold red]Dropping the db", align="left") await db.run_sync(metadata.drop_all) console.rule("[bold red]Dropping the version table", align="left") # Create a properly defined Alembic version table with required column structure # Using a temporary metadata instance prevents modifying the shared metadata object temp_metadata = MetaData() alembic_version_table = Table( version_table_name, temp_metadata, Column("version_num", String(32), primary_key=True, nullable=False), ) await db.run_sync(alembic_version_table.drop, checkfirst=True) console.rule("[bold yellow]Successfully dropped all objects", align="left") if _is_sync(engine): return _drop_tables_sync(engine) return await _drop_tables_async(engine) async def dump_tables( dump_dir: Path, session: "Union[AbstractContextManager[Session], AbstractAsyncContextManager[AsyncSession]]", models: "list[type[DeclarativeBase]]", ) -> None: from types import new_class from advanced_alchemy._serialization import encode_json try: from rich import get_console except ImportError as e: # pragma: no cover msg = "rich" raise MissingDependencyError(msg, install_package="cli") from e console = get_console() def _is_sync( session: "Union[AbstractAsyncContextManager[AsyncSession], AbstractContextManager[Session]]", ) -> "TypeIs[AbstractContextManager[Session]]": return isinstance(session, AbstractContextManager) def _dump_table_sync(session: "AbstractContextManager[Session]") -> None: from advanced_alchemy.repository import SQLAlchemySyncRepository with session as _session: for model in models: json_path = dump_dir / f"{model.__tablename__}.json" console.rule( f"[yellow bold]Dumping table '{json_path.stem}' to '{json_path}'", style="yellow", align="left", ) repo = new_class( "repo", (SQLAlchemySyncRepository,), exec_body=lambda ns, model=model: ns.setdefault("model_type", model), # type: ignore[misc] ) json_path.write_text(encode_json([model_to_dict(row) for row in repo(session=_session).list()])) async def _dump_table_async(session: "AbstractAsyncContextManager[AsyncSession]") -> None: from advanced_alchemy.repository import SQLAlchemyAsyncRepository async with session as _session: for model in models: json_path = dump_dir / f"{model.__tablename__}.json" console.rule( f"[yellow bold]Dumping table '{json_path.stem}' to '{json_path}'", style="yellow", align="left", ) repo = new_class( "repo", (SQLAlchemyAsyncRepository,), exec_body=lambda ns, model=model: ns.setdefault("model_type", model), # type: ignore[misc] ) json_path.write_text(encode_json([model_to_dict(row) for row in await repo(session=_session).list()])) await async_(dump_dir.mkdir)(exist_ok=True) if _is_sync(session): return await async_(_dump_table_sync)(session) return await _dump_table_async(session) python-advanced-alchemy-1.9.3/advanced_alchemy/base.py000066400000000000000000000617741516556515500230200ustar00rootroot00000000000000"""Common base classes for SQLAlchemy declarative models.""" import contextlib import datetime import re from collections.abc import Iterator, Mapping from typing import TYPE_CHECKING, Any, Optional, Protocol, Union, cast, runtime_checkable from uuid import UUID from sqlalchemy import Date, MetaData, String from sqlalchemy.ext.asyncio import AsyncAttrs from sqlalchemy.orm import ( DeclarativeBase, Mapper, class_mapper, declared_attr, ) from sqlalchemy.orm import ( registry as SQLAlchemyRegistry, # noqa: N812 ) from sqlalchemy.orm.decl_base import ( _TableArgsType as TableArgsType, # pyright: ignore[reportPrivateUsage] ) from sqlalchemy.types import TypeEngine from typing_extensions import Self, TypeVar from advanced_alchemy.mixins import ( AuditColumns, BigIntPrimaryKey, IdentityPrimaryKey, NanoIDPrimaryKey, UUIDPrimaryKey, UUIDv6PrimaryKey, UUIDv7PrimaryKey, ) from advanced_alchemy.types import GUID, DateTimeUTC, FileObject, FileObjectList, JsonB, StoredObject from advanced_alchemy.utils.dataclass import DataclassProtocol if TYPE_CHECKING: from sqlalchemy.sql import FromClause from sqlalchemy.sql.schema import ( _NamingSchemaParameter as NamingSchemaParameter, # pyright: ignore[reportPrivateUsage] ) __all__ = ( "AdvancedDeclarativeBase", "BasicAttributes", "BigIntAuditBase", "BigIntBase", "BigIntBaseT", "CommonTableAttributes", "DefaultBase", "IdentityAuditBase", "IdentityBase", "IdentityBaseT", "ModelProtocol", "NanoIDAuditBase", "NanoIDBase", "NanoIDBaseT", "SQLQuery", "TableArgsType", "UUIDAuditBase", "UUIDBase", "UUIDBaseT", "UUIDv6AuditBase", "UUIDv6Base", "UUIDv6BaseT", "UUIDv7AuditBase", "UUIDv7Base", "UUIDv7BaseT", "convention", "create_registry", "merge_table_arguments", "metadata_registry", "model_to_dict", "orm_registry", "table_name_regexp", ) UUIDBaseT = TypeVar("UUIDBaseT", bound="UUIDBase") """Type variable for :class:`UUIDBase`.""" BigIntBaseT = TypeVar("BigIntBaseT", bound="BigIntBase") """Type variable for :class:`BigIntBase`.""" IdentityBaseT = TypeVar("IdentityBaseT", bound="IdentityBase") """Type variable for :class:`IdentityBase`.""" UUIDv6BaseT = TypeVar("UUIDv6BaseT", bound="UUIDv6Base") """Type variable for :class:`UUIDv6Base`.""" UUIDv7BaseT = TypeVar("UUIDv7BaseT", bound="UUIDv7Base") """Type variable for :class:`UUIDv7Base`.""" NanoIDBaseT = TypeVar("NanoIDBaseT", bound="NanoIDBase") """Type variable for :class:`NanoIDBase`.""" convention: "NamingSchemaParameter" = { "ix": "ix_%(column_0_label)s", "uq": "uq_%(table_name)s_%(column_0_name)s", "ck": "ck_%(table_name)s_%(constraint_name)s", "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", "pk": "pk_%(table_name)s", } """Templates for automated constraint name generation.""" table_name_regexp = re.compile(r"((?<=[a-z0-9])[A-Z]|(?!^)[A-Z](?=[a-z]))") """Regular expression for table name""" def merge_table_arguments(cls: type[DeclarativeBase], table_args: Optional[TableArgsType] = None) -> TableArgsType: """Merge Table Arguments. This function helps merge table arguments when using mixins that include their own table args, making it easier to append additional information such as comments or constraints to the model. Args: cls (type[:class:`sqlalchemy.orm.DeclarativeBase`]): The model that will get the table args. table_args (:class:`TableArgsType`, optional): Additional information to add to table_args. Returns: :class:`TableArgsType`: Merged table arguments. """ args: list[Any] = [] kwargs: dict[str, Any] = {} mixin_table_args = (getattr(super(base_cls, cls), "__table_args__", None) for base_cls in cls.__bases__) # pyright: ignore[reportUnknownParameter,reportUnknownArgumentType,reportArgumentType] for arg_to_merge in (*mixin_table_args, table_args): if arg_to_merge: if isinstance(arg_to_merge, tuple): last_positional_arg = arg_to_merge[-1] # pyright: ignore[reportUnknownVariableType] args.extend(arg_to_merge[:-1]) # pyright: ignore[reportUnknownArgumentType] if isinstance(last_positional_arg, dict): kwargs.update(last_positional_arg) # pyright: ignore[reportUnknownArgumentType] else: args.append(last_positional_arg) else: kwargs.update(arg_to_merge) # pyright: ignore if args: if kwargs: return (*args, kwargs) return tuple(args) return kwargs @runtime_checkable class ModelProtocol(Protocol): """The base SQLAlchemy model protocol. Defines the minimal contract for a SQLAlchemy-mapped model. Any class with a mapper and table (including SQLModel ``table=True`` models) satisfies this protocol. Attributes: __table__ (:class:`sqlalchemy.sql.FromClause`): The table associated with the model. __mapper__ (:class:`sqlalchemy.orm.Mapper`): The mapper for the model. __name__ (str): The name of the model. """ if TYPE_CHECKING: __table__: FromClause __mapper__: Mapper[Any] __name__: str def model_to_dict(instance: "ModelProtocol", exclude: Optional[set[str]] = None) -> dict[str, Any]: """Convert a mapped model instance to a dictionary. Works with any SQLAlchemy-mapped model, including: - Advanced Alchemy models (delegates to ``to_dict()``) - SQLModel ``table=True`` models (uses mapper-based column iteration) - Any other mapped class Args: instance: A SQLAlchemy-mapped model instance. exclude: Optional set of field names to exclude from the output. Returns: A dictionary of column names to values. """ to_dict_fn = getattr(instance, "to_dict", None) if to_dict_fn is not None and callable(to_dict_fn): return to_dict_fn(exclude=exclude) # type: ignore[no-any-return] exclude_fields: set[str] = {"sa_orm_sentinel", "_sentinel"} with contextlib.suppress(AttributeError): exclude_fields = exclude_fields.union(cast("set[str]", instance._sa_instance_state.unloaded)) # type: ignore[attr-defined,union-attr] # noqa: SLF001 if exclude: exclude_fields = exclude_fields.union(exclude) mapper = class_mapper(type(instance)) return { field: getattr(instance, field) for field in mapper.columns.keys() # noqa: SIM118 if field not in exclude_fields } class BasicAttributes: """Basic attributes for SQLAlchemy tables and queries. Provides a method to convert the model to a dictionary representation. Methods: to_dict: Converts the model to a dictionary, excluding specified fields. :no-index: """ if TYPE_CHECKING: __name__: str __table__: FromClause __mapper__: Mapper[Any] def to_dict(self, exclude: Optional[set[str]] = None) -> dict[str, Any]: """Convert model to dictionary. Returns: Dict[str, Any]: A dict representation of the model """ exclude = {"sa_orm_sentinel", "_sentinel"}.union(self._sa_instance_state.unloaded).union(exclude or []) # type: ignore[attr-defined] return { field: getattr(self, field) for field in self.__mapper__.columns.keys() # noqa: SIM118 if field not in exclude } class CommonTableAttributes(BasicAttributes): """Common attributes for SQLAlchemy tables. Inherits from :class:`BasicAttributes` and provides a mechanism to infer table names from class names while respecting SQLAlchemy's inheritance patterns. This mixin supports all three SQLAlchemy inheritance patterns: - **Single Table Inheritance (STI)**: Child classes automatically use parent's table - **Joined Table Inheritance (JTI)**: Child classes have their own tables with foreign keys - **Concrete Table Inheritance (CTI)**: Child classes have independent tables Attributes: __tablename__ (str | None): The inferred table name, or None for Single Table Inheritance children. """ def __init_subclass__(cls, **kwargs: Any) -> None: """Hook called when a subclass is created. This method intercepts class creation to correctly handle ``__tablename__`` for Single Table Inheritance (STI) hierarchies. When a parent class explicitly defines ``__tablename__``, subclasses would normally inherit that string value. For STI, child classes must have ``__tablename__`` resolve to ``None`` to indicate they share the parent's table. This hook enforces that rule. The detection logic identifies STI children by checking: 1. Class doesn't explicitly define ``__tablename__`` in its own ``__dict__`` 2. AND doesn't have ``concrete=True`` (which would make it CTI) 3. AND doesn't define ``polymorphic_on`` in its own ``__mapper_args__`` (which would make it a base) 4. AND inherits from a parent that defines ``polymorphic_on`` in ``__mapper_args__`` (STI hierarchy) For intermediate classes without ``polymorphic_identity`` but with a parent that has ``polymorphic_on``, SQLAlchemy can emit a warning. When an intermediate class should not be instantiated, set ``polymorphic_abstract=True`` in ``__mapper_args__`` or mark it with ``__abstract__ = True``. This allows both usage patterns: 1. Auto-generated names (don't set ``__tablename__`` on parent) 2. Explicit names (set ``__tablename__`` on parent, STI still works) """ if "__tablename__" in cls.__dict__: super().__init_subclass__(**kwargs) return cls_dict = cast("Mapping[str, Any]", cls.__dict__) own_mapper_args = cls_dict.get("__mapper_args__") own_mapper_args_dict = cast("dict[str, Any]", own_mapper_args) if isinstance(own_mapper_args, dict) else {} if own_mapper_args_dict.get("concrete", False): super().__init_subclass__(**kwargs) return if "polymorphic_on" in own_mapper_args_dict: super().__init_subclass__(**kwargs) return for parent in cls.__mro__[1:]: parent_mapper_args = getattr(parent, "__mapper_args__", None) if isinstance(parent_mapper_args, dict) and "polymorphic_on" in parent_mapper_args: cls.__tablename__ = None # type: ignore[misc] break super().__init_subclass__(**kwargs) if TYPE_CHECKING: __tablename__: Optional[str] else: @declared_attr.directive @classmethod def __tablename__(cls) -> Optional[str]: """Generate table name automatically for base models. This is called for models that do not have an explicit ``__tablename__``. For STI child models, ``__init_subclass__`` will have already set ``__tablename__ = None``, so this function returns ``None`` to indicate the child should use the parent's table. The generation logic: 1. If class explicitly defines ``__tablename__`` in its ``__dict__``, use that 2. Otherwise, generate from class name using snake_case conversion Returns: str | None: Table name generated from class name in snake_case, or None for STI children. Example: Single Table Inheritance (both patterns work):: # Pattern 1: Auto-generated table name (recommended) class Employee(UUIDBase): # __tablename__ auto-generated as "employee" type: Mapped[str] __mapper_args__ = { "polymorphic_on": "type", "polymorphic_identity": "employee", } class Manager(Employee): # __tablename__ = None (set by __init_subclass__) department: Mapped[str | None] __mapper_args__ = {"polymorphic_identity": "manager"} # Pattern 2: Explicit table name on parent class Employee(UUIDBase): __tablename__ = "custom_employee" # Explicit! type: Mapped[str] __mapper_args__ = { "polymorphic_on": "type", "polymorphic_identity": "employee", } class Manager(Employee): # __tablename__ = None (set by __init_subclass__) # Still uses parent's "custom_employee" table department: Mapped[str | None] __mapper_args__ = {"polymorphic_identity": "manager"} Joined Table Inheritance:: class Employee(UUIDBase): __tablename__ = "employee" type: Mapped[str] __mapper_args__ = {"polymorphic_on": "type"} class Manager(Employee): __tablename__ = "manager" # Explicit - has own table id: Mapped[int] = mapped_column( ForeignKey("employee.id"), primary_key=True ) department: Mapped[str] __mapper_args__ = {"polymorphic_identity": "manager"} Concrete Table Inheritance:: class Employee(UUIDBase): __tablename__ = "employee" id: Mapped[int] = mapped_column(primary_key=True) class Manager(Employee): __tablename__ = "manager" # Independent table __mapper_args__ = {"concrete": True} """ cls_dict = cast("Mapping[str, Any]", cls.__dict__) if "__tablename__" in cls_dict: return cast("Optional[str]", cls_dict["__tablename__"]) mapper_args = getattr(cls, "__mapper_args__", {}) mapper_args_dict = cast("dict[str, Any]", mapper_args) if isinstance(mapper_args, dict) else {} if mapper_args_dict.get("concrete", False) or "polymorphic_on" in mapper_args_dict: return table_name_regexp.sub(r"_\1", cls.__name__).lower() for parent in cls.__mro__[1:]: parent_mapper_args = getattr(parent, "__mapper_args__", None) if isinstance(parent_mapper_args, dict) and "polymorphic_on" in parent_mapper_args: return None return table_name_regexp.sub(r"_\1", cls.__name__).lower() def create_registry( custom_annotation_map: Optional[dict[Any, Union[type[TypeEngine[Any]], TypeEngine[Any]]]] = None, ) -> SQLAlchemyRegistry: """Create a new SQLAlchemy registry. Args: custom_annotation_map (dict, optional): Custom type annotations to use for the registry. Returns: :class:`sqlalchemy.orm.registry`: A new SQLAlchemy registry with the specified type annotations. """ import uuid as core_uuid meta = MetaData(naming_convention=convention) type_annotation_map: dict[Any, Union[type[TypeEngine[Any]], TypeEngine[Any]]] = { UUID: GUID, core_uuid.UUID: GUID, datetime.datetime: DateTimeUTC, datetime.date: Date, dict: JsonB, dict[str, Any]: JsonB, dict[str, str]: JsonB, DataclassProtocol: JsonB, FileObject: StoredObject, FileObjectList: StoredObject, } with contextlib.suppress(ImportError): from pydantic import AnyHttpUrl, AnyUrl, EmailStr, IPvAnyAddress, IPvAnyInterface, IPvAnyNetwork, Json type_annotation_map.update( { EmailStr: String, AnyUrl: String, AnyHttpUrl: String, Json: JsonB, IPvAnyAddress: String, IPvAnyInterface: String, IPvAnyNetwork: String, } ) with contextlib.suppress(ImportError): from msgspec import Struct type_annotation_map[Struct] = JsonB if custom_annotation_map is not None: type_annotation_map.update(custom_annotation_map) return SQLAlchemyRegistry(metadata=meta, type_annotation_map=type_annotation_map) orm_registry = create_registry() class MetadataRegistry: """A registry for metadata. Provides methods to get and set metadata for different bind keys. Methods: get: Retrieves the metadata for a given bind key. set: Sets the metadata for a given bind key. """ _instance: Optional["MetadataRegistry"] = None _registry: dict[Union[str, None], MetaData] = {None: orm_registry.metadata} def __new__(cls) -> Self: if cls._instance is None: cls._instance = super().__new__(cls) return cast("Self", cls._instance) def get(self, bind_key: Optional[str] = None) -> MetaData: """Get the metadata for the given bind key. Args: bind_key (Optional[str]): The bind key for the metadata. Returns: :class:`sqlalchemy.MetaData`: The metadata for the given bind key. """ return self._registry.setdefault(bind_key, MetaData(naming_convention=convention)) def set(self, bind_key: Optional[str], metadata: MetaData) -> None: """Set the metadata for the given bind key. Args: bind_key (Optional[str]): The bind key for the metadata. metadata (:class:`sqlalchemy.MetaData`): The metadata to set. """ self._registry[bind_key] = metadata def __iter__(self) -> Iterator[Union[str, None]]: return iter(self._registry) def __getitem__(self, bind_key: Union[str, None]) -> MetaData: return self._registry[bind_key] def __setitem__(self, bind_key: Union[str, None], metadata: MetaData) -> None: self._registry[bind_key] = metadata def __contains__(self, bind_key: Union[str, None]) -> bool: return bind_key in self._registry metadata_registry = MetadataRegistry() class AdvancedDeclarativeBase(DeclarativeBase): """A subclass of declarative base that allows for overriding of the registry. Inherits from :class:`sqlalchemy.orm.DeclarativeBase`. Attributes: registry (:class:`sqlalchemy.orm.registry`): The registry for the declarative base. __metadata_registry__ (:class:`~advanced_alchemy.base.MetadataRegistry`): The metadata registry. __bind_key__ (Optional[:class:`str`]): The bind key for the metadata. """ registry = orm_registry __abstract__ = True __metadata_registry__: MetadataRegistry = MetadataRegistry() __bind_key__: Optional[str] = None def __init_subclass__(cls, **kwargs: Any) -> None: bind_key = getattr(cls, "__bind_key__", None) if bind_key is not None: cls.metadata = cls.__metadata_registry__.get(bind_key) elif None not in cls.__metadata_registry__ and getattr(cls, "metadata", None) is not None: cls.__metadata_registry__[None] = cls.metadata super().__init_subclass__(**kwargs) class UUIDBase(UUIDPrimaryKey, CommonTableAttributes, AdvancedDeclarativeBase, AsyncAttrs): """Base for all SQLAlchemy declarative models with UUID v4 primary keys. .. seealso:: :class:`CommonTableAttributes` :class:`advanced_alchemy.mixins.UUIDPrimaryKey` :class:`AdvancedDeclarativeBase` :class:`AsyncAttrs` """ __abstract__ = True class UUIDAuditBase(CommonTableAttributes, UUIDPrimaryKey, AuditColumns, AdvancedDeclarativeBase, AsyncAttrs): """Base for declarative models with UUID v4 primary keys and audit columns. .. seealso:: :class:`CommonTableAttributes` :class:`advanced_alchemy.mixins.UUIDPrimaryKey` :class:`advanced_alchemy.mixins.AuditColumns` :class:`AdvancedDeclarativeBase` :class:`AsyncAttrs` """ __abstract__ = True class UUIDv6Base(UUIDv6PrimaryKey, CommonTableAttributes, AdvancedDeclarativeBase, AsyncAttrs): """Base for all SQLAlchemy declarative models with UUID v6 primary keys. .. seealso:: :class:`advanced_alchemy.mixins.UUIDv6PrimaryKey` :class:`CommonTableAttributes` :class:`AdvancedDeclarativeBase` :class:`AsyncAttrs` """ __abstract__ = True class UUIDv6AuditBase(CommonTableAttributes, UUIDv6PrimaryKey, AuditColumns, AdvancedDeclarativeBase, AsyncAttrs): """Base for declarative models with UUID v6 primary keys and audit columns. .. seealso:: :class:`CommonTableAttributes` :class:`advanced_alchemy.mixins.UUIDv6PrimaryKey` :class:`advanced_alchemy.mixins.AuditColumns` :class:`AdvancedDeclarativeBase` :class:`AsyncAttrs` """ __abstract__ = True class UUIDv7Base(UUIDv7PrimaryKey, CommonTableAttributes, AdvancedDeclarativeBase, AsyncAttrs): """Base for all SQLAlchemy declarative models with UUID v7 primary keys. .. seealso:: :class:`advanced_alchemy.mixins.UUIDv7PrimaryKey` :class:`CommonTableAttributes` :class:`AdvancedDeclarativeBase` :class:`AsyncAttrs` """ __abstract__ = True class UUIDv7AuditBase(CommonTableAttributes, UUIDv7PrimaryKey, AuditColumns, AdvancedDeclarativeBase, AsyncAttrs): """Base for declarative models with UUID v7 primary keys and audit columns. .. seealso:: :class:`CommonTableAttributes` :class:`advanced_alchemy.mixins.UUIDv7PrimaryKey` :class:`advanced_alchemy.mixins.AuditColumns` :class:`AdvancedDeclarativeBase` :class:`AsyncAttrs` """ __abstract__ = True class NanoIDBase(NanoIDPrimaryKey, CommonTableAttributes, AdvancedDeclarativeBase, AsyncAttrs): """Base for all SQLAlchemy declarative models with Nano ID primary keys. .. seealso:: :class:`advanced_alchemy.mixins.NanoIDPrimaryKey` :class:`CommonTableAttributes` :class:`AdvancedDeclarativeBase` :class:`AsyncAttrs` """ __abstract__ = True class NanoIDAuditBase(CommonTableAttributes, NanoIDPrimaryKey, AuditColumns, AdvancedDeclarativeBase, AsyncAttrs): """Base for declarative models with Nano ID primary keys and audit columns. .. seealso:: :class:`CommonTableAttributes` :class:`advanced_alchemy.mixins.NanoIDPrimaryKey` :class:`advanced_alchemy.mixins.AuditColumns` :class:`AdvancedDeclarativeBase` :class:`AsyncAttrs` """ __abstract__ = True class BigIntBase(BigIntPrimaryKey, CommonTableAttributes, AdvancedDeclarativeBase, AsyncAttrs): """Base for all SQLAlchemy declarative models with BigInt primary keys. .. seealso:: :class:`advanced_alchemy.mixins.BigIntPrimaryKey` :class:`CommonTableAttributes` :class:`AdvancedDeclarativeBase` :class:`AsyncAttrs` """ __abstract__ = True class BigIntAuditBase(CommonTableAttributes, BigIntPrimaryKey, AuditColumns, AdvancedDeclarativeBase, AsyncAttrs): """Base for declarative models with BigInt primary keys and audit columns. .. seealso:: :class:`CommonTableAttributes` :class:`advanced_alchemy.mixins.BigIntPrimaryKey` :class:`advanced_alchemy.mixins.AuditColumns` :class:`AdvancedDeclarativeBase` :class:`AsyncAttrs` """ __abstract__ = True class IdentityBase(IdentityPrimaryKey, CommonTableAttributes, AdvancedDeclarativeBase, AsyncAttrs): """Base for all SQLAlchemy declarative models with database IDENTITY primary keys. This model uses the database native IDENTITY feature for generating primary keys instead of using database sequences. .. seealso:: :class:`advanced_alchemy.mixins.IdentityPrimaryKey` :class:`CommonTableAttributes` :class:`AdvancedDeclarativeBase` :class:`AsyncAttrs` """ __abstract__ = True class IdentityAuditBase(CommonTableAttributes, IdentityPrimaryKey, AuditColumns, AdvancedDeclarativeBase, AsyncAttrs): """Base for declarative models with database IDENTITY primary keys and audit columns. This model uses the database native IDENTITY feature for generating primary keys instead of using database sequences. .. seealso:: :class:`CommonTableAttributes` :class:`advanced_alchemy.mixins.IdentityPrimaryKey` :class:`advanced_alchemy.mixins.AuditColumns` :class:`AdvancedDeclarativeBase` :class:`AsyncAttrs` """ __abstract__ = True class DefaultBase(CommonTableAttributes, AdvancedDeclarativeBase, AsyncAttrs): """Base for all SQLAlchemy declarative models. No primary key is added. .. seealso:: :class:`CommonTableAttributes` :class:`AdvancedDeclarativeBase` :class:`AsyncAttrs` """ __abstract__ = True class SQLQuery(BasicAttributes, AdvancedDeclarativeBase, AsyncAttrs): """Base for all SQLAlchemy custom mapped objects. .. seealso:: :class:`BasicAttributes` :class:`AdvancedDeclarativeBase` :class:`AsyncAttrs` """ __abstract__ = True __allow_unmapped__ = True python-advanced-alchemy-1.9.3/advanced_alchemy/cache/000077500000000000000000000000001516556515500225605ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/advanced_alchemy/cache/__init__.py000066400000000000000000000064261516556515500247010ustar00rootroot00000000000000"""Dogpile.cache integration for Advanced Alchemy. This module provides optional caching support for SQLAlchemy repositories using dogpile.cache. It supports multiple cache backends (Redis, Memcached, file, memory) and provides automatic cache invalidation on model changes. Features: - Multiple backend support (Redis, Memcached, file, memory, null) - Commit-aware cache invalidation via SQLAlchemy events - Version-based invalidation for list queries - JSON serialization for cached models (configurable) - Graceful degradation when dogpile.cache is not installed - Per-process singleflight to reduce stampedes on cache miss Example: Using the config system (recommended):: from advanced_alchemy.cache import CacheConfig from advanced_alchemy.config import SQLAlchemyAsyncConfig from advanced_alchemy.repository import ( SQLAlchemyAsyncRepository, ) # Configure database with caching db_config = SQLAlchemyAsyncConfig( connection_string="sqlite+aiosqlite:///app.db", cache_config=CacheConfig( backend="dogpile.cache.memory", expiration_time=300, ), ) # Cache listeners are auto-registered, cache_manager is stored # in session.info and auto-retrieved by repositories. class UserRepository(SQLAlchemyAsyncRepository[User]): model_type = User async with db_config.get_session() as session: repo = UserRepository(session=session) user = await repo.get( 1 ) # First call hits DB and caches user = await repo.get( 1 ) # Second call returns cached result Redis configuration:: cache_config = CacheConfig( backend="dogpile.cache.redis", expiration_time=3600, arguments={ "host": "localhost", "port": 6379, "db": 0, "distributed_lock": True, }, ) Note: This module requires the optional ``dogpile.cache`` dependency. Install with: ``pip install advanced-alchemy[dogpile]`` Without dogpile.cache installed, the cache manager will use a NullRegion that provides the same interface but doesn't cache. Manual Setup: If not using the config system, call ``setup_cache_listeners()`` during application initialization and pass cache_manager explicitly:: from advanced_alchemy.cache import ( CacheConfig, CacheManager, setup_cache_listeners, ) cache_manager = CacheManager( CacheConfig(backend="dogpile.cache.memory") ) setup_cache_listeners() repo = UserRepository( session=session, cache_manager=cache_manager ) """ from advanced_alchemy._listeners import setup_cache_listeners from advanced_alchemy.cache.config import CacheConfig from advanced_alchemy.cache.manager import DOGPILE_CACHE_INSTALLED, CacheManager from advanced_alchemy.cache.serializers import default_deserializer, default_serializer __all__ = ( "DOGPILE_CACHE_INSTALLED", "CacheConfig", "CacheManager", "default_deserializer", "default_serializer", "setup_cache_listeners", ) python-advanced-alchemy-1.9.3/advanced_alchemy/cache/_null.py000066400000000000000000000105611516556515500242460ustar00rootroot00000000000000"""Null cache region implementation for when dogpile.cache is not installed.""" from collections.abc import Awaitable from typing import Any, Callable, Optional, Protocol, TypeVar __all__ = ( "NO_VALUE", "AsyncCacheRegionProtocol", "NullRegion", "SyncCacheRegionProtocol", ) T = TypeVar("T") class SyncCacheRegionProtocol(Protocol): """Protocol defining the synchronous cache region interface used by CacheManager. This protocol is compatible with both dogpile.cache.CacheRegion and NullRegion. """ def get(self, key: str, expiration_time: Optional[int] = None) -> Any: ... def get_or_create( self, key: str, creator: Callable[[], T], expiration_time: Optional[int] = None, ) -> T: ... def set(self, key: str, value: Any) -> None: ... def delete(self, key: str) -> None: ... def invalidate(self) -> None: ... def configure( self, backend: str, expiration_time: Optional[int] = None, arguments: Optional[dict[str, Any]] = None, **kwargs: Any, ) -> "SyncCacheRegionProtocol": ... class AsyncCacheRegionProtocol(Protocol): """Protocol defining the asynchronous cache region interface. This protocol defines async versions of cache region operations, suitable for use with native async cache backends. """ async def get(self, key: str, expiration_time: Optional[int] = None) -> Any: ... async def get_or_create( self, key: str, creator: Callable[[], Awaitable[T]], expiration_time: Optional[int] = None, ) -> T: ... async def set(self, key: str, value: Any) -> None: ... async def delete(self, key: str) -> None: ... async def invalidate(self) -> None: ... async def configure( self, backend: str, expiration_time: Optional[int] = None, arguments: Optional[dict[str, Any]] = None, **kwargs: Any, ) -> "AsyncCacheRegionProtocol": ... class _NoValue: """Sentinel value to indicate a cache miss. This is compatible with ``dogpile.cache.api.NO_VALUE`` and is used when dogpile.cache is not installed to maintain API compatibility. """ __slots__ = () def __repr__(self) -> str: return "" NO_VALUE: Any = _NoValue() class NullRegion: """A no-op cache region that provides the same interface as dogpile.cache.CacheRegion. This class is used when dogpile.cache is not installed or when caching is disabled. All operations are no-ops that don't actually cache anything. The interface matches the subset of dogpile.cache.CacheRegion methods that are used by the CacheManager. """ __slots__ = () def get(self, key: str, expiration_time: Optional[int] = None) -> Any: """Get a value from the cache (always returns NO_VALUE). Args: key: The cache key. expiration_time: Ignored. Returns: Always returns NO_VALUE to indicate a cache miss. """ return NO_VALUE def get_or_create( self, key: str, creator: Callable[[], T], expiration_time: Optional[int] = None, ) -> T: """Get or create a value (always calls the creator). Args: key: The cache key. creator: Function to create the value. expiration_time: Ignored. Returns: The result of calling the creator function. """ return creator() def set(self, key: str, value: Any) -> None: """Set a value in the cache (no-op). Args: key: The cache key. value: The value to cache. """ def delete(self, key: str) -> None: """Delete a value from the cache (no-op). Args: key: The cache key to delete. """ def invalidate(self) -> None: """Invalidate all cached values (no-op).""" def configure( self, backend: str, expiration_time: Optional[int] = None, arguments: Optional[dict[str, Any]] = None, **kwargs: Any, ) -> "NullRegion": """Configure the region (no-op, returns self). Args: backend: Ignored. expiration_time: Ignored. arguments: Ignored. **kwargs: Ignored. Returns: Self for method chaining. """ return self python-advanced-alchemy-1.9.3/advanced_alchemy/cache/config.py000066400000000000000000000070251516556515500244030ustar00rootroot00000000000000"""Configuration classes for dogpile.cache integration.""" from dataclasses import dataclass, field from typing import Any, Callable, Optional __all__ = ("CacheConfig",) def _default_arguments() -> dict[str, Any]: return {} @dataclass class CacheConfig: """Configuration for a dogpile.cache region. This dataclass holds configuration options for setting up a cache region using dogpile.cache. It supports multiple backends (Redis, Memcached, file, memory) and provides sensible defaults for getting started quickly. Example: Basic memory cache configuration:: config = CacheConfig( backend="dogpile.cache.memory", expiration_time=300, ) Redis cache configuration:: config = CacheConfig( backend="dogpile.cache.redis", expiration_time=3600, arguments={ "host": "localhost", "port": 6379, "db": 0, }, ) """ backend: str = "dogpile.cache.null" """Cache backend identifier. Common backends: - ``dogpile.cache.null``: No-op cache (default, for development) - ``dogpile.cache.memory``: In-process memory cache - ``dogpile.cache.redis``: Redis backend - ``dogpile.cache.memcached``: Memcached backend - ``dogpile.cache.dbm``: DBM file-based cache """ expiration_time: int = 3600 """Default TTL (time-to-live) in seconds for cached items. Set to ``-1`` for no expiration. Default is 3600 (1 hour). """ arguments: dict[str, Any] = field(default_factory=_default_arguments) """Backend-specific configuration arguments. These are passed directly to the dogpile.cache backend. See dogpile.cache documentation for backend-specific options. Example for Redis:: {"host": "localhost", "port": 6379, "db": 0} Example for Memcached:: {"url": ["127.0.0.1:11211"]} """ key_prefix: str = "aa:" """Prefix for all cache keys. This helps avoid key collisions when sharing a cache backend with other applications. Default is ``aa:`` (advanced-alchemy). """ enabled: bool = True """Enable or disable caching globally. When ``False``, all cache operations are bypassed and data is always fetched from the database. Useful for debugging or testing. """ serializer: Optional[Callable[[Any], bytes]] = None """Custom serializer function. If ``None``, uses the default JSON serializer which handles SQLAlchemy models, datetime objects, and UUIDs. The function should accept any value and return bytes. """ deserializer: Optional[Callable[[bytes, "type[Any]"], Any]] = None """Custom deserializer function. If ``None``, uses the default JSON deserializer. The function should accept bytes and a model class, returning an instance of that class. """ region_factory: Optional[Callable[["CacheConfig"], Any]] = None """Optional hook to construct a cache region instance. This exists to keep the repository/service integration stable even if Advanced Alchemy swaps the underlying cache backend in the future. If provided, :class:`~advanced_alchemy.cache.CacheManager` will call this factory instead of using ``dogpile.cache.make_region()``. The returned object must implement the subset of dogpile's region API that AA relies on (e.g. ``get()``, ``set()``, ``delete()``, ``invalidate()``, and optionally ``get_or_create()``). """ python-advanced-alchemy-1.9.3/advanced_alchemy/cache/manager.py000066400000000000000000000675051516556515500245610ustar00rootroot00000000000000"""Cache manager for dogpile.cache integration with SQLAlchemy repositories.""" import asyncio import base64 import concurrent.futures import logging import threading import uuid from collections.abc import Coroutine from functools import partial from typing import TYPE_CHECKING, Any, Callable, Optional, TypeVar, cast from advanced_alchemy.cache._null import NO_VALUE, NullRegion, SyncCacheRegionProtocol from advanced_alchemy.cache.serializers import default_deserializer, default_serializer from advanced_alchemy.utils.sync_tools import async_ if TYPE_CHECKING: from advanced_alchemy.cache.config import CacheConfig __all__ = ( "DOGPILE_CACHE_INSTALLED", "DOGPILE_NO_VALUE", "CacheManager", ) logger = logging.getLogger("advanced_alchemy.cache") T = TypeVar("T") # Type alias for the make_region factory function MakeRegionFunc = Callable[[], SyncCacheRegionProtocol] # Stub implementations for when dogpile.cache is not installed def _make_region_stub() -> SyncCacheRegionProtocol: """Stub make_region that returns a NullRegion when dogpile.cache is not installed.""" return NullRegion() _dogpile_no_value_stub: object = NO_VALUE """Stub NO_VALUE sentinel when dogpile.cache is not installed.""" # Try to import real dogpile.cache implementation at runtime _make_region: MakeRegionFunc _dogpile_no_value: object try: from dogpile.cache import ( # pyright: ignore[reportMissingImports] make_region as _make_region_real, # pyright: ignore[reportUnknownVariableType] ) from dogpile.cache.api import ( # pyright: ignore[reportMissingImports] NO_VALUE as _dogpile_no_value_real, # noqa: N811 # pyright: ignore[reportUnknownVariableType] ) _make_region = cast("MakeRegionFunc", _make_region_real) _dogpile_no_value = cast("object", _dogpile_no_value_real) DOGPILE_CACHE_INSTALLED = True # pyright: ignore[reportConstantRedefinition] except ImportError: # pragma: no cover _make_region = _make_region_stub _dogpile_no_value = _dogpile_no_value_stub DOGPILE_CACHE_INSTALLED = False # pyright: ignore[reportConstantRedefinition] DOGPILE_NO_VALUE: object = _dogpile_no_value """Sentinel value indicating a cache miss (from dogpile or NullRegion).""" class CacheManager: """Manages dogpile.cache regions with model-aware invalidation. This class provides a high-level interface for caching SQLAlchemy model instances using dogpile.cache. It handles serialization, cache key generation, and model-version-based invalidation for list queries. All cache operations are available in both sync and async variants: - Sync methods end with ``_sync`` (e.g., ``get_sync``, ``set_sync``) - Async methods end with ``_async`` (e.g., ``get_async``, ``set_async``) Async methods use ``asyncio.to_thread()`` with capacity limiting to prevent blocking the event loop when using network-based backends like Redis or Memcached. Features: - Lazy initialization of cache regions - Model-aware cache key generation - Version-based invalidation for list queries - Graceful degradation when dogpile.cache is not installed - Support for custom serializers - Both sync and async operation support Example: Sync usage:: from advanced_alchemy.cache import CacheConfig, CacheManager config = CacheConfig( backend="dogpile.cache.memory", expiration_time=300, ) manager = CacheManager(config) # Cache a value (sync) result = manager.get_or_create_sync( "users:1", lambda: fetch_user_from_db(1), ) Async usage:: # Cache a value (async) result = await manager.get_or_create_async( "users:1", lambda: fetch_user_from_db(1), ) """ __slots__ = ( "_async_inflight", "_async_inflight_lock", "_model_versions", "_region", "_sync_inflight", "_sync_inflight_lock", "config", ) def __init__(self, config: "CacheConfig") -> None: """Initialize the cache manager. Args: config: Configuration for the cache region. Note: Model version tokens are stored in-cache for cross-process consistency. A random token is used per commit to avoid lost updates without requiring backend-specific atomic increment support. Per-process singleflight registries (async and sync) are best-effort; they reduce stampedes within a single process but do not provide cross-process locking. """ self.config = config # Model version tokens are stored in-cache for cross-process consistency. # Use a random token per commit to avoid lost updates without requiring # backend-specific atomic increment support. self._region: Optional[SyncCacheRegionProtocol] = None self._model_versions: dict[str, str] = {} self._async_inflight: dict[str, asyncio.Task[Any]] = {} self._async_inflight_lock: Optional[asyncio.Lock] = None self._sync_inflight: dict[str, concurrent.futures.Future[Any]] = {} self._sync_inflight_lock = threading.Lock() @property def _inflight_lock_async(self) -> asyncio.Lock: """Lazily create the asyncio lock used for async singleflight.""" if self._async_inflight_lock is None: self._async_inflight_lock = asyncio.Lock() return self._async_inflight_lock @property def region(self) -> SyncCacheRegionProtocol: """Get the cache region, creating it if necessary. The region is lazily initialized on first access. If dogpile.cache is not installed or initialization fails, returns a NullRegion that provides the same interface but doesn't actually cache anything. Returns: The configured cache region or a NullRegion fallback. """ if self._region is None: self._region = self._create_region() return self._region def _create_region(self) -> SyncCacheRegionProtocol: """Create and configure the dogpile.cache region. Returns: A configured CacheRegion or NullRegion if dogpile.cache is not installed or configuration fails. """ if self.config.region_factory is not None: try: created_region = cast("SyncCacheRegionProtocol", self.config.region_factory(self.config)) except Exception: logger.exception("Failed to construct cache region via region_factory, using NullRegion") return NullRegion() else: logger.debug("Configured cache region via region_factory") return created_region if not DOGPILE_CACHE_INSTALLED: logger.info("dogpile.cache is not installed, using NullRegion") return NullRegion() if not self.config.enabled: logger.debug("Caching is disabled, using NullRegion") return NullRegion() try: region: SyncCacheRegionProtocol = _make_region().configure( self.config.backend, expiration_time=self.config.expiration_time, arguments=self.config.arguments, ) except Exception: logger.exception("Failed to configure cache region, using NullRegion") return NullRegion() else: logger.debug("Configured cache region with backend: %s", self.config.backend) return region def _singleflight_async_cleanup(self, key: str, task: asyncio.Task[Any], *_: Any) -> None: """Cleanup callback for async singleflight tasks. This runs in the event loop thread; we can safely mutate the in-flight dict directly without scheduling another task. """ if self._async_inflight.get(key) is task: self._async_inflight.pop(key, None) def _make_key(self, key: str) -> str: """Generate a full cache key with the configured prefix. Args: key: The base cache key. Returns: The prefixed cache key. """ return f"{self.config.key_prefix}{key}" # ========================================================================= # Sync Methods (canonical implementations) # ========================================================================= def get_sync(self, key: str) -> object: """Get a value from the cache (sync). Args: key: The cache key (without prefix). Returns: The cached value or NO_VALUE if not found. """ if not self.config.enabled: return DOGPILE_NO_VALUE return self.region.get(self._make_key(key)) def set_sync(self, key: str, value: Any) -> None: """Set a value in the cache (sync). Args: key: The cache key (without prefix). value: The value to cache. """ if not self.config.enabled: return self.region.set(self._make_key(key), value) def delete_sync(self, key: str) -> None: """Delete a value from the cache (sync). Args: key: The cache key (without prefix). """ self.region.delete(self._make_key(key)) def get_or_create_sync( self, key: str, creator: Callable[[], T], expiration_time: Optional[int] = None, ) -> T: """Get a value from cache or create it using the creator function (sync). This method uses dogpile.cache's get_or_create which provides mutex-based protection against the "thundering herd" problem when multiple requests try to create the same value simultaneously. Note: The creator function must be synchronous. For async creators, you must await them before passing to this method. Args: key: The cache key (without prefix). creator: A synchronous callable that returns the value to cache on miss. expiration_time: Optional override for the default TTL. Returns: The cached or newly created value. """ if not self.config.enabled: return creator() region = self.region full_key = self._make_key(key) if hasattr(region, "get_or_create"): return region.get_or_create( full_key, creator, expiration_time=expiration_time or self.config.expiration_time, ) cached = region.get(full_key) if cached is not DOGPILE_NO_VALUE and cached is not NO_VALUE: return cast("T", cached) value = creator() region.set(full_key, value) return value def get_entity_sync( self, model_name: str, entity_id: Any, model_class: type[T], bind_group: Optional[str] = None, ) -> Optional[T]: """Get a cached entity by model name and ID (sync). Args: model_name: The model/table name. entity_id: The entity's primary key value. model_class: The SQLAlchemy model class for deserialization. bind_group: Optional routing group for multi-master configurations. When provided, entity caches are namespaced by bind_group to prevent data leaks between database shards/replicas. Returns: The cached model instance or None if not found. """ key = f"{model_name}:{bind_group}:get:{entity_id}" if bind_group else f"{model_name}:get:{entity_id}" cached = self.get_sync(key) if cached is DOGPILE_NO_VALUE or cached is NO_VALUE: return None if not isinstance(cached, (bytes, bytearray)): self.delete_sync(key) return None deserializer = self.config.deserializer or default_deserializer try: payload = bytes(cached) if isinstance(cached, bytearray) else cached result: T = deserializer(payload, model_class) except Exception: logger.exception("Failed to deserialize cached entity %s:%s", model_name, entity_id) # Remove corrupted cache entry self.delete_sync(key) return None else: return result def set_entity_sync( self, model_name: str, entity_id: Any, entity: Any, bind_group: Optional[str] = None, ) -> None: """Cache an entity (sync). Args: model_name: The model/table name. entity_id: The entity's primary key value. entity: The SQLAlchemy model instance to cache. bind_group: Optional routing group for multi-master configurations. When provided, entity caches are namespaced by bind_group to prevent data leaks between database shards/replicas. """ key = f"{model_name}:{bind_group}:get:{entity_id}" if bind_group else f"{model_name}:get:{entity_id}" serializer = self.config.serializer or default_serializer try: serialized = serializer(entity) self.set_sync(key, serialized) except Exception: logger.exception("Failed to serialize entity %s:%s", model_name, entity_id) def invalidate_entity_sync(self, model_name: str, entity_id: Any, bind_group: Optional[str] = None) -> None: """Invalidate the cache for a specific entity (sync). This should be called after an entity is created, updated, or deleted to ensure the cache doesn't serve stale data. Args: model_name: The model/table name. entity_id: The entity's primary key value. bind_group: Optional routing group for multi-master configurations. When provided, only the cache entry for that bind_group is invalidated. """ key = f"{model_name}:{bind_group}:get:{entity_id}" if bind_group else f"{model_name}:get:{entity_id}" self.delete_sync(key) logger.debug("Invalidated cache for %s:%s (bind_group=%s)", model_name, entity_id, bind_group) def bump_model_version_sync(self, model_name: str) -> str: """Bump the version token for a model (sync). This is used for version-based invalidation of list queries. When a model is created, updated, or deleted, the version is bumped to a new random token, which effectively invalidates all list query caches that include that token in their cache key. Args: model_name: The model/table name. Returns: The new version token. """ token = uuid.uuid4().hex self._model_versions[model_name] = token # Store in cache for distributed consistency self.set_sync(f"{model_name}:version", token) logger.debug("Bumped version token for %s to %s", model_name, token) return token def get_model_version_sync(self, model_name: str) -> str: """Get the current version token for a model (sync). This is used to include the version in list query cache keys, ensuring that list caches are invalidated when models change. Args: model_name: The model/table name. Returns: The current version token ("0" if not set). """ # Check local cache first if model_name in self._model_versions: return self._model_versions[model_name] # Check distributed cache cached = self.get_sync(f"{model_name}:version") if cached is not DOGPILE_NO_VALUE and cached is not NO_VALUE and isinstance(cached, str): self._model_versions[model_name] = cached return cached return "0" def get_list_sync(self, key: str, model_class: type[T]) -> Optional[list[T]]: """Get a cached list of entities (sync). The list is stored as base64-encoded serialized entity payloads. Args: key: Cache key (without prefix). model_class: Model class for deserialization. Returns: A list of detached model instances or None if not found. """ cached = self.get_sync(key) if cached is DOGPILE_NO_VALUE or cached is NO_VALUE: return None if not isinstance(cached, list): return None cached_list = cast("list[Any]", cached) # type: ignore[redundant-cast] deserializer = self.config.deserializer or default_deserializer results: list[T] = [] try: for item in cached_list: if not isinstance(item, str): return None raw = base64.b64decode(item.encode("ascii")) results.append(deserializer(raw, model_class)) except Exception: logger.exception("Failed to deserialize cached list for key %s", key) self.delete_sync(key) return None return results def set_list_sync(self, key: str, items: list[Any]) -> None: """Cache a list of entities (sync). Args: key: Cache key (without prefix). items: List of entities to cache. """ serializer = self.config.serializer or default_serializer try: payload = [base64.b64encode(serializer(item)).decode("ascii") for item in items] self.set_sync(key, payload) except Exception: logger.exception("Failed to serialize cached list for key %s", key) def get_list_and_count_sync(self, key: str, model_class: type[T]) -> Optional[tuple[list[T], int]]: """Get a cached list+count payload (sync).""" cached = self.get_sync(key) if cached is DOGPILE_NO_VALUE or cached is NO_VALUE: return None if not isinstance(cached, dict): return None cached_payload: dict[str, Any] = cast("dict[str, Any]", cached) items_raw = cached_payload.get("items") count_raw = cached_payload.get("count") if not isinstance(items_raw, list) or not isinstance(count_raw, int): return None items_list = cast("list[Any]", items_raw) # type: ignore[redundant-cast] deserializer = self.config.deserializer or default_deserializer results: list[T] = [] try: for item in items_list: if not isinstance(item, str): return None raw = base64.b64decode(item.encode("ascii")) results.append(deserializer(raw, model_class)) except Exception: logger.exception("Failed to deserialize cached list_and_count for key %s", key) self.delete_sync(key) return None return results, count_raw def set_list_and_count_sync(self, key: str, items: list[Any], count: int) -> None: """Cache a list+count payload (sync).""" serializer = self.config.serializer or default_serializer try: payload = { "items": [base64.b64encode(serializer(item)).decode("ascii") for item in items], "count": count, } self.set_sync(key, payload) except Exception: logger.exception("Failed to serialize cached list_and_count for key %s", key) def singleflight_sync(self, key: str, creator: Callable[[], T]) -> T: """Coalesce concurrent sync cache misses per-process. This reduces stampedes in thread-based sync apps. It does not provide cross-process locking. """ with self._sync_inflight_lock: future: Optional[concurrent.futures.Future[Any]] = self._sync_inflight.get(key) if future is None: future = concurrent.futures.Future() self._sync_inflight[key] = future is_owner = True else: is_owner = False if not is_owner: return cast("T", future.result()) try: result = creator() except Exception as e: future.set_exception(e) raise else: future.set_result(result) return result finally: with self._sync_inflight_lock: if self._sync_inflight.get(key) is future: self._sync_inflight.pop(key, None) def invalidate_all_sync(self) -> None: """Invalidate all cached values (sync). Warning: This invalidates the entire region, not just keys with the configured prefix. Use with caution in shared cache environments. """ self.region.invalidate() self._model_versions.clear() logger.info("Invalidated entire cache region") # ========================================================================= # Async Methods (thin wrappers using async_() for thread offloading) # ========================================================================= async def get_async(self, key: str) -> object: """Get a value from the cache (async). Args: key: The cache key (without prefix). Returns: The cached value or NO_VALUE if not found. """ return await async_(self.get_sync)(key) async def set_async(self, key: str, value: Any) -> None: """Set a value in the cache (async). Args: key: The cache key (without prefix). value: The value to cache. """ await async_(self.set_sync)(key, value) async def delete_async(self, key: str) -> None: """Delete a value from the cache (async). Args: key: The cache key (without prefix). """ await async_(self.delete_sync)(key) async def get_or_create_async( self, key: str, creator: Callable[[], T], expiration_time: Optional[int] = None, ) -> T: """Get a value from cache or create it using the creator function (async). This method uses dogpile.cache's get_or_create which provides mutex-based protection against the "thundering herd" problem when multiple requests try to create the same value simultaneously. Note: The creator function must be synchronous since dogpile.cache runs in a thread pool. For async creators, you must await them and wrap the result before passing to this method. Args: key: The cache key (without prefix). creator: A synchronous callable that returns the value to cache on miss. expiration_time: Optional override for the default TTL. Returns: The cached or newly created value. """ return await async_(self.get_or_create_sync)(key, creator, expiration_time) async def get_entity_async( self, model_name: str, entity_id: Any, model_class: type[T], bind_group: Optional[str] = None, ) -> Optional[T]: """Get a cached entity by model name and ID (async). Args: model_name: The model/table name. entity_id: The entity's primary key value. model_class: The SQLAlchemy model class for deserialization. bind_group: Optional routing group for multi-master configurations. When provided, entity caches are namespaced by bind_group to prevent data leaks between database shards/replicas. Returns: The cached model instance or None if not found. """ return await async_(self.get_entity_sync)(model_name, entity_id, model_class, bind_group) async def set_entity_async( self, model_name: str, entity_id: Any, entity: Any, bind_group: Optional[str] = None, ) -> None: """Cache an entity (async). Args: model_name: The model/table name. entity_id: The entity's primary key value. entity: The SQLAlchemy model instance to cache. bind_group: Optional routing group for multi-master configurations. When provided, entity caches are namespaced by bind_group to prevent data leaks between database shards/replicas. """ await async_(self.set_entity_sync)(model_name, entity_id, entity, bind_group) async def invalidate_entity_async(self, model_name: str, entity_id: Any, bind_group: Optional[str] = None) -> None: """Invalidate the cache for a specific entity (async). This should be called after an entity is created, updated, or deleted to ensure the cache doesn't serve stale data. Args: model_name: The model/table name. entity_id: The entity's primary key value. bind_group: Optional routing group for multi-master configurations. When provided, only the cache entry for that bind_group is invalidated. """ await async_(self.invalidate_entity_sync)(model_name, entity_id, bind_group) async def bump_model_version_async(self, model_name: str) -> str: """Bump the version token for a model (async). This is used for version-based invalidation of list queries. When a model is created, updated, or deleted, the version is bumped, which effectively invalidates all list query caches that include that model's version in their cache key. Args: model_name: The model/table name. Returns: The new version token. """ return await async_(self.bump_model_version_sync)(model_name) async def get_model_version_async(self, model_name: str) -> str: """Get the current version token for a model (async). This is used to include the version in list query cache keys, ensuring that list caches are invalidated when models change. Args: model_name: The model/table name. Returns: The current version token ("0" if not set). """ return await async_(self.get_model_version_sync)(model_name) async def get_list_async(self, key: str, model_class: type[T]) -> Optional[list[T]]: """Get a cached list of entities (async).""" return await async_(self.get_list_sync)(key, model_class) async def set_list_async(self, key: str, items: list[Any]) -> None: """Cache a list of entities (async).""" await async_(self.set_list_sync)(key, items) async def get_list_and_count_async(self, key: str, model_class: type[T]) -> Optional[tuple[list[T], int]]: """Get a cached list+count payload (async).""" return await async_(self.get_list_and_count_sync)(key, model_class) async def set_list_and_count_async(self, key: str, items: list[Any], count: int) -> None: """Cache a list+count payload (async).""" await async_(self.set_list_and_count_sync)(key, items, count) async def singleflight_async(self, key: str, creator: Callable[[], Coroutine[Any, Any, T]]) -> T: """Coalesce concurrent async cache misses per-process. The creator is invoked once per key at a time; concurrent callers await the same in-flight task. This does not provide cross-process locking. """ async with self._inflight_lock_async: task = self._async_inflight.get(key) if task is None: task = asyncio.create_task(creator()) self._async_inflight[key] = task task.add_done_callback(partial(self._singleflight_async_cleanup, key)) return await asyncio.shield(task) async def invalidate_all_async(self) -> None: """Invalidate all cached values (async). Warning: This invalidates the entire region, not just keys with the configured prefix. Use with caution in shared cache environments. """ await async_(self.invalidate_all_sync)() python-advanced-alchemy-1.9.3/advanced_alchemy/cache/serializers.py000066400000000000000000000073131516556515500254720ustar00rootroot00000000000000"""Serialization utilities for caching SQLAlchemy models.""" from typing import Any, TypeVar from sqlalchemy import inspect as sa_inspect from advanced_alchemy._serialization import ( decode_complex_type, decode_json, encode_complex_type, encode_json, ) __all__ = ( "default_deserializer", "default_serializer", ) T = TypeVar("T") _MODEL_KEY = "__aa_model__" """Metadata key for the model class name in serialized data.""" _TABLE_KEY = "__aa_table__" """Metadata key for the table name in serialized data.""" def default_serializer(model: Any) -> bytes: """Serialize a SQLAlchemy model instance to JSON bytes. This function extracts column values from a SQLAlchemy model and serializes them to JSON format. The serialized data includes metadata about the model class for validation during deserialization. Note: Relationships are NOT serialized. Only column values are included. The deserialized object will be a detached instance without relationship data loaded. Args: model: The SQLAlchemy model instance to serialize. Returns: JSON-encoded bytes representation of the model. Example: Serializing a model:: user = User(id=1, name="John", email="john@example.com") data = default_serializer(user) # b'{"__aa_model__": "User", "__aa_table__": "users", "id": 1, ...}' """ mapper = sa_inspect(model.__class__) data: dict[str, Any] = { _MODEL_KEY: model.__class__.__name__, _TABLE_KEY: model.__class__.__tablename__, } for column in mapper.columns: # Skip internal SQLAlchemy sentinel columns (e.g., sa_orm_sentinel) if getattr(column, "_insert_sentinel", False): continue value = getattr(model, column.key) # Encode special types into JSON-friendly marker structures. if (encoded := encode_complex_type(value)) is not None: data[column.key] = encoded else: data[column.key] = value return encode_json(data).encode("utf-8") def default_deserializer(data: bytes, model_class: type[T]) -> T: """Deserialize JSON bytes to a SQLAlchemy model instance. Creates a new, detached instance of the model class populated with the serialized column values. The instance is NOT attached to any session and should be treated as a read-only snapshot. Warning: The returned instance is detached and does not have relationships loaded. Accessing lazy-loaded relationships will raise DetachedInstanceError. Use ``session.merge()`` if you need to work with relationships. Args: data: JSON bytes to deserialize. model_class: The SQLAlchemy model class to instantiate. Returns: A new, detached instance of the model class. Raises: ValueError: If the serialized data is for a different model class. Example: Deserializing data:: data = b'{"__aa_model__": "User", "id": 1, "name": "John"}' user = default_deserializer(data, User) # user is a detached User instance """ parsed_raw = decode_json(data) parsed = decode_complex_type(parsed_raw) # Validate model class matches serialized_model = parsed.pop(_MODEL_KEY, None) parsed.pop(_TABLE_KEY, None) # Remove table key, not needed for instantiation if serialized_model and serialized_model != model_class.__name__: msg = f"Cannot deserialize {serialized_model} data as {model_class.__name__}" raise ValueError(msg) # Create detached instance using constructor # This properly initializes SQLAlchemy's ORM state instance: T = model_class(**parsed) return instance python-advanced-alchemy-1.9.3/advanced_alchemy/cli.py000066400000000000000000000634331516556515500226470ustar00rootroot00000000000000import sys from collections.abc import Sequence from pathlib import Path from typing import TYPE_CHECKING, Optional, Union, cast if TYPE_CHECKING: from alembic.migration import MigrationContext from alembic.operations.ops import MigrationScript, UpgradeOps from click import Group from advanced_alchemy.config import SQLAlchemyAsyncConfig, SQLAlchemySyncConfig __all__ = ("add_migration_commands", "get_alchemy_group") def get_alchemy_group() -> "Group": """Get the Advanced Alchemy CLI group. Raises: MissingDependencyError: If the `click` package is not installed. Returns: The Advanced Alchemy CLI group. """ from advanced_alchemy.exceptions import MissingDependencyError from advanced_alchemy.utils.cli_tools import click, group if click is None: # pragma: no cover - defensive guard raise MissingDependencyError(package="click", install_package="cli") @group(name="alchemy") # pyright: ignore @click.option( "--config", help="Dotted path to SQLAlchemy config(s) (e.g. 'myapp.config.alchemy_configs')", required=True, type=str, ) @click.pass_context def alchemy_group(ctx: "click.Context", config: str) -> None: """Advanced Alchemy CLI commands.""" from pathlib import Path from rich import get_console from advanced_alchemy.utils import module_loader console = get_console() ctx.ensure_object(dict) # Add current working directory to sys.path to allow loading local config modules cwd = str(Path.cwd()) if cwd not in sys.path: sys.path.insert(0, cwd) try: config_instance = module_loader.import_string(config) if isinstance(config_instance, Sequence): ctx.obj["configs"] = config_instance else: ctx.obj["configs"] = [config_instance] except ImportError as e: console.print(f"[red]Error loading config: {e}[/]") ctx.exit(1) finally: # Clean up: remove the cwd from sys.path if we added it if cwd in sys.path and sys.path[0] == cwd: sys.path.remove(cwd) return alchemy_group def add_migration_commands(database_group: Optional["Group"] = None) -> "Group": # noqa: C901, PLR0915 """Add migration commands to the database group. Args: database_group: The database group to add the commands to. Raises: MissingDependencyError: If the `click` package is not installed. Returns: The database group with the migration commands added. """ from rich import get_console from advanced_alchemy.utils.cli_tools import click console = get_console() if database_group is None: database_group = get_alchemy_group() bind_key_option = click.option( "--bind-key", help="Specify which SQLAlchemy config to use by bind key", type=str, default=None, ) verbose_option = click.option( "--verbose", help="Enable verbose output.", type=bool, default=False, is_flag=True, ) no_prompt_option = click.option( "--no-prompt", help="Do not prompt for confirmation before executing the command.", type=bool, default=False, required=False, show_default=True, is_flag=True, ) def get_config_by_bind_key( ctx: "click.Context", bind_key: Optional[str] ) -> "Union[SQLAlchemyAsyncConfig, SQLAlchemySyncConfig]": """Get the SQLAlchemy config for the specified bind key. Args: ctx: The click context. bind_key: The bind key to get the config for. Returns: The SQLAlchemy config for the specified bind key. """ configs = ctx.obj["configs"] if bind_key is None: return cast("Union[SQLAlchemyAsyncConfig, SQLAlchemySyncConfig]", configs[0]) for config in configs: if config.bind_key == bind_key: return cast("Union[SQLAlchemyAsyncConfig, SQLAlchemySyncConfig]", config) console.print(f"[red]No config found for bind key: {bind_key}[/]") sys.exit(1) @database_group.command( name="show-current-revision", help="Shows the current revision for the database.", ) @bind_key_option @verbose_option def show_database_revision(bind_key: Optional[str], verbose: bool) -> None: # pyright: ignore[reportUnusedFunction] """Show current database revision.""" from advanced_alchemy.alembic.commands import AlembicCommands ctx = cast("click.Context", click.get_current_context()) console.rule("[yellow]Listing current revision[/]", align="left") sqlalchemy_config = get_config_by_bind_key(ctx, bind_key) alembic_commands = AlembicCommands(sqlalchemy_config=sqlalchemy_config) alembic_commands.current(verbose=verbose) @database_group.command( name="downgrade", help="Downgrade database to a specific revision.", ) @bind_key_option @click.option("--sql", type=bool, help="Generate SQL output for offline migrations.", default=False, is_flag=True) @click.option( "--tag", help="an arbitrary 'tag' that can be intercepted by custom env.py scripts via the .EnvironmentContext.get_tag_argument method.", type=str, default=None, ) @no_prompt_option @click.argument( "revision", type=str, default="-1", ) def downgrade_database( # pyright: ignore[reportUnusedFunction] bind_key: Optional[str], revision: str, sql: bool, tag: Optional[str], no_prompt: bool ) -> None: """Downgrade the database to the latest revision.""" from rich.prompt import Confirm from advanced_alchemy.alembic.commands import AlembicCommands ctx = cast("click.Context", click.get_current_context()) console.rule("[yellow]Starting database downgrade process[/]", align="left") input_confirmed = ( True if no_prompt else Confirm.ask(f"Are you sure you want to downgrade the database to the `{revision}` revision?") ) if input_confirmed: sqlalchemy_config = get_config_by_bind_key(ctx, bind_key) alembic_commands = AlembicCommands(sqlalchemy_config=sqlalchemy_config) alembic_commands.downgrade(revision=revision, sql=sql, tag=tag) @database_group.command( name="upgrade", help="Upgrade database to a specific revision.", ) @bind_key_option @click.option("--sql", type=bool, help="Generate SQL output for offline migrations.", default=False, is_flag=True) @click.option( "--tag", help="an arbitrary 'tag' that can be intercepted by custom env.py scripts via the .EnvironmentContext.get_tag_argument method.", type=str, default=None, ) @no_prompt_option @click.argument( "revision", type=str, default="head", ) def upgrade_database( # pyright: ignore[reportUnusedFunction] bind_key: Optional[str], revision: str, sql: bool, tag: Optional[str], no_prompt: bool ) -> None: """Upgrade the database to the latest revision.""" from rich.prompt import Confirm from advanced_alchemy.alembic.commands import AlembicCommands ctx = cast("click.Context", click.get_current_context()) console.rule("[yellow]Starting database upgrade process[/]", align="left") input_confirmed = ( True if no_prompt else Confirm.ask(f"[bold]Are you sure you want migrate the database to the `{revision}` revision?[/]") ) if input_confirmed: sqlalchemy_config = get_config_by_bind_key(ctx, bind_key) alembic_commands = AlembicCommands(sqlalchemy_config=sqlalchemy_config) alembic_commands.upgrade(revision=revision, sql=sql, tag=tag) @database_group.command( help="Stamp the revision table with the given revision; don't run any migrations", ) @click.argument("revision", type=str) @bind_key_option @click.option("--sql", is_flag=True, default=False, help="Generate SQL output for offline migrations") @click.option( "--tag", type=str, default=None, help="Arbitrary 'tag' that can be intercepted by custom env.py scripts" ) @click.option("--purge", is_flag=True, default=False, help="Delete all entries in version table before stamping") def stamp(bind_key: Optional[str], revision: str, sql: bool, tag: Optional[str], purge: bool) -> None: # pyright: ignore[reportUnusedFunction] """Stamp the revision table with the given revision.""" from advanced_alchemy.alembic.commands import AlembicCommands ctx = cast("click.Context", click.get_current_context()) console.rule("[yellow]Stamping revision table[/]", align="left") sqlalchemy_config = get_config_by_bind_key(ctx, bind_key) alembic_commands = AlembicCommands(sqlalchemy_config=sqlalchemy_config) alembic_commands.stamp(revision=revision, sql=sql, tag=tag, purge=purge) @database_group.command( name="check", help="Check if the target database is up to date", ) @bind_key_option def check_revision(bind_key: Optional[str]) -> None: # pyright: ignore[reportUnusedFunction] """Check for pending upgrade operations.""" from advanced_alchemy.alembic.commands import AlembicCommands ctx = cast("click.Context", click.get_current_context()) console.rule("[yellow]Checking for pending migrations[/]", align="left") sqlalchemy_config = get_config_by_bind_key(ctx, bind_key) alembic_commands = AlembicCommands(sqlalchemy_config=sqlalchemy_config) alembic_commands.check() @database_group.command( name="edit", help="Edit a revision file using $EDITOR", ) @click.argument("revision", type=str) @bind_key_option def edit_revision(bind_key: Optional[str], revision: str) -> None: # pyright: ignore[reportUnusedFunction] """Edit revision script with system editor.""" from advanced_alchemy.alembic.commands import AlembicCommands ctx = cast("click.Context", click.get_current_context()) console.rule(f"[yellow]Opening revision {revision} in editor[/]", align="left") sqlalchemy_config = get_config_by_bind_key(ctx, bind_key) alembic_commands = AlembicCommands(sqlalchemy_config=sqlalchemy_config) alembic_commands.edit(revision=revision) @database_group.command( name="ensure-version", help="Create the alembic version table if it doesn't exist", ) @bind_key_option @click.option("--sql", is_flag=True, default=False, help="Generate SQL output instead of executing") def ensure_version_table(bind_key: Optional[str], sql: bool) -> None: # pyright: ignore[reportUnusedFunction] """Ensure alembic version table exists.""" from advanced_alchemy.alembic.commands import AlembicCommands ctx = cast("click.Context", click.get_current_context()) console.rule("[yellow]Ensuring version table exists[/]", align="left") sqlalchemy_config = get_config_by_bind_key(ctx, bind_key) alembic_commands = AlembicCommands(sqlalchemy_config=sqlalchemy_config) alembic_commands.ensure_version(sql=sql) @database_group.command( name="heads", help="Show current available heads in the script directory", ) @bind_key_option @verbose_option @click.option( "--resolve-dependencies", is_flag=True, default=False, help="Resolve dependencies between heads", ) def show_heads(bind_key: Optional[str], verbose: bool, resolve_dependencies: bool) -> None: # pyright: ignore[reportUnusedFunction] """Show current heads.""" from advanced_alchemy.alembic.commands import AlembicCommands ctx = cast("click.Context", click.get_current_context()) console.rule("[yellow]Showing current heads[/]", align="left") sqlalchemy_config = get_config_by_bind_key(ctx, bind_key) alembic_commands = AlembicCommands(sqlalchemy_config=sqlalchemy_config) alembic_commands.heads(verbose=verbose, resolve_dependencies=resolve_dependencies) @database_group.command( name="history", help="List changeset scripts in chronological order", ) @bind_key_option @verbose_option @click.option( "--rev-range", type=str, default=None, help="Revision range (e.g., 'base:head', 'abc:def')", ) @click.option( "--indicate-current", is_flag=True, default=False, help="Indicate the current revision", ) def show_history( # pyright: ignore[reportUnusedFunction] bind_key: Optional[str], verbose: bool, rev_range: Optional[str], indicate_current: bool, ) -> None: """Show revision history.""" from advanced_alchemy.alembic.commands import AlembicCommands ctx = cast("click.Context", click.get_current_context()) console.rule("[yellow]Showing revision history[/]", align="left") sqlalchemy_config = get_config_by_bind_key(ctx, bind_key) alembic_commands = AlembicCommands(sqlalchemy_config=sqlalchemy_config) alembic_commands.history( rev_range=rev_range, verbose=verbose, indicate_current=indicate_current, ) @database_group.command( name="merge", help="Merge two revisions together, creating a new migration file", ) @click.argument("revisions", type=str) @bind_key_option @click.option("-m", "--message", type=str, default=None, help="Merge message") @click.option("--branch-label", type=str, default=None, help="Branch label for merge revision") @click.option("--rev-id", type=str, default=None, help="Specify custom revision ID") @no_prompt_option def merge_revisions( # pyright: ignore[reportUnusedFunction] bind_key: Optional[str], revisions: str, message: Optional[str], branch_label: Optional[str], rev_id: Optional[str], no_prompt: bool, ) -> None: """Merge revisions (resolves multiple heads).""" from rich.prompt import Prompt from advanced_alchemy.alembic.commands import AlembicCommands ctx = cast("click.Context", click.get_current_context()) console.rule("[yellow]Merging revisions[/]", align="left") if message is None: message = "merge revisions" if no_prompt else Prompt.ask("Enter merge message") sqlalchemy_config = get_config_by_bind_key(ctx, bind_key) alembic_commands = AlembicCommands(sqlalchemy_config=sqlalchemy_config) alembic_commands.merge( revisions=revisions, message=message, branch_label=branch_label, rev_id=rev_id, ) @database_group.command( name="show", help="Show the revision denoted by the given symbol", ) @click.argument("revision", type=str) @bind_key_option def show_revision(bind_key: Optional[str], revision: str) -> None: # pyright: ignore[reportUnusedFunction] """Show details of a specific revision.""" from advanced_alchemy.alembic.commands import AlembicCommands ctx = cast("click.Context", click.get_current_context()) console.rule(f"[yellow]Showing revision {revision}[/]", align="left") sqlalchemy_config = get_config_by_bind_key(ctx, bind_key) alembic_commands = AlembicCommands(sqlalchemy_config=sqlalchemy_config) alembic_commands.show(rev=revision) @database_group.command( name="branches", help="Show current branch points in the migration history", ) @bind_key_option @verbose_option def show_branches(bind_key: Optional[str], verbose: bool) -> None: # pyright: ignore[reportUnusedFunction] """Show branch points.""" from advanced_alchemy.alembic.commands import AlembicCommands ctx = cast("click.Context", click.get_current_context()) console.rule("[yellow]Showing branch points[/]", align="left") sqlalchemy_config = get_config_by_bind_key(ctx, bind_key) alembic_commands = AlembicCommands(sqlalchemy_config=sqlalchemy_config) alembic_commands.branches(verbose=verbose) @database_group.command( name="list-templates", help="List available Alembic migration templates", ) @bind_key_option def list_init_templates(bind_key: Optional[str]) -> None: # pyright: ignore[reportUnusedFunction] """List available initialization templates.""" from advanced_alchemy.alembic.commands import AlembicCommands ctx = cast("click.Context", click.get_current_context()) console.rule("[yellow]Available templates[/]", align="left") sqlalchemy_config = get_config_by_bind_key(ctx, bind_key) alembic_commands = AlembicCommands(sqlalchemy_config=sqlalchemy_config) alembic_commands.list_templates() @database_group.command( name="init", help="Initialize migrations for the project.", ) @bind_key_option @click.argument( "directory", default=None, required=False, ) @click.option("--multidb", is_flag=True, default=False, help="Support multiple databases") @click.option("--package", is_flag=True, default=True, help="Create `__init__.py` for created folder") @no_prompt_option def init_alembic( # pyright: ignore[reportUnusedFunction] bind_key: Optional[str], directory: Optional[str], multidb: bool, package: bool, no_prompt: bool ) -> None: """Initialize the database migrations.""" from rich.prompt import Confirm from advanced_alchemy.alembic.commands import AlembicCommands ctx = cast("click.Context", click.get_current_context()) console.rule("[yellow]Initializing database migrations.", align="left") input_confirmed = ( True if no_prompt else Confirm.ask("[bold]Are you sure you want initialize migrations for the project?[/]") ) if input_confirmed: configs = [get_config_by_bind_key(ctx, bind_key)] if bind_key is not None else ctx.obj["configs"] for config in configs: directory = config.alembic_config.script_location if directory is None else directory alembic_commands = AlembicCommands(sqlalchemy_config=config) alembic_commands.init(directory=cast("str", directory), multidb=multidb, package=package) @database_group.command( name="make-migrations", help="Create a new migration revision.", ) @bind_key_option @click.option("-m", "--message", default=None, help="Revision message") @click.option( "--autogenerate/--no-autogenerate", default=True, help="Automatically populate revision with detected changes" ) @click.option("--sql", is_flag=True, default=False, help="Export to `.sql` instead of writing to the database.") @click.option("--head", default="head", help="Specify head revision to use as base for new revision.") @click.option( "--splice", is_flag=True, default=False, help='Allow a non-head revision as the "head" to splice onto' ) @click.option("--branch-label", default=None, help="Specify a branch label to apply to the new revision") @click.option("--version-path", default=None, help="Specify specific path from config for version file") @click.option("--rev-id", default=None, help="Specify a ID to use for revision.") @no_prompt_option def create_revision( # pyright: ignore[reportUnusedFunction] bind_key: Optional[str], message: Optional[str], autogenerate: bool, sql: bool, head: str, splice: bool, branch_label: Optional[str], version_path: Optional[str], rev_id: Optional[str], no_prompt: bool, ) -> None: """Create a new database revision.""" from rich.prompt import Prompt from advanced_alchemy.alembic.commands import AlembicCommands def process_revision_directives( context: "MigrationContext", # noqa: ARG001 revision: tuple[str], # noqa: ARG001 directives: list["MigrationScript"], ) -> None: """Handle revision directives.""" if autogenerate and cast("UpgradeOps", directives[0].upgrade_ops).is_empty(): console.rule( "[magenta]The generation of a migration file is being skipped because it would result in an empty file.", style="magenta", align="left", ) console.rule( "[magenta]More information can be found here. https://alembic.sqlalchemy.org/en/latest/autogenerate.html#what-does-autogenerate-detect-and-what-does-it-not-detect", style="magenta", align="left", ) console.rule( "[magenta]If you intend to create an empty migration file, use the --no-autogenerate option.", style="magenta", align="left", ) directives.clear() ctx = cast("click.Context", click.get_current_context()) console.rule("[yellow]Starting database upgrade process[/]", align="left") if message is None: message = "autogenerated" if no_prompt else Prompt.ask("Please enter a message describing this revision") sqlalchemy_config = get_config_by_bind_key(ctx, bind_key) alembic_commands = AlembicCommands(sqlalchemy_config=sqlalchemy_config) alembic_commands.revision( message=message, autogenerate=autogenerate, sql=sql, head=head, splice=splice, branch_label=branch_label, version_path=version_path, rev_id=rev_id, process_revision_directives=process_revision_directives, # type: ignore[arg-type] ) @database_group.command(name="drop-all", help="Drop all tables from the database.") @bind_key_option @no_prompt_option def drop_all(bind_key: Optional[str], no_prompt: bool) -> None: # pyright: ignore[reportUnusedFunction] """Drop all tables from the database.""" from anyio import run from rich.prompt import Confirm from advanced_alchemy.alembic.utils import drop_all from advanced_alchemy.base import metadata_registry ctx = cast("click.Context", click.get_current_context()) console.rule("[yellow]Dropping all tables from the database[/]", align="left") input_confirmed = no_prompt or Confirm.ask( "[bold red]Are you sure you want to drop all tables from the database?" ) async def _drop_all( configs: "Sequence[Union[SQLAlchemyAsyncConfig, SQLAlchemySyncConfig]]", ) -> None: for config in configs: engine = config.get_engine() await drop_all(engine, config.alembic_config.version_table_name, metadata_registry.get(config.bind_key)) if input_confirmed: configs = [get_config_by_bind_key(ctx, bind_key)] if bind_key is not None else ctx.obj["configs"] run(_drop_all, configs) @database_group.command(name="dump-data", help="Dump specified tables from the database to JSON files.") @bind_key_option @click.option( "--table", "table_names", help="Name of the table to dump. Multiple tables can be specified. Use '*' to dump all tables.", type=str, required=True, multiple=True, ) @click.option( "--dir", "dump_dir", help="Directory to save the JSON files. Defaults to WORKDIR/fixtures", type=click.Path(path_type=Path), default=Path.cwd() / "fixtures", required=False, ) def dump_table_data(bind_key: Optional[str], table_names: tuple[str, ...], dump_dir: Path) -> None: # pyright: ignore[reportUnusedFunction] """Dump table data to JSON files.""" from anyio import run from rich.prompt import Confirm from advanced_alchemy.alembic.utils import dump_tables from advanced_alchemy.base import metadata_registry, orm_registry ctx = cast("click.Context", click.get_current_context()) all_tables = "*" in table_names if all_tables and not Confirm.ask( "[yellow bold]You have specified '*'. Are you sure you want to dump all tables from the database?", ): return console.rule("[red bold]No data was dumped.", style="red", align="left") async def _dump_tables() -> None: configs = [get_config_by_bind_key(ctx, bind_key)] if bind_key is not None else ctx.obj["configs"] for config in configs: target_tables = set(metadata_registry.get(config.bind_key).tables) if not all_tables: for table_name in set(table_names) - target_tables: console.rule( f"[red bold]Skipping table '{table_name}' because it is not available in the default registry", style="red", align="left", ) target_tables.intersection_update(table_names) else: console.rule("[yellow bold]Dumping all tables", style="yellow", align="left") models = [ mapper.class_ for mapper in orm_registry.mappers if mapper.class_.__table__.name in target_tables ] await dump_tables(dump_dir, config.get_session(), models) console.rule("[green bold]Data dump complete", align="left") return run(_dump_tables) return database_group python-advanced-alchemy-1.9.3/advanced_alchemy/config/000077500000000000000000000000001516556515500227625ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/advanced_alchemy/config/__init__.py000066400000000000000000000022161516556515500250740ustar00rootroot00000000000000from advanced_alchemy.config.asyncio import AlembicAsyncConfig, AsyncSessionConfig, SQLAlchemyAsyncConfig from advanced_alchemy.config.common import ( ConnectionT, EngineT, GenericAlembicConfig, GenericSessionConfig, GenericSQLAlchemyConfig, SessionMakerT, SessionT, ) from advanced_alchemy.config.engine import EngineConfig from advanced_alchemy.config.routing import ReplicaConfig, RoutingConfig, RoutingStrategy from advanced_alchemy.config.sync import AlembicSyncConfig, SQLAlchemySyncConfig, SyncSessionConfig from advanced_alchemy.config.types import CommitStrategy, TypeDecodersSequence, TypeEncodersMap __all__ = ( "AlembicAsyncConfig", "AlembicSyncConfig", "AsyncSessionConfig", "CommitStrategy", "ConnectionT", "EngineConfig", "EngineT", "GenericAlembicConfig", "GenericSQLAlchemyConfig", "GenericSessionConfig", "ReplicaConfig", "RoutingConfig", "RoutingStrategy", "SQLAlchemyAsyncConfig", "SQLAlchemySyncConfig", "SessionMakerT", "SessionT", "SyncSessionConfig", "TypeDecodersSequence", "TypeEncodersMap", ) python-advanced-alchemy-1.9.3/advanced_alchemy/config/asyncio.py000066400000000000000000000201721516556515500250030ustar00rootroot00000000000000from collections.abc import AsyncGenerator from contextlib import asynccontextmanager from dataclasses import dataclass, field from typing import TYPE_CHECKING, Callable, Optional, Union, cast from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.orm import sessionmaker as sync_sessionmaker from advanced_alchemy._listeners import set_async_context from advanced_alchemy.config.common import ( GenericAlembicConfig, GenericSessionConfig, GenericSQLAlchemyConfig, ) from advanced_alchemy.exceptions import ImproperConfigurationError from advanced_alchemy.utils.dataclass import Empty if TYPE_CHECKING: from typing import Callable from sqlalchemy.orm import Session from advanced_alchemy.config.routing import RoutingConfig from advanced_alchemy.utils.dataclass import EmptyType __all__ = ( "AlembicAsyncConfig", "AsyncSessionConfig", "SQLAlchemyAsyncConfig", ) @dataclass class AsyncSessionConfig(GenericSessionConfig[AsyncConnection, AsyncEngine, AsyncSession]): """SQLAlchemy async session config.""" sync_session_class: "Optional[Union[type[Session], EmptyType]]" = Empty """A :class:`Session ` subclass or other callable which will be used to construct the :class:`Session ` which will be proxied. This parameter may be used to provide custom :class:`Session ` subclasses. Defaults to the :attr:`AsyncSession.sync_session_class ` class-level attribute.""" @dataclass class AlembicAsyncConfig(GenericAlembicConfig): """Configuration for an Async Alembic's Config class. .. seealso:: https://alembic.sqlalchemy.org/en/latest/api/config.html """ @dataclass class SQLAlchemyAsyncConfig(GenericSQLAlchemyConfig[AsyncEngine, AsyncSession, async_sessionmaker[AsyncSession]]): """Async SQLAlchemy Configuration. Note: The alembic configuration options are documented in the Alembic documentation. Example: Basic async configuration:: config = SQLAlchemyAsyncConfig( connection_string="postgresql+asyncpg://user:pass@localhost/db", ) Configuration with read/write routing:: from advanced_alchemy.config.routing import RoutingConfig config = SQLAlchemyAsyncConfig( routing_config=RoutingConfig( primary_connection_string="postgresql+asyncpg://user:pass@primary/db", read_replicas=[ "postgresql+asyncpg://user:pass@replica/db" ], ), ) """ create_engine_callable: "Callable[[str], AsyncEngine]" = create_async_engine """Callable that creates an :class:`AsyncEngine ` instance or instance of its subclass. """ session_config: AsyncSessionConfig = field(default_factory=AsyncSessionConfig) # pyright: ignore[reportIncompatibleVariableOverride] """Configuration options for the :class:`async_sessionmaker`.""" session_maker_class: "type[async_sessionmaker[AsyncSession]]" = async_sessionmaker # pyright: ignore[reportIncompatibleVariableOverride] """Sessionmaker class to use.""" alembic_config: "AlembicAsyncConfig" = field(default_factory=AlembicAsyncConfig) """Configuration for the SQLAlchemy Alembic migrations. The configuration options are documented in the Alembic documentation. """ routing_config: "Optional[RoutingConfig]" = None """Optional read/write routing configuration. When provided, enables automatic routing of read operations to replicas and write operations to the primary database. .. note:: When using ``routing_config``, do not set ``connection_string``. The primary connection is specified in the routing config. """ def __post_init__(self) -> None: # Validate routing config vs connection_string if self.routing_config is not None and self.connection_string is not None: msg = "Provide either 'connection_string' or 'routing_config', not both" raise ImproperConfigurationError(msg) # If routing_config is set, use its primary as the connection_string for compatibility if self.routing_config is not None: self.connection_string = self.routing_config.primary_connection_string if self.connection_string is None: # Try to get from default group engines configs = self.routing_config.get_engine_configs(self.routing_config.default_group) if configs: self.connection_string = configs[0].connection_string super().__post_init__() def __hash__(self) -> int: return super().__hash__() def __eq__(self, other: object) -> bool: return super().__eq__(other) def create_session_maker(self) -> "Callable[[], AsyncSession]": """Get a session maker. If routing is configured, returns a routing-aware session maker. Otherwise, returns a standard session maker. Returns: A callable that creates session instances. """ if self.session_maker: return self.session_maker from sqlalchemy import event from advanced_alchemy._listeners import ( AsyncCacheListener, AsyncFileObjectListener, touch_updated_timestamp, ) # Use routing session maker if routing is configured if self.routing_config is not None: from advanced_alchemy.routing import RoutingAsyncSessionMaker routing_maker: Callable[[], AsyncSession] = RoutingAsyncSessionMaker( routing_config=self.routing_config, engine_config=self.engine_config_dict, session_config=self.session_config_dict, ) self.session_maker = routing_maker else: self.session_maker = cast("Callable[[], AsyncSession]", super().create_session_maker()) # type: ignore[redundant-cast] if isinstance(self.session_maker, async_sessionmaker): session_maker = cast( "async_sessionmaker[AsyncSession]", self.session_maker, # pyright: ignore[reportUnknownMemberType] ) # async_sessionmaker does not support Session-level events directly. # Create a sync sessionmaker, register events on it, and inject it # as sync_session_class so events fire on the underlying sync Session. sync_maker = sync_sessionmaker() if self.enable_file_object_listener: event.listen(sync_maker, "before_flush", AsyncFileObjectListener.before_flush) event.listen(sync_maker, "after_commit", AsyncFileObjectListener.after_commit) event.listen(sync_maker, "after_rollback", AsyncFileObjectListener.after_rollback) if self.enable_touch_updated_timestamp_listener: event.listen(sync_maker, "before_flush", touch_updated_timestamp) event.listen(sync_maker, "after_commit", AsyncCacheListener.after_commit) event.listen(sync_maker, "after_rollback", AsyncCacheListener.after_rollback) session_maker.configure(sync_session_class=sync_maker) if self.session_maker is None: # pyright: ignore msg = "Session maker was not initialized." # type: ignore[unreachable] raise ImproperConfigurationError(msg) return cast("async_sessionmaker[AsyncSession]", self.session_maker) # pyright: ignore[reportUnknownMemberType] @asynccontextmanager async def get_session( self, ) -> AsyncGenerator[AsyncSession, None]: """Get a session from the session maker. Yields: AsyncGenerator[AsyncSession, None]: An async context manager that yields an AsyncSession. """ session_maker = self.create_session_maker() set_async_context(True) async with session_maker() as session: yield session python-advanced-alchemy-1.9.3/advanced_alchemy/config/common.py000066400000000000000000000345741516556515500246410ustar00rootroot00000000000000from dataclasses import dataclass, field from pathlib import Path from typing import TYPE_CHECKING, Any, Callable, ClassVar, Generic, Optional, Union, cast from typing_extensions import TypeVar from advanced_alchemy.base import metadata_registry from advanced_alchemy.config.engine import EngineConfig from advanced_alchemy.exceptions import ImproperConfigurationError from advanced_alchemy.utils.dataclass import Empty, simple_asdict if TYPE_CHECKING: from sqlalchemy import Connection, Engine, MetaData from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, AsyncSession, async_sessionmaker from sqlalchemy.orm import Mapper, Query, Session, sessionmaker from sqlalchemy.orm.session import JoinTransactionMode from sqlalchemy.sql import TableClause from advanced_alchemy.utils.dataclass import EmptyType __all__ = ( "ALEMBIC_TEMPLATE_PATH", "ConnectionT", "EngineT", "GenericAlembicConfig", "GenericSQLAlchemyConfig", "GenericSessionConfig", "SessionMakerT", "SessionT", ) ALEMBIC_TEMPLATE_PATH = f"{Path(__file__).parent.parent}/alembic/templates" """Path to the Alembic templates.""" ConnectionT = TypeVar("ConnectionT", bound="Union[Connection, AsyncConnection]") """Type variable for SQLAlchemy connection types. .. seealso:: :class:`sqlalchemy.Connection` :class:`sqlalchemy.ext.asyncio.AsyncConnection` """ EngineT = TypeVar("EngineT", bound="Union[Engine, AsyncEngine]") """Type variable for a SQLAlchemy engine. .. seealso:: :class:`sqlalchemy.Engine` :class:`sqlalchemy.ext.asyncio.AsyncEngine` """ SessionT = TypeVar("SessionT", bound="Union[Session, AsyncSession]") """Type variable for a SQLAlchemy session. .. seealso:: :class:`sqlalchemy.Session` :class:`sqlalchemy.ext.asyncio.AsyncSession` """ SessionMakerT = TypeVar("SessionMakerT", bound="Union[sessionmaker[Session], async_sessionmaker[AsyncSession]]") """Type variable for a SQLAlchemy sessionmaker. .. seealso:: :class:`sqlalchemy.orm.sessionmaker` :class:`sqlalchemy.ext.asyncio.async_sessionmaker` """ @dataclass class GenericSessionConfig(Generic[ConnectionT, EngineT, SessionT]): """SQLAlchemy async session config. Types: ConnectionT: :class:`sqlalchemy.Connection` | :class:`sqlalchemy.ext.asyncio.AsyncConnection` EngineT: :class:`sqlalchemy.Engine` | :class:`sqlalchemy.ext.asyncio.AsyncEngine` SessionT: :class:`sqlalchemy.Session` | :class:`sqlalchemy.ext.asyncio.AsyncSession` """ autobegin: "Union[bool, EmptyType]" = Empty """Automatically start transactions when database access is requested by an operation. Bool or :class:`Empty ` """ autoflush: "Union[bool, EmptyType]" = Empty """When ``True``, all query operations will issue a flush call to this :class:`Session ` before proceeding""" bind: "Optional[Union[EngineT, ConnectionT, EmptyType]]" = Empty """The :class:`Engine ` or :class:`Connection ` that new :class:`Session ` objects will be bound to.""" binds: "Optional[Union[dict[Union[type[Any], Mapper[Any], TableClause, str], Union[EngineT, ConnectionT]], EmptyType]]" = Empty """A dictionary which may specify any number of :class:`Engine ` or :class:`Connection ` objects as the source of connectivity for SQL operations on a per-entity basis. The keys of the dictionary consist of any series of mapped classes, arbitrary Python classes that are bases for mapped classes, :class:`Table ` objects and :class:`Mapper ` objects. The values of the dictionary are then instances of :class:`Engine ` or less commonly :class:`Connection ` objects.""" class_: "Union[type[SessionT], EmptyType]" = Empty """Class to use in order to create new :class:`Session ` objects.""" expire_on_commit: "Union[bool, EmptyType]" = Empty """If ``True``, all instances will be expired after each commit.""" info: "Optional[Union[dict[str, Any], EmptyType]]" = Empty """Optional dictionary of information that will be available via the :attr:`Session.info `""" join_transaction_mode: "Union[JoinTransactionMode, EmptyType]" = Empty """Describes the transactional behavior to take when a given bind is a Connection that has already begun a transaction outside the scope of this Session; in other words the :attr:`Connection.in_transaction() ` method returns True.""" query_cls: "Optional[Union[type[Query], EmptyType]]" = Empty # pyright: ignore[reportMissingTypeArgument] """Class which should be used to create new Query objects, as returned by the :attr:`Session.query() ` method.""" twophase: "Union[bool, EmptyType]" = Empty """When ``True``, all transactions will be started as a "two phase" transaction, i.e. using the "two phase" semantics of the database in use along with an XID. During a :attr:`commit() `, after :attr:`flush() ` has been issued for all attached databases, the :attr:`TwoPhaseTransaction.prepare() ` method on each database`s :class:`TwoPhaseTransaction ` will be called. This allows each database to roll back the entire transaction, before each transaction is committed.""" @dataclass class GenericSQLAlchemyConfig(Generic[EngineT, SessionT, SessionMakerT]): """Common SQLAlchemy Configuration. Types: EngineT: :class:`sqlalchemy.Engine` or :class:`sqlalchemy.ext.asyncio.AsyncEngine` SessionT: :class:`sqlalchemy.Session` or :class:`sqlalchemy.ext.asyncio.AsyncSession` SessionMakerT: :class:`sqlalchemy.orm.sessionmaker` or :class:`sqlalchemy.ext.asyncio.async_sessionmaker` """ create_engine_callable: "Callable[[str], EngineT]" """Callable that creates an :class:`AsyncEngine ` instance or instance of its subclass. """ session_config: "GenericSessionConfig[Any, Any, Any]" """Configuration options for either the :class:`async_sessionmaker ` or :class:`sessionmaker `. """ session_maker_class: "type[Union[sessionmaker[Session], async_sessionmaker[AsyncSession]]]" """Sessionmaker class to use. .. seealso:: :class:`sqlalchemy.orm.sessionmaker` :class:`sqlalchemy.ext.asyncio.async_sessionmaker` """ connection_string: "Optional[str]" = field(default=None) """Database connection string in one of the formats supported by SQLAlchemy. Notes: - For async connections, the connection string must include the correct async prefix. e.g. ``'postgresql+asyncpg://...'`` instead of ``'postgresql://'``, and for sync connections its the opposite. """ engine_config: "EngineConfig" = field(default_factory=EngineConfig) """Configuration for the SQLAlchemy engine. The configuration options are documented in the SQLAlchemy documentation. """ session_maker: "Optional[Callable[[], SessionT]]" = None """Callable that returns a session. If provided, the plugin will use this rather than instantiate a sessionmaker. """ engine_instance: "Optional[EngineT]" = None """Optional engine to use. If set, the plugin will use the provided instance rather than instantiate an engine. """ create_all: bool = False """If true, all models are automatically created on engine creation.""" metadata: "Optional[MetaData]" = None """Optional metadata to use. If set, the plugin will use the provided instance rather than the default metadata.""" bind_key: "Optional[str]" = None """Bind key to register a metadata to a specific engine configuration.""" enable_touch_updated_timestamp_listener: bool = True """Enable Created/Updated Timestamp event listener. This is a listener that will update ``created_at`` and ``updated_at`` columns on record modification. Disable if you plan to bring your own update mechanism for these columns""" enable_file_object_listener: bool = True """Enable FileObject listener. This is a listener that will automatically save and delete :class:`FileObject ` instances when they are saved or deleted. Disable if you plan to bring your own save/delete mechanism for these columns""" file_object_raise_on_error: bool = True """Control FileObject error handling behavior. - ``False``: Log warnings on file operation failures, don't raise exceptions - ``True`` (default): Raise exceptions on file operation failures """ _SESSION_SCOPE_KEY_REGISTRY: "ClassVar[set[str]]" = field(init=False, default=cast("set[str]", set())) """Internal counter for ensuring unique identification of session scope keys in the class.""" _ENGINE_APP_STATE_KEY_REGISTRY: "ClassVar[set[str]]" = field(init=False, default=cast("set[str]", set())) """Internal counter for ensuring unique identification of engine app state keys in the class.""" _SESSIONMAKER_APP_STATE_KEY_REGISTRY: "ClassVar[set[str]]" = field(init=False, default=cast("set[str]", set())) """Internal counter for ensuring unique identification of sessionmaker state keys in the class.""" def __post_init__(self) -> None: if self.connection_string is not None and self.engine_instance is not None: msg = "Only one of 'connection_string' or 'engine_instance' can be provided." raise ImproperConfigurationError(msg) if self.metadata is None: self.metadata = metadata_registry.get(self.bind_key) else: metadata_registry.set(self.bind_key, self.metadata) # Store file_object_raise_on_error in session_config.info # Ensure session_config.info is a dict (convert from Empty if needed) if self.session_config.info is Empty: self.session_config.info = {} if isinstance(self.session_config.info, dict): self.session_config.info["file_object_raise_on_error"] = self.file_object_raise_on_error def __hash__(self) -> int: # pragma: no cover return hash( ( self.__class__.__qualname__, self.connection_string, self.engine_config.__class__.__qualname__, self.bind_key, ) ) def __eq__(self, other: object) -> bool: return self.__hash__() == other.__hash__() @property def engine_config_dict(self) -> dict[str, Any]: """Return the engine configuration as a dict. Returns: A string keyed dict of config kwargs for the SQLAlchemy :func:`sqlalchemy.get_engine` function. """ return simple_asdict(self.engine_config, exclude_empty=True) @property def session_config_dict(self) -> dict[str, Any]: """Return the session configuration as a dict. Returns: A string keyed dict of config kwargs for the SQLAlchemy :class:`sqlalchemy.orm.sessionmaker` class. """ return simple_asdict(self.session_config, exclude_empty=True) def get_engine(self) -> EngineT: """Return an engine. If none exists yet, create one. Raises: ImproperConfigurationError: if neither `connection_string` nor `engine_instance` are provided. Returns: :class:`sqlalchemy.Engine` or :class:`sqlalchemy.ext.asyncio.AsyncEngine` instance used by the plugin. """ if self.engine_instance: return self.engine_instance if self.connection_string is None: msg = "One of 'connection_string' or 'engine_instance' must be provided." raise ImproperConfigurationError(msg) engine_config = self.engine_config_dict try: self.engine_instance = self.create_engine_callable(self.connection_string, **engine_config) except TypeError: # likely due to a dialect that doesn't support json type del engine_config["json_deserializer"] del engine_config["json_serializer"] self.engine_instance = self.create_engine_callable(self.connection_string, **engine_config) return self.engine_instance def create_session_maker(self) -> "Callable[[], SessionT]": # pragma: no cover """Get a session maker. If none exists yet, create one. Returns: :class:`sqlalchemy.orm.sessionmaker` or :class:`sqlalchemy.ext.asyncio.async_sessionmaker` factory used by the plugin. """ if self.session_maker: return self.session_maker session_kws = self.session_config_dict if session_kws.get("bind") is None: session_kws["bind"] = self.get_engine() self.session_maker = cast("Callable[[], SessionT]", self.session_maker_class(**session_kws)) return self.session_maker @dataclass class GenericAlembicConfig: """Configuration for Alembic's :class:`Config `. For details see: https://alembic.sqlalchemy.org/en/latest/api/config.html """ script_config: str = "alembic.ini" """A path to the Alembic configuration file such as ``alembic.ini``. If left unset, the default configuration will be used. """ version_table_name: str = "alembic_versions" """Configure the name of the table used to hold the applied alembic revisions. Defaults to ``alembic_versions``. """ version_table_schema: "Optional[str]" = None """Configure the schema to use for the alembic revisions revisions. If unset, it defaults to connection's default schema.""" script_location: str = "migrations" """A path to save generated migrations.""" toml_file: "Optional[str]" = None """A path to the Alembic pyproject.toml configuration file. If left unset, the default configuration will be used. """ user_module_prefix: "Optional[str]" = "sa." """User module prefix.""" render_as_batch: bool = True """Render as batch.""" compare_type: bool = False """Compare type.""" template_path: str = ALEMBIC_TEMPLATE_PATH """Template path.""" python-advanced-alchemy-1.9.3/advanced_alchemy/config/engine.py000066400000000000000000000261731516556515500246120ustar00rootroot00000000000000from dataclasses import dataclass from typing import TYPE_CHECKING, Callable, Literal, Optional, Union from advanced_alchemy._serialization import decode_json, encode_json from advanced_alchemy.utils.dataclass import Empty if TYPE_CHECKING: from collections.abc import Mapping from typing import Any from sqlalchemy.engine.interfaces import IsolationLevel from sqlalchemy.pool import Pool from typing_extensions import TypeAlias from advanced_alchemy.utils.dataclass import EmptyType _EchoFlagType: "TypeAlias" = 'Union[None, bool, Literal["debug"]]' _ParamStyle = Literal["qmark", "numeric", "named", "format", "pyformat", "numeric_dollar"] __all__ = ("EngineConfig",) @dataclass class EngineConfig: """Configuration for SQLAlchemy's Engine. This class provides configuration options for SQLAlchemy engine creation. See: https://docs.sqlalchemy.org/en/20/core/engines.html """ connect_args: "Union[dict[Any, Any], EmptyType]" = Empty """A dictionary of arguments which will be passed directly to the DBAPI's ``connect()`` method as keyword arguments. """ echo: "Union[_EchoFlagType, EmptyType]" = Empty """If ``True``, the Engine will log all statements as well as a ``repr()`` of their parameter lists to the default log handler, which defaults to ``sys.stdout`` for output. If set to the string "debug", result rows will be printed to the standard output as well. The echo attribute of Engine can be modified at any time to turn logging on and off; direct control of logging is also available using the standard Python logging module. """ echo_pool: "Union[_EchoFlagType, EmptyType]" = Empty """If ``True``, the connection pool will log informational output such as when connections are invalidated as well as when connections are recycled to the default log handler, which defaults to sys.stdout for output. If set to the string "debug", the logging will include pool checkouts and checkins. Direct control of logging is also available using the standard Python logging module.""" enable_from_linting: "Union[bool, EmptyType]" = Empty """Defaults to True. Will emit a warning if a given SELECT statement is found to have un-linked FROM elements which would cause a cartesian product.""" execution_options: "Union[Mapping[str, Any], EmptyType]" = Empty """Dictionary execution options which will be applied to all connections. See :attr:`Connection.execution_options() ` for details.""" hide_parameters: "Union[bool, EmptyType]" = Empty """Boolean, when set to ``True``, SQL statement parameters will not be displayed in INFO logging nor will they be formatted into the string representation of :class:`StatementError ` objects.""" insertmanyvalues_page_size: "Union[int, EmptyType]" = Empty """Number of rows to format into an INSERT statement when the statement uses โ€œinsertmanyvaluesโ€ mode, which is a paged form of bulk insert that is used for many backends when using executemany execution typically in conjunction with RETURNING. Defaults to 1000, but may also be subject to dialect-specific limiting factors which may override this value on a per-statement basis.""" isolation_level: "Union[IsolationLevel, EmptyType]" = Empty """Optional string name of an isolation level which will be set on all new connections unconditionally. Isolation levels are typically some subset of the string names "SERIALIZABLE", "REPEATABLE READ", "READ COMMITTED", "READ UNCOMMITTED" and "AUTOCOMMIT" based on backend.""" json_deserializer: "Callable[[str], Any]" = decode_json """For dialects that support the :class:`JSON ` datatype, this is a Python callable that will convert a JSON string to a Python object. By default, this is set to Litestar's :attr:`decode_json() <.serialization.decode_json>` function.""" json_serializer: "Callable[[Any], str]" = encode_json """For dialects that support the JSON datatype, this is a Python callable that will render a given object as JSON. By default, Litestar's :attr:`encode_json() <.serialization.encode_json>` is used.""" label_length: "Optional[Union[int, EmptyType]]" = Empty """Optional integer value which limits the size of dynamically generated column labels to that many characters. If less than 6, labels are generated as โ€œ_(counter)โ€. If ``None``, the value of ``dialect.max_identifier_length``, which may be affected via the :attr:`get_engine.max_identifier_length parameter `, is used instead. The value of :attr:`get_engine.label_length ` may not be larger than that of :attr:`get_engine.max_identifier_length `.""" logging_name: "Union[str, EmptyType]" = Empty """String identifier which will be used within the โ€œnameโ€ field of logging records generated within the โ€œsqlalchemy.engineโ€ logger. Defaults to a hexstring of the object`s id.""" max_identifier_length: "Optional[Union[int, EmptyType]]" = Empty """Override the max_identifier_length determined by the dialect. if ``None`` or ``0``, has no effect. This is the database`s configured maximum number of characters that may be used in a SQL identifier such as a table name, column name, or label name. All dialects determine this value automatically, however in the case of a new database version for which this value has changed but SQLAlchemy`s dialect has not been adjusted, the value may be passed here.""" max_overflow: "Union[int, EmptyType]" = Empty """The number of connections to allow in connection pool โ€œoverflowโ€, that is connections that can be opened above and beyond the pool_size setting, which defaults to five. This is only used with :class:`QueuePool `.""" module: "Optional[Union[Any, EmptyType]]" = Empty """Reference to a Python module object (the module itself, not its string name). Specifies an alternate DBAPI module to be used by the engine`s dialect. Each sub-dialect references a specific DBAPI which will be imported before first connect. This parameter causes the import to be bypassed, and the given module to be used instead. Can be used for testing of DBAPIs as well as to inject โ€œmockโ€ DBAPI implementations into the :class:`Engine `.""" paramstyle: "Optional[Union[_ParamStyle, EmptyType]]" = Empty """The paramstyle to use when rendering bound parameters. This style defaults to the one recommended by the DBAPI itself, which is retrieved from the ``.paramstyle`` attribute of the DBAPI. However, most DBAPIs accept more than one paramstyle, and in particular it may be desirable to change a โ€œnamedโ€ paramstyle into a โ€œpositionalโ€ one, or vice versa. When this attribute is passed, it should be one of the values "qmark", "numeric", "named", "format" or "pyformat", and should correspond to a parameter style known to be supported by the DBAPI in use.""" pool: "Optional[Union[Pool, EmptyType]]" = Empty """An already-constructed instance of :class:`Pool `, such as a :class:`QueuePool ` instance. If non-None, this pool will be used directly as the underlying connection pool for the engine, bypassing whatever connection parameters are present in the URL argument. For information on constructing connection pools manually, see `Connection Pooling `_.""" poolclass: "Optional[Union[type[Pool], EmptyType]]" = Empty """A :class:`Pool ` subclass, which will be used to create a connection pool instance using the connection parameters given in the URL. Note this differs from pool in that you don`t actually instantiate the pool in this case, you just indicate what type of pool to be used.""" pool_logging_name: "Union[str, EmptyType]" = Empty """String identifier which will be used within the โ€œnameโ€ field of logging records generated within the โ€œsqlalchemy.poolโ€ logger. Defaults to a hexstring of the object`s id.""" pool_pre_ping: "Union[bool, EmptyType]" = Empty """If True will enable the connection pool โ€œpre-pingโ€ feature that tests connections for liveness upon each checkout.""" pool_size: "Union[int, EmptyType]" = Empty """The number of connections to keep open inside the connection pool. This used with :class:`QueuePool ` as well as :class:`SingletonThreadPool `. With :class:`QueuePool `, a pool_size setting of ``0`` indicates no limit; to disable pooling, set ``poolclass`` to :class:`NullPool ` instead.""" pool_recycle: "Union[int, EmptyType]" = Empty """This setting causes the pool to recycle connections after the given number of seconds has passed. It defaults to ``-1``, or no timeout. For example, setting to ``3600`` means connections will be recycled after one hour. Note that MySQL in particular will disconnect automatically if no activity is detected on a connection for eight hours (although this is configurable with the MySQLDB connection itself and the server configuration as well).""" pool_reset_on_return: 'Union[Literal["rollback", "commit"], EmptyType]' = Empty """Set the :attr:`Pool.reset_on_return ` object, which can be set to the values ``"rollback"``, ``"commit"``, or ``None``.""" pool_timeout: "Union[int, EmptyType]" = Empty """Number of seconds to wait before giving up on getting a connection from the pool. This is only used with :class:`QueuePool `. This can be a float but is subject to the limitations of Python time functions which may not be reliable in the tens of milliseconds.""" pool_use_lifo: "Union[bool, EmptyType]" = Empty """Use LIFO (last-in-first-out) when retrieving connections from :class:`QueuePool ` instead of FIFO (first-in-first-out). Using LIFO, a server-side timeout scheme can reduce the number of connections used during non-peak periods of use. When planning for server-side timeouts, ensure that a recycle or pre-ping strategy is in use to gracefully handle stale connections.""" plugins: "Union[list[str], EmptyType]" = Empty """String list of plugin names to load. See :class:`CreateEnginePlugin ` for background.""" query_cache_size: "Union[int, EmptyType]" = Empty """Size of the cache used to cache the SQL string form of queries. Set to zero to disable caching. See :attr:`query_cache_size ` for more info. """ use_insertmanyvalues: "Union[bool, EmptyType]" = Empty """``True`` by default, use the โ€œinsertmanyvaluesโ€ execution style for INSERT..RETURNING statements by default.""" python-advanced-alchemy-1.9.3/advanced_alchemy/config/routing.py000066400000000000000000000101421516556515500250210ustar00rootroot00000000000000"""Read/Write routing configuration for Advanced Alchemy. This module provides configuration classes for read/write replica routing, enabling automatic routing of read operations to read replicas while directing write operations to the primary database. """ from dataclasses import dataclass, field from enum import Enum, auto from typing import Optional, Union __all__ = ( "ReplicaConfig", "RoutingConfig", "RoutingStrategy", ) class RoutingStrategy(Enum): """Strategy for selecting engines from a group. Determines how the routing layer chooses which engine to use when multiple engines are configured for a routing group. """ ROUND_ROBIN = auto() """Cycle through engines in order.""" RANDOM = auto() """Select engines randomly.""" def _default_read_replicas() -> list[Union[str, "EngineConfig"]]: """Return an empty list for read replica configuration.""" return [] def _default_engines() -> dict[str, list[Union[str, "EngineConfig"]]]: """Return default empty engines map.""" return {} @dataclass class EngineConfig: """Configuration for a single database engine.""" connection_string: str """Connection string for the engine.""" weight: int = 1 """Relative weight for load balancing (higher weight = more traffic).""" name: str = "" """Optional human-readable name for this engine.""" # Alias for backward compatibility ReplicaConfig = EngineConfig @dataclass class RoutingConfig: """Read/Write routing configuration. This configuration enables automatic routing of database operations to different engine groups (e.g., writer, reader, analytics). """ primary_connection_string: Optional[str] = None """Legacy: Connection string for the primary (write) database. Mapped to ``engines[default_group]``. """ read_replicas: list[Union[str, EngineConfig]] = field(default_factory=_default_read_replicas) """Legacy: Read replica connection strings or configs. Mapped to ``engines[read_group]``. """ engines: dict[str, list[Union[str, EngineConfig]]] = field(default_factory=_default_engines) """Dictionary mapping group names to lists of engine configs. Example: .. code-block:: python { "writer": ["postgres://primary"], "reader": ["postgres://rep1", "postgres://rep2"], "analytics": ["postgres://warehouse"] } """ default_group: str = "default" """Name of the group to use for write operations.""" read_group: str = "read" """Name of the group to use for read operations.""" routing_strategy: RoutingStrategy = RoutingStrategy.ROUND_ROBIN """Strategy for selecting engines within a group.""" enabled: bool = True """Enable/disable routing.""" sticky_after_write: bool = True """Stick to writer after first write in context (read-your-writes).""" reset_stickiness_on_commit: bool = True """Reset stickiness after commit.""" def __post_init__(self) -> None: """Normalize configuration.""" # Migrate legacy config to engines map if self.primary_connection_string: if self.default_group not in self.engines: self.engines[self.default_group] = [] self.engines[self.default_group].insert( 0, EngineConfig(connection_string=self.primary_connection_string, name="primary") ) if self.read_replicas: if self.read_group not in self.engines: self.engines[self.read_group] = [] self.engines[self.read_group].extend(self.read_replicas) def get_engine_configs(self, group: str) -> list[EngineConfig]: """Get engine configs for a specific group. Args: group: Name of the engine group. Returns: List of :class:`EngineConfig` instances. """ if group not in self.engines: return [] configs = self.engines[group] return [ config if isinstance(config, EngineConfig) else EngineConfig(connection_string=config) for config in configs ] python-advanced-alchemy-1.9.3/advanced_alchemy/config/sync.py000066400000000000000000000156601516556515500243200ustar00rootroot00000000000000"""Sync SQLAlchemy configuration module.""" from contextlib import contextmanager from dataclasses import dataclass, field from typing import TYPE_CHECKING, Optional, cast from sqlalchemy import Connection, Engine, create_engine from sqlalchemy.orm import Session, sessionmaker from advanced_alchemy._listeners import set_async_context from advanced_alchemy.config.common import GenericAlembicConfig, GenericSessionConfig, GenericSQLAlchemyConfig from advanced_alchemy.exceptions import ImproperConfigurationError if TYPE_CHECKING: from collections.abc import Generator from typing import Callable from advanced_alchemy.config.routing import RoutingConfig __all__ = ( "AlembicSyncConfig", "SQLAlchemySyncConfig", "SyncSessionConfig", ) @dataclass class SyncSessionConfig(GenericSessionConfig[Connection, Engine, Session]): """Configuration for synchronous SQLAlchemy sessions.""" @dataclass class AlembicSyncConfig(GenericAlembicConfig): """Configuration for Alembic's synchronous migrations. For details see: https://alembic.sqlalchemy.org/en/latest/api/config.html """ @dataclass class SQLAlchemySyncConfig(GenericSQLAlchemyConfig[Engine, Session, sessionmaker[Session]]): """Synchronous SQLAlchemy Configuration. Note: The alembic configuration options are documented in the Alembic documentation. Example: Basic sync configuration:: config = SQLAlchemySyncConfig( connection_string="postgresql://user:pass@localhost/db", ) Configuration with read/write routing:: from advanced_alchemy.config.routing import RoutingConfig config = SQLAlchemySyncConfig( routing_config=RoutingConfig( primary_connection_string="postgresql://user:pass@primary/db", read_replicas=["postgresql://user:pass@replica/db"], ), ) """ create_engine_callable: "Callable[[str], Engine]" = create_engine """Callable that creates an :class:`Engine ` instance or instance of its subclass.""" session_config: SyncSessionConfig = field(default_factory=SyncSessionConfig) # pyright: ignore[reportIncompatibleVariableOverride] """Configuration options for the :class:`sessionmaker`.""" session_maker_class: type[sessionmaker[Session]] = sessionmaker # pyright: ignore[reportIncompatibleVariableOverride] """Sessionmaker class to use.""" alembic_config: AlembicSyncConfig = field(default_factory=AlembicSyncConfig) """Configuration for the SQLAlchemy Alembic migrations. The configuration options are documented in the Alembic documentation. """ routing_config: "Optional[RoutingConfig]" = None """Optional read/write routing configuration. When provided, enables automatic routing of read operations to replicas and write operations to the primary database. .. note:: When using ``routing_config``, do not set ``connection_string``. The primary connection is specified in the routing config. """ def __post_init__(self) -> None: # Validate routing config vs connection_string if self.routing_config is not None and self.connection_string is not None: msg = "Provide either 'connection_string' or 'routing_config', not both" raise ImproperConfigurationError(msg) # If routing_config is set, use its primary as the connection_string for compatibility if self.routing_config is not None: self.connection_string = self.routing_config.primary_connection_string if self.connection_string is None: # Try to get from default group engines configs = self.routing_config.get_engine_configs(self.routing_config.default_group) if configs: self.connection_string = configs[0].connection_string super().__post_init__() def __hash__(self) -> int: return super().__hash__() def __eq__(self, other: object) -> bool: return super().__eq__(other) def create_session_maker(self) -> "Callable[[], Session]": """Get a session maker. If routing is configured, returns a routing-aware session maker. Otherwise, returns a standard session maker. Returns: A callable that creates session instances. """ if self.session_maker: return self.session_maker from sqlalchemy import event from advanced_alchemy._listeners import ( SyncCacheListener, SyncFileObjectListener, touch_updated_timestamp, ) # Use routing session maker if routing is configured if self.routing_config is not None: from advanced_alchemy.routing import RoutingSyncSessionMaker routing_maker: Callable[[], Session] = RoutingSyncSessionMaker( routing_config=self.routing_config, engine_config=self.engine_config_dict, session_config=self.session_config_dict, ) self.session_maker = routing_maker else: self.session_maker = super().create_session_maker() if isinstance(self.session_maker, sessionmaker): session_maker = cast( "sessionmaker[Session]", self.session_maker, # pyright: ignore[reportUnknownMemberType] ) if self.enable_file_object_listener: event.listen(session_maker, "before_flush", SyncFileObjectListener.before_flush) event.listen(session_maker, "after_commit", SyncFileObjectListener.after_commit) event.listen(session_maker, "after_rollback", SyncFileObjectListener.after_rollback) if self.enable_touch_updated_timestamp_listener: event.listen(session_maker, "before_flush", touch_updated_timestamp) event.listen(session_maker, "after_commit", SyncCacheListener.after_commit) event.listen(session_maker, "after_rollback", SyncCacheListener.after_rollback) if self.session_maker is None: # pyright: ignore msg = "Session maker was not initialized." # type: ignore[unreachable] raise ImproperConfigurationError(msg) return cast("sessionmaker[Session]", self.session_maker) # pyright: ignore[reportUnknownMemberType] @contextmanager def get_session(self) -> "Generator[Session, None, None]": """Get a session context manager. Yields: Generator[sqlalchemy.orm.Session, None, None]: A context manager yielding an active SQLAlchemy Session. Examples: Using the session context manager: >>> with config.get_session() as session: ... session.execute(...) """ session_maker = self.create_session_maker() set_async_context(False) with session_maker() as session: yield session python-advanced-alchemy-1.9.3/advanced_alchemy/config/types.py000066400000000000000000000014751516556515500245070ustar00rootroot00000000000000"""Type aliases and constants used in the package config.""" from collections.abc import Mapping, Sequence from typing import Any, Callable, Literal from typing_extensions import TypeAlias TypeEncodersMap: TypeAlias = Mapping[Any, Callable[[Any], Any]] """Type alias for a mapping of type encoders. Maps types to their encoder functions. """ TypeDecodersSequence: TypeAlias = Sequence[tuple[Callable[[Any], bool], Callable[[Any, Any], Any]]] """Type alias for a sequence of type decoders. Each tuple contains a type check predicate and its corresponding decoder function. """ CommitStrategy: TypeAlias = Literal["always", "match_status"] """Commit strategy for SQLAlchemy sessions. Values: always: Always commit the session after operations match_status: Only commit if the HTTP status code indicates success """ python-advanced-alchemy-1.9.3/advanced_alchemy/exceptions.py000066400000000000000000000305211516556515500242510ustar00rootroot00000000000000import re from collections.abc import Generator from contextlib import contextmanager from typing import Any, Callable, Optional, TypedDict, Union, cast from sqlalchemy.exc import IntegrityError as SQLAlchemyIntegrityError from sqlalchemy.exc import InvalidRequestError as SQLAlchemyInvalidRequestError from sqlalchemy.exc import MultipleResultsFound, SQLAlchemyError, StatementError __all__ = ( "AdvancedAlchemyError", "DuplicateKeyError", "ErrorMessages", "ForeignKeyError", "ImproperConfigurationError", "IntegrityError", "MissingDependencyError", "MultipleResultsFoundError", "NotFoundError", "RepositoryError", "SerializationError", "wrap_sqlalchemy_exception", ) DUPLICATE_KEY_REGEXES = { "postgresql": [ re.compile( r"^.*duplicate\s+key.*\"(?P[^\"]+)\"\s*\n.*Key\s+\((?P.*)\)=\((?P.*)\)\s+already\s+exists.*$", ), re.compile(r"^.*duplicate\s+key.*\"(?P[^\"]+)\"\s*\n.*$"), ], "sqlite": [ re.compile(r"^.*columns?(?P[^)]+)(is|are)\s+not\s+unique$"), re.compile(r"^.*UNIQUE\s+constraint\s+failed:\s+(?P.+)$"), re.compile(r"^.*PRIMARY\s+KEY\s+must\s+be\s+unique.*$"), ], "mysql": [ re.compile(r"^.*\b1062\b.*Duplicate entry '(?P.*)' for key '(?P[^']+)'.*$"), re.compile(r"^.*\b1062\b.*Duplicate entry \\'(?P.*)\\' for key \\'(?P.+)\\'.*$"), ], "oracle": [], "spanner+spanner": [], "duckdb": [], "mssql": [], "bigquery": [], "cockroach": [], } FOREIGN_KEY_REGEXES = { "postgresql": [ re.compile( r".*on table \"(?P[^\"]+)\" violates " r"foreign key constraint \"(?P[^\"]+)\".*\n" r"DETAIL: Key \((?P.+)\)=\(.+\) " r"is (not present in|still referenced from) table " r"\"(?P[^\"]+)\".", ), ], "sqlite": [ re.compile(r"(?i).*foreign key constraint failed"), ], "mysql": [ re.compile( r".*Cannot (add|delete) or update a (child|parent) row: " r'a foreign key constraint fails \([`"].+[`"]\.[`"](?P
.+)[`"], ' r'CONSTRAINT [`"](?P.+)[`"] FOREIGN KEY ' r'\([`"](?P.+)[`"]\) REFERENCES [`"](?P.+)[`"] ', ), ], "oracle": [], "spanner+spanner": [], "duckdb": [], "mssql": [], "bigquery": [], "cockroach": [], } CHECK_CONSTRAINT_REGEXES = { "postgresql": [ re.compile(r".*new row for relation \"(?P
.+)\" violates check constraint (?P.+)"), ], "sqlite": [], "mysql": [], "oracle": [], "spanner+spanner": [], "duckdb": [], "mssql": [], "bigquery": [], "cockroach": [], } class AdvancedAlchemyError(Exception): """Base exception class from which all Advanced Alchemy exceptions inherit.""" detail: str def __init__(self, *args: Any, detail: str = "") -> None: """Initialize ``AdvancedAlchemyException``. Args: *args: args are converted to :class:`str` before passing to :class:`Exception` detail: detail of the exception. """ str_args = [str(arg) for arg in args if arg] if not detail: if str_args: detail, *str_args = str_args elif hasattr(self, "detail"): detail = self.detail self.detail = detail super().__init__(*str_args) def __repr__(self) -> str: if self.detail: return f"{self.__class__.__name__} - {self.detail}" return self.__class__.__name__ def __str__(self) -> str: return " ".join((*self.args, self.detail)).strip() class MissingDependencyError(AdvancedAlchemyError, ImportError): """Missing optional dependency. This exception is raised when a module depends on a dependency that has not been installed. Args: package: Name of the missing package. install_package: Optional alternative package name to install. """ def __init__(self, package: str, install_package: Optional[str] = None) -> None: super().__init__( f"Package {package!r} is not installed but required. You can install it by running " f"'pip install advanced_alchemy[{install_package or package}]' to install advanced_alchemy with the required extra " f"or 'pip install {install_package or package}' to install the package separately", ) class ImproperConfigurationError(AdvancedAlchemyError): """Improper Configuration error. This exception is raised when there is an issue with the configuration of a module. Args: *args: Variable length argument list passed to parent class. detail: Detailed error message. """ class SerializationError(AdvancedAlchemyError): """Encoding or decoding error. This exception is raised when serialization or deserialization of an object fails. Args: *args: Variable length argument list passed to parent class. detail: Detailed error message. """ class RepositoryError(AdvancedAlchemyError): """Base repository exception type. Args: *args: Variable length argument list passed to parent class. detail: Detailed error message. """ class IntegrityError(RepositoryError): """Data integrity error. Args: *args: Variable length argument list passed to parent class. detail: Detailed error message. """ class DuplicateKeyError(IntegrityError): """Duplicate key error. Args: *args: Variable length argument list passed to parent class. detail: Detailed error message. """ class ForeignKeyError(IntegrityError): """Foreign key error. Args: *args: Variable length argument list passed to parent class. detail: Detailed error message. """ class NotFoundError(RepositoryError): """Not found error. This exception is raised when a requested resource is not found. Args: *args: Variable length argument list passed to parent class. detail: Detailed error message. """ class MultipleResultsFoundError(RepositoryError): """Multiple results found error. This exception is raised when a single result was expected but multiple were found. Args: *args: Variable length argument list passed to parent class. detail: Detailed error message. """ class InvalidRequestError(RepositoryError): """Invalid request error. This exception is raised when SQLAlchemy is unable to complete the request due to a runtime error Args: *args: Variable length argument list passed to parent class. detail: Detailed error message. """ class ErrorMessages(TypedDict, total=False): duplicate_key: Union[str, Callable[[Exception], str]] integrity: Union[str, Callable[[Exception], str]] foreign_key: Union[str, Callable[[Exception], str]] multiple_rows: Union[str, Callable[[Exception], str]] check_constraint: Union[str, Callable[[Exception], str]] other: Union[str, Callable[[Exception], str]] not_found: Union[str, Callable[[Exception], str]] def _get_error_message(error_messages: ErrorMessages, key: str, exc: Exception) -> str: template: Union[str, Callable[[Exception], str]] = error_messages.get(key, f"{key} error: {exc}") # type: ignore[assignment] if callable(template): # pyright: ignore[reportUnknownArgumentType] template = template(exc) # pyright: ignore[reportUnknownVariableType] return template # pyright: ignore[reportUnknownVariableType] @contextmanager def wrap_sqlalchemy_exception( # noqa: C901, PLR0915 error_messages: Optional[ErrorMessages] = None, dialect_name: Optional[str] = None, wrap_exceptions: bool = True, ) -> Generator[None, None, None]: """Do something within context to raise a ``RepositoryError`` chained from an original ``SQLAlchemyError``. >>> try: ... with wrap_sqlalchemy_exception(): ... raise SQLAlchemyError("Original Exception") ... except RepositoryError as exc: ... print( ... f"caught repository exception from {type(exc.__context__)}" ... ) caught repository exception from Args: error_messages: Error messages to use for the exception. dialect_name: The name of the dialect to use for the exception. wrap_exceptions: Wrap SQLAlchemy exceptions in a ``RepositoryError``. When set to ``False``, the original exception will be raised. Raises: NotFoundError: Raised when no rows matched the specified data. MultipleResultsFound: Raised when multiple rows matched the specified data. IntegrityError: Raised when an integrity error occurs. InvalidRequestError: Raised when an invalid request was made to SQLAlchemy. RepositoryError: Raised for other SQLAlchemy errors. AttributeError: Raised when an attribute error occurs during processing. SQLAlchemyError: Raised for general SQLAlchemy errors. StatementError: Raised when there is an issue processing the statement. MultipleResultsFoundError: Raised when multiple rows matched the specified data. """ try: yield except NotFoundError as exc: if wrap_exceptions is False: raise if error_messages is not None: msg = _get_error_message(error_messages=error_messages, key="not_found", exc=exc) else: msg = "No rows matched the specified data" raise NotFoundError(detail=msg) from exc except MultipleResultsFound as exc: if wrap_exceptions is False: raise if error_messages is not None: msg = _get_error_message(error_messages=error_messages, key="multiple_rows", exc=exc) else: msg = "Multiple rows matched the specified data" raise MultipleResultsFoundError(detail=msg) from exc except SQLAlchemyIntegrityError as exc: if wrap_exceptions is False: raise if error_messages is not None and dialect_name is not None: keys_to_regex = { "duplicate_key": (DUPLICATE_KEY_REGEXES.get(dialect_name, []), DuplicateKeyError), "check_constraint": (CHECK_CONSTRAINT_REGEXES.get(dialect_name, []), IntegrityError), "foreign_key": (FOREIGN_KEY_REGEXES.get(dialect_name, []), ForeignKeyError), } detail = " - ".join(str(exc_arg) for exc_arg in exc.orig.args) if exc.orig.args else "" # type: ignore[union-attr] # pyright: ignore[reportArgumentType,reportOptionalMemberAccess] for key, (regexes, exception) in keys_to_regex.items(): for regex in regexes: if (match := regex.findall(detail)) and match[0]: raise exception( detail=_get_error_message(error_messages=error_messages, key=key, exc=exc), ) from exc raise IntegrityError( detail=_get_error_message(error_messages=error_messages, key="integrity", exc=exc), ) from exc raise IntegrityError(detail=f"An integrity error occurred: {exc}") from exc except SQLAlchemyInvalidRequestError as exc: if wrap_exceptions is False: raise raise InvalidRequestError(detail="An invalid request was made.") from exc except StatementError as exc: if wrap_exceptions is False: raise raise IntegrityError( detail=cast("str", getattr(exc.orig, "detail", "There was an issue processing the statement.")) ) from exc except SQLAlchemyError as exc: if wrap_exceptions is False: raise if error_messages is not None: msg = _get_error_message(error_messages=error_messages, key="other", exc=exc) else: msg = f"An exception occurred: {exc}" raise RepositoryError(detail=msg) from exc except AttributeError as exc: if wrap_exceptions is False: raise if error_messages is not None: msg = _get_error_message(error_messages=error_messages, key="other", exc=exc) else: msg = f"An attribute error occurred during processing: {exc}" raise RepositoryError(detail=msg) from exc python-advanced-alchemy-1.9.3/advanced_alchemy/extensions/000077500000000000000000000000001516556515500237145ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/advanced_alchemy/extensions/__init__.py000066400000000000000000000000001516556515500260130ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/advanced_alchemy/extensions/fastapi/000077500000000000000000000000001516556515500253435ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/advanced_alchemy/extensions/fastapi/__init__.py000066400000000000000000000023671516556515500274640ustar00rootroot00000000000000"""FastAPI extension for Advanced Alchemy. This module provides FastAPI integration for Advanced Alchemy, including session management, database migrations, and service utilities. """ from advanced_alchemy import base, exceptions, filters, mixins, operations, repository, service, types, utils from advanced_alchemy.alembic.commands import AlembicCommands from advanced_alchemy.config import AlembicAsyncConfig, AlembicSyncConfig, AsyncSessionConfig, SyncSessionConfig from advanced_alchemy.extensions.fastapi import providers from advanced_alchemy.extensions.fastapi.cli import get_database_migration_plugin from advanced_alchemy.extensions.fastapi.config import EngineConfig, SQLAlchemyAsyncConfig, SQLAlchemySyncConfig from advanced_alchemy.extensions.fastapi.extension import AdvancedAlchemy, assign_cli_group __all__ = ( "AdvancedAlchemy", "AlembicAsyncConfig", "AlembicCommands", "AlembicSyncConfig", "AsyncSessionConfig", "EngineConfig", "SQLAlchemyAsyncConfig", "SQLAlchemySyncConfig", "SyncSessionConfig", "assign_cli_group", "base", "exceptions", "filters", "get_database_migration_plugin", "mixins", "operations", "providers", "repository", "service", "types", "utils", ) python-advanced-alchemy-1.9.3/advanced_alchemy/extensions/fastapi/cli.py000066400000000000000000000027521516556515500264720ustar00rootroot00000000000000from typing import TYPE_CHECKING, Optional, cast from advanced_alchemy.cli import add_migration_commands from advanced_alchemy.utils.cli_tools import click, group if TYPE_CHECKING: from fastapi import FastAPI from advanced_alchemy.extensions.fastapi.extension import AdvancedAlchemy def get_database_migration_plugin(app: "FastAPI") -> "AdvancedAlchemy": # pragma: no cover """Retrieve the Advanced Alchemy extension from a FastAPI application instance. Args: app: The FastAPI application instance. Raises: ImproperConfigurationError: If the Advanced Alchemy extension is not properly configured. Returns: The Advanced Alchemy extension instance. """ from advanced_alchemy.exceptions import ImproperConfigurationError extension = cast("Optional[AdvancedAlchemy]", getattr(app.state, "advanced_alchemy", None)) if extension is None: msg = "Failed to initialize database CLI. The Advanced Alchemy extension is not properly configured." raise ImproperConfigurationError(msg) return extension def register_database_commands(app: "FastAPI") -> click.Group: # pragma: no cover @group(name="database", aliases=["db"]) @click.pass_context def database_group(ctx: click.Context) -> None: """Manage SQLAlchemy database components.""" ctx.ensure_object(dict) ctx.obj["configs"] = get_database_migration_plugin(app).config add_migration_commands(database_group) return database_group python-advanced-alchemy-1.9.3/advanced_alchemy/extensions/fastapi/config.py000066400000000000000000000003101516556515500271540ustar00rootroot00000000000000from advanced_alchemy.extensions.starlette import EngineConfig, SQLAlchemyAsyncConfig, SQLAlchemySyncConfig __all__ = ( "EngineConfig", "SQLAlchemyAsyncConfig", "SQLAlchemySyncConfig", ) python-advanced-alchemy-1.9.3/advanced_alchemy/extensions/fastapi/extension.py000066400000000000000000000134141516556515500277340ustar00rootroot00000000000000from collections.abc import Sequence from typing import ( TYPE_CHECKING, Any, Optional, Union, overload, ) from advanced_alchemy.extensions.fastapi.cli import register_database_commands from advanced_alchemy.extensions.fastapi.config import SQLAlchemyAsyncConfig, SQLAlchemySyncConfig from advanced_alchemy.extensions.starlette import AdvancedAlchemy as StarletteAdvancedAlchemy from advanced_alchemy.service import ( Empty, EmptyType, ErrorMessages, LoadSpec, ModelT, ) if TYPE_CHECKING: from collections.abc import AsyncGenerator, Callable, Generator, Sequence from fastapi import FastAPI from sqlalchemy import Select from advanced_alchemy import filters from advanced_alchemy.extensions.fastapi.config import SQLAlchemyAsyncConfig, SQLAlchemySyncConfig from advanced_alchemy.extensions.fastapi.providers import ( AsyncServiceT_co, DependencyDefaults, FilterConfig, SyncServiceT_co, ) __all__ = ("AdvancedAlchemy",) def assign_cli_group(app: "FastAPI") -> None: # pragma: no cover try: from fastapi_cli.cli import app as fastapi_cli_app # pyright: ignore[reportUnknownVariableType] from typer.main import get_group except ImportError: print("FastAPI CLI is not installed. Skipping CLI registration.") # noqa: T201 return click_app = get_group(fastapi_cli_app) # pyright: ignore[reportUnknownArgumentType] click_app.add_command(register_database_commands(app)) class AdvancedAlchemy(StarletteAdvancedAlchemy): """AdvancedAlchemy integration for FastAPI applications. This class manages SQLAlchemy sessions and engine lifecycle within a FastAPI application. It provides middleware for handling transactions based on commit strategies. """ def __init__( self, config: "Union[SQLAlchemyAsyncConfig, SQLAlchemySyncConfig, Sequence[Union[SQLAlchemyAsyncConfig, SQLAlchemySyncConfig]]]", app: "Optional[FastAPI]" = None, ) -> None: super().__init__(config, app) @overload def provide_service( self, service_class: type["AsyncServiceT_co"], # pyright: ignore /, key: "Optional[str]" = None, statement: "Optional[Select[tuple[ModelT]]]" = None, error_messages: "Optional[Union[ErrorMessages, EmptyType]]" = Empty, load: "Optional[LoadSpec]" = None, execution_options: "Optional[dict[str, Any]]" = None, uniquify: Optional[bool] = None, count_with_window_function: Optional[bool] = None, ) -> "Callable[..., AsyncGenerator[AsyncServiceT_co, None]]": ... @overload def provide_service( self, service_class: type["SyncServiceT_co"], # pyright: ignore /, key: "Optional[str]" = None, statement: "Optional[Select[tuple[ModelT]]]" = None, error_messages: "Optional[Union[ErrorMessages, EmptyType]]" = Empty, load: "Optional[LoadSpec]" = None, execution_options: "Optional[dict[str, Any]]" = None, uniquify: Optional[bool] = None, count_with_window_function: Optional[bool] = None, ) -> "Callable[..., Generator[SyncServiceT_co, None, None]]": ... def provide_service( # pragma: no cover self, service_class: type[Union["AsyncServiceT_co", "SyncServiceT_co"]], /, key: "Optional[str]" = None, statement: "Optional[Select[tuple[ModelT]]]" = None, error_messages: "Optional[Union[ErrorMessages, EmptyType]]" = Empty, load: "Optional[LoadSpec]" = None, execution_options: "Optional[dict[str, Any]]" = None, uniquify: Optional[bool] = None, count_with_window_function: Optional[bool] = None, ) -> "Callable[..., Union[AsyncGenerator[AsyncServiceT_co, None], Generator[SyncServiceT_co, None, None]]]": """Provides a service instance for dependency injection. Args: service_class: The service class to provide. key: Optional key for the service. statement: Optional SQLAlchemy statement. error_messages: Optional error messages. load: Optional load specification. execution_options: Optional execution options. uniquify: Optional flag to uniquify the service. count_with_window_function: Optional flag to use window function for counting. Returns: A callable that returns an async generator for async services or a generator for sync services. """ from advanced_alchemy.extensions.fastapi.providers import provide_service as _provide_service return _provide_service( service_class, extension=self, key=key, statement=statement, error_messages=error_messages, load=load, execution_options=execution_options, uniquify=uniquify, count_with_window_function=count_with_window_function, ) @staticmethod def provide_filters( # pragma: no cover config: "FilterConfig", /, dep_defaults: "Optional[DependencyDefaults]" = None, ) -> "Callable[..., list[filters.FilterTypes]]": """Provides filters for dependency injection. Args: config: The filters to provide. dep_defaults: Optional key for the filters. Returns: A callable that returns an async generator for async filters or a generator for sync filters. """ from advanced_alchemy.extensions.fastapi.providers import DEPENDENCY_DEFAULTS from advanced_alchemy.extensions.fastapi.providers import provide_filters as _provide_filters if dep_defaults is None: dep_defaults = DEPENDENCY_DEFAULTS return _provide_filters(config, dep_defaults=dep_defaults) python-advanced-alchemy-1.9.3/advanced_alchemy/extensions/fastapi/providers.py000066400000000000000000000763131516556515500277440ustar00rootroot00000000000000# pyright: ignore """Application dependency providers generators for FastAPI. This module contains functions to create dependency providers for filters, similar to the Litestar extension, but tailored for FastAPI. """ import contextlib import datetime import inspect import logging from collections.abc import AsyncGenerator, Generator from typing import ( TYPE_CHECKING, Annotated, Any, Callable, Literal, NamedTuple, Optional, TypeVar, Union, cast, overload, ) from uuid import UUID from fastapi import Depends, Query, Request from fastapi.exceptions import RequestValidationError from sqlalchemy import Select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session from typing_extensions import NotRequired, TypedDict from advanced_alchemy.extensions.fastapi.extension import AdvancedAlchemy from advanced_alchemy.filters import ( BeforeAfter, CollectionFilter, FilterTypes, LimitOffset, NotInCollectionFilter, OrderBy, SearchFilter, ) from advanced_alchemy.service import ( Empty, EmptyType, ErrorMessages, LoadSpec, ModelT, SQLAlchemyAsyncRepositoryService, SQLAlchemySyncRepositoryService, ) from advanced_alchemy.utils.singleton import SingletonMeta from advanced_alchemy.utils.text import camelize logger = logging.getLogger("advanced_alchemy.extensions.fastapi") if TYPE_CHECKING: from advanced_alchemy.extensions.fastapi import SQLAlchemyAsyncConfig, SQLAlchemySyncConfig T = TypeVar("T") DTorNone = Optional[datetime.datetime] StringOrNone = Optional[str] UuidOrNone = Optional[str] # FastAPI doesn't automatically parse UUIDs from query params like Litestar IntOrNone = Optional[int] BooleanOrNone = Optional[bool] SortOrder = Literal["asc", "desc"] SortOrderOrNone = Optional[SortOrder] FilterConfigValues = Union[ bool, str, list[str], type[Union[str, int]] ] # Simplified compared to Litestar's UUID/int flexibility for now AsyncServiceT_co = TypeVar("AsyncServiceT_co", bound=SQLAlchemyAsyncRepositoryService[Any, Any], covariant=True) SyncServiceT_co = TypeVar("SyncServiceT_co", bound=SQLAlchemySyncRepositoryService[Any, Any], covariant=True) HashableValue = Union[str, int, float, bool, None] HashableType = Union[HashableValue, tuple[Any, ...], tuple[tuple[str, Any], ...], tuple[HashableValue, ...]] class FieldNameType(NamedTuple): """Type for field name and associated type information. This allows for specifying both the field name and the expected type for filter values. """ name: str """Name of the field to filter on.""" type_hint: type[Any] = str """Type of the filter value. Defaults to str.""" class DependencyDefaults: """Default values for dependency generation.""" CREATED_FILTER_DEPENDENCY_KEY: str = "created_filter" """Key for the created filter dependency.""" ID_FILTER_DEPENDENCY_KEY: str = "id_filter" """Key for the id filter dependency.""" LIMIT_OFFSET_FILTER_DEPENDENCY_KEY: str = "limit_offset_filter" """Key for the limit offset dependency.""" UPDATED_FILTER_DEPENDENCY_KEY: str = "updated_filter" """Key for the updated filter dependency.""" ORDER_BY_FILTER_DEPENDENCY_KEY: str = "order_by_filter" """Key for the order by dependency.""" SEARCH_FILTER_DEPENDENCY_KEY: str = "search_filter" """Key for the search filter dependency.""" DEFAULT_PAGINATION_SIZE: int = 20 """Default pagination size.""" DEPENDENCY_DEFAULTS = DependencyDefaults() class DependencyCache(metaclass=SingletonMeta): """Simple dependency cache for the application. This is used to help memoize dependencies that are generated dynamically.""" def __init__(self) -> None: self.dependencies: dict[int, Callable[[Any], list[FilterTypes]]] = {} def add_dependencies(self, key: int, dependencies: Callable[[Any], list[FilterTypes]]) -> None: self.dependencies[key] = dependencies def get_dependencies(self, key: int) -> Optional[Callable[[Any], list[FilterTypes]]]: return self.dependencies.get(key) dep_cache = DependencyCache() class FilterConfig(TypedDict): """Configuration for generating dynamic filters for FastAPI.""" id_filter: NotRequired[type[Union[UUID, int, str]]] """Indicates that the id filter should be enabled.""" id_field: NotRequired[str] """The field on the model that stored the primary key or identifier. Defaults to 'id'.""" sort_field: NotRequired[Union[str, set[str]]] """The default field(s) to use for the sort filter.""" sort_order: NotRequired[SortOrder] """The default order to use for the sort filter. Defaults to 'desc'.""" pagination_type: NotRequired[Literal["limit_offset"]] """When set, pagination is enabled based on the type specified.""" pagination_size: NotRequired[int] """The size of the pagination. Defaults to `DEFAULT_PAGINATION_SIZE`.""" search: NotRequired[Union[str, set[str]]] """Fields to enable search on. Can be a comma-separated string or a set of field names.""" search_ignore_case: NotRequired[bool] """When set, search is case insensitive by default. Defaults to False.""" created_at: NotRequired[bool] """When set, created_at filter is enabled. Defaults to 'created_at' field.""" updated_at: NotRequired[bool] """When set, updated_at filter is enabled. Defaults to 'updated_at' field.""" not_in_fields: NotRequired[Union[FieldNameType, set[FieldNameType]]] """Fields that support not-in collection filters. Can be a single field or a set of fields with type information.""" in_fields: NotRequired[Union[FieldNameType, set[FieldNameType]]] """Fields that support in-collection filters. Can be a single field or a set of fields with type information.""" def _should_commit_for_status(status_code: int, commit_mode: str) -> bool: """Determine if we should commit based on status code and commit mode. Args: status_code: The HTTP response status code. commit_mode: The configured commit mode. Returns: True if the transaction should be committed, False otherwise. """ if commit_mode == "manual": return False if commit_mode == "autocommit": return 200 <= status_code < 300 # noqa: PLR2004 if commit_mode == "autocommit_include_redirect": return 200 <= status_code < 400 # noqa: PLR2004 return False @overload def provide_service( service_class: type["AsyncServiceT_co"], /, extension: AdvancedAlchemy, key: Optional[str] = None, statement: Optional[Select[tuple[ModelT]]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, count_with_window_function: Optional[bool] = None, ) -> Callable[..., AsyncGenerator[AsyncServiceT_co, None]]: ... @overload def provide_service( service_class: type["SyncServiceT_co"], /, extension: AdvancedAlchemy, key: Optional[str] = None, statement: Optional[Select[tuple[ModelT]]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, count_with_window_function: Optional[bool] = None, ) -> Callable[..., Generator[SyncServiceT_co, None, None]]: ... def provide_service( # noqa: C901, PLR0915 service_class: type[Union["AsyncServiceT_co", "SyncServiceT_co"]], /, extension: AdvancedAlchemy, key: Optional[str] = None, statement: Optional[Select[tuple[ModelT]]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, count_with_window_function: Optional[bool] = None, ) -> Callable[..., Union[AsyncGenerator[AsyncServiceT_co, None], Generator[SyncServiceT_co, None, None]]]: """Create a dependency provider for a service. This function creates a generator-based dependency that manages the service lifecycle. The generator owns the session lifecycle and handles commit/rollback/close operations to ensure proper connection pool management (especially important for asyncpg). Returns: A dependency provider for the service. """ if issubclass(service_class, SQLAlchemyAsyncRepositoryService) or service_class is SQLAlchemyAsyncRepositoryService: # type: ignore[comparison-overlap] async_config = cast("Optional[SQLAlchemyAsyncConfig]", extension.get_config(key)) async def provide_async_service( request: Request, db_session: AsyncSession = Depends(extension.provide_session(key)), # noqa: B008 ) -> AsyncGenerator[AsyncServiceT_co, None]: # type: ignore[union-attr,unused-ignore] session_key = async_config.session_key if async_config else "db_session" # Mark session as generator-managed to prevent middleware cleanup setattr(request.state, f"{session_key}_generator_managed", True) exc_info: Optional[BaseException] = None try: async with service_class.new( # type: ignore[union-attr,unused-ignore] session=db_session, # type: ignore[arg-type, unused-ignore] statement=statement, config=async_config, # type: ignore[arg-type] error_messages=error_messages, load=load, execution_options=execution_options, uniquify=uniquify, count_with_window_function=count_with_window_function, ) as service: yield service except BaseException as e: exc_info = e raise finally: # Get response status stored by middleware response_status = getattr(request.state, f"{session_key}_response_status", None) commit_mode = async_config.commit_mode if async_config else "manual" should_commit = ( exc_info is None and response_status is not None and _should_commit_for_status(response_status, commit_mode) ) # Each cleanup operation is individually protected so that failures # in one step do not prevent subsequent steps or mask the original exception. try: if should_commit: await db_session.commit() else: await db_session.rollback() except Exception: if exc_info is not None: logger.debug("Session commit/rollback failed during cleanup", exc_info=True) else: raise try: await db_session.close() except Exception: if exc_info is not None: logger.debug("Session close failed during cleanup", exc_info=True) else: raise # Clean up request state for attr in [ session_key, f"{session_key}_generator_managed", f"{session_key}_response_status", ]: with contextlib.suppress(Exception): delattr(request.state, attr) return provide_async_service sync_config = cast("Optional[SQLAlchemySyncConfig]", extension.get_config(key)) def provide_sync_service( request: Request, db_session: Session = Depends(extension.provide_session(key)), # noqa: B008 ) -> Generator[SyncServiceT_co, None, None]: session_key = sync_config.session_key if sync_config else "db_session" # Mark session as generator-managed to prevent middleware cleanup setattr(request.state, f"{session_key}_generator_managed", True) exc_info: Optional[BaseException] = None try: with service_class.new( session=db_session, # type: ignore[arg-type, unused-ignore] statement=statement, config=sync_config, error_messages=error_messages, load=load, execution_options=execution_options, uniquify=uniquify, count_with_window_function=count_with_window_function, ) as service: yield service except BaseException as e: exc_info = e raise finally: # Get response status stored by middleware response_status = getattr(request.state, f"{session_key}_response_status", None) commit_mode = sync_config.commit_mode if sync_config else "manual" should_commit = ( exc_info is None and response_status is not None and _should_commit_for_status(response_status, commit_mode) ) # Each cleanup operation is individually protected so that failures # in one step do not prevent subsequent steps or mask the original exception. try: if should_commit: db_session.commit() else: db_session.rollback() except Exception: if exc_info is not None: logger.debug("Session commit/rollback failed during cleanup", exc_info=True) else: raise try: db_session.close() except Exception: if exc_info is not None: logger.debug("Session close failed during cleanup", exc_info=True) else: raise # Clean up request state for attr in [ session_key, f"{session_key}_generator_managed", f"{session_key}_response_status", ]: with contextlib.suppress(Exception): delattr(request.state, attr) return provide_sync_service def provide_filters( config: FilterConfig, dep_defaults: DependencyDefaults = DEPENDENCY_DEFAULTS, ) -> Callable[..., list[FilterTypes]]: """Create FastAPI dependency providers for filters based on the provided configuration. Returns: A FastAPI dependency provider function that aggregates multiple filter dependencies. """ # Check if any filters are actually requested in the config filter_keys = { "id_filter", "created_at", "updated_at", "pagination_type", "search", "sort_field", "not_in_fields", "in_fields", } has_filters = False for key in filter_keys: value = config.get(key) if value is not None and value is not False and value != []: has_filters = True break if not has_filters: return list # Calculate cache key using hashable version of config cache_key = hash(_make_hashable(config)) # Check cache first cached_dep = dep_cache.get_dependencies(cache_key) if cached_dep is not None: return cached_dep dep = _create_filter_aggregate_function_fastapi(config, dep_defaults) dep_cache.add_dependencies(cache_key, dep) return dep def _make_hashable(value: Any) -> HashableType: """Convert a value into a hashable type. This function converts any value into a hashable type by: - Converting dictionaries to sorted tuples of (key, value) pairs - Converting lists and sets to sorted tuples - Preserving primitive types (str, int, float, bool, None) - Converting any other type to its string representation Args: value: Any value that needs to be made hashable. Returns: A hashable version of the value. """ if isinstance(value, dict): # Convert dict to tuple of tuples with sorted keys items = [] for k in sorted(value.keys()): # pyright: ignore v = value[k] # pyright: ignore items.append((str(k), _make_hashable(v))) # pyright: ignore return tuple(items) # pyright: ignore if isinstance(value, (list, set)): hashable_items = [_make_hashable(item) for item in value] # pyright: ignore filtered_items = [item for item in hashable_items if item is not None] # pyright: ignore return tuple(sorted(filtered_items, key=str)) if isinstance(value, (str, int, float, bool, type(None))): return value return str(value) def _create_filter_aggregate_function_fastapi( # noqa: C901, PLR0915 config: FilterConfig, dep_defaults: DependencyDefaults = DEPENDENCY_DEFAULTS, ) -> Callable[..., list[FilterTypes]]: """Create a FastAPI dependency provider function that aggregates multiple filter dependencies. Returns: A FastAPI dependency provider function that aggregates multiple filter dependencies. """ params: list[inspect.Parameter] = [] annotations: dict[str, Any] = {} # Add id filter providers if (id_filter := config.get("id_filter", False)) is not False: def provide_id_filter( # pyright: ignore[reportUnknownParameterType] ids: Annotated[ # type: ignore Optional[list[id_filter]], # pyright: ignore Query( alias="ids", required=False, description="IDs to filter by.", ), ] = None, ) -> Optional[CollectionFilter[id_filter]]: # type: ignore return CollectionFilter[id_filter](field_name=config.get("id_field", "id"), values=ids) if ids else None # type: ignore params.append( inspect.Parameter( name=dep_defaults.ID_FILTER_DEPENDENCY_KEY, kind=inspect.Parameter.KEYWORD_ONLY, annotation=Annotated[Optional[CollectionFilter[id_filter]], Depends(provide_id_filter)], # type: ignore ) ) annotations[dep_defaults.ID_FILTER_DEPENDENCY_KEY] = Annotated[ Optional[CollectionFilter[id_filter]], Depends(provide_id_filter) # type: ignore ] # Add created_at filter providers if config.get("created_at", False): def provide_created_at_filter( before: Annotated[ Optional[str], Query( alias="createdBefore", description="Filter by created date before this timestamp.", json_schema_extra={"format": "date-time"}, ), ] = None, after: Annotated[ Optional[str], Query( alias="createdAfter", description="Filter by created date after this timestamp.", json_schema_extra={"format": "date-time"}, ), ] = None, ) -> Optional[BeforeAfter]: before_dt = None after_dt = None # Validate both parameters regardless of endpoint path if before is not None: try: before_dt = datetime.datetime.fromisoformat(before.replace("Z", "+00:00")) except (ValueError, TypeError, AttributeError) as e: raise RequestValidationError( errors=[{"loc": ["query", "createdBefore"], "msg": "Invalid date format"}] ) from e if after is not None: try: after_dt = datetime.datetime.fromisoformat(after.replace("Z", "+00:00")) except (ValueError, TypeError, AttributeError) as e: raise RequestValidationError( errors=[{"loc": ["query", "createdAfter"], "msg": "Invalid date format"}] ) from e return ( BeforeAfter(field_name="created_at", before=before_dt, after=after_dt) if before_dt or after_dt else None # pyright: ignore ) param_name = dep_defaults.CREATED_FILTER_DEPENDENCY_KEY params.append( inspect.Parameter( name=param_name, kind=inspect.Parameter.KEYWORD_ONLY, annotation=Annotated[Optional[BeforeAfter], Depends(provide_created_at_filter)], ) ) annotations[param_name] = Annotated[Optional[BeforeAfter], Depends(provide_created_at_filter)] # Add updated_at filter providers if config.get("updated_at", False): def provide_updated_at_filter( before: Annotated[ Optional[str], Query( alias="updatedBefore", description="Filter by updated date before this timestamp.", json_schema_extra={"format": "date-time"}, ), ] = None, after: Annotated[ Optional[str], Query( alias="updatedAfter", description="Filter by updated date after this timestamp.", json_schema_extra={"format": "date-time"}, ), ] = None, ) -> Optional[BeforeAfter]: before_dt = None after_dt = None # Validate both parameters regardless of endpoint path if before is not None: try: before_dt = datetime.datetime.fromisoformat(before.replace("Z", "+00:00")) except (ValueError, TypeError, AttributeError) as e: raise RequestValidationError( errors=[{"loc": ["query", "updatedBefore"], "msg": "Invalid date format"}] ) from e if after is not None: try: after_dt = datetime.datetime.fromisoformat(after.replace("Z", "+00:00")) except (ValueError, TypeError, AttributeError) as e: raise RequestValidationError( errors=[{"loc": ["query", "updatedAfter"], "msg": "Invalid date format"}] ) from e return ( BeforeAfter(field_name="updated_at", before=before_dt, after=after_dt) if before_dt or after_dt else None # pyright: ignore ) param_name = dep_defaults.UPDATED_FILTER_DEPENDENCY_KEY params.append( inspect.Parameter( name=param_name, kind=inspect.Parameter.KEYWORD_ONLY, annotation=Annotated[Optional[BeforeAfter], Depends(provide_updated_at_filter)], ) ) annotations[param_name] = Annotated[Optional[BeforeAfter], Depends(provide_updated_at_filter)] # Add pagination filter providers if config.get("pagination_type") == "limit_offset": def provide_limit_offset_pagination( current_page: Annotated[ int, Query( ge=1, alias="currentPage", description="Page number for pagination.", ), ] = 1, page_size: Annotated[ int, Query( ge=1, alias="pageSize", description="Number of items per page.", ), ] = config.get("pagination_size", dep_defaults.DEFAULT_PAGINATION_SIZE), ) -> LimitOffset: return LimitOffset(limit=page_size, offset=page_size * (current_page - 1)) param_name = dep_defaults.LIMIT_OFFSET_FILTER_DEPENDENCY_KEY params.append( inspect.Parameter( name=param_name, kind=inspect.Parameter.KEYWORD_ONLY, annotation=Annotated[LimitOffset, Depends(provide_limit_offset_pagination)], ) ) annotations[param_name] = Annotated[LimitOffset, Depends(provide_limit_offset_pagination)] # Add search filter providers if search_fields := config.get("search"): def provide_search_filter( search_string: Annotated[ Optional[str], Query( required=False, alias="searchString", description="Search term.", ), ] = None, ignore_case: Annotated[ Optional[bool], Query( required=False, alias="searchIgnoreCase", description="Whether search should be case-insensitive.", ), ] = config.get("search_ignore_case", False), ) -> SearchFilter: field_names = set(search_fields.split(",")) if isinstance(search_fields, str) else search_fields return SearchFilter( field_name=field_names, value=search_string, # type: ignore[arg-type] ignore_case=ignore_case or False, ) param_name = dep_defaults.SEARCH_FILTER_DEPENDENCY_KEY params.append( inspect.Parameter( name=param_name, kind=inspect.Parameter.KEYWORD_ONLY, annotation=Annotated[Optional[SearchFilter], Depends(provide_search_filter)], ) ) annotations[param_name] = Annotated[Optional[SearchFilter], Depends(provide_search_filter)] # Add sort filter providers if sort_field := config.get("sort_field"): sort_order_default = config.get("sort_order", "desc") def provide_order_by( field_name: Annotated[ str, Query( alias="orderBy", description="Field to order by.", required=False, ), ] = sort_field, # type: ignore[assignment] sort_order: Annotated[ Optional[SortOrder], Query( alias="sortOrder", description="Sort order ('asc' or 'desc').", required=False, ), ] = sort_order_default, ) -> OrderBy: return OrderBy(field_name=field_name, sort_order=sort_order or sort_order_default) param_name = dep_defaults.ORDER_BY_FILTER_DEPENDENCY_KEY params.append( inspect.Parameter( name=param_name, kind=inspect.Parameter.KEYWORD_ONLY, annotation=Annotated[OrderBy, Depends(provide_order_by)], ) ) annotations[param_name] = Annotated[OrderBy, Depends(provide_order_by)] # Add not_in filter providers if not_in_fields := config.get("not_in_fields"): not_in_fields = {not_in_fields} if isinstance(not_in_fields, (str, FieldNameType)) else not_in_fields for field_def in not_in_fields: # Capture field_def by value to avoid Python closure late binding gotcha # Without default parameter, all closures would reference the loop variable's final value def create_not_in_filter_provider( # pyright: ignore field_name: FieldNameType = field_def, ) -> Callable[..., Optional[NotInCollectionFilter[Any]]]: def provide_not_in_filter( # pyright: ignore values: Annotated[ # type: ignore Optional[set[field_name.type_hint]], # pyright: ignore Query( alias=camelize(f"{field_name.name}_not_in"), description=f"Filter {field_name.name} not in values", ), ] = None, ) -> Optional[NotInCollectionFilter[field_name.type_hint]]: # type: ignore return NotInCollectionFilter(field_name=field_name.name, values=values) if values else None # pyright: ignore return provide_not_in_filter # pyright: ignore provider = create_not_in_filter_provider() # pyright: ignore param_name = f"{field_def.name}_not_in_filter" params.append( inspect.Parameter( name=param_name, kind=inspect.Parameter.KEYWORD_ONLY, annotation=Annotated[Optional[NotInCollectionFilter[field_def.type_hint]], Depends(provider)], # type: ignore ) ) annotations[param_name] = Annotated[Optional[NotInCollectionFilter[field_def.type_hint]], Depends(provider)] # type: ignore # Add in filter providers if in_fields := config.get("in_fields"): in_fields = {in_fields} if isinstance(in_fields, (str, FieldNameType)) else in_fields for field_def in in_fields: # Capture field_def by value to avoid Python closure late binding gotcha # Without default parameter, all closures would reference the loop variable's final value def create_in_filter_provider( # pyright: ignore field_name: FieldNameType = field_def, ) -> Callable[..., Optional[CollectionFilter[Any]]]: def provide_in_filter( # pyright: ignore values: Annotated[ # type: ignore Optional[set[field_name.type_hint]], # pyright: ignore Query( alias=camelize(f"{field_name.name}_in"), description=f"Filter {field_name.name} in values", ), ] = None, ) -> Optional[CollectionFilter[field_name.type_hint]]: # type: ignore return CollectionFilter(field_name=field_name.name, values=values) if values else None # pyright: ignore return provide_in_filter # pyright: ignore provider = create_in_filter_provider() # type: ignore param_name = f"{field_def.name}_in_filter" params.append( inspect.Parameter( name=param_name, kind=inspect.Parameter.KEYWORD_ONLY, annotation=Annotated[Optional[CollectionFilter[field_def.type_hint]], Depends(provider)], # type: ignore ) ) annotations[param_name] = Annotated[Optional[CollectionFilter[field_def.type_hint]], Depends(provider)] # type: ignore _aggregate_filter_function.__signature__ = inspect.Signature( # type: ignore parameters=params, return_annotation=Annotated[list[FilterTypes], Depends(_aggregate_filter_function)], ) return _aggregate_filter_function def _aggregate_filter_function(**kwargs: Any) -> list[FilterTypes]: filters: list[FilterTypes] = [] for filter_value in kwargs.values(): if filter_value is None: continue if isinstance(filter_value, list): filters.extend(cast("list[FilterTypes]", filter_value)) elif isinstance(filter_value, SearchFilter) and filter_value.value is None: # pyright: ignore # noqa: SIM114 continue # type: ignore elif isinstance(filter_value, OrderBy) and filter_value.field_name is None: # pyright: ignore continue # type: ignore else: filters.append(cast("FilterTypes", filter_value)) return filters python-advanced-alchemy-1.9.3/advanced_alchemy/extensions/flask/000077500000000000000000000000001516556515500250145ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/advanced_alchemy/extensions/flask/__init__.py000066400000000000000000000023261516556515500271300ustar00rootroot00000000000000"""Flask extension for Advanced Alchemy. This module provides Flask integration for Advanced Alchemy, including session management, database migrations, and service utilities. """ from advanced_alchemy import base, exceptions, filters, mixins, operations, repository, service, types, utils from advanced_alchemy.alembic.commands import AlembicCommands from advanced_alchemy.config import AlembicAsyncConfig, AlembicSyncConfig, AsyncSessionConfig, SyncSessionConfig from advanced_alchemy.extensions.flask.cli import get_database_migration_plugin from advanced_alchemy.extensions.flask.config import EngineConfig, SQLAlchemyAsyncConfig, SQLAlchemySyncConfig from advanced_alchemy.extensions.flask.extension import AdvancedAlchemy from advanced_alchemy.extensions.flask.utils import FlaskServiceMixin __all__ = ( "AdvancedAlchemy", "AlembicAsyncConfig", "AlembicCommands", "AlembicSyncConfig", "AsyncSessionConfig", "EngineConfig", "FlaskServiceMixin", "SQLAlchemyAsyncConfig", "SQLAlchemySyncConfig", "SyncSessionConfig", "base", "exceptions", "filters", "get_database_migration_plugin", "mixins", "operations", "repository", "service", "types", "utils", ) python-advanced-alchemy-1.9.3/advanced_alchemy/extensions/flask/cli.py000066400000000000000000000033001516556515500261310ustar00rootroot00000000000000"""Command-line interface utilities for Flask integration. This module provides CLI commands for database management in Flask applications. """ from contextlib import suppress from typing import TYPE_CHECKING, cast from flask.cli import with_appcontext from advanced_alchemy.cli import add_migration_commands from advanced_alchemy.utils.cli_tools import click, group if TYPE_CHECKING: from flask import Flask from advanced_alchemy.extensions.flask.extension import AdvancedAlchemy def get_database_migration_plugin(app: "Flask") -> "AdvancedAlchemy": """Retrieve the Advanced Alchemy extension from the Flask application. Args: app: The :class:`flask.Flask` application instance. Returns: :class:`AdvancedAlchemy`: The Advanced Alchemy extension instance. Raises: :exc:`advanced_alchemy.exceptions.ImproperConfigurationError`: If the extension is not found. """ from advanced_alchemy.exceptions import ImproperConfigurationError with suppress(KeyError): return cast("AdvancedAlchemy", app.extensions["advanced_alchemy"]) msg = "Failed to initialize database migrations. The Advanced Alchemy extension is not properly configured." raise ImproperConfigurationError(msg) @group(name="database", aliases=["db"]) # pyright: ignore @with_appcontext def database_group() -> None: """Manage SQLAlchemy database components. This command group provides database management commands like migrations. """ ctx = cast("click.Context", click.get_current_context()) app = ctx.obj.load_app() ctx.obj = {"app": app, "configs": get_database_migration_plugin(app).config} add_migration_commands(database_group) # pyright: ignore python-advanced-alchemy-1.9.3/advanced_alchemy/extensions/flask/config.py000066400000000000000000000246011516556515500266360ustar00rootroot00000000000000"""Configuration classes for Flask integration. This module provides configuration classes for integrating SQLAlchemy with Flask applications, including both synchronous and asynchronous database configurations. """ from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Callable, Optional, TypeVar, Union, cast from click import echo from flask import g, has_request_context from sqlalchemy.exc import OperationalError from typing_extensions import Literal from advanced_alchemy._serialization import decode_json, encode_json from advanced_alchemy.base import metadata_registry from advanced_alchemy.config import EngineConfig as _EngineConfig from advanced_alchemy.config.asyncio import SQLAlchemyAsyncConfig as _SQLAlchemyAsyncConfig from advanced_alchemy.config.sync import SQLAlchemySyncConfig as _SQLAlchemySyncConfig from advanced_alchemy.exceptions import ImproperConfigurationError from advanced_alchemy.service import schema_dump if TYPE_CHECKING: from flask import Flask, Response from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session from advanced_alchemy.utils.portals import Portal __all__ = ("EngineConfig", "SQLAlchemyAsyncConfig", "SQLAlchemySyncConfig") ConfigT = TypeVar("ConfigT", bound="Union[SQLAlchemySyncConfig, SQLAlchemyAsyncConfig]") def serializer(value: "Any") -> str: """Serialize JSON field values. Calls the `:func:schema_dump` function to convert the value to a built-in before encoding. Args: value: Any JSON serializable value. Returns: str: JSON string representation of the value. """ return encode_json(schema_dump(value)) @dataclass class EngineConfig(_EngineConfig): """Configuration for SQLAlchemy's Engine. This class extends the base EngineConfig with Flask-specific JSON serialization options. For details see: https://docs.sqlalchemy.org/en/20/core/engines.html Attributes: json_deserializer: Callable for converting JSON strings to Python objects. json_serializer: Callable for converting Python objects to JSON strings. """ json_deserializer: "Callable[[str], Any]" = decode_json """For dialects that support the :class:`~sqlalchemy.types.JSON` datatype, this is a Python callable that will convert a JSON string to a Python object.""" json_serializer: "Callable[[Any], str]" = serializer """For dialects that support the JSON datatype, this is a Python callable that will render a given object as JSON.""" @dataclass class SQLAlchemySyncConfig(_SQLAlchemySyncConfig): """Flask-specific synchronous SQLAlchemy configuration. Attributes: app: The Flask application instance. commit_mode: The commit mode to use for database sessions. """ app: "Optional[Flask]" = None """The Flask application instance.""" commit_mode: Literal["manual", "autocommit", "autocommit_include_redirect"] = "manual" """The commit mode to use for database sessions.""" def create_session_maker(self) -> "Callable[[], Session]": """Get a session maker. If none exists yet, create one. Returns: Callable[[], Session]: Session factory used by the plugin. """ if self.session_maker: return self.session_maker session_kws = self.session_config_dict if self.engine_instance is None: self.engine_instance = self.get_engine() if session_kws.get("bind") is None: session_kws["bind"] = self.engine_instance self.session_maker = self.session_maker_class(**session_kws) return self.session_maker def init_app(self, app: "Flask", portal: "Optional[Portal]" = None) -> None: """Initialize the Flask application with this configuration. Args: app: The Flask application instance. portal: The portal to use for thread-safe communication. Unused in synchronous configurations. """ self.app = app self.bind_key = self.bind_key or "default" if self.create_all: self.create_all_metadata() if self.commit_mode != "manual": self._setup_session_handling(app) def _setup_session_handling(self, app: "Flask") -> None: """Set up the session handling for the Flask application. Args: app: The Flask application instance. """ @app.after_request def handle_db_session(response: "Response") -> "Response": # pyright: ignore[reportUnusedFunction] """Commit the session if the response meets the commit criteria.""" if not has_request_context(): return response db_session = cast("Optional[Session]", g.pop(f"advanced_alchemy_session_{self.bind_key}", None)) if db_session is not None: if (self.commit_mode == "autocommit" and 200 <= response.status_code < 300) or ( # noqa: PLR2004 self.commit_mode == "autocommit_include_redirect" and 200 <= response.status_code < 400 # noqa: PLR2004 ): db_session.commit() db_session.close() return response def close_engines(self, portal: "Portal") -> None: """Close the engines. Args: portal: The portal to use for thread-safe communication. """ if self.engine_instance is not None: self.engine_instance.dispose() def create_all_metadata(self) -> None: # pragma: no cover """Create all metadata tables in the database.""" if self.engine_instance is None: self.engine_instance = self.get_engine() with self.engine_instance.begin() as conn: try: metadata_registry.get(None if self.bind_key == "default" else self.bind_key).create_all(conn) except OperationalError as exc: echo(f" * Could not create target metadata. Reason: {exc}") else: echo(" * Created target metadata.") @dataclass class SQLAlchemyAsyncConfig(_SQLAlchemyAsyncConfig): """Flask-specific asynchronous SQLAlchemy configuration. Attributes: app: The Flask application instance. commit_mode: The commit mode to use for database sessions. """ app: "Optional[Flask]" = None """The Flask application instance.""" commit_mode: Literal["manual", "autocommit", "autocommit_include_redirect"] = "manual" """The commit mode to use for database sessions.""" def create_session_maker(self) -> "Callable[[], AsyncSession]": """Get a session maker. If none exists yet, create one. Returns: Callable[[], AsyncSession]: Session factory used by the plugin. """ if self.session_maker: return self.session_maker session_kws = self.session_config_dict if self.engine_instance is None: self.engine_instance = self.get_engine() if session_kws.get("bind") is None: session_kws["bind"] = self.engine_instance self.session_maker = self.session_maker_class(**session_kws) return self.session_maker def init_app(self, app: "Flask", portal: "Optional[Portal]" = None) -> None: """Initialize the Flask application with this configuration. Args: app: The Flask application instance. portal: The portal to use for thread-safe communication. Raises: ImproperConfigurationError: If portal is not provided for async configuration. """ self.app = app self.bind_key = self.bind_key or "default" if portal is None: msg = "Portal is required for asynchronous configurations" raise ImproperConfigurationError(msg) if self.create_all: _ = portal.call(self.create_all_metadata) self._setup_session_handling(app, portal) def _setup_session_handling(self, app: "Flask", portal: "Portal") -> None: """Set up the session handling for the Flask application. Args: app: The Flask application instance. portal: The portal to use for thread-safe communication. """ @app.after_request def handle_db_session(response: "Response") -> "Response": # pyright: ignore[reportUnusedFunction] """Commit the session if the response meets the commit criteria.""" if not has_request_context(): return response db_session = cast("Optional[AsyncSession]", g.pop(f"advanced_alchemy_session_{self.bind_key}", None)) if db_session is not None: p = getattr(db_session, "_session_portal", None) or portal if (self.commit_mode == "autocommit" and 200 <= response.status_code < 300) or ( # noqa: PLR2004 self.commit_mode == "autocommit_include_redirect" and 200 <= response.status_code < 400 # noqa: PLR2004 ): _ = p.call(db_session.commit) _ = p.call(db_session.close) return response @app.teardown_appcontext def close_db_session(_: "Optional[BaseException]" = None) -> None: # pyright: ignore[reportUnusedFunction] """Close the session at the end of the request.""" db_session = cast("Optional[AsyncSession]", g.pop(f"advanced_alchemy_session_{self.bind_key}", None)) if db_session is not None: p = getattr(db_session, "_session_portal", None) or portal _ = p.call(db_session.close) def close_engines(self, portal: "Portal") -> None: """Close the engines. Args: portal: The portal to use for thread-safe communication. """ if self.engine_instance is not None: _ = portal.call(self.engine_instance.dispose) async def create_all_metadata(self) -> None: # pragma: no cover """Create all metadata tables in the database.""" if self.engine_instance is None: self.engine_instance = self.get_engine() async with self.engine_instance.begin() as conn: try: await conn.run_sync( metadata_registry.get(None if self.bind_key == "default" else self.bind_key).create_all ) await conn.commit() except OperationalError as exc: echo(f" * Could not create target metadata. Reason: {exc}") else: echo(" * Created target metadata.") python-advanced-alchemy-1.9.3/advanced_alchemy/extensions/flask/extension.py000066400000000000000000000207351516556515500274110ustar00rootroot00000000000000# ruff: noqa: ARG001 """Flask extension for Advanced Alchemy.""" from collections.abc import Generator, Sequence from contextlib import contextmanager, suppress from typing import TYPE_CHECKING, Callable, Optional, Union, cast from flask import g from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session from advanced_alchemy._listeners import set_async_context from advanced_alchemy.exceptions import ImproperConfigurationError from advanced_alchemy.extensions.flask.cli import database_group from advanced_alchemy.extensions.flask.config import SQLAlchemyAsyncConfig, SQLAlchemySyncConfig from advanced_alchemy.routing.context import reset_routing_context from advanced_alchemy.utils.portals import Portal, PortalProvider if TYPE_CHECKING: import click from flask import Flask class AdvancedAlchemy: """Flask extension for Advanced Alchemy.""" __slots__ = ( "_config", "_has_async_config", "_session_makers", "portal_provider", ) def __init__( self, config: "Union[SQLAlchemySyncConfig, SQLAlchemyAsyncConfig, Sequence[Union[SQLAlchemySyncConfig, SQLAlchemyAsyncConfig]]]", app: "Optional[Flask]" = None, *, portal_provider: "Optional[PortalProvider]" = None, ) -> None: """Initialize the extension.""" self.portal_provider = portal_provider if portal_provider is not None else PortalProvider() self._config = config if isinstance(config, Sequence) else [config] self._has_async_config = any(isinstance(c, SQLAlchemyAsyncConfig) for c in self.config) self._session_makers: dict[str, Callable[..., Union[AsyncSession, Session]]] = {} if app is not None: self.init_app(app) @property def portal(self) -> "Portal": """Get the portal.""" return self.portal_provider.portal @property def config(self) -> "Sequence[Union[SQLAlchemyAsyncConfig, SQLAlchemySyncConfig]]": """Get the SQLAlchemy configuration(s).""" return self._config @property def is_async_enabled(self) -> bool: """Return True if any of the database configs are async.""" return self._has_async_config def _is_async(self, bind_key: str) -> bool: """Check if the config for the given bind key is async. Args: bind_key: The bind key to check. Returns: True if the config is async, False otherwise. """ config = next((c for c in self.config if (c.bind_key or "default") == bind_key), None) return isinstance(config, SQLAlchemyAsyncConfig) def init_app(self, app: "Flask") -> None: """Initialize the Flask application. Args: app: The Flask application to initialize. Raises: ImproperConfigurationError: If the extension is already registered on the Flask application. """ if "advanced_alchemy" in app.extensions: msg = "Advanced Alchemy extension is already registered on this Flask application." raise ImproperConfigurationError(msg) if self._has_async_config: self.portal_provider.start() # Create tables for async configs for cfg in self._config: if isinstance(cfg, SQLAlchemyAsyncConfig): self.portal_provider.portal.call(cfg.create_all_metadata) # Register shutdown handler for the portal @app.teardown_appcontext def shutdown_portal(exception: "Optional[BaseException]" = None) -> None: # pyright: ignore[reportUnusedFunction] """Stop the portal when the application shuts down.""" if not app.debug: # Don't stop portal in debug mode with suppress(Exception): self.portal_provider.stop() # Initialize each config with the app for config in self.config: config.init_app(app, self.portal_provider.portal) bind_key = config.bind_key if config.bind_key is not None else "default" session_maker = config.create_session_maker() self._session_makers[bind_key] = session_maker # Register session cleanup only app.teardown_appcontext(self._teardown_appcontext) app.extensions["advanced_alchemy"] = self db_group_cmd = cast("click.Command", database_group) app.cli.add_command(db_group_cmd) def _teardown_appcontext(self, exception: "Optional[BaseException]" = None) -> None: """Clean up resources when the application context ends.""" for key in list(g): if key.startswith("advanced_alchemy_session_"): session = getattr(g, key) if isinstance(session, AsyncSession): # Close async sessions through the portal with suppress(ImproperConfigurationError): self.portal_provider.portal.call(session.close) else: session.close() delattr(g, key) def get_session(self, bind_key: str = "default") -> "Union[AsyncSession, Session]": """Get a new session from the configured session factory. Args: bind_key: The bind key to use for the session. Returns: A new session from the configured session factory. Raises: ImproperConfigurationError: If no session maker is found for the bind key. """ if bind_key == "default" and len(self.config) == 1: bind_key = self.config[0].bind_key if self.config[0].bind_key is not None else "default" session_key = f"advanced_alchemy_session_{bind_key}" if hasattr(g, session_key): return cast("Union[AsyncSession, Session]", getattr(g, session_key)) # Reset routing context for request-scoped isolation when creating a new session reset_routing_context() session_maker = self._session_makers.get(bind_key) if session_maker is None: msg = f'No session maker found for bind key "{bind_key}"' raise ImproperConfigurationError(msg) session = session_maker() set_async_context(self._is_async(bind_key)) if self._has_async_config: # Ensure portal is started if not self.portal_provider.is_running: self.portal_provider.start() setattr(session, "_session_portal", self.portal_provider.portal) setattr(g, session_key, session) return session def get_async_session(self, bind_key: str = "default") -> AsyncSession: """Get an async session from the configured session factory. Args: bind_key: The bind key to use for the session. Raises: ImproperConfigurationError: If the session is not an async session. Returns: An async session from the configured session factory. """ session = self.get_session(bind_key) if not isinstance(session, AsyncSession): msg = f"Expected async session for bind key {bind_key}, but got {type(session)}" raise ImproperConfigurationError(msg) return session def get_sync_session(self, bind_key: str = "default") -> Session: """Get a sync session from the configured session factory. Args: bind_key: The bind key to use for the session. Raises: ImproperConfigurationError: If the session is not a sync session. Returns: A sync session from the configured session factory. """ session = self.get_session(bind_key) if not isinstance(session, Session): msg = f"Expected sync session for bind key {bind_key}, but got {type(session)}" raise ImproperConfigurationError(msg) return session @contextmanager def with_session( # pragma: no cover (more on this later) self, bind_key: str = "default" ) -> "Generator[Union[AsyncSession, Session], None, None]": """Provide a transactional scope around a series of operations. Args: bind_key: The bind key to use for the session. Yields: A session. """ session = self.get_session(bind_key) try: yield session finally: if isinstance(session, AsyncSession): with suppress(ImproperConfigurationError): self.portal_provider.portal.call(session.close) else: session.close() python-advanced-alchemy-1.9.3/advanced_alchemy/extensions/flask/utils.py000066400000000000000000000022451516556515500265310ustar00rootroot00000000000000"""Flask-specific service classes. This module provides Flask-specific service mixins and utilities for integrating with the Advanced Alchemy service layer. """ from typing import Any from flask import Response, current_app from advanced_alchemy.extensions.flask.config import serializer class FlaskServiceMixin: """Flask service mixin. This mixin provides Flask-specific functionality for services. """ def jsonify( self, data: Any, *args: Any, status_code: int = 200, **kwargs: Any, ) -> Response: """Convert data to a Flask JSON response. Args: data: Data to serialize to JSON. *args: Additional positional arguments passed to Flask's response class. status_code: HTTP status code for the response. Defaults to 200. **kwargs: Additional keyword arguments passed to Flask's response class. Returns: :class:`flask.Response`: A Flask response with JSON content type. """ return current_app.response_class( serializer(data), status=status_code, mimetype="application/json", ) python-advanced-alchemy-1.9.3/advanced_alchemy/extensions/litestar/000077500000000000000000000000001516556515500255435ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/advanced_alchemy/extensions/litestar/__init__.py000066400000000000000000000053561516556515500276650ustar00rootroot00000000000000from advanced_alchemy import base, exceptions, filters, mixins, operations, repository, service, types, utils from advanced_alchemy.alembic.commands import AlembicCommands from advanced_alchemy.config import AlembicAsyncConfig, AlembicSyncConfig, AsyncSessionConfig, SyncSessionConfig from advanced_alchemy.extensions.litestar import providers, session, store from advanced_alchemy.extensions.litestar.cli import get_database_migration_plugin from advanced_alchemy.extensions.litestar.dto import SQLAlchemyDTO, SQLAlchemyDTOConfig from advanced_alchemy.extensions.litestar.plugins import ( EngineConfig, SQLAlchemyAsyncConfig, SQLAlchemyInitPlugin, SQLAlchemyPlugin, SQLAlchemySerializationPlugin, SQLAlchemySyncConfig, ) from advanced_alchemy.extensions.litestar.plugins.init.config.asyncio import ( autocommit_before_send_handler as async_autocommit_before_send_handler, ) from advanced_alchemy.extensions.litestar.plugins.init.config.asyncio import ( autocommit_handler_maker as async_autocommit_handler_maker, ) from advanced_alchemy.extensions.litestar.plugins.init.config.asyncio import ( default_before_send_handler as async_default_before_send_handler, ) from advanced_alchemy.extensions.litestar.plugins.init.config.asyncio import ( default_handler_maker as async_default_handler_maker, ) from advanced_alchemy.extensions.litestar.plugins.init.config.sync import ( autocommit_before_send_handler as sync_autocommit_before_send_handler, ) from advanced_alchemy.extensions.litestar.plugins.init.config.sync import ( autocommit_handler_maker as sync_autocommit_handler_maker, ) from advanced_alchemy.extensions.litestar.plugins.init.config.sync import ( default_before_send_handler as sync_default_before_send_handler, ) from advanced_alchemy.extensions.litestar.plugins.init.config.sync import ( default_handler_maker as sync_default_handler_maker, ) __all__ = ( "AlembicAsyncConfig", "AlembicCommands", "AlembicSyncConfig", "AsyncSessionConfig", "EngineConfig", "SQLAlchemyAsyncConfig", "SQLAlchemyDTO", "SQLAlchemyDTOConfig", "SQLAlchemyInitPlugin", "SQLAlchemyPlugin", "SQLAlchemySerializationPlugin", "SQLAlchemySyncConfig", "SyncSessionConfig", "async_autocommit_before_send_handler", "async_autocommit_handler_maker", "async_default_before_send_handler", "async_default_handler_maker", "base", "exceptions", "filters", "get_database_migration_plugin", "mixins", "operations", "providers", "repository", "service", "session", "store", "sync_autocommit_before_send_handler", "sync_autocommit_handler_maker", "sync_default_before_send_handler", "sync_default_handler_maker", "types", "utils", ) python-advanced-alchemy-1.9.3/advanced_alchemy/extensions/litestar/_utils.py000066400000000000000000000036371516556515500274250ustar00rootroot00000000000000from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from litestar.types import Scope __all__ = ( "delete_aa_scope_state", "get_aa_scope_state", "set_aa_scope_state", ) _SCOPE_NAMESPACE = "_aa_connection_state" def get_aa_scope_state(scope: "Scope", key: str, default: Any = None, pop: bool = False) -> Any: """Get an internal value from connection scope state. Note: If called with a default value, this method behaves like to `dict.set_default()`, both setting the key in the namespace to the default value, and returning it. If called without a default value, the method behaves like `dict.get()`, returning ``None`` if the key does not exist. Args: scope: The connection scope. key: Key to get from internal namespace in scope state. default: Default value to return. pop: Boolean flag dictating whether the value should be deleted from the state. Returns: Value mapped to ``key`` in internal connection scope namespace. """ namespace = scope.setdefault(_SCOPE_NAMESPACE, {}) # type: ignore[misc] return namespace.pop(key, default) if pop else namespace.get(key, default) # pyright: ignore[reportUnknownVariableType,reportUnknownMemberType] def set_aa_scope_state(scope: "Scope", key: str, value: Any) -> None: """Set an internal value in connection scope state. Args: scope: The connection scope. key: Key to set under internal namespace in scope state. value: Value for key. """ scope.setdefault(_SCOPE_NAMESPACE, {})[key] = value # type: ignore[misc] def delete_aa_scope_state(scope: "Scope", key: str) -> None: """Delete an internal value from connection scope state. Args: scope: The connection scope. key: Key to set under internal namespace in scope state. """ del scope.setdefault(_SCOPE_NAMESPACE, {})[key] # type: ignore[misc] python-advanced-alchemy-1.9.3/advanced_alchemy/extensions/litestar/cli.py000066400000000000000000000026771516556515500267000ustar00rootroot00000000000000from contextlib import suppress from typing import TYPE_CHECKING from litestar.cli._utils import LitestarGroup # pyright: ignore from advanced_alchemy.cli import add_migration_commands from advanced_alchemy.utils.cli_tools import click, group if TYPE_CHECKING: from litestar import Litestar from advanced_alchemy.extensions.litestar.plugins import SQLAlchemyInitPlugin def get_database_migration_plugin(app: "Litestar") -> "SQLAlchemyInitPlugin": """Retrieve a database migration plugin from the Litestar application's plugins. Args: app: The Litestar application Returns: The database migration plugin Raises: ImproperConfigurationError: If the database migration plugin is not found """ from advanced_alchemy.exceptions import ImproperConfigurationError from advanced_alchemy.extensions.litestar.plugins import SQLAlchemyInitPlugin with suppress(KeyError): return app.plugins.get(SQLAlchemyInitPlugin) msg = "Failed to initialize database migrations. The required plugin (SQLAlchemyPlugin or SQLAlchemyInitPlugin) is missing." raise ImproperConfigurationError(msg) @group(cls=LitestarGroup, name="database", aliases=["db"]) # pyright: ignore def database_group(ctx: "click.Context") -> None: """Manage SQLAlchemy database components.""" ctx.obj = {"app": ctx.obj, "configs": get_database_migration_plugin(ctx.obj.app).config} add_migration_commands(database_group) python-advanced-alchemy-1.9.3/advanced_alchemy/extensions/litestar/dto.py000066400000000000000000000560141516556515500267110ustar00rootroot00000000000000# ruff: noqa: C901 import logging from collections.abc import Collection, Generator from collections.abc import Set as AbstractSet from dataclasses import asdict, dataclass, field, replace from functools import cached_property, singledispatchmethod from typing import ( Any, ClassVar, Generic, Literal, Optional, Union, ) from litestar.dto.base_dto import AbstractDTO from litestar.dto.config import DTOConfig from litestar.dto.data_structures import DTOFieldDefinition from litestar.dto.field import DTO_FIELD_META_KEY, DTOField, Mark from litestar.types.empty import Empty from litestar.typing import FieldDefinition from litestar.utils.signature import ParsedSignature from sqlalchemy import Column, inspect, orm, sql from sqlalchemy.ext.associationproxy import AssociationProxy, AssociationProxyExtensionType from sqlalchemy.ext.hybrid import HybridExtensionType, hybrid_property from sqlalchemy.orm import ( ColumnProperty, CompositeProperty, DeclarativeBase, DynamicMapped, InspectionAttr, InstrumentedAttribute, Mapped, MappedColumn, NotExtension, QueryableAttribute, Relationship, RelationshipDirection, RelationshipProperty, WriteOnlyMapped, ) from sqlalchemy.sql.expression import ColumnClause, Label from typing_extensions import TypeAlias, TypeVar from advanced_alchemy.exceptions import ImproperConfigurationError __all__ = ("SQLAlchemyDTO",) logger = logging.getLogger(__name__) T = TypeVar("T", bound="Union[DeclarativeBase, Collection[DeclarativeBase]]") ElementType: TypeAlias = Union[ "Column[Any]", "RelationshipProperty[Any]", "CompositeProperty[Any]", "ColumnClause[Any]", "Label[Any]" ] SQLA_NS = {**vars(orm), **vars(sql)} @dataclass(frozen=True) class SQLAlchemyDTOConfig(DTOConfig): """Additional controls for the generated SQLAlchemy DTO.""" exclude: AbstractSet[Union[str, InstrumentedAttribute[Any]]] = field(default_factory=set) # type: ignore[assignment] # pyright: ignore[reportIncompatibleVariableOverride] """Explicitly exclude fields from the generated DTO. If exclude is specified, all fields not specified in exclude will be included by default. Notes: - The field names are dot-separated paths to nested fields, e.g. ``"address.street"`` will exclude the ``"street"`` field from a nested ``"address"`` model. - 'exclude' mutually exclusive with 'include' - specifying both values will raise an ``ImproperlyConfiguredException``. """ include: AbstractSet[Union[str, InstrumentedAttribute[Any]]] = field(default_factory=set) # type: ignore[assignment] # pyright: ignore[reportIncompatibleVariableOverride] """Explicitly include fields in the generated DTO. If include is specified, all fields not specified in include will be excluded by default. Notes: - The field names are dot-separated paths to nested fields, e.g. ``"address.street"`` will include the ``"street"`` field from a nested ``"address"`` model. - 'include' mutually exclusive with 'exclude' - specifying both values will raise an ``ImproperlyConfiguredException``. """ rename_fields: dict[Union[str, InstrumentedAttribute[Any]], str] = field(default_factory=dict) # type: ignore[assignment] # pyright: ignore[reportIncompatibleVariableOverride] """Mapping of field names, to new name.""" include_implicit_fields: Union[bool, Literal["hybrid-only"]] = True """Fields that are implicitly mapped are included. Turning this off will lead to exclude all fields not using ``Mapped`` annotation, When setting this to ``hybrid-only``, all implicitly mapped fields are excluded with the exception for hybrid properties. """ def __post_init__(self) -> None: super().__post_init__() object.__setattr__( self, "exclude", {f.key if isinstance(f, InstrumentedAttribute) else f for f in self.exclude} ) object.__setattr__( self, "include", {f.key if isinstance(f, InstrumentedAttribute) else f for f in self.include} ) object.__setattr__( self, "rename_fields", {f.key if isinstance(f, InstrumentedAttribute) else f: v for f, v in self.rename_fields.items()}, ) class SQLAlchemyDTO(AbstractDTO[T], Generic[T]): """Support for domain modelling with SQLAlchemy.""" config: ClassVar[SQLAlchemyDTOConfig] @staticmethod def _ensure_sqla_dto_config(config: Union[DTOConfig, SQLAlchemyDTOConfig]) -> SQLAlchemyDTOConfig: if not isinstance(config, SQLAlchemyDTOConfig): return SQLAlchemyDTOConfig(**asdict(config)) return config def __init_subclass__(cls, **kwargs: Any) -> None: super().__init_subclass__(**kwargs) if hasattr(cls, "config"): cls.config = cls._ensure_sqla_dto_config(cls.config) # pyright: ignore[reportIncompatibleVariableOverride] @singledispatchmethod @classmethod def handle_orm_descriptor( cls, extension_type: Union[NotExtension, AssociationProxyExtensionType, HybridExtensionType], orm_descriptor: InspectionAttr, key: str, model_type_hints: dict[str, FieldDefinition], model_name: str, ) -> list[DTOFieldDefinition]: msg = f"Unsupported extension type: {extension_type}" raise NotImplementedError(msg) @handle_orm_descriptor.register(NotExtension) @classmethod def _( cls, extension_type: NotExtension, key: str, orm_descriptor: InspectionAttr, model_type_hints: dict[str, FieldDefinition], model_name: str, ) -> list[DTOFieldDefinition]: if not isinstance(orm_descriptor, QueryableAttribute): # pragma: no cover msg = f"Unexpected descriptor type for '{extension_type}': '{orm_descriptor}'" raise NotImplementedError(msg) elem: ElementType if isinstance( orm_descriptor.property, # pyright: ignore[reportUnknownMemberType] ColumnProperty, # pragma: no cover ): if not isinstance( orm_descriptor.property.expression, # pyright: ignore[reportUnknownMemberType] (Column, ColumnClause, Label), ): msg = f"Expected 'Column', got: '{orm_descriptor.property.expression}, {type(orm_descriptor.property.expression)}'" # pyright: ignore[reportUnknownMemberType] raise NotImplementedError(msg) elem = orm_descriptor.property.expression # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType] elif isinstance(orm_descriptor.property, (RelationshipProperty, CompositeProperty)): # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType] elem = orm_descriptor.property # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType] else: # pragma: no cover msg = f"Unhandled property type: '{orm_descriptor.property}'" # pyright: ignore[reportUnknownMemberType] raise NotImplementedError(msg) default, default_factory = _detect_defaults(elem) # pyright: ignore try: if (field_definition := model_type_hints[key]).origin in { Mapped, WriteOnlyMapped, DynamicMapped, Relationship, }: (field_definition,) = field_definition.inner_types else: # pragma: no cover msg = f"Expected 'Mapped' origin, got: '{field_definition.origin}'" raise NotImplementedError(msg) except KeyError: field_definition = parse_type_from_element(elem, orm_descriptor) # pyright: ignore dto_field = elem.info.get(DTO_FIELD_META_KEY) if hasattr(elem, "info") else None # pyright: ignore if dto_field is None and isinstance(orm_descriptor, InstrumentedAttribute) and hasattr(orm_descriptor, "info"): # pyright: ignore dto_field = orm_descriptor.info.get(DTO_FIELD_META_KEY) # pyright: ignore if dto_field is None: dto_field = DTOField() return [ DTOFieldDefinition.from_field_definition( field_definition=replace( field_definition, name=key, default=default, ), default_factory=default_factory, dto_field=dto_field, model_name=model_name, ), ] @handle_orm_descriptor.register(AssociationProxyExtensionType) @classmethod def _( cls, extension_type: AssociationProxyExtensionType, key: str, orm_descriptor: InspectionAttr, model_type_hints: dict[str, FieldDefinition], model_name: str, ) -> list[DTOFieldDefinition]: if not isinstance(orm_descriptor, AssociationProxy): # pragma: no cover msg = f"Unexpected descriptor type '{orm_descriptor}' for '{extension_type}'" raise NotImplementedError(msg) if (field_definition := model_type_hints[key]).origin is AssociationProxy: (field_definition,) = field_definition.inner_types else: # pragma: no cover msg = f"Expected 'AssociationProxy' origin, got: '{field_definition.origin}'" raise NotImplementedError(msg) return [ DTOFieldDefinition.from_field_definition( field_definition=replace( field_definition, name=key, default=Empty, ), default_factory=None, dto_field=orm_descriptor.info.get( DTO_FIELD_META_KEY, DTOField(mark=Mark.READ_ONLY) ), # Mark as read-only model_name=model_name, ), ] @handle_orm_descriptor.register(HybridExtensionType) @classmethod def _( cls, extension_type: HybridExtensionType, key: str, orm_descriptor: InspectionAttr, model_type_hints: dict[str, FieldDefinition], model_name: str, ) -> list[DTOFieldDefinition]: if not isinstance(orm_descriptor, hybrid_property): msg = f"Unexpected descriptor type '{orm_descriptor}' for '{extension_type}'" raise NotImplementedError(msg) getter_sig = ParsedSignature.from_fn(orm_descriptor.fget, {}) # pyright: ignore[reportUnknownArgumentType,reportUnknownMemberType,reportAttributeAccessIssue] field_defs = [ DTOFieldDefinition.from_field_definition( field_definition=replace( getter_sig.return_type, name=orm_descriptor.__name__, default=Empty, ), default_factory=None, dto_field=orm_descriptor.info.get( DTO_FIELD_META_KEY, DTOField(mark=Mark.READ_ONLY) ), # Mark as read-only model_name=model_name, ), ] if orm_descriptor.fset is not None: # pyright: ignore[reportUnknownMemberType] setter_sig = ParsedSignature.from_fn(orm_descriptor.fset, {}) # pyright: ignore[reportUnknownArgumentType,reportUnknownMemberType] field_defs.append( DTOFieldDefinition.from_field_definition( field_definition=replace( next(iter(setter_sig.parameters.values())), name=orm_descriptor.__name__, default=Empty, ), default_factory=None, dto_field=orm_descriptor.info.get( DTO_FIELD_META_KEY, DTOField(mark=Mark.WRITE_ONLY) ), # Mark as read-only model_name=model_name, ), ) return field_defs @classmethod def _get_property_fields( cls, model_type: "type[DeclarativeBase]", processed_fields: set[str] ) -> "dict[str, FieldDefinition]": """Get fields defined as @property or @cached_property on the model. Properties are marked read-only; setter support is not implemented. Args: model_type: The SQLAlchemy model type to extract properties from. processed_fields: Fields to exclude Returns: A dictionary mapping property names to their field definitions. """ namespace = cls.get_model_namespace(model_type) sqla_internal_properties = {"awaitable_attrs", "registry", "metadata"} exclude = {*processed_fields, *sqla_internal_properties} properties: dict[str, FieldDefinition] = {} # hint[janek]: don't use inspect.getmembers, as it will evaluate descriptors # (including ORM descriptors, such as hybrid_property), which might have side # effects. from 3.11 onwards, inspect.getmembers_static is available, which does # not evaluate descriptors, however, for our use case, doing this manually still # offers the advantage of being able to skip previously processed fields. for name in dir(model_type): if name in exclude: continue try: member = getattr(model_type, name) except AttributeError: continue if isinstance(member, cached_property): func = member.func elif isinstance(member, property): if member.fget is None: continue func = member.fget else: continue try: sig = ParsedSignature.from_fn(func, namespace) properties[name] = replace(sig.return_type, name=name) except (AttributeError, TypeError, ValueError) as e: logger.debug( "could not parse type hint for property %s.%s: %s, using Any type", model_type.__name__, name, e, ) properties[name] = FieldDefinition.from_annotation(Any, name=name) return properties @classmethod def generate_field_definitions(cls, model_type: type[DeclarativeBase]) -> Generator[DTOFieldDefinition, None, None]: """Generate DTO field definitions from a SQLAlchemy model. Args: model_type (typing.Type[sqlalchemy.orm.DeclarativeBase]): The SQLAlchemy model type to generate field definitions from. Yields: collections.abc.Generator[litestar.dto.data_structures.DTOFieldDefinition, None, None]: A generator yielding DTO field definitions. Raises: RuntimeError: If the mapper cannot be found for the model type. """ if (mapper := inspect(model_type)) is None: # pragma: no cover # pyright: ignore[reportUnnecessaryComparison] msg = "Unexpected `None` value for mapper." # type: ignore[unreachable] raise RuntimeError(msg) # includes SQLAlchemy names and other mapped class names in the forward reference resolution namespace namespace = {**SQLA_NS, **{m.class_.__name__: m.class_ for m in mapper.registry.mappers if m is not mapper}} model_type_hints = cls.get_model_type_hints(model_type, namespace=namespace) model_name = model_type.__name__ include_implicit_fields = cls.config.include_implicit_fields # the same hybrid property descriptor can be included in `all_orm_descriptors` multiple times, once # for each method name it is bound to. We only need to see it once, so track views of it here. seen_hybrid_descriptors: set[hybrid_property] = set() # pyright: ignore[reportUnknownVariableType,reportMissingTypeArgument] skipped_descriptors: set[str] = set() processed_fields: set[str] = set() for composite_property in mapper.composites: # pragma: no cover for attr in composite_property.attrs: if isinstance(attr, (MappedColumn, Column)): skipped_descriptors.add(attr.name) elif isinstance(attr, str): skipped_descriptors.add(attr) processed_fields.update(skipped_descriptors) yielded_sqla_keys: set[str] = set() # Keep track of keys yielded by SQLAlchemy logic for key, orm_descriptor in mapper.all_orm_descriptors.items(): processed_fields.add(key) if is_hybrid_property := isinstance(orm_descriptor, hybrid_property): if orm_descriptor in seen_hybrid_descriptors: continue seen_hybrid_descriptors.add(orm_descriptor) # pyright: ignore[reportUnknownMemberType] if key in skipped_descriptors: continue should_skip_descriptor = False dto_field: Optional[DTOField] = None if hasattr(orm_descriptor, "property"): # pyright: ignore[reportUnknownArgumentType] # Access info safely, checking if property exists first prop = getattr(orm_descriptor, "property", None) # pyright: ignore[reportUnknownArgumentType] if prop and hasattr(prop, "info"): dto_field = prop.info.get(DTO_FIELD_META_KEY) elif hasattr(orm_descriptor, "info"): # pyright: ignore[reportUnknownArgumentType] dto_field = orm_descriptor.info.get(DTO_FIELD_META_KEY) # pyright: ignore[reportUnknownArgumentType,reportUnknownMemberType,reportAttributeAccessIssue,reportUnknownVariableType] # Case 1 is_field_marked_not_private = dto_field and dto_field.mark is not Mark.PRIVATE # pyright: ignore[reportUnknownVariableType,reportUnknownMemberType] # Case 2 should_exclude_anything_implicit = not include_implicit_fields and key not in model_type_hints # Case 3 should_exclude_non_hybrid_only = ( not is_hybrid_property and include_implicit_fields == "hybrid-only" and key not in model_type_hints ) # Descriptor is marked with either Mark.READ_ONLY or Mark.WRITE_ONLY (see Case 1): # - always include it regardless of anything else. # Descriptor is not marked: # - It's implicit BUT config excludes anything implicit (see Case 2): exclude # - It's implicit AND not hybrid BUT config includes hybrid-only implicit descriptors (Case 3): exclude should_skip_descriptor = not is_field_marked_not_private and ( should_exclude_anything_implicit or should_exclude_non_hybrid_only ) if should_skip_descriptor: continue # Yield definitions from SQLAlchemy descriptor handling definitions = cls.handle_orm_descriptor( orm_descriptor.extension_type, key, orm_descriptor, model_type_hints, model_name, ) for definition in definitions: yielded_sqla_keys.add(definition.name) # Track yielded key yield definition property_fields = cls._get_property_fields(model_type, processed_fields) for key, property_field_definition in property_fields.items(): if key.startswith("_") or key in yielded_sqla_keys: continue yield DTOFieldDefinition.from_field_definition( field_definition=replace( property_field_definition, name=key, default=Empty, ), model_name=model_name, default_factory=None, dto_field=DTOField(mark=Mark.READ_ONLY), ) @classmethod def detect_nested_field(cls, field_definition: FieldDefinition) -> bool: return field_definition.is_subclass_of(DeclarativeBase) def _detect_defaults(elem: ElementType) -> tuple[Any, Any]: default: Any = Empty default_factory: Any = None # pyright:ignore if sqla_default := getattr(elem, "default", None): if sqla_default.is_scalar: default = sqla_default.arg elif sqla_default.is_callable: def default_factory(d: Any = sqla_default) -> Any: return d.arg({}) elif sqla_default.is_sequence or sqla_default.is_sentinel: # SQLAlchemy sequences represent server side defaults # so we cannot infer a reasonable default value for # them on the client side pass else: msg = "Unexpected default type" raise ValueError(msg) elif (isinstance(elem, RelationshipProperty) and detect_nullable_relationship(elem)) or getattr( elem, "nullable", False ): default = None return default, default_factory def parse_type_from_element(elem: ElementType, orm_descriptor: InspectionAttr) -> FieldDefinition: # noqa: PLR0911 """Parses a type from a SQLAlchemy element. Args: elem: The SQLAlchemy element to parse. orm_descriptor: The attribute `elem` was extracted from. Raises: ImproperConfigurationError: If the type cannot be parsed. Returns: FieldDefinition: The parsed type. """ if isinstance(elem, Column): if elem.nullable: return FieldDefinition.from_annotation(Optional[elem.type.python_type]) return FieldDefinition.from_annotation(elem.type.python_type) if isinstance(elem, RelationshipProperty): if ( elem.direction in {RelationshipDirection.ONETOMANY, RelationshipDirection.MANYTOMANY} and elem.uselist is not False ): collection_type = FieldDefinition.from_annotation(elem.collection_class or list) # pyright: ignore[reportUnknownMemberType] return FieldDefinition.from_annotation(collection_type.safe_generic_origin[elem.mapper.class_]) if detect_nullable_relationship(elem): return FieldDefinition.from_annotation(Optional[elem.mapper.class_]) return FieldDefinition.from_annotation(elem.mapper.class_) if isinstance(elem, CompositeProperty): return FieldDefinition.from_annotation(elem.composite_class) if isinstance(orm_descriptor, InstrumentedAttribute): return FieldDefinition.from_annotation(orm_descriptor.type.python_type) msg = f"Unable to parse type from element '{elem}'. Consider adding a type hint." raise ImproperConfigurationError(msg) def detect_nullable_relationship(elem: RelationshipProperty[Any]) -> bool: """Detects if a relationship is nullable. For MANYTOONE relationships (FK on this side), checks if all FK columns are nullable. For ONETOMANY with uselist=False (inverse side of one-to-one), the related object may not exist since no local FK enforces it, so these are always treated as nullable. Args: elem: The relationship to check. Returns: bool: ``True`` if the relationship is nullable, ``False`` otherwise. """ if elem.direction == RelationshipDirection.MANYTOONE: return all(c.nullable for c in elem.local_columns) return elem.direction == RelationshipDirection.ONETOMANY and elem.uselist is False python-advanced-alchemy-1.9.3/advanced_alchemy/extensions/litestar/exception_handler.py000066400000000000000000000033051516556515500316110ustar00rootroot00000000000000from typing import TYPE_CHECKING, Any from litestar.connection import Request from litestar.connection.base import AuthT, StateT, UserT from litestar.exceptions import ( ClientException, HTTPException, InternalServerException, NotFoundException, ) from litestar.exceptions.responses import ( create_debug_response, # pyright: ignore[reportUnknownVariableType] create_exception_response, # pyright: ignore[reportUnknownVariableType] ) from litestar.response import Response from litestar.status_codes import ( HTTP_409_CONFLICT, ) from advanced_alchemy.exceptions import ( DuplicateKeyError, ForeignKeyError, IntegrityError, NotFoundError, RepositoryError, ) if TYPE_CHECKING: from litestar.connection import Request from litestar.connection.base import AuthT, StateT, UserT from litestar.response import Response class ConflictError(ClientException): """Request conflict with the current state of the target resource.""" status_code: int = HTTP_409_CONFLICT def exception_to_http_response(request: "Request[UserT, AuthT, StateT]", exc: "RepositoryError") -> "Response[Any]": """Handler for all exceptions subclassed from HTTPException.""" if isinstance(exc, NotFoundError): http_exc: type[HTTPException] = NotFoundException elif isinstance(exc, (DuplicateKeyError, IntegrityError, ForeignKeyError)): http_exc = ConflictError else: http_exc = InternalServerException if request.app.debug: return create_debug_response(request, exc) # pyright: ignore[reportUnknownVariableType] return create_exception_response(request, http_exc(detail=str(exc.detail))) # pyright: ignore[reportUnknownVariableType] python-advanced-alchemy-1.9.3/advanced_alchemy/extensions/litestar/plugins/000077500000000000000000000000001516556515500272245ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/advanced_alchemy/extensions/litestar/plugins/__init__.py000066400000000000000000000034351516556515500313420ustar00rootroot00000000000000from collections.abc import Sequence from typing import Union from litestar.config.app import AppConfig from litestar.plugins import InitPluginProtocol from advanced_alchemy.extensions.litestar.plugins import _slots_base from advanced_alchemy.extensions.litestar.plugins.init import ( EngineConfig, SQLAlchemyAsyncConfig, SQLAlchemyInitPlugin, SQLAlchemySyncConfig, ) from advanced_alchemy.extensions.litestar.plugins.serialization import SQLAlchemySerializationPlugin class SQLAlchemyPlugin(InitPluginProtocol, _slots_base.SlotsBase): """A plugin that provides SQLAlchemy integration.""" def __init__( self, config: Union[ SQLAlchemyAsyncConfig, SQLAlchemySyncConfig, Sequence[Union[SQLAlchemyAsyncConfig, SQLAlchemySyncConfig]] ], ) -> None: """Initialize ``SQLAlchemyPlugin``. Args: config: configure DB connection and hook handlers and dependencies. """ self._config = config if isinstance(config, Sequence) else [config] @property def config( self, ) -> Sequence[Union[SQLAlchemyAsyncConfig, SQLAlchemySyncConfig]]: return self._config def on_app_init(self, app_config: AppConfig) -> AppConfig: """Configure application for use with SQLAlchemy. Args: app_config: The :class:`AppConfig <.config.app.AppConfig>` instance. Returns: The :class:`AppConfig <.config.app.AppConfig>` instance. """ app_config.plugins.extend([SQLAlchemyInitPlugin(config=self._config), SQLAlchemySerializationPlugin()]) return app_config __all__ = ( "EngineConfig", "SQLAlchemyAsyncConfig", "SQLAlchemyInitPlugin", "SQLAlchemyPlugin", "SQLAlchemySerializationPlugin", "SQLAlchemySyncConfig", ) python-advanced-alchemy-1.9.3/advanced_alchemy/extensions/litestar/plugins/_slots_base.py000066400000000000000000000004341516556515500320740ustar00rootroot00000000000000"""Base class that aggregates slots for all SQLAlchemy plugins. See: https://stackoverflow.com/questions/53060607/python-3-6-5-multiple-bases-have-instance-lay-out-conflict-when-multi-inherit """ class SlotsBase: __slots__ = ( "_config", "_type_dto_map", ) python-advanced-alchemy-1.9.3/advanced_alchemy/extensions/litestar/plugins/init/000077500000000000000000000000001516556515500301675ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/advanced_alchemy/extensions/litestar/plugins/init/__init__.py000066400000000000000000000005421516556515500323010ustar00rootroot00000000000000from advanced_alchemy.extensions.litestar.plugins.init.config import ( EngineConfig, SQLAlchemyAsyncConfig, SQLAlchemySyncConfig, ) from advanced_alchemy.extensions.litestar.plugins.init.plugin import SQLAlchemyInitPlugin __all__ = ( "EngineConfig", "SQLAlchemyAsyncConfig", "SQLAlchemyInitPlugin", "SQLAlchemySyncConfig", ) python-advanced-alchemy-1.9.3/advanced_alchemy/extensions/litestar/plugins/init/config/000077500000000000000000000000001516556515500314345ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/advanced_alchemy/extensions/litestar/plugins/init/config/__init__.py000066400000000000000000000005671516556515500335550ustar00rootroot00000000000000from advanced_alchemy.extensions.litestar.plugins.init.config.asyncio import SQLAlchemyAsyncConfig from advanced_alchemy.extensions.litestar.plugins.init.config.engine import EngineConfig from advanced_alchemy.extensions.litestar.plugins.init.config.sync import SQLAlchemySyncConfig __all__ = ( "EngineConfig", "SQLAlchemyAsyncConfig", "SQLAlchemySyncConfig", ) python-advanced-alchemy-1.9.3/advanced_alchemy/extensions/litestar/plugins/init/config/asyncio.py000066400000000000000000000301251516556515500334540ustar00rootroot00000000000000import logging from collections.abc import AsyncGenerator, Coroutine from contextlib import asynccontextmanager from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Callable, Literal, Optional, Union, cast from litestar.cli._utils import console # pyright: ignore from litestar.constants import HTTP_RESPONSE_START from sqlalchemy.exc import OperationalError from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession from advanced_alchemy.base import metadata_registry from advanced_alchemy.config.asyncio import SQLAlchemyAsyncConfig as _SQLAlchemyAsyncConfig from advanced_alchemy.extensions.litestar._utils import ( delete_aa_scope_state, get_aa_scope_state, set_aa_scope_state, ) from advanced_alchemy.extensions.litestar.plugins.init.config.common import ( SESSION_SCOPE_KEY, SESSION_TERMINUS_ASGI_EVENTS, ) from advanced_alchemy.extensions.litestar.plugins.init.config.engine import EngineConfig from advanced_alchemy.routing.context import reset_routing_context logger = logging.getLogger("advanced_alchemy.extensions.litestar") if TYPE_CHECKING: from collections.abc import AsyncGenerator, Coroutine from litestar import Litestar from litestar.datastructures.state import State from litestar.types import BeforeMessageSendHookHandler, Message, Scope # noinspection PyUnresolvedReferences __all__ = ( "SQLAlchemyAsyncConfig", "autocommit_before_send_handler", "autocommit_handler_maker", "default_before_send_handler", "default_handler_maker", ) def default_handler_maker( session_scope_key: str = SESSION_SCOPE_KEY, ) -> "Callable[[Message, Scope], Coroutine[Any, Any, None]]": """Set up the handler to issue a transaction commit or rollback based on specified status codes Args: session_scope_key: The key to use within the application state Returns: The handler callable """ async def handler(message: "Message", scope: "Scope") -> None: """Handle commit/rollback, closing and cleaning up sessions before sending. Args: message: ASGI-``Message`` scope: An ASGI-``Scope`` """ session = cast("Optional[AsyncSession]", get_aa_scope_state(scope, session_scope_key)) if session and message["type"] in SESSION_TERMINUS_ASGI_EVENTS: await session.close() delete_aa_scope_state(scope, session_scope_key) return handler default_before_send_handler = default_handler_maker() def autocommit_handler_maker( commit_on_redirect: bool = False, extra_commit_statuses: Optional[set[int]] = None, extra_rollback_statuses: Optional[set[int]] = None, session_scope_key: str = SESSION_SCOPE_KEY, ) -> "Callable[[Message, Scope], Coroutine[Any, Any, None]]": """Set up the handler to issue a transaction commit or rollback based on specified status codes Args: commit_on_redirect: Issue a commit when the response status is a redirect (``3XX``) extra_commit_statuses: A set of additional status codes that trigger a commit extra_rollback_statuses: A set of additional status codes that trigger a rollback session_scope_key: The key to use within the application state Raises: ValueError: If the extra commit statuses and extra rollback statuses share any status codes Returns: The handler callable """ if extra_commit_statuses is None: extra_commit_statuses = set() if extra_rollback_statuses is None: extra_rollback_statuses = set() if len(extra_commit_statuses & extra_rollback_statuses) > 0: msg = "Extra rollback statuses and commit statuses must not share any status codes" raise ValueError(msg) commit_range = range(200, 400 if commit_on_redirect else 300) async def handler(message: "Message", scope: "Scope") -> None: """Handle commit/rollback, closing and cleaning up sessions before sending. Args: message: ASGI-``litestar.types.Message`` scope: An ASGI-``litestar.types.Scope`` """ session = cast("Optional[AsyncSession]", get_aa_scope_state(scope, session_scope_key)) try: if session is not None and message["type"] == HTTP_RESPONSE_START: if (message["status"] in commit_range or message["status"] in extra_commit_statuses) and message[ "status" ] not in extra_rollback_statuses: await session.commit() else: await session.rollback() except Exception: # noqa: BLE001 logger.debug("Session commit/rollback failed during cleanup", exc_info=True) finally: if session and message["type"] in SESSION_TERMINUS_ASGI_EVENTS: try: await session.close() except Exception: # noqa: BLE001 logger.debug("Session close failed during cleanup", exc_info=True) delete_aa_scope_state(scope, session_scope_key) return handler autocommit_before_send_handler = autocommit_handler_maker() @dataclass class SQLAlchemyAsyncConfig(_SQLAlchemyAsyncConfig): """Litestar Async SQLAlchemy Configuration.""" before_send_handler: Optional[ Union["BeforeMessageSendHookHandler", Literal["autocommit", "autocommit_include_redirects"]] ] = None """Handler to call before the ASGI message is sent. The handler should handle closing the session stored in the ASGI scope, if it's still open, and committing and uncommitted data. """ engine_dependency_key: str = "db_engine" """Key to use for the dependency injection of database engines.""" session_dependency_key: str = "db_session" """Key to use for the dependency injection of database sessions.""" engine_app_state_key: str = "db_engine" """Key under which to store the SQLAlchemy engine in the application :class:`State ` instance. """ session_maker_app_state_key: str = "session_maker_class" """Key under which to store the SQLAlchemy :class:`sessionmaker ` in the application :class:`State ` instance. """ session_scope_key: str = SESSION_SCOPE_KEY """Key under which to store the SQLAlchemy scope in the application.""" engine_config: EngineConfig = field(default_factory=EngineConfig) # pyright: ignore[reportIncompatibleVariableOverride] """Configuration for the SQLAlchemy engine. The configuration options are documented in the SQLAlchemy documentation. """ set_default_exception_handler: bool = True """Sets the default exception handler on application start.""" def _ensure_unique(self, registry_name: str, key: str, new_key: Optional[str] = None, _iter: int = 0) -> str: new_key = new_key if new_key is not None else key if new_key in getattr(self.__class__, registry_name, {}): _iter += 1 new_key = self._ensure_unique(registry_name, key, f"{key}_{_iter}", _iter) return new_key def __post_init__(self) -> None: self.session_scope_key = self._ensure_unique("_SESSION_SCOPE_KEY_REGISTRY", self.session_scope_key) self.engine_app_state_key = self._ensure_unique("_ENGINE_APP_STATE_KEY_REGISTRY", self.engine_app_state_key) self.session_maker_app_state_key = self._ensure_unique( "_SESSIONMAKER_APP_STATE_KEY_REGISTRY", self.session_maker_app_state_key, ) self.__class__._SESSION_SCOPE_KEY_REGISTRY.add(self.session_scope_key) # noqa: SLF001 self.__class__._ENGINE_APP_STATE_KEY_REGISTRY.add(self.engine_app_state_key) # noqa: SLF001 self.__class__._SESSIONMAKER_APP_STATE_KEY_REGISTRY.add(self.session_maker_app_state_key) # noqa: SLF001 if self.before_send_handler is None: self.before_send_handler = default_handler_maker(session_scope_key=self.session_scope_key) if self.before_send_handler == "autocommit": self.before_send_handler = autocommit_handler_maker(session_scope_key=self.session_scope_key) if self.before_send_handler == "autocommit_include_redirects": self.before_send_handler = autocommit_handler_maker( session_scope_key=self.session_scope_key, commit_on_redirect=True, ) super().__post_init__() def create_session_maker(self) -> "Callable[[], AsyncSession]": """Get a session maker. If none exists yet, create one. Returns: Session factory used by the plugin. """ if self.session_maker: return self.session_maker session_kws = self.session_config_dict if session_kws.get("bind") is None: session_kws["bind"] = self.get_engine() return self.session_maker_class(**session_kws) # pyright: ignore[reportUnknownVariableType,reportUnknownMemberType] @asynccontextmanager async def lifespan( self, app: "Litestar", ) -> "AsyncGenerator[None, None]": deps = self.create_app_state_items() app.state.update(deps) try: if self.create_all: await self.create_all_metadata(app) yield finally: if self.engine_dependency_key in deps: engine = deps[self.engine_dependency_key] if hasattr(engine, "dispose"): await cast("AsyncEngine", engine).dispose() def provide_engine(self, state: "State") -> "AsyncEngine": """Create an engine instance. Args: state: The ``Litestar.state`` instance. Returns: An engine instance. """ return cast("AsyncEngine", state.get(self.engine_app_state_key)) def provide_session(self, state: "State", scope: "Scope") -> "AsyncSession": """Create a session instance. Args: state: The ``Litestar.state`` instance. scope: The current connection's scope. Returns: A session instance. """ # Import locally to avoid potential circular dependency issues at module level from advanced_alchemy._listeners import set_async_context session = cast("Optional[AsyncSession]", get_aa_scope_state(scope, self.session_scope_key)) if session is None: # Reset routing context for request-scoped isolation when creating a new session reset_routing_context() session_maker = cast("Callable[[], AsyncSession]", state[self.session_maker_app_state_key]) session = session_maker() set_aa_scope_state(scope, self.session_scope_key, session) set_async_context(True) # Set context before yielding return session @property def signature_namespace(self) -> dict[str, Any]: """Return the plugin's signature namespace. Returns: A string keyed dict of names to be added to the namespace for signature forward reference resolution. """ return {"AsyncEngine": AsyncEngine, "AsyncSession": AsyncSession} async def create_all_metadata(self, app: "Litestar") -> None: """Create all metadata Args: app (Litestar): The ``Litestar`` instance """ async with self.get_engine().begin() as conn: try: await conn.run_sync(metadata_registry.get(self.bind_key).create_all) except OperationalError as exc: console.print(f"[bold red] * Could not create target metadata. Reason: {exc}") def create_app_state_items(self) -> dict[str, Any]: """Key/value pairs to be stored in application state. Returns: A dictionary of key/value pairs to be stored in application state. """ return { self.engine_app_state_key: self.get_engine(), self.session_maker_app_state_key: self.create_session_maker(), } def update_app_state(self, app: "Litestar") -> None: """Set the app state with engine and session. Args: app: The ``Litestar`` instance. """ app.state.update(self.create_app_state_items()) python-advanced-alchemy-1.9.3/advanced_alchemy/extensions/litestar/plugins/init/config/common.py000066400000000000000000000005211516556515500332740ustar00rootroot00000000000000from litestar.constants import HTTP_DISCONNECT, HTTP_RESPONSE_START, WEBSOCKET_CLOSE, WEBSOCKET_DISCONNECT SESSION_SCOPE_KEY = "_sqlalchemy_db_session" """Session scope key.""" SESSION_TERMINUS_ASGI_EVENTS = {HTTP_RESPONSE_START, HTTP_DISCONNECT, WEBSOCKET_DISCONNECT, WEBSOCKET_CLOSE} """ASGI events that terminate a session scope.""" python-advanced-alchemy-1.9.3/advanced_alchemy/extensions/litestar/plugins/init/config/engine.py000066400000000000000000000022301516556515500332500ustar00rootroot00000000000000from dataclasses import dataclass from typing import Any, Callable from litestar.serialization import decode_json, encode_json from advanced_alchemy.config import EngineConfig as _EngineConfig __all__ = ("EngineConfig",) def serializer(value: Any) -> str: """Serialize JSON field values. Args: value: Any json serializable value. Returns: JSON string. """ return encode_json(value).decode("utf-8") @dataclass class EngineConfig(_EngineConfig): """Configuration for SQLAlchemy's :class:`Engine `. For details see: https://docs.sqlalchemy.org/en/20/core/engines.html """ json_deserializer: Callable[[str], Any] = decode_json """For dialects that support the :class:`JSON ` datatype, this is a Python callable that will convert a JSON string to a Python object. By default, this is set to Litestar's decode_json function.""" json_serializer: Callable[[Any], str] = serializer """For dialects that support the JSON datatype, this is a Python callable that will render a given object as JSON. By default, Litestar's encode_json function is used.""" python-advanced-alchemy-1.9.3/advanced_alchemy/extensions/litestar/plugins/init/config/sync.py000066400000000000000000000273411516556515500327710ustar00rootroot00000000000000import logging from contextlib import asynccontextmanager from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Callable, Literal, Optional, Union, cast from litestar.cli._utils import console # pyright: ignore from litestar.constants import HTTP_RESPONSE_START from sqlalchemy import Engine from sqlalchemy.exc import OperationalError from sqlalchemy.orm import Session from advanced_alchemy.base import metadata_registry from advanced_alchemy.config.sync import SQLAlchemySyncConfig as _SQLAlchemySyncConfig from advanced_alchemy.extensions.litestar._utils import ( delete_aa_scope_state, get_aa_scope_state, set_aa_scope_state, ) from advanced_alchemy.extensions.litestar.plugins.init.config.common import ( SESSION_SCOPE_KEY, SESSION_TERMINUS_ASGI_EVENTS, ) from advanced_alchemy.extensions.litestar.plugins.init.config.engine import EngineConfig from advanced_alchemy.routing.context import reset_routing_context logger = logging.getLogger("advanced_alchemy.extensions.litestar") if TYPE_CHECKING: from collections.abc import AsyncGenerator from litestar import Litestar from litestar.datastructures.state import State from litestar.types import BeforeMessageSendHookHandler, Message, Scope __all__ = ( "SQLAlchemySyncConfig", "autocommit_before_send_handler", "autocommit_handler_maker", "default_before_send_handler", "default_handler_maker", ) def default_handler_maker( session_scope_key: str = SESSION_SCOPE_KEY, ) -> "Callable[[Message, Scope], None]": """Set up the handler to issue a transaction commit or rollback based on specified status codes Args: session_scope_key: The key to use within the application state Returns: The handler callable """ def handler(message: "Message", scope: "Scope") -> None: """Handle commit/rollback, closing and cleaning up sessions before sending. Args: message: ASGI-``Message`` scope: An ASGI-``Scope`` Returns: None """ session = cast("Optional[Session]", get_aa_scope_state(scope, session_scope_key)) if session and message["type"] in SESSION_TERMINUS_ASGI_EVENTS: session.close() delete_aa_scope_state(scope, session_scope_key) return handler default_before_send_handler = default_handler_maker() def autocommit_handler_maker( commit_on_redirect: bool = False, extra_commit_statuses: "Optional[set[int]]" = None, extra_rollback_statuses: "Optional[set[int]]" = None, session_scope_key: str = SESSION_SCOPE_KEY, ) -> "Callable[[Message, Scope], None]": """Set up the handler to issue a transaction commit or rollback based on specified status codes Args: commit_on_redirect: Issue a commit when the response status is a redirect (``3XX``) extra_commit_statuses: A set of additional status codes that trigger a commit extra_rollback_statuses: A set of additional status codes that trigger a rollback session_scope_key: The key to use within the application state Raises: ValueError: If extra rollback statuses and commit statuses share any status codes Returns: The handler callable """ if extra_commit_statuses is None: extra_commit_statuses = set() if extra_rollback_statuses is None: extra_rollback_statuses = set() if len(extra_commit_statuses & extra_rollback_statuses) > 0: msg = "Extra rollback statuses and commit statuses must not share any status codes" raise ValueError(msg) commit_range = range(200, 400 if commit_on_redirect else 300) def handler(message: "Message", scope: "Scope") -> None: """Handle commit/rollback, closing and cleaning up sessions before sending. Args: message: ASGI-``Message`` scope: An ASGI-``Scope`` """ session = cast("Optional[Session]", get_aa_scope_state(scope, session_scope_key)) try: if session is not None and message["type"] == HTTP_RESPONSE_START: if (message["status"] in commit_range or message["status"] in extra_commit_statuses) and message[ "status" ] not in extra_rollback_statuses: session.commit() else: session.rollback() except Exception: # noqa: BLE001 logger.debug("Session commit/rollback failed during cleanup", exc_info=True) finally: if session and message["type"] in SESSION_TERMINUS_ASGI_EVENTS: try: session.close() except Exception: # noqa: BLE001 logger.debug("Session close failed during cleanup", exc_info=True) delete_aa_scope_state(scope, session_scope_key) return handler autocommit_before_send_handler = autocommit_handler_maker() @dataclass class SQLAlchemySyncConfig(_SQLAlchemySyncConfig): """Litestar Sync SQLAlchemy Configuration.""" before_send_handler: Optional[ Union["BeforeMessageSendHookHandler", Literal["autocommit", "autocommit_include_redirects"]] ] = None """Handler to call before the ASGI message is sent. The handler should handle closing the session stored in the ASGI scope, if it's still open, and committing and uncommitted data. """ engine_dependency_key: str = "db_engine" """Key to use for the dependency injection of database engines.""" session_dependency_key: str = "db_session" """Key to use for the dependency injection of database sessions.""" engine_app_state_key: str = "db_engine" """Key under which to store the SQLAlchemy engine in the application :class:`State <.datastructures.State>` instance. """ session_maker_app_state_key: str = "session_maker_class" """Key under which to store the SQLAlchemy :class:`sessionmaker ` in the application :class:`State <.datastructures.State>` instance. """ session_scope_key: str = SESSION_SCOPE_KEY """Key under which to store the SQLAlchemy scope in the application.""" engine_config: EngineConfig = field(default_factory=EngineConfig) # pyright: ignore[reportIncompatibleVariableOverride] """Configuration for the SQLAlchemy engine. The configuration options are documented in the SQLAlchemy documentation. """ set_default_exception_handler: bool = True """Sets the default exception handler on application start.""" def _ensure_unique(self, registry_name: str, key: str, new_key: Optional[str] = None, _iter: int = 0) -> str: new_key = new_key if new_key is not None else key if new_key in getattr(self.__class__, registry_name, {}): _iter += 1 new_key = self._ensure_unique(registry_name, key, f"{key}_{_iter}", _iter) return new_key def __post_init__(self) -> None: self.session_scope_key = self._ensure_unique("_SESSION_SCOPE_KEY_REGISTRY", self.session_scope_key) self.engine_app_state_key = self._ensure_unique("_ENGINE_APP_STATE_KEY_REGISTRY", self.engine_app_state_key) self.session_maker_app_state_key = self._ensure_unique( "_SESSIONMAKER_APP_STATE_KEY_REGISTRY", self.session_maker_app_state_key, ) self.__class__._SESSION_SCOPE_KEY_REGISTRY.add(self.session_scope_key) # noqa: SLF001 self.__class__._ENGINE_APP_STATE_KEY_REGISTRY.add(self.engine_app_state_key) # noqa: SLF001 self.__class__._SESSIONMAKER_APP_STATE_KEY_REGISTRY.add(self.session_maker_app_state_key) # noqa: SLF001 if self.before_send_handler is None: self.before_send_handler = default_handler_maker(session_scope_key=self.session_scope_key) if self.before_send_handler == "autocommit": self.before_send_handler = autocommit_handler_maker(session_scope_key=self.session_scope_key) if self.before_send_handler == "autocommit_include_redirects": self.before_send_handler = autocommit_handler_maker( session_scope_key=self.session_scope_key, commit_on_redirect=True, ) super().__post_init__() def create_session_maker(self) -> "Callable[[], Session]": """Get a session maker. If none exists yet, create one. Returns: Session factory used by the plugin. """ if self.session_maker: return self.session_maker session_kws = self.session_config_dict if session_kws.get("bind") is None: session_kws["bind"] = self.get_engine() return self.session_maker_class(**session_kws) @asynccontextmanager async def lifespan( self, app: "Litestar", ) -> "AsyncGenerator[None, None]": deps = self.create_app_state_items() app.state.update(deps) try: if self.create_all: self.create_all_metadata(app) yield finally: if self.engine_dependency_key in deps: engine = deps[self.engine_dependency_key] if hasattr(engine, "dispose"): cast("Engine", engine).dispose() def provide_engine(self, state: "State") -> "Engine": """Create an engine instance. Args: state: The ``Litestar.state`` instance. Returns: An engine instance. """ return cast("Engine", state.get(self.engine_app_state_key)) def provide_session(self, state: "State", scope: "Scope") -> "Session": """Create a session instance. Args: state: The ``Litestar.state`` instance. scope: The current connection's scope. Returns: A session instance. """ # Import locally to avoid potential circular dependency issues at module level from advanced_alchemy._listeners import set_async_context session = cast("Optional[Session]", get_aa_scope_state(scope, self.session_scope_key)) if session is None: # Reset routing context for request-scoped isolation when creating a new session reset_routing_context() session_maker = cast("Callable[[], Session]", state[self.session_maker_app_state_key]) session = session_maker() set_aa_scope_state(scope, self.session_scope_key, session) set_async_context(False) # Set context before yielding return session @property def signature_namespace(self) -> "dict[str, Any]": """Return the plugin's signature namespace. Returns: A string keyed dict of names to be added to the namespace for signature forward reference resolution. """ return {"Engine": Engine, "Session": Session} def create_all_metadata(self, app: "Litestar") -> None: """Create all metadata Args: app (Litestar): The ``Litestar`` instance """ with self.get_engine().begin() as conn: try: metadata_registry.get(self.bind_key).create_all(bind=conn) except OperationalError as exc: console.print(f"[bold red] * Could not create target metadata. Reason: {exc}") def create_app_state_items(self) -> "dict[str, Any]": """Key/value pairs to be stored in application state. Returns: A dictionary of key/value pairs to be stored in application state. """ return { self.engine_app_state_key: self.get_engine(), self.session_maker_app_state_key: self.create_session_maker(), } def update_app_state(self, app: "Litestar") -> None: """Set the app state with engine and session. Args: app: The ``Litestar`` instance. """ app.state.update(self.create_app_state_items()) python-advanced-alchemy-1.9.3/advanced_alchemy/extensions/litestar/plugins/init/plugin.py000066400000000000000000000152501516556515500320420ustar00rootroot00000000000000import contextlib from collections.abc import Sequence from typing import TYPE_CHECKING, Any, Union, cast from litestar.di import Provide from litestar.dto import DTOData from litestar.params import Dependency, Parameter from litestar.plugins import CLIPlugin, InitPluginProtocol from sqlalchemy.ext.asyncio import AsyncSession, async_scoped_session from sqlalchemy.orm import Session, scoped_session from advanced_alchemy.exceptions import ImproperConfigurationError, RepositoryError from advanced_alchemy.extensions.litestar.exception_handler import exception_to_http_response from advanced_alchemy.extensions.litestar.plugins import _slots_base from advanced_alchemy.filters import ( BeforeAfter, CollectionFilter, ComparisonFilter, ExistsFilter, FilterGroup, FilterMap, FilterTypes, InAnyFilter, LimitOffset, LogicalOperatorMap, MultiFilter, NotExistsFilter, NotInCollectionFilter, NotInSearchFilter, OnBeforeAfter, OrderBy, SearchFilter, StatementFilter, StatementTypeT, ) from advanced_alchemy.service import ModelDictListT, ModelDictT, ModelDTOT, ModelOrRowMappingT, ModelT, OffsetPagination if TYPE_CHECKING: from click import Group from litestar.config.app import AppConfig from litestar.types import BeforeMessageSendHookHandler from advanced_alchemy.extensions.litestar.plugins.init.config import SQLAlchemyAsyncConfig, SQLAlchemySyncConfig __all__ = ("SQLAlchemyInitPlugin",) signature_namespace_values: dict[str, Any] = { "BeforeAfter": BeforeAfter, "OnBeforeAfter": OnBeforeAfter, "CollectionFilter": CollectionFilter, "LimitOffset": LimitOffset, "OrderBy": OrderBy, "SearchFilter": SearchFilter, "NotInCollectionFilter": NotInCollectionFilter, "NotInSearchFilter": NotInSearchFilter, "FilterTypes": FilterTypes, "OffsetPagination": OffsetPagination, "ExistsFilter": ExistsFilter, "Parameter": Parameter, "Dependency": Dependency, "DTOData": DTOData, "Sequence": Sequence, "ModelT": ModelT, "ModelDictT": ModelDictT, "ModelDTOT": ModelDTOT, "ModelDictListT": ModelDictListT, "ModelOrRowMappingT": ModelOrRowMappingT, "Session": Session, "scoped_session": scoped_session, "AsyncSession": AsyncSession, "async_scoped_session": async_scoped_session, "FilterGroup": FilterGroup, "NotExistsFilter": NotExistsFilter, "MultiFilter": MultiFilter, "ComparisonFilter": ComparisonFilter, "StatementTypeT": StatementTypeT, "StatementFilter": StatementFilter, "LogicalOperatorMap": LogicalOperatorMap, "InAnyFilter": InAnyFilter, "FilterMap": FilterMap, } class SQLAlchemyInitPlugin(InitPluginProtocol, CLIPlugin, _slots_base.SlotsBase): """SQLAlchemy application lifecycle configuration.""" def __init__( self, config: Union[ "SQLAlchemyAsyncConfig", "SQLAlchemySyncConfig", "Sequence[Union[SQLAlchemyAsyncConfig, SQLAlchemySyncConfig]]", ], ) -> None: """Initialize ``SQLAlchemyPlugin``. Args: config: configure DB connection and hook handlers and dependencies. """ self._config = config @property def config(self) -> "Sequence[Union[SQLAlchemyAsyncConfig, SQLAlchemySyncConfig]]": return self._config if isinstance(self._config, Sequence) else [self._config] def on_cli_init(self, cli: "Group") -> None: from advanced_alchemy.extensions.litestar.cli import database_group cli.add_command(database_group) def _validate_config(self) -> None: configs = self._config if isinstance(self._config, Sequence) else [self._config] scope_keys = {config.session_scope_key for config in configs} engine_keys = {config.engine_dependency_key for config in configs} session_keys = {config.session_dependency_key for config in configs} if len(configs) > 1 and any(len(i) != len(configs) for i in (scope_keys, engine_keys, session_keys)): raise ImproperConfigurationError( detail="When using multiple configurations, please ensure the `session_dependency_key` and `engine_dependency_key` settings are unique across all configs. Additionally, iF you are using a custom `before_send` handler, ensure `session_scope_key` is unique.", ) def on_app_init(self, app_config: "AppConfig") -> "AppConfig": """Configure application for use with SQLAlchemy. Args: app_config: The :class:`AppConfig <.config.app.AppConfig>` instance. """ self._validate_config() with contextlib.suppress(ImportError): from asyncpg.pgproto import pgproto # pyright: ignore[reportMissingImports] signature_namespace_values.update({"pgproto.UUID": pgproto.UUID}) app_config.type_encoders = {pgproto.UUID: str, **(app_config.type_encoders or {})} with contextlib.suppress(ImportError): import uuid_utils # pyright: ignore[reportMissingImports] signature_namespace_values.update({"uuid_utils.UUID": uuid_utils.UUID}) # pyright: ignore[reportUnknownMemberType] app_config.type_encoders = {uuid_utils.UUID: str, **(app_config.type_encoders or {})} # pyright: ignore[reportUnknownMemberType] app_config.type_decoders = [ (lambda x: x is uuid_utils.UUID, lambda t, v: t(str(v))), # pyright: ignore[reportUnknownMemberType] *(app_config.type_decoders or []), ] configure_exception_handler = False for config in self.config: if config.set_default_exception_handler: configure_exception_handler = True signature_namespace_values.update(config.signature_namespace) app_config.lifespan.append(config.lifespan) # pyright: ignore[reportUnknownMemberType] app_config.dependencies.update( { config.engine_dependency_key: Provide(config.provide_engine, sync_to_thread=False), config.session_dependency_key: Provide(config.provide_session, sync_to_thread=False), }, ) app_config.before_send.append(cast("BeforeMessageSendHookHandler", config.before_send_handler)) app_config.signature_namespace.update(signature_namespace_values) if configure_exception_handler and not any( isinstance(exc, int) or issubclass(exc, RepositoryError) for exc in app_config.exception_handlers # pyright: ignore[reportUnknownMemberType] ): app_config.exception_handlers.update({RepositoryError: exception_to_http_response}) # pyright: ignore[reportUnknownMemberType] return app_config python-advanced-alchemy-1.9.3/advanced_alchemy/extensions/litestar/plugins/serialization.py000066400000000000000000000026461516556515500324630ustar00rootroot00000000000000from typing import Any from litestar.plugins import SerializationPlugin from litestar.typing import FieldDefinition from sqlalchemy.orm import DeclarativeBase from advanced_alchemy.extensions.litestar.dto import SQLAlchemyDTO from advanced_alchemy.extensions.litestar.plugins import _slots_base class SQLAlchemySerializationPlugin(SerializationPlugin, _slots_base.SlotsBase): def __init__(self) -> None: self._type_dto_map: dict[type[DeclarativeBase], type[SQLAlchemyDTO[Any]]] = {} def supports_type(self, field_definition: FieldDefinition) -> bool: return ( field_definition.is_collection and field_definition.has_inner_subclass_of(DeclarativeBase) ) or field_definition.is_subclass_of(DeclarativeBase) def create_dto_for_type(self, field_definition: FieldDefinition) -> type[SQLAlchemyDTO[Any]]: # assumes that the type is a container of SQLAlchemy models or a single SQLAlchemy model annotation = next( ( inner_type.annotation for inner_type in field_definition.inner_types if inner_type.is_subclass_of(DeclarativeBase) ), field_definition.annotation, ) if annotation in self._type_dto_map: return self._type_dto_map[annotation] self._type_dto_map[annotation] = dto_type = SQLAlchemyDTO[annotation] # type:ignore[valid-type] return dto_type python-advanced-alchemy-1.9.3/advanced_alchemy/extensions/litestar/providers.py000066400000000000000000001007171516556515500301400ustar00rootroot00000000000000# ruff: noqa: B008 """Application dependency providers generators. This module contains functions to create dependency providers for services and filters. You should not have modify this module very often and should only be invoked under normal usage. """ import datetime import inspect from collections.abc import AsyncGenerator, Callable, Generator from typing import ( TYPE_CHECKING, Any, Literal, NamedTuple, Optional, TypedDict, TypeVar, Union, cast, overload, ) from uuid import UUID from litestar.di import Provide from litestar.params import Dependency, Parameter from typing_extensions import NotRequired from advanced_alchemy.filters import ( BeforeAfter, CollectionFilter, FilterTypes, LimitOffset, NotInCollectionFilter, OrderBy, SearchFilter, ) from advanced_alchemy.service import ( Empty, EmptyType, ErrorMessages, LoadSpec, ModelT, SQLAlchemyAsyncRepositoryService, SQLAlchemySyncRepositoryService, ) from advanced_alchemy.utils.singleton import SingletonMeta from advanced_alchemy.utils.text import camelize if TYPE_CHECKING: from sqlalchemy import Select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session from advanced_alchemy.extensions.litestar.plugins.init.config.asyncio import SQLAlchemyAsyncConfig from advanced_alchemy.extensions.litestar.plugins.init.config.sync import SQLAlchemySyncConfig DTorNone = Optional[datetime.datetime] StringOrNone = Optional[str] UuidOrNone = Optional[UUID] IntOrNone = Optional[int] BooleanOrNone = Optional[bool] SortOrder = Literal["asc", "desc"] SortOrderOrNone = Optional[SortOrder] AsyncServiceT_co = TypeVar("AsyncServiceT_co", bound=SQLAlchemyAsyncRepositoryService[Any, Any], covariant=True) SyncServiceT_co = TypeVar("SyncServiceT_co", bound=SQLAlchemySyncRepositoryService[Any, Any], covariant=True) HashableValue = Union[str, int, float, bool, None] HashableType = Union[HashableValue, tuple[Any, ...], tuple[tuple[str, Any], ...], tuple[HashableValue, ...]] class DependencyDefaults: FILTERS_DEPENDENCY_KEY: str = "filters" """Key for the filters dependency.""" CREATED_FILTER_DEPENDENCY_KEY: str = "created_filter" """Key for the created filter dependency.""" ID_FILTER_DEPENDENCY_KEY: str = "id_filter" """Key for the id filter dependency.""" LIMIT_OFFSET_FILTER_DEPENDENCY_KEY: str = "limit_offset_filter" """Key for the limit offset dependency.""" UPDATED_FILTER_DEPENDENCY_KEY: str = "updated_filter" """Key for the updated filter dependency.""" ORDER_BY_FILTER_DEPENDENCY_KEY: str = "order_by_filter" """Key for the order by dependency.""" SEARCH_FILTER_DEPENDENCY_KEY: str = "search_filter" """Key for the search filter dependency.""" DEFAULT_PAGINATION_SIZE: int = 20 """Default pagination size.""" DEPENDENCY_DEFAULTS = DependencyDefaults() class FieldNameType(NamedTuple): """Type for field name and associated type information. This allows for specifying both the field name and the expected type for filter values. """ name: str """Name of the field to filter on.""" type_hint: type[Any] = str """Type of the filter value. Defaults to str.""" class FilterConfig(TypedDict): """Configuration for generating dynamic filters.""" id_filter: NotRequired[type[Union[UUID, int, str]]] """Indicates that the id filter should be enabled. When set, the type specified will be used for the :class:`CollectionFilter`.""" id_field: NotRequired[str] """The field on the model that stored the primary key or identifier.""" sort_field: NotRequired[str] """The default field to use for the sort filter.""" sort_order: NotRequired[SortOrder] """The default order to use for the sort filter.""" pagination_type: NotRequired[Literal["limit_offset"]] """When set, pagination is enabled based on the type specified.""" pagination_size: NotRequired[int] """The size of the pagination. Defaults to `DEFAULT_PAGINATION_SIZE`.""" search: NotRequired[Union[str, set[str], list[str]]] """Fields to enable search on. Can be a comma-separated string or a set of field names.""" search_ignore_case: NotRequired[bool] """When set, search is case insensitive by default.""" created_at: NotRequired[bool] """When set, created_at filter is enabled.""" updated_at: NotRequired[bool] """When set, updated_at filter is enabled.""" not_in_fields: NotRequired[Union[FieldNameType, set[FieldNameType], list[Union[str, FieldNameType]]]] """Fields that support not-in collection filters. Can be a single field or a set of fields with type information.""" in_fields: NotRequired[Union[FieldNameType, set[FieldNameType], list[Union[str, FieldNameType]]]] """Fields that support in-collection filters. Can be a single field or a set of fields with type information.""" class DependencyCache(metaclass=SingletonMeta): """Simple dependency cache for the application. This is used to help memoize dependencies that are generated dynamically.""" def __init__(self) -> None: self.dependencies: dict[Union[int, str], dict[str, Provide]] = {} def add_dependencies(self, key: Union[int, str], dependencies: dict[str, Provide]) -> None: self.dependencies[key] = dependencies def get_dependencies(self, key: Union[int, str]) -> Optional[dict[str, Provide]]: return self.dependencies.get(key) dep_cache = DependencyCache() @overload def create_service_provider( service_class: type["AsyncServiceT_co"], /, statement: "Optional[Select[tuple[ModelT]]]" = None, config: "Optional[SQLAlchemyAsyncConfig]" = None, error_messages: "Optional[Union[ErrorMessages, EmptyType]]" = Empty, load: "Optional[LoadSpec]" = None, execution_options: "Optional[dict[str, Any]]" = None, uniquify: Optional[bool] = None, count_with_window_function: Optional[bool] = None, ) -> Callable[..., AsyncGenerator[AsyncServiceT_co, None]]: ... @overload def create_service_provider( service_class: type["SyncServiceT_co"], /, statement: "Optional[Select[tuple[ModelT]]]" = None, config: "Optional[SQLAlchemySyncConfig]" = None, error_messages: "Optional[Union[ErrorMessages, EmptyType]]" = Empty, load: "Optional[LoadSpec]" = None, execution_options: "Optional[dict[str, Any]]" = None, uniquify: Optional[bool] = None, count_with_window_function: Optional[bool] = None, ) -> Callable[..., Generator[SyncServiceT_co, None, None]]: ... def create_service_provider( service_class: type[Union["AsyncServiceT_co", "SyncServiceT_co"]], /, statement: "Optional[Select[tuple[ModelT]]]" = None, config: "Optional[Union[SQLAlchemyAsyncConfig, SQLAlchemySyncConfig]]" = None, error_messages: "Optional[Union[ErrorMessages, EmptyType]]" = Empty, load: "Optional[LoadSpec]" = None, execution_options: "Optional[dict[str, Any]]" = None, uniquify: Optional[bool] = None, count_with_window_function: Optional[bool] = None, ) -> Callable[..., Union["AsyncGenerator[AsyncServiceT_co, None]", "Generator[SyncServiceT_co,None, None]"]]: """Create a dependency provider for a service with a configurable session key. Args: service_class: The service class inheriting from SQLAlchemyAsyncRepositoryService or SQLAlchemySyncRepositoryService. statement: An optional SQLAlchemy Select statement to scope the service. config: An optional SQLAlchemy configuration object. error_messages: Optional custom error messages for the service. load: Optional LoadSpec for eager loading relationships. execution_options: Optional dictionary of execution options for SQLAlchemy. uniquify: Optional flag to uniquify results. count_with_window_function: Optional flag to use window function for counting. Returns: A dependency provider function suitable for Litestar's DI system. """ session_dependency_key = config.session_dependency_key if config else "db_session" if issubclass(service_class, SQLAlchemyAsyncRepositoryService) or service_class is SQLAlchemyAsyncRepositoryService: # type: ignore[comparison-overlap] session_type_annotation = "Optional[AsyncSession]" return_type_annotation = AsyncGenerator[service_class, None] # type: ignore[valid-type] async def provide_service_async(*args: Any, **kwargs: Any) -> "AsyncGenerator[AsyncServiceT_co, None]": db_session = cast("Optional[AsyncSession]", args[0] if args else kwargs.get(session_dependency_key)) async with service_class.new( # type: ignore[union-attr] session=db_session, # type: ignore[arg-type] statement=statement, config=cast("Optional[SQLAlchemyAsyncConfig]", config), # type: ignore[arg-type] error_messages=error_messages, load=load, execution_options=execution_options, uniquify=uniquify, count_with_window_function=count_with_window_function, ) as service: yield service session_param = inspect.Parameter( name=session_dependency_key, kind=inspect.Parameter.POSITIONAL_OR_KEYWORD, default=Dependency(skip_validation=True), annotation=session_type_annotation, ) provider_signature = inspect.Signature( parameters=[session_param], return_annotation=return_type_annotation, ) provide_service_async.__signature__ = provider_signature # type: ignore[attr-defined] provide_service_async.__annotations__ = { session_dependency_key: session_type_annotation, "return": return_type_annotation, } return provide_service_async session_type_annotation = "Optional[Session]" return_type_annotation = Generator[service_class, None, None] # type: ignore[misc,assignment,valid-type] def provide_service_sync(*args: Any, **kwargs: Any) -> "Generator[SyncServiceT_co, None, None]": db_session = cast("Optional[Session]", args[0] if args else kwargs.get(session_dependency_key)) with service_class.new( session=db_session, statement=statement, config=cast("Optional[SQLAlchemySyncConfig]", config), error_messages=error_messages, load=load, execution_options=execution_options, uniquify=uniquify, count_with_window_function=count_with_window_function, ) as service: yield service session_param = inspect.Parameter( name=session_dependency_key, kind=inspect.Parameter.POSITIONAL_OR_KEYWORD, default=Dependency(skip_validation=True), annotation=session_type_annotation, ) provider_signature = inspect.Signature( parameters=[session_param], return_annotation=return_type_annotation, ) provide_service_sync.__signature__ = provider_signature # type: ignore[attr-defined] provide_service_sync.__annotations__ = { session_dependency_key: session_type_annotation, "return": return_type_annotation, } return provide_service_sync def create_service_dependencies( service_class: type[Union["AsyncServiceT_co", "SyncServiceT_co"]], /, key: str, statement: "Optional[Select[tuple[ModelT]]]" = None, config: "Optional[Union[SQLAlchemyAsyncConfig, SQLAlchemySyncConfig]]" = None, error_messages: "Optional[Union[ErrorMessages, EmptyType]]" = Empty, load: "Optional[LoadSpec]" = None, execution_options: "Optional[dict[str, Any]]" = None, filters: "Optional[FilterConfig]" = None, uniquify: Optional[bool] = None, count_with_window_function: Optional[bool] = None, dep_defaults: "DependencyDefaults" = DEPENDENCY_DEFAULTS, ) -> dict[str, Provide]: """Create a dependency provider for the combined filter function. Args: key: The key to use for the dependency provider. service_class: The service class to create a dependency provider for. statement: The statement to use for the service. config: The configuration to use for the service. error_messages: The error messages to use for the service. load: The load spec to use for the service. execution_options: The execution options to use for the service. filters: The filter configuration to use for the service. uniquify: Whether to uniquify the service. count_with_window_function: Whether to count with a window function. dep_defaults: The dependency defaults to use for the service. Returns: A dictionary of dependency providers for the service. """ if issubclass(service_class, SQLAlchemyAsyncRepositoryService) or service_class is SQLAlchemyAsyncRepositoryService: # type: ignore[comparison-overlap] svc = create_service_provider( # type: ignore[type-var,misc,unused-ignore] service_class, statement, cast("Optional[SQLAlchemyAsyncConfig]", config), error_messages, load, execution_options, uniquify, count_with_window_function, ) deps = {key: Provide(svc)} else: svc = create_service_provider( # type: ignore[assignment] service_class, statement, cast("Optional[SQLAlchemySyncConfig]", config), error_messages, load, execution_options, uniquify, count_with_window_function, ) deps = {key: Provide(svc, sync_to_thread=False)} if filters: deps.update(create_filter_dependencies(filters, dep_defaults)) return deps def create_filter_dependencies( config: FilterConfig, dep_defaults: DependencyDefaults = DEPENDENCY_DEFAULTS ) -> dict[str, Provide]: """Create a dependency provider for the combined filter function. Args: config: FilterConfig instance with desired settings. dep_defaults: Dependency defaults to use for the filter dependencies Returns: A dependency provider function for the combined filter function. """ cache_key = hash(_make_hashable(config)) deps = dep_cache.get_dependencies(cache_key) if deps is not None: return deps deps = _create_statement_filters(config, dep_defaults) dep_cache.add_dependencies(cache_key, deps) return deps def _make_hashable(value: Any) -> HashableType: """Convert a value into a hashable type. This function converts any value into a hashable type by: - Converting dictionaries to sorted tuples of (key, value) pairs - Converting lists and sets to sorted tuples - Preserving primitive types (str, int, float, bool, None) - Converting any other type to its string representation Args: value: Any value that needs to be made hashable. Returns: A hashable version of the value. """ if isinstance(value, dict): # Convert dict to tuple of tuples with sorted keys items = [] for k in sorted(value.keys()): # pyright: ignore v = value[k] # pyright: ignore items.append((str(k), _make_hashable(v))) # pyright: ignore return tuple(items) # pyright: ignore if isinstance(value, (list, set)): hashable_items = [_make_hashable(item) for item in value] # pyright: ignore filtered_items = [item for item in hashable_items if item is not None] # pyright: ignore return tuple(sorted(filtered_items, key=str)) if isinstance(value, (str, int, float, bool, type(None))): return value return str(value) def _create_statement_filters( # noqa: C901 config: FilterConfig, dep_defaults: DependencyDefaults = DEPENDENCY_DEFAULTS ) -> dict[str, Provide]: """Create filter dependencies based on configuration. Args: config (FilterConfig): Configuration dictionary specifying which filters to enable dep_defaults (DependencyDefaults): Dependency defaults to use for the filter dependencies Returns: dict[str, Provide]: Dictionary of filter provider functions """ filters: dict[str, Provide] = {} if config.get("id_filter", False): def provide_id_filter( # pyright: ignore[reportUnknownParameterType] ids: Optional[list[str]] = Parameter(query="ids", default=None, required=False), ) -> CollectionFilter: # pyright: ignore[reportMissingTypeArgument] return CollectionFilter(field_name=config.get("id_field", "id"), values=ids) filters[dep_defaults.ID_FILTER_DEPENDENCY_KEY] = Provide(provide_id_filter, sync_to_thread=False) # pyright: ignore[reportUnknownArgumentType] if config.get("created_at", False): def provide_created_filter( before: DTorNone = Parameter(query="createdBefore", default=None, required=False), after: DTorNone = Parameter(query="createdAfter", default=None, required=False), ) -> BeforeAfter: return BeforeAfter("created_at", before, after) filters[dep_defaults.CREATED_FILTER_DEPENDENCY_KEY] = Provide(provide_created_filter, sync_to_thread=False) if config.get("updated_at", False): def provide_updated_filter( before: DTorNone = Parameter(query="updatedBefore", default=None, required=False), after: DTorNone = Parameter(query="updatedAfter", default=None, required=False), ) -> BeforeAfter: return BeforeAfter("updated_at", before, after) filters[dep_defaults.UPDATED_FILTER_DEPENDENCY_KEY] = Provide(provide_updated_filter, sync_to_thread=False) if config.get("pagination_type") == "limit_offset": def provide_limit_offset_pagination( current_page: int = Parameter(ge=1, query="currentPage", default=1, required=False), page_size: int = Parameter( query="pageSize", ge=1, default=config.get("pagination_size", dep_defaults.DEFAULT_PAGINATION_SIZE), required=False, ), ) -> LimitOffset: return LimitOffset(page_size, page_size * (current_page - 1)) filters[dep_defaults.LIMIT_OFFSET_FILTER_DEPENDENCY_KEY] = Provide( provide_limit_offset_pagination, sync_to_thread=False ) if search_fields := config.get("search"): def provide_search_filter( search_string: StringOrNone = Parameter( title="Field to search", query="searchString", default=None, required=False, ), ignore_case: BooleanOrNone = Parameter( title="Search should be case sensitive", query="searchIgnoreCase", default=config.get("search_ignore_case", False), required=False, ), ) -> SearchFilter: # Handle both string and set input types for search fields field_names = set(search_fields.split(",")) if isinstance(search_fields, str) else set(search_fields) return SearchFilter( field_name=field_names, value=search_string, # type: ignore[arg-type] ignore_case=ignore_case or False, ) filters[dep_defaults.SEARCH_FILTER_DEPENDENCY_KEY] = Provide(provide_search_filter, sync_to_thread=False) if sort_field := config.get("sort_field"): def provide_order_by( field_name: StringOrNone = Parameter( title="Order by field", query="orderBy", default=sort_field, required=False, ), sort_order: SortOrderOrNone = Parameter( title="Field to search", query="sortOrder", default=config.get("sort_order", "desc"), required=False, ), ) -> OrderBy: return OrderBy(field_name=field_name, sort_order=sort_order) # type: ignore[arg-type] filters[dep_defaults.ORDER_BY_FILTER_DEPENDENCY_KEY] = Provide(provide_order_by, sync_to_thread=False) # Add not_in filter providers if not_in_fields := config.get("not_in_fields"): # Get all field names, handling both strings and FieldNameType objects not_in_fields = {not_in_fields} if isinstance(not_in_fields, (str, FieldNameType)) else not_in_fields for field_def in not_in_fields: field_def = FieldNameType(name=field_def, type_hint=str) if isinstance(field_def, str) else field_def # Capture field_def by value to avoid Python closure late binding gotcha # Without default parameter, all closures would reference the loop variable's final value def create_not_in_filter_provider( # pyright: ignore field_name: FieldNameType = field_def, # type: ignore[assignment] ) -> Callable[..., Optional[NotInCollectionFilter[Any]]]: param_name = f"{field_name.name}_not_in" def provide_not_in_filter( # pyright: ignore **kwargs: Any, ) -> Optional[NotInCollectionFilter[field_name.type_hint]]: # type: ignore values = kwargs.get(param_name) return ( NotInCollectionFilter[field_name.type_hint](field_name=field_name.name, values=values) # type: ignore if values else None ) provide_not_in_filter.__name__ = f"provide_not_in_filter_{field_name.name}" provide_not_in_filter.__signature__ = inspect.Signature( # type: ignore[attr-defined] parameters=[ inspect.Parameter( name=param_name, kind=inspect.Parameter.POSITIONAL_OR_KEYWORD, default=Parameter( query=camelize(param_name), default=None, required=False, ), annotation=Optional[list[field_name.type_hint]], # type: ignore ) ], return_annotation=Optional[NotInCollectionFilter[field_name.type_hint]], # type: ignore ) provide_not_in_filter.__annotations__ = { param_name: Optional[list[field_name.type_hint]], # type: ignore "return": Optional[NotInCollectionFilter[field_name.type_hint]], # type: ignore } return provide_not_in_filter # pyright: ignore provider = create_not_in_filter_provider(field_def) # pyright: ignore filters[f"{field_def.name}_not_in_filter"] = Provide(provider, sync_to_thread=False) # pyright: ignore # Add in filter providers if in_fields := config.get("in_fields"): # Get all field names, handling both strings and FieldNameType objects in_fields = {in_fields} if isinstance(in_fields, (str, FieldNameType)) else in_fields for field_def in in_fields: field_def = FieldNameType(name=field_def, type_hint=str) if isinstance(field_def, str) else field_def # Capture field_def by value to avoid Python closure late binding gotcha # Without default parameter, all closures would reference the loop variable's final value def create_in_filter_provider( # pyright: ignore field_name: FieldNameType = field_def, # type: ignore[assignment] ) -> Callable[..., Optional[CollectionFilter[Any]]]: param_name = f"{field_name.name}_in" def provide_in_filter( # pyright: ignore **kwargs: Any, ) -> Optional[CollectionFilter[field_name.type_hint]]: # type: ignore # pyright: ignore values = kwargs.get(param_name) return ( CollectionFilter[field_name.type_hint](field_name=field_name.name, values=values) # type: ignore # pyright: ignore if values else None ) provide_in_filter.__name__ = f"provide_in_filter_{field_name.name}" provide_in_filter.__signature__ = inspect.Signature( # type: ignore[attr-defined] parameters=[ inspect.Parameter( name=param_name, kind=inspect.Parameter.POSITIONAL_OR_KEYWORD, default=Parameter( query=camelize(param_name), default=None, required=False, ), annotation=Optional[list[field_name.type_hint]], # type: ignore ) ], return_annotation=Optional[CollectionFilter[field_name.type_hint]], # type: ignore ) provide_in_filter.__annotations__ = { param_name: Optional[list[field_name.type_hint]], # type: ignore "return": Optional[CollectionFilter[field_name.type_hint]], # type: ignore } return provide_in_filter # pyright: ignore provider = create_in_filter_provider(field_def) # type: ignore filters[f"{field_def.name}_in_filter"] = Provide(provider, sync_to_thread=False) # pyright: ignore if filters: filters[dep_defaults.FILTERS_DEPENDENCY_KEY] = Provide( _create_filter_aggregate_function(config), sync_to_thread=False ) return filters def _create_filter_aggregate_function(config: FilterConfig) -> Callable[..., list[FilterTypes]]: # noqa: C901, PLR0915 """Create a filter function based on the provided configuration. Args: config: The filter configuration. Returns: A function that returns a list of filters based on the configuration. """ parameters: dict[str, inspect.Parameter] = {} annotations: dict[str, Any] = {} # Build parameters based on config if cls := config.get("id_filter"): parameters["id_filter"] = inspect.Parameter( name="id_filter", kind=inspect.Parameter.POSITIONAL_OR_KEYWORD, default=Dependency(skip_validation=True), annotation=CollectionFilter[cls], # type: ignore[valid-type] ) annotations["id_filter"] = CollectionFilter[cls] # type: ignore[valid-type] if config.get("created_at"): parameters["created_filter"] = inspect.Parameter( name="created_filter", kind=inspect.Parameter.POSITIONAL_OR_KEYWORD, default=Dependency(skip_validation=True), annotation=BeforeAfter, ) annotations["created_filter"] = BeforeAfter if config.get("updated_at"): parameters["updated_filter"] = inspect.Parameter( name="updated_filter", kind=inspect.Parameter.POSITIONAL_OR_KEYWORD, default=Dependency(skip_validation=True), annotation=BeforeAfter, ) annotations["updated_filter"] = BeforeAfter if config.get("search"): parameters["search_filter"] = inspect.Parameter( name="search_filter", kind=inspect.Parameter.POSITIONAL_OR_KEYWORD, default=Dependency(skip_validation=True), annotation=SearchFilter, ) annotations["search_filter"] = SearchFilter if config.get("pagination_type") == "limit_offset": parameters["limit_offset_filter"] = inspect.Parameter( name="limit_offset_filter", kind=inspect.Parameter.POSITIONAL_OR_KEYWORD, default=Dependency(skip_validation=True), annotation=LimitOffset, ) annotations["limit_offset_filter"] = LimitOffset if config.get("sort_field"): parameters["order_by_filter"] = inspect.Parameter( name="order_by_filter", kind=inspect.Parameter.POSITIONAL_OR_KEYWORD, default=Dependency(skip_validation=True), annotation=OrderBy, ) annotations["order_by_filter"] = OrderBy # Add parameters for not_in filters if not_in_fields := config.get("not_in_fields"): for field_def in not_in_fields: field_def = FieldNameType(name=field_def, type_hint=str) if isinstance(field_def, str) else field_def parameters[f"{field_def.name}_not_in_filter"] = inspect.Parameter( name=f"{field_def.name}_not_in_filter", kind=inspect.Parameter.POSITIONAL_OR_KEYWORD, default=Dependency(skip_validation=True), annotation=NotInCollectionFilter[field_def.type_hint], # type: ignore ) annotations[f"{field_def.name}_not_in_filter"] = NotInCollectionFilter[field_def.type_hint] # type: ignore # Add parameters for in filters if in_fields := config.get("in_fields"): for field_def in in_fields: field_def = FieldNameType(name=field_def, type_hint=str) if isinstance(field_def, str) else field_def parameters[f"{field_def.name}_in_filter"] = inspect.Parameter( name=f"{field_def.name}_in_filter", kind=inspect.Parameter.POSITIONAL_OR_KEYWORD, default=Dependency(skip_validation=True), annotation=CollectionFilter[field_def.type_hint], # type: ignore ) annotations[f"{field_def.name}_in_filter"] = CollectionFilter[field_def.type_hint] # type: ignore def provide_filters(**kwargs: FilterTypes) -> list[FilterTypes]: """Provide filter dependencies based on configuration. Args: **kwargs: Filter parameters dynamically provided based on configuration. Returns: list[FilterTypes]: List of configured filters. """ filters: list[FilterTypes] = [] if id_filter := kwargs.get("id_filter"): filters.append(id_filter) if created_filter := kwargs.get("created_filter"): filters.append(created_filter) if limit_offset := kwargs.get("limit_offset_filter"): filters.append(limit_offset) if updated_filter := kwargs.get("updated_filter"): filters.append(updated_filter) if ( (search_filter := cast("Optional[SearchFilter]", kwargs.get("search_filter"))) and search_filter is not None # pyright: ignore[reportUnnecessaryComparison] and search_filter.field_name is not None # pyright: ignore[reportUnnecessaryComparison] and search_filter.value is not None # pyright: ignore[reportUnnecessaryComparison] ): filters.append(search_filter) if ( (order_by := cast("Optional[OrderBy]", kwargs.get("order_by_filter"))) and order_by is not None # pyright: ignore[reportUnnecessaryComparison] and order_by.field_name is not None # pyright: ignore[reportUnnecessaryComparison] ): filters.append(order_by) # Add not_in filters if not_in_fields := config.get("not_in_fields"): # Get all field names, handling both strings and FieldNameType objects not_in_fields = {not_in_fields} if isinstance(not_in_fields, (str, FieldNameType)) else not_in_fields for field_def in not_in_fields: field_def = FieldNameType(name=field_def, type_hint=str) if isinstance(field_def, str) else field_def filter_ = kwargs.get(f"{field_def.name}_not_in_filter") if filter_ is not None: filters.append(filter_) # Add in filters if in_fields := config.get("in_fields"): # Get all field names, handling both strings and FieldNameType objects in_fields = {in_fields} if isinstance(in_fields, (str, FieldNameType)) else in_fields for field_def in in_fields: field_def = FieldNameType(name=field_def, type_hint=str) if isinstance(field_def, str) else field_def filter_ = kwargs.get(f"{field_def.name}_in_filter") if filter_ is not None: filters.append(filter_) return filters # Set both signature and annotations provide_filters.__signature__ = inspect.Signature( # type: ignore parameters=list(parameters.values()), return_annotation=list[FilterTypes], ) provide_filters.__annotations__ = annotations provide_filters.__annotations__["return"] = list[FilterTypes] return provide_filters python-advanced-alchemy-1.9.3/advanced_alchemy/extensions/litestar/session.py000066400000000000000000000464641516556515500276160ustar00rootroot00000000000000import datetime from abc import ABC, abstractmethod from copy import deepcopy from typing import TYPE_CHECKING, Any, Final, Generic, Optional, TypeVar, Union, cast from litestar.exceptions import ImproperlyConfiguredException from litestar.middleware.session.server_side import ServerSideSessionBackend, ServerSideSessionConfig from sqlalchemy import ( BooleanClauseList, Dialect, Index, LargeBinary, ScalarResult, String, UniqueConstraint, delete, func, select, ) from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import Mapped, Session, declarative_mixin, declared_attr, mapped_column from advanced_alchemy.base import UUIDv7Base from advanced_alchemy.extensions.litestar.plugins.init import ( SQLAlchemyAsyncConfig, SQLAlchemySyncConfig, ) from advanced_alchemy.operations import OnConflictUpsert from advanced_alchemy.utils.sync_tools import async_ if TYPE_CHECKING: from litestar.stores.base import Store from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm.decl_base import _TableArgsType as TableArgsType # pyright: ignore[reportPrivateUsage] from sqlalchemy.sql import Select from sqlalchemy.sql.elements import BooleanClauseList SQLAlchemyConfig = Union[SQLAlchemyAsyncConfig, SQLAlchemySyncConfig] SQLAlchemyConfigT = TypeVar("SQLAlchemyConfigT", bound=SQLAlchemyConfig) SessionModelT = TypeVar("SessionModelT", bound="SessionModelMixin") # Session ID field limit as defined in the database schema SESSION_ID_MAX_LENGTH = 255 # PostgreSQL version supporting MERGE (same as store.py) _POSTGRES_VERSION_SUPPORTING_MERGE: Final = 15 # Temporary toggle to disable PostgreSQL MERGE due to locking concerns _DISABLE_POSTGRES_MERGE: Final = True @declarative_mixin class SessionModelMixin(UUIDv7Base): """Mixin for session storage.""" __abstract__ = True @declared_attr.directive @classmethod def __table_args__(cls) -> "TableArgsType": return ( UniqueConstraint( cls.session_id, name=f"uq_{cls.__tablename__}_session_id", ).ddl_if(callable_=cls._create_unique_session_id_constraint), Index( f"ix_{cls.__tablename__}_session_id_unique", cls.session_id, unique=True, ).ddl_if(callable_=cls._create_unique_session_id_index), ) @declared_attr def session_id(cls) -> Mapped[str]: return mapped_column(String(length=255), nullable=False) @declared_attr def data(cls) -> Mapped[bytes]: return mapped_column(LargeBinary, nullable=False) @declared_attr def expires_at(cls) -> Mapped[datetime.datetime]: return mapped_column(index=True) @classmethod def _create_unique_session_id_index(cls, *_: Any, **kwargs: Any) -> bool: dialect_name = kwargs.get("dialect", {}).name if "dialect" in kwargs else "" return bool("spanner" in dialect_name.lower()) @classmethod def _create_unique_session_id_constraint(cls, *_: Any, **kwargs: Any) -> bool: dialect_name = kwargs.get("dialect", {}).name if "dialect" in kwargs else "" return "spanner" not in dialect_name.lower() @hybrid_property def is_expired(self) -> bool: # pyright: ignore """Boolean indicating if the session has expired. Returns: `True` if the session has expired, otherwise `False` """ return datetime.datetime.now(datetime.timezone.utc) > self.expires_at @is_expired.expression # type: ignore[no-redef] def is_expired(cls) -> "BooleanClauseList": # noqa: N805 """SQL-Expression to check if the session has expired. Returns: SQL-Expression to check if the session has expired. """ return cast("BooleanClauseList", func.now() > cls.expires_at) class SQLAlchemySessionBackendBase(ServerSideSessionBackend, ABC, Generic[SQLAlchemyConfigT]): """Session backend to store data in a database with SQLAlchemy. Works with both sync and async engines. Notes: - Requires `sqlalchemy` which needs to be installed separately, and a configured SQLAlchemyPlugin. """ __slots__ = ("_model", "_session_maker") def __init__( self, config: "ServerSideSessionConfig", alchemy_config: "SQLAlchemyConfigT", model: "type[SessionModelMixin]", ) -> None: """Initialize `BaseSQLAlchemyBackend`. Args: config: An instance of `SQLAlchemyBackendConfig` alchemy_config: An instance of `SQLAlchemyConfig` model: A mapped model subclassing `SessionModelMixin` """ self._model = model self._config = config self._alchemy = alchemy_config def __deepcopy__(self, memo: dict[int, Any]) -> "SQLAlchemySessionBackendBase[SQLAlchemyConfigT]": """Custom deepcopy implementation to handle unpicklable SQLAlchemy objects.""" # Create a new instance with the same configuration cls = self.__class__ # Create a shallow copy first new_obj = cls.__new__(cls) memo[id(self)] = new_obj # Copy the ServerSideSessionConfig safely - it should be serializable try: new_obj._config = deepcopy(self.config, memo) # noqa: SLF001 except (TypeError, AttributeError): # If config can't be deep-copied, just reference the original new_obj._config = self.config # noqa: SLF001 # Model classes are safe to reference directly new_obj._model = self.model # noqa: SLF001 # SQLAlchemy config contains unpicklable objects, so we reference the original # This is safe because configs are typically shared and immutable new_obj._alchemy = self.alchemy # noqa: SLF001 return new_obj def _select_session_obj(self, session_id: str) -> "Select[tuple[SessionModelMixin]]": return select(self._model).where(self._model.session_id == session_id) def _update_session_expiry(self, session_obj: "SessionModelMixin") -> None: session_obj.expires_at = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta( seconds=self.config.max_age ) @staticmethod def supports_merge(dialect: "Optional[Dialect]" = None, force_disable_merge: bool = False) -> bool: """Check if the dialect supports MERGE statements for upserts.""" return bool( dialect and ( ( dialect.server_version_info is not None and dialect.server_version_info[0] >= _POSTGRES_VERSION_SUPPORTING_MERGE and dialect.name == "postgresql" and not _DISABLE_POSTGRES_MERGE # Temporary PostgreSQL MERGE disable ) or dialect.name == "oracle" ) and not force_disable_merge ) @staticmethod def supports_upsert(dialect: "Optional[Dialect]" = None, force_disable_upsert: bool = False) -> bool: """Check if the dialect supports native upsert operations.""" return bool( dialect and (dialect.name in {"postgresql", "cockroachdb", "sqlite", "mysql", "mariadb", "duckdb"}) and not force_disable_upsert ) @abstractmethod async def delete_expired(self) -> None: """Delete all expired sessions from the database.""" @property def model(self) -> "type[SessionModelMixin]": return self._model @property def config(self) -> "ServerSideSessionConfig": return self._config @config.setter def config(self, value: "ServerSideSessionConfig") -> None: self._config = value @property def alchemy(self) -> "SQLAlchemyConfigT": return self._alchemy @property def _backend_class(self) -> "type[Union[SQLAlchemySyncSessionBackend, SQLAlchemyAsyncSessionBackend]]": """Return either `SQLAlchemyBackend` or `AsyncSQLAlchemyBackend`, depending on the engine type configured in the `SQLAlchemyPlugin` """ if isinstance(self.alchemy, SQLAlchemyAsyncConfig): return SQLAlchemyAsyncSessionBackend return SQLAlchemySyncSessionBackend class SQLAlchemyAsyncSessionBackend(SQLAlchemySessionBackendBase[SQLAlchemyAsyncConfig]): """Asynchronous SQLAlchemy backend.""" async def _get_session_obj(self, *, db_session: "AsyncSession", session_id: str) -> Optional[SessionModelMixin]: return ( cast( "ScalarResult[Optional[SessionModelMixin]]", await db_session.scalars(self._select_session_obj(session_id)), ) ).one_or_none() async def get(self, /, session_id: str, store: "Store") -> Optional[bytes]: """Retrieve data associated with `session_id`. Args: session_id: The session-ID store: The store to get the session from (not used in this backend) Returns: The session data, if existing, otherwise `None`. """ session_id = session_id[:SESSION_ID_MAX_LENGTH] if len(session_id) > SESSION_ID_MAX_LENGTH else session_id async with self.alchemy.get_session() as db_session: session_obj = await self._get_session_obj(db_session=db_session, session_id=session_id) if session_obj: if not session_obj.is_expired: data = session_obj.data self._update_session_expiry(session_obj) await db_session.commit() return data await db_session.delete(session_obj) await db_session.commit() return None async def set(self, /, session_id: str, data: bytes, store: "Store") -> None: """Store `data` under the `session_id` for later retrieval. If there is already data associated with `session_id`, replace it with `data` and reset its expiry time Args: session_id: The session-ID. data: Serialized session data store: The store to store the session in (not used in this backend) """ session_id = session_id[:SESSION_ID_MAX_LENGTH] if len(session_id) > SESSION_ID_MAX_LENGTH else session_id expires_at = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=self.config.max_age) async with self.alchemy.get_session() as db_session: if db_session.bind is None: # pyright: ignore[reportUnnecessaryComparison] msg = "Database connection is not available" # type: ignore[unreachable] raise ImproperlyConfiguredException(msg) dialect = db_session.bind.dialect dialect_name = dialect.name values = { "session_id": session_id, "data": data, "expires_at": expires_at, } conflict_columns = ["session_id"] update_columns = ["data", "expires_at"] if OnConflictUpsert.supports_native_upsert(dialect_name): upsert_stmt = OnConflictUpsert.create_upsert( table=self._model.__table__, # type: ignore[arg-type] values=values, conflict_columns=conflict_columns, update_columns=update_columns, dialect_name=dialect_name, validate_identifiers=False, ) await db_session.execute(upsert_stmt) elif self.supports_merge(dialect): merge_stmt, additional_params = OnConflictUpsert.create_merge_upsert( table=self._model.__table__, # type: ignore[arg-type] values=values, conflict_columns=conflict_columns, update_columns=update_columns, dialect_name=dialect_name, validate_identifiers=False, ) # Merge additional Oracle parameters with original values merge_values = {**values, **additional_params} await db_session.execute(merge_stmt, merge_values) else: # Fallback logic: Check existence, then update or insert session_obj = await self._get_session_obj(db_session=db_session, session_id=session_id) if not session_obj: session_obj = self._model(session_id=session_id) db_session.add(session_obj) session_obj.data = data session_obj.expires_at = expires_at await db_session.commit() async def delete(self, /, session_id: str, store: "Store") -> None: """Delete the data associated with `session_id`. Fails silently if no such session-ID exists. Args: session_id: The session-ID store: The store to delete the session from (not used in this backend) """ session_id = session_id[:SESSION_ID_MAX_LENGTH] if len(session_id) > SESSION_ID_MAX_LENGTH else session_id async with self.alchemy.get_session() as db_session: await db_session.execute(delete(self._model).where(self._model.session_id == session_id)) await db_session.commit() async def delete_all(self, /, store: "Store") -> None: """Delete all session data.""" async with self.alchemy.get_session() as db_session: await db_session.execute(delete(self._model)) await db_session.commit() async def delete_expired(self) -> None: """Delete all expired session from the database.""" async with self.alchemy.get_session() as db_session: await db_session.execute(delete(self._model).where(self._model.is_expired)) await db_session.commit() class SQLAlchemySyncSessionBackend(SQLAlchemySessionBackendBase[SQLAlchemySyncConfig]): """Synchronous SQLAlchemy backend.""" def _get_session_obj(self, *, db_session: "Session", session_id: str) -> "Optional[SessionModelMixin]": return db_session.scalars(self._select_session_obj(session_id)).one_or_none() def _get_sync(self, session_id: str) -> Optional[bytes]: session_id = session_id[:SESSION_ID_MAX_LENGTH] if len(session_id) > SESSION_ID_MAX_LENGTH else session_id with self.alchemy.get_session() as db_session: session_obj = self._get_session_obj(db_session=db_session, session_id=session_id) if session_obj: if not session_obj.is_expired: data = session_obj.data self._update_session_expiry(session_obj) db_session.commit() return data db_session.delete(session_obj) db_session.commit() return None async def get(self, /, session_id: str, store: "Store") -> Optional[bytes]: """Retrieve data associated with `session_id`. Args: session_id: The session-ID store: The store to get the session from Returns: The session data, if existing, otherwise `None`. """ return await async_(self._get_sync)(session_id) def _set_sync(self, session_id: str, data: bytes) -> None: session_id = session_id[:SESSION_ID_MAX_LENGTH] if len(session_id) > SESSION_ID_MAX_LENGTH else session_id expires_at = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=self.config.max_age) with self.alchemy.get_session() as db_session: if db_session.bind is None: msg = "Database connection is not available" raise ImproperlyConfiguredException(msg) dialect = db_session.bind.dialect dialect_name = dialect.name values = { "session_id": session_id, "data": data, "expires_at": expires_at, } conflict_columns = ["session_id"] update_columns = ["data", "expires_at"] if OnConflictUpsert.supports_native_upsert(dialect_name): upsert_stmt = OnConflictUpsert.create_upsert( table=self._model.__table__, # type: ignore[arg-type] values=values, conflict_columns=conflict_columns, update_columns=update_columns, dialect_name=dialect_name, validate_identifiers=False, ) db_session.execute(upsert_stmt) elif self.supports_merge(dialect): merge_stmt, additional_params = OnConflictUpsert.create_merge_upsert( table=self._model.__table__, # type: ignore[arg-type] values=values, conflict_columns=conflict_columns, update_columns=update_columns, dialect_name=dialect_name, validate_identifiers=False, ) # Merge additional Oracle parameters with original values merge_values = {**values, **additional_params} db_session.execute(merge_stmt, merge_values) else: # Fallback logic: Check existence, then update or insert session_obj = self._get_session_obj(db_session=db_session, session_id=session_id) if not session_obj: session_obj = self._model(session_id=session_id) db_session.add(session_obj) session_obj.data = data session_obj.expires_at = expires_at db_session.commit() async def set(self, /, session_id: str, data: bytes, store: "Store") -> None: """Store `data` under the `session_id` for later retrieval. If there is already data associated with `session_id`, replace it with `data` and reset its expiry time Args: session_id: The session-ID data: Serialized session data store: The store to store the session in """ return await async_(self._set_sync)(session_id, data) def _delete_sync(self, session_id: str) -> None: session_id = session_id[:SESSION_ID_MAX_LENGTH] if len(session_id) > SESSION_ID_MAX_LENGTH else session_id with self.alchemy.get_session() as db_session: db_session.execute(delete(self._model).where(self._model.session_id == session_id)) db_session.commit() async def delete(self, /, session_id: str, store: "Store") -> None: """Delete the data associated with `session_id`. Fails silently if no such session-ID exists. Args: session_id: The session-ID store: The store to delete the session from """ return await async_(self._delete_sync)(session_id) def _delete_all_sync(self) -> None: with self.alchemy.get_session() as db_session: db_session.execute(delete(self._model)) db_session.commit() async def delete_all(self) -> None: """Delete all session data.""" return await async_(self._delete_all_sync)() def _delete_expired_sync(self) -> None: with self.alchemy.get_session() as db_session: db_session.execute(delete(self._model).where(self._model.is_expired)) db_session.commit() async def delete_expired(self) -> None: """Delete all expired session from the database.""" return await async_(self._delete_expired_sync)() python-advanced-alchemy-1.9.3/advanced_alchemy/extensions/litestar/store.py000066400000000000000000000617721516556515500272660ustar00rootroot00000000000000import base64 import datetime from contextlib import asynccontextmanager, contextmanager from typing import TYPE_CHECKING, Any, Final, Generic, Optional, TypeVar, Union, cast from litestar.exceptions import ImproperlyConfiguredException from litestar.stores.base import NamespacedStore from litestar.types import Empty, EmptyType from litestar.utils.empty import value_or_default from sqlalchemy import ( BooleanClauseList, Dialect, Index, LargeBinary, String, UniqueConstraint, delete, func, insert, select, update, ) from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import Mapped, Session, declarative_mixin, declared_attr, mapped_column from advanced_alchemy.base import UUIDv7Base # Import config types and async_ utility from advanced_alchemy.extensions.litestar.plugins.init import ( SQLAlchemyAsyncConfig, SQLAlchemySyncConfig, ) from advanced_alchemy.operations import OnConflictUpsert from advanced_alchemy.utils.sync_tools import async_ if TYPE_CHECKING: from collections.abc import AsyncGenerator, Generator from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm.decl_base import _TableArgsType as TableArgsType # pyright: ignore[reportPrivateUsage] from sqlalchemy.sql.elements import BooleanClauseList SQLAlchemyConfigT = TypeVar("SQLAlchemyConfigT", bound=Union[SQLAlchemyAsyncConfig, SQLAlchemySyncConfig]) __all__ = ("SQLAlchemyStore", "StoreModelMixin") _POSTGRES_VERSION_SUPPORTING_MERGE: Final = 15 # Temporary toggle to disable PostgreSQL MERGE due to locking concerns _DISABLE_POSTGRES_MERGE: Final = True @declarative_mixin class StoreModelMixin(UUIDv7Base): """Mixin for session storage.""" __abstract__ = True @declared_attr.directive @classmethod def __table_args__(cls) -> "TableArgsType": return ( UniqueConstraint( cls.key, cls.namespace, name=f"uq_{cls.__tablename__}_key_namespace", ).ddl_if(callable_=cls._create_unique_store_key_namespace_constraint), Index( f"ix_{cls.__tablename__}_key_namespace_unique", cls.key, cls.namespace, unique=True, ).ddl_if(callable_=cls._create_unique_store_key_namespace_index), ) @declared_attr def key(cls) -> Mapped[str]: return mapped_column(String(length=255), nullable=False) @declared_attr def namespace(cls) -> Mapped[str]: return mapped_column(String(length=255), nullable=False) @declared_attr def value(cls) -> Mapped[bytes]: return mapped_column(LargeBinary, nullable=False) @declared_attr def expires_at(cls) -> Mapped[datetime.datetime]: return mapped_column(index=True) @classmethod def _create_unique_store_key_namespace_index(cls, *_: Any, **kwargs: Any) -> bool: dialect_name = kwargs.get("dialect", {}).name if "dialect" in kwargs else "" return bool("spanner" in dialect_name.lower()) @classmethod def _create_unique_store_key_namespace_constraint(cls, *_: Any, **kwargs: Any) -> bool: dialect_name = kwargs.get("dialect", {}).name if "dialect" in kwargs else "" return "spanner" not in dialect_name.lower() @hybrid_property def is_expired(self) -> bool: # pyright: ignore """Boolean indicating if the session has expired. Returns: `True` if the session has expired, otherwise `False` """ return datetime.datetime.now(datetime.timezone.utc) > self.expires_at @is_expired.expression # type: ignore[no-redef] def is_expired(cls) -> "BooleanClauseList": # noqa: N805 """SQL-Expression to check if the session has expired. Returns: SQL-Expression to check if the session has expired. """ return cast("BooleanClauseList", func.now() > cls.expires_at) class SQLAlchemyStore(NamespacedStore, Generic[SQLAlchemyConfigT]): """SQLAlchemy based, thread and process safe asynchronous key/value store. Supports both synchronous and asynchronous SQLAlchemy configurations. """ __slots__ = ("_config", "_is_async", "_model", "_session_maker") def __init__( self, config: "SQLAlchemyConfigT", model: "type[StoreModelMixin]" = StoreModelMixin, namespace: "Optional[Union[str, EmptyType]]" = Empty, ) -> None: """Initialize :class:`SQLAlchemyStore`. Args: config: An instance of ``SQLAlchemyAsyncConfig`` or ``SQLAlchemySyncConfig``. model: The SQLAlchemy model to use for storing data. Defaults to :class:`StoreItem`. namespace: A virtual namespace for keys. If not given, defaults to ``LITESTAR``. Namespacing can be explicitly disabled by passing ``None``. This will make :meth:`.delete_all` unavailable. """ self._config = config self._model = model self._is_async = isinstance(config, SQLAlchemyAsyncConfig) self.namespace: Optional[str] = value_or_default(namespace, "LITESTAR") @asynccontextmanager async def _get_async_session(self) -> "AsyncGenerator[AsyncSession, None]": if not self._is_async: # This should ideally not be called if configured for sync, # but provides a safeguard. msg = "Store configured for synchronous operation." raise ImproperlyConfiguredException(msg) async with cast("AsyncSession", self._config.get_session()) as session: yield session @contextmanager def _get_sync_session(self) -> "Generator[Session, None, None]": if self._is_async: msg = "Store configured for asynchronous operation." raise ImproperlyConfiguredException(msg) with cast("Session", self._config.get_session()) as session: yield session @staticmethod def supports_merge(dialect: Optional[Dialect] = None, force_disable_merge: bool = False) -> bool: return bool( dialect and ( ( dialect.server_version_info is not None and dialect.server_version_info[0] >= _POSTGRES_VERSION_SUPPORTING_MERGE and dialect.name == "postgresql" and not _DISABLE_POSTGRES_MERGE # Temporary PostgreSQL MERGE disable ) or dialect.name == "oracle" ) and not force_disable_merge ) @staticmethod def supports_upsert(dialect: Optional[Dialect] = None, force_disable_upsert: bool = False) -> bool: return bool( dialect and (dialect.name in {"postgresql", "cockroachdb", "sqlite", "mysql", "mariadb", "duckdb"}) and not force_disable_upsert ) def _make_key(self, key: str) -> tuple[str, Optional[str]]: """Return the key and namespace tuple, handling potential None namespace.""" return key, self.namespace def _decode_base64_value(self, value: Optional[bytes], dialect_name: str) -> Optional[bytes]: """Decode base64 encoded value from Spanner if needed. Spanner automatically base64-encodes binary data when storing it, so we need to decode it when retrieving. """ if value is None or not dialect_name.startswith("spanner"): return value try: return base64.b64decode(value) except Exception: # noqa: BLE001 return value def _set_sync( self, key: str, value: Union[bytes, str], expires_in: Optional[Union[int, datetime.timedelta]] = None ) -> None: """Synchronous implementation for setting a value.""" db_key, db_namespace = self._make_key(key) expires_at: Optional[datetime.datetime] = None serialized_value = value if isinstance(value, bytes) else value.encode("utf-8") if expires_in is not None: delta = expires_in if isinstance(expires_in, datetime.timedelta) else datetime.timedelta(seconds=expires_in) expires_at = datetime.datetime.now(datetime.timezone.utc) + delta with self._get_sync_session() as session, session.begin(): if session.bind is None: msg = "Database connection is not available" raise ImproperlyConfiguredException(msg) dialect = session.bind.dialect dialect_name = dialect.name values = { "key": db_key, "namespace": db_namespace, "value": serialized_value, "expires_at": expires_at, } conflict_columns = ["key", "namespace"] update_columns = ["value", "expires_at"] if OnConflictUpsert.supports_native_upsert(dialect_name): upsert_stmt = OnConflictUpsert.create_upsert( table=self._model.__table__, # type: ignore[arg-type] values=values, conflict_columns=conflict_columns, update_columns=update_columns, dialect_name=dialect_name, validate_identifiers=False, ) session.execute(upsert_stmt) elif self.supports_merge(dialect): merge_stmt, additional_params = OnConflictUpsert.create_merge_upsert( table=self._model.__table__, # type: ignore[arg-type] values=values, conflict_columns=conflict_columns, update_columns=update_columns, dialect_name=dialect_name, validate_identifiers=False, ) # Merge additional Oracle parameters with original values merge_values = {**values, **additional_params} session.execute(merge_stmt, merge_values) else: # Fallback logic: Check existence, then update or insert existing = session.execute( select(1).where(self._model.key == db_key, self._model.namespace == db_namespace) ).scalar_one_or_none() if existing: session.execute( update(self._model) .where(self._model.key == db_key, self._model.namespace == db_namespace) .values(value=serialized_value, expires_at=expires_at) ) else: session.execute( insert(self._model).values( key=db_key, namespace=db_namespace, value=serialized_value, expires_at=expires_at ) ) session.commit() async def _set_async( self, key: str, value: Union[bytes, str], expires_in: Optional[Union[int, datetime.timedelta]] = None ) -> None: """Asynchronous implementation for setting a value.""" db_key, db_namespace = self._make_key(key) serialized_value = value if isinstance(value, bytes) else value.encode("utf-8") expires_at: Optional[datetime.datetime] = None if expires_in is not None: delta = expires_in if isinstance(expires_in, datetime.timedelta) else datetime.timedelta(seconds=expires_in) expires_at = datetime.datetime.now(datetime.timezone.utc) + delta async with self._get_async_session() as session, session.begin(): if session.bind is None: # pyright: ignore[reportUnnecessaryComparison] msg = "Database connection is not available" # type: ignore[unreachable] raise ImproperlyConfiguredException(msg) dialect = session.bind.dialect dialect_name = dialect.name values = { "key": db_key, "namespace": db_namespace, "value": serialized_value, "expires_at": expires_at, } conflict_columns = ["key", "namespace"] update_columns = ["value", "expires_at"] if OnConflictUpsert.supports_native_upsert(dialect_name): upsert_stmt = OnConflictUpsert.create_upsert( table=self._model.__table__, # type: ignore[arg-type] values=values, conflict_columns=conflict_columns, update_columns=update_columns, dialect_name=dialect_name, validate_identifiers=False, ) await session.execute(upsert_stmt) elif self.supports_merge(dialect): merge_stmt, additional_params = OnConflictUpsert.create_merge_upsert( table=self._model.__table__, # type: ignore[arg-type] values=values, conflict_columns=conflict_columns, update_columns=update_columns, dialect_name=dialect_name, validate_identifiers=False, ) # Merge additional Oracle parameters with original values merge_values = {**values, **additional_params} await session.execute(merge_stmt, merge_values) else: # Fallback logic: Check existence, then update or insert existing_id = ( await session.execute( select(self._model.id).where( self._model.key == db_key, self._model.namespace == db_namespace, ) ) ).scalar_one_or_none() if existing_id: await session.execute( update(self._model) .where(self._model.id == existing_id) .values(value=serialized_value, expires_at=expires_at) ) else: await session.execute( insert(self._model).values( key=db_key, namespace=db_namespace, value=serialized_value, expires_at=expires_at ) ) await session.commit() async def set( self, key: str, value: Union[bytes, str], expires_in: Optional[Union[int, datetime.timedelta]] = None ) -> None: """Set a value. Handles both sync and async backends.""" if self._is_async: await self._set_async(key, value, expires_in) else: await async_(self._set_sync)(key, value, expires_in) def _get_sync(self, key: str, renew_for: Optional[Union[int, datetime.timedelta]] = None) -> Optional[bytes]: db_key, db_namespace = self._make_key(key) now = datetime.datetime.now(datetime.timezone.utc) with self._get_sync_session() as session, session.begin(): if session.bind is None: msg = "Database connection is not available" raise ImproperlyConfiguredException(msg) dialect_name = session.bind.dialect.name value = session.execute( select(self._model.value).where( self._model.key == db_key, self._model.namespace == db_namespace, (self._model.expires_at.is_(None)) | (self._model.expires_at > now), ) ).scalar_one_or_none() if value: if renew_for: delta = ( renew_for if isinstance(renew_for, datetime.timedelta) else datetime.timedelta(seconds=renew_for) ) session.execute( update(self._model) .where(self._model.key == db_key, self._model.namespace == db_namespace) .values(expires_at=now + delta) ) session.commit() return self._decode_base64_value(value, dialect_name) session.commit() # Commit even if not found return None async def _get_async(self, key: str, renew_for: Optional[Union[int, datetime.timedelta]] = None) -> Optional[bytes]: db_key, db_namespace = self._make_key(key) now = datetime.datetime.now(datetime.timezone.utc) async with self._get_async_session() as session, session.begin(): if session.bind is None: # pyright: ignore[reportUnnecessaryComparison] msg = "Database connection is not available" # type: ignore[unreachable] raise ImproperlyConfiguredException(msg) dialect_name = session.bind.dialect.name value = ( await session.execute( select(self._model.value).where( self._model.key == db_key, self._model.namespace == db_namespace, (self._model.expires_at.is_(None)) | (self._model.expires_at > now), ) ) ).scalar_one_or_none() if value: if renew_for: delta = ( renew_for if isinstance(renew_for, datetime.timedelta) else datetime.timedelta(seconds=renew_for) ) await session.execute( update(self._model) .where(self._model.key == db_key, self._model.namespace == db_namespace) .values(expires_at=now + delta) ) await session.commit() return self._decode_base64_value(value, dialect_name) await session.commit() # Commit even if not found return None async def get(self, key: str, renew_for: Optional[Union[int, datetime.timedelta]] = None) -> Optional[bytes]: """Get a value. Handles both sync and async backends. Args: key: The key to get the value for. renew_for: The amount of time to renew the value for. Returns: The value of the key, or None if the key does not exist or has expired. """ if self._is_async: return await self._get_async(key, renew_for) return await async_(self._get_sync)(key, renew_for) def _delete_sync(self, key: str) -> None: db_key, db_namespace = self._make_key(key) with self._get_sync_session() as session, session.begin(): session.execute(delete(self._model).where(self._model.key == db_key, self._model.namespace == db_namespace)) session.commit() async def _delete_async(self, key: str) -> None: db_key, db_namespace = self._make_key(key) async with self._get_async_session() as session, session.begin(): await session.execute( delete(self._model).where(self._model.key == db_key, self._model.namespace == db_namespace) ) await session.commit() async def delete(self, key: str) -> None: """Delete a value. Handles both sync and async backends. Args: key: The key to delete. """ if self._is_async: await self._delete_async(key) else: await async_(self._delete_sync)(key) def _delete_all_sync(self) -> None: if self.namespace is None: msg = "Cannot perform delete operation: No namespace configured" raise ImproperlyConfiguredException(msg) db_namespace = self.namespace with self._get_sync_session() as session, session.begin(): session.execute(delete(self._model).where(self._model.namespace == db_namespace)) session.commit() async def _delete_all_async(self) -> None: if self.namespace is None: msg = "Cannot perform delete operation: No namespace configured" raise ImproperlyConfiguredException(msg) db_namespace = self.namespace async with self._get_async_session() as session, session.begin(): await session.execute(delete(self._model).where(self._model.namespace == db_namespace)) await session.commit() async def delete_all(self) -> None: """Delete all values in the namespace. Handles both sync and async backends. Args: key: The key to delete. """ if self._is_async: await self._delete_all_async() else: await async_(self._delete_all_sync)() def _exists_sync(self, key: str) -> bool: db_key, db_namespace = self._make_key(key) now = datetime.datetime.now(datetime.timezone.utc) with self._get_sync_session() as session: # Use count for potentially better performance if only existence is needed stmt = ( select(self._model.key) .where( self._model.key == db_key, self._model.namespace == db_namespace, (self._model.expires_at.is_(None)) | (self._model.expires_at > now), ) .limit(1) ) # limit 1 is important for performance return session.execute(stmt).scalar_one_or_none() is not None async def _exists_async(self, key: str) -> bool: db_key, db_namespace = self._make_key(key) now = datetime.datetime.now(datetime.timezone.utc) async with self._get_async_session() as session: # Use count for potentially better performance if only existence is needed stmt = ( select(self._model.key) .where( self._model.key == db_key, self._model.namespace == db_namespace, (self._model.expires_at.is_(None)) | (self._model.expires_at > now), ) .limit(1) ) result = await session.execute(stmt) return result.scalar_one_or_none() is not None async def exists(self, key: str) -> bool: """Check if a key exists. Handles both sync and async backends. Args: key: The key to check if it exists. Returns: True if the key exists, False otherwise. """ if self._is_async: return await self._exists_async(key) return await async_(self._exists_sync)(key) def _expires_in_sync(self, key: str) -> Optional[int]: db_key, db_namespace = self._make_key(key) now = datetime.datetime.now(datetime.timezone.utc) with self._get_sync_session() as session: stmt = select(self._model.expires_at).where( self._model.key == db_key, self._model.namespace == db_namespace, self._model.expires_at.is_not(None), # Explicitly check for non-null expiry self._model.expires_at > now, ) expires_at = session.execute(stmt).scalar_one_or_none() if expires_at: return int((expires_at - now).total_seconds()) return None async def _expires_in_async(self, key: str) -> Optional[int]: db_key, db_namespace = self._make_key(key) now = datetime.datetime.now(datetime.timezone.utc) async with self._get_async_session() as session: stmt = select(self._model.expires_at).where( self._model.key == db_key, self._model.namespace == db_namespace, self._model.expires_at.is_not(None), # Explicitly check for non-null expiry self._model.expires_at > now, ) result = await session.execute(stmt) expires_at = result.scalar_one_or_none() if expires_at: return int((expires_at - now).total_seconds()) return None async def expires_in(self, key: str) -> Optional[int]: """Get expiration time. Handles both sync and async backends. Args: key: The key to get the expiration time for. Returns: The expiration time in seconds, or None if the key does not exist or has no expiration time. """ if self._is_async: return await self._expires_in_async(key) return await async_(self._expires_in_sync)(key) def with_namespace(self, namespace: str) -> "SQLAlchemyStore[SQLAlchemyConfigT]": """Return a new :class:`SQLAlchemyStore` with a nested virtual key namespace.""" new_namespace = f"{self.namespace}_{namespace}" if self.namespace else namespace # We pass the original config, model type to the new instance return type(self)( config=self._config, # Pass the original config model=self._model, namespace=new_namespace, ) async def __aexit__( self, exc_type: object, exc_val: object, exc_tb: object, ) -> None: # Session lifecycle is managed by the context managers (_get_sync_session/_get_async_session) # or externally via the provided config's lifespan manager. No explicit cleanup needed here. pass python-advanced-alchemy-1.9.3/advanced_alchemy/extensions/sanic/000077500000000000000000000000001516556515500250115ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/advanced_alchemy/extensions/sanic/__init__.py000066400000000000000000000015411516556515500271230ustar00rootroot00000000000000from advanced_alchemy import base, exceptions, filters, mixins, operations, repository, service, types, utils from advanced_alchemy.alembic.commands import AlembicCommands from advanced_alchemy.config import ( AlembicAsyncConfig, AlembicSyncConfig, AsyncSessionConfig, SyncSessionConfig, ) from advanced_alchemy.extensions.sanic.config import EngineConfig, SQLAlchemyAsyncConfig, SQLAlchemySyncConfig from advanced_alchemy.extensions.sanic.extension import AdvancedAlchemy __all__ = ( "AdvancedAlchemy", "AlembicAsyncConfig", "AlembicCommands", "AlembicSyncConfig", "AsyncSessionConfig", "EngineConfig", "SQLAlchemyAsyncConfig", "SQLAlchemySyncConfig", "SyncSessionConfig", "base", "exceptions", "filters", "mixins", "operations", "repository", "service", "types", "utils", ) python-advanced-alchemy-1.9.3/advanced_alchemy/extensions/sanic/config.py000066400000000000000000000567221516556515500266440ustar00rootroot00000000000000"""Configuration classes for Sanic integration. This module provides configuration classes for integrating SQLAlchemy with Sanic applications, including both synchronous and asynchronous database configurations. """ import asyncio import contextlib import logging from dataclasses import dataclass, field from typing import Any, Callable, Optional, cast from click import echo from sanic import HTTPResponse, Request, Sanic from sqlalchemy.exc import OperationalError from advanced_alchemy.exceptions import ImproperConfigurationError try: from sanic_ext import Extend SANIC_INSTALLED = True except ModuleNotFoundError: # pragma: no cover SANIC_INSTALLED = False # pyright: ignore[reportConstantRedefinition] Extend = type("Extend", (), {}) # type: ignore from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker from sqlalchemy.orm import Session, sessionmaker from typing_extensions import Literal from advanced_alchemy._listeners import set_async_context from advanced_alchemy._serialization import decode_json, encode_json from advanced_alchemy.base import metadata_registry from advanced_alchemy.config import EngineConfig as _EngineConfig from advanced_alchemy.config.asyncio import SQLAlchemyAsyncConfig as _SQLAlchemyAsyncConfig from advanced_alchemy.config.sync import SQLAlchemySyncConfig as _SQLAlchemySyncConfig from advanced_alchemy.service import schema_dump logger = logging.getLogger("advanced_alchemy.extensions.sanic") def _make_unique_context_key(app: "Sanic[Any, Any]", key: str) -> str: # pragma: no cover """Generates a unique context key for the Sanic application. Ensures that the key does not already exist in the application's state. Args: app (sanic.Sanic): The Sanic application instance. key (str): The base key name. Returns: str: A unique key name. """ i = 0 while True: if not hasattr(app.ctx, key): return key key = f"{key}_{i}" i += i def serializer(value: Any) -> str: """Serialize JSON field values. Args: value: Any JSON serializable value. Returns: str: JSON string representation of the value. """ return encode_json(schema_dump(value)) @dataclass class EngineConfig(_EngineConfig): """Configuration for SQLAlchemy's Engine. This class extends the base EngineConfig with Sanic-specific JSON serialization options. For details see: https://docs.sqlalchemy.org/en/20/core/engines.html Attributes: json_deserializer: Callable for converting JSON strings to Python objects. json_serializer: Callable for converting Python objects to JSON strings. """ json_deserializer: Callable[[str], Any] = decode_json """For dialects that support the :class:`~sqlalchemy.types.JSON` datatype, this is a Python callable that will convert a JSON string to a Python object. But default, this uses the built-in serializers.""" json_serializer: Callable[[Any], str] = serializer """For dialects that support the JSON datatype, this is a Python callable that will render a given object as JSON. By default, By default, the built-in serializer is used.""" @dataclass class SQLAlchemyAsyncConfig(_SQLAlchemyAsyncConfig): """SQLAlchemy Async config for Sanic.""" _app: "Optional[Sanic[Any, Any]]" = None """The Sanic application instance.""" commit_mode: Literal["manual", "autocommit", "autocommit_include_redirect"] = "manual" """The commit mode to use for database sessions.""" engine_key: str = "db_engine" """Key to use for the dependency injection of database engines.""" session_key: str = "db_session" """Key to use for the dependency injection of database sessions.""" session_maker_key: str = "session_maker_class" """Key under which to store the SQLAlchemy :class:`sessionmaker ` in the application state instance. """ engine_config: EngineConfig = field(default_factory=EngineConfig) # pyright: ignore[reportIncompatibleVariableOverride] """Configuration for the SQLAlchemy engine. The configuration options are documented in the SQLAlchemy documentation. """ async def create_all_metadata(self) -> None: # pragma: no cover """Create all metadata tables in the database.""" if self.engine_instance is None: self.engine_instance = self.get_engine() async with self.engine_instance.begin() as conn: try: await conn.run_sync( metadata_registry.get(None if self.bind_key == "default" else self.bind_key).create_all ) await conn.commit() except OperationalError as exc: echo(f" * Could not create target metadata. Reason: {exc}") else: echo(" * Created target metadata.") @property def app(self) -> "Sanic[Any, Any]": """The Sanic application instance. Raises: ImproperConfigurationError: If the application is not initialized. """ if self._app is None: msg = "The Sanic application instance is not set." raise ImproperConfigurationError(msg) return self._app def init_app(self, app: "Sanic[Any, Any]", bootstrap: "Extend") -> None: # pyright: ignore[reportUnknownParameterType,reportInvalidTypeForm] """Initialize the Sanic application with this configuration. Args: app: The Sanic application instance. bootstrap: The Sanic extension bootstrap. """ self._app = app self.bind_key = self.bind_key or "default" _ = self.create_session_maker() self.session_key = _make_unique_context_key(app, f"advanced_alchemy_async_session_{self.session_key}") self.engine_key = _make_unique_context_key(app, f"advanced_alchemy_async_engine_{self.engine_key}") self.session_maker_key = _make_unique_context_key( app, f"advanced_alchemy_async_session_maker_{self.session_maker_key}" ) self.startup(bootstrap) # pyright: ignore[reportUnknownMemberType,reportUnknownArgumentType] def startup(self, bootstrap: "Extend") -> None: # pyright: ignore[reportUnknownParameterType,reportInvalidTypeForm] """Initialize the Sanic application with this configuration. Args: bootstrap: The Sanic extension bootstrap. """ @self.app.before_server_start # pyright: ignore[reportUnknownMemberType] async def on_startup(_: Any) -> None: # pyright: ignore[reportUnusedFunction] setattr(self.app.ctx, self.engine_key, self.get_engine()) # pyright: ignore[reportUnknownMemberType,reportUnknownArgumentType,reportOptionalMemberAccess] setattr(self.app.ctx, self.session_maker_key, self.create_session_maker()) # pyright: ignore[reportUnknownMemberType,reportUnknownArgumentType,reportOptionalMemberAccess] bootstrap.add_dependency( # pyright: ignore[reportUnknownMemberType] AsyncEngine, self.get_engine_from_request, ) bootstrap.add_dependency( # pyright: ignore[reportUnknownMemberType] async_sessionmaker[AsyncSession], self.get_sessionmaker_from_request, ) bootstrap.add_dependency( # pyright: ignore[reportUnknownMemberType] AsyncSession, self.get_session_from_request, ) await self.on_startup() @self.app.after_server_stop # pyright: ignore[reportUnknownMemberType] async def on_shutdown(_: Any) -> None: # pyright: ignore[reportUnusedFunction] if self.engine_instance is not None: await self.engine_instance.dispose() if hasattr(self.app.ctx, self.engine_key): # pyright: ignore[reportUnknownMemberType,reportUnknownArgumentType,reportOptionalMemberAccess] delattr(self.app.ctx, self.engine_key) # pyright: ignore[reportUnknownMemberType,reportUnknownArgumentType,reportOptionalMemberAccess] if hasattr(self.app.ctx, self.session_maker_key): # pyright: ignore[reportUnknownMemberType,reportUnknownArgumentType,reportOptionalMemberAccess] delattr(self.app.ctx, self.session_maker_key) # pyright: ignore[reportUnknownMemberType,reportUnknownArgumentType,reportOptionalMemberAccess] @self.app.middleware("request") # type: ignore[misc,untyped-decorator] # pyright: ignore[reportUnknownMemberType] async def on_request(request: Request) -> None: # pyright: ignore[reportUnusedFunction] session = cast("Optional[AsyncSession]", getattr(request.ctx, self.session_key, None)) if session is None: setattr(request.ctx, self.session_key, self.get_session()) set_async_context(True) @self.app.middleware("response") # type: ignore[misc,untyped-decorator] async def on_response(request: Request, response: HTTPResponse) -> None: # pyright: ignore[reportUnusedFunction] session = cast("Optional[AsyncSession]", getattr(request.ctx, self.session_key, None)) if session is not None: await self.session_handler(session=session, request=request, response=response) async def on_startup(self) -> None: """Initialize the Sanic application with this configuration.""" if self.create_all: await self.create_all_metadata() def create_session_maker(self) -> Callable[[], "AsyncSession"]: """Get a session maker. If none exists yet, create one. Returns: Callable[[], Session]: Session factory used by the plugin. """ if self.session_maker: return self.session_maker session_kws = self.session_config_dict if self.engine_instance is None: self.engine_instance = self.get_engine() if session_kws.get("bind") is None: session_kws["bind"] = self.engine_instance self.session_maker = self.session_maker_class(**session_kws) return self.session_maker async def session_handler( self, session: "AsyncSession", request: "Request", response: "HTTPResponse" ) -> None: # pragma: no cover """Handles the session after a request is processed. Applies the commit strategy and ensures the session is closed. Args: session (sqlalchemy.ext.asyncio.AsyncSession): The database session. request (sanic.Request): The incoming HTTP request. response (sanic.HTTPResponse): The outgoing HTTP response. """ try: if (self.commit_mode == "autocommit" and 200 <= response.status < 300) or ( # noqa: PLR2004 self.commit_mode == "autocommit_include_redirect" and 200 <= response.status < 400 # noqa: PLR2004 ): await session.commit() else: await session.rollback() except Exception: # noqa: BLE001 logger.debug("Session commit/rollback failed during cleanup", exc_info=True) finally: try: await session.close() except Exception: # noqa: BLE001 logger.debug("Session close failed during cleanup", exc_info=True) with contextlib.suppress(AttributeError, KeyError): delattr(request.ctx, self.session_key) def get_engine_from_request(self, request: "Request") -> AsyncEngine: """Retrieve the engine from the request context. Args: request (sanic.Request): The incoming request. Returns: AsyncEngine: The SQLAlchemy engine. """ return cast("AsyncEngine", getattr(request.app.ctx, self.engine_key, self.get_engine())) # pragma: no cover def get_sessionmaker_from_request(self, request: "Request") -> async_sessionmaker[AsyncSession]: """Retrieve the session maker from the request context. Args: request (sanic.Request): The incoming request. Returns: SessionMakerT: The session maker. """ return cast( "async_sessionmaker[AsyncSession]", getattr(request.app.ctx, self.session_maker_key, None) ) # pragma: no cover def get_session_from_request(self, request: Request) -> AsyncSession: """Retrieve the session from the request context. Args: request (sanic.Request): The incoming request. Returns: SessionT: The session associated with the request. """ return cast("AsyncSession", getattr(request.ctx, self.session_key, None)) # pragma: no cover async def close_engine(self) -> None: # pragma: no cover """Close the engine.""" if self.engine_instance is not None: await self.engine_instance.dispose() async def on_shutdown(self) -> None: # pragma: no cover """Handles the shutdown event by disposing of the SQLAlchemy engine. Ensures that all connections are properly closed during application shutdown. """ await self.close_engine() if hasattr(self.app.ctx, self.engine_key): # pyright: ignore[reportUnknownMemberType,reportUnknownArgumentType,reportOptionalMemberAccess] delattr(self.app.ctx, self.engine_key) # pyright: ignore[reportUnknownMemberType,reportUnknownArgumentType,reportOptionalMemberAccess] if hasattr(self.app.ctx, self.session_maker_key): # pyright: ignore[reportUnknownMemberType,reportUnknownArgumentType,reportOptionalMemberAccess] delattr(self.app.ctx, self.session_maker_key) # pyright: ignore[reportUnknownMemberType,reportUnknownArgumentType,reportOptionalMemberAccess] @dataclass class SQLAlchemySyncConfig(_SQLAlchemySyncConfig): """SQLAlchemy Sync config for Starlette.""" _app: "Optional[Sanic[Any, Any]]" = None """The Sanic application instance.""" commit_mode: Literal["manual", "autocommit", "autocommit_include_redirect"] = "manual" """The commit mode to use for database sessions.""" engine_key: str = "db_engine" """Key to use for the dependency injection of database engines.""" session_key: str = "db_session" """Key to use for the dependency injection of database sessions.""" session_maker_key: str = "session_maker_class" """Key under which to store the SQLAlchemy :class:`sessionmaker ` in the application state instance. """ engine_config: EngineConfig = field(default_factory=EngineConfig) # pyright: ignore[reportIncompatibleVariableOverride] """Configuration for the SQLAlchemy engine. The configuration options are documented in the SQLAlchemy documentation. """ @property def app(self) -> "Sanic[Any, Any]": """The Sanic application instance. Raises: ImproperConfigurationError: If the application is not initialized. """ if self._app is None: msg = "The Sanic application instance is not set." raise ImproperConfigurationError(msg) return self._app async def create_all_metadata(self) -> None: # pragma: no cover """Create all metadata tables in the database.""" if self.engine_instance is None: self.engine_instance = self.get_engine() with self.engine_instance.begin() as conn: try: loop = asyncio.get_event_loop() await loop.run_in_executor( None, metadata_registry.get(None if self.bind_key == "default" else self.bind_key).create_all, conn ) except OperationalError as exc: echo(f" * Could not create target metadata. Reason: {exc}") def init_app(self, app: "Sanic[Any, Any]", bootstrap: "Extend") -> None: # pyright: ignore[reportUnknownParameterType,reportInvalidTypeForm] """Initialize the Sanic application with this configuration. Args: app: The Sanic application instance. bootstrap: The Sanic extension bootstrap. """ self._app = app self.bind_key = self.bind_key or "default" _ = self.create_session_maker() self.session_key = _make_unique_context_key(app, f"advanced_alchemy_sync_session_{self.session_key}") self.engine_key = _make_unique_context_key(app, f"advanced_alchemy_sync_engine_{self.engine_key}") self.session_maker_key = _make_unique_context_key( app, f"advanced_alchemy_sync_session_maker_{self.session_maker_key}" ) self.startup(bootstrap) # pyright: ignore[reportUnknownMemberType,reportUnknownArgumentType] def startup(self, bootstrap: "Extend") -> None: # pyright: ignore[reportUnknownParameterType,reportInvalidTypeForm] """Initialize the Sanic application with this configuration. Args: bootstrap: The Sanic extension bootstrap. """ @self.app.before_server_start # pyright: ignore[reportUnknownMemberType] async def on_startup(_: Any) -> None: # pyright: ignore[reportUnusedFunction] setattr(self.app.ctx, self.engine_key, self.get_engine()) # pyright: ignore[reportUnknownMemberType,reportUnknownArgumentType,reportOptionalMemberAccess] setattr(self.app.ctx, self.session_maker_key, self.create_session_maker()) # pyright: ignore[reportUnknownMemberType,reportUnknownArgumentType,reportOptionalMemberAccess] bootstrap.add_dependency( # pyright: ignore[reportUnknownMemberType] AsyncEngine, self.get_engine_from_request, ) bootstrap.add_dependency( # pyright: ignore[reportUnknownMemberType] sessionmaker[Session], self.get_sessionmaker_from_request, ) bootstrap.add_dependency( # pyright: ignore[reportUnknownMemberType] AsyncSession, self.get_session_from_request, ) await self.on_startup() @self.app.after_server_stop # pyright: ignore[reportUnknownMemberType] async def on_shutdown(_: Any) -> None: # pyright: ignore[reportUnusedFunction] await self.on_shutdown() @self.app.middleware("request") # type: ignore[misc,untyped-decorator] # pyright: ignore[reportUnknownMemberType] async def on_request(request: Request) -> None: # pyright: ignore[reportUnusedFunction] session = cast("Optional[Session]", getattr(request.ctx, self.session_key, None)) if session is None: setattr(request.ctx, self.session_key, self.get_session()) set_async_context(False) @self.app.middleware("response") # type: ignore[misc,untyped-decorator] async def on_response(request: Request, response: HTTPResponse) -> None: # pyright: ignore[reportUnusedFunction] session = cast("Optional[Session]", getattr(request.ctx, self.session_key, None)) if session is not None: await self.session_handler(session=session, request=request, response=response) async def on_startup(self) -> None: """Initialize the Sanic application with this configuration.""" if self.create_all: await self.create_all_metadata() def create_session_maker(self) -> Callable[[], "Session"]: """Get a session maker. If none exists yet, create one. Returns: Callable[[], Session]: Session factory used by the plugin. """ if self.session_maker: return self.session_maker session_kws = self.session_config_dict if self.engine_instance is None: self.engine_instance = self.get_engine() if session_kws.get("bind") is None: session_kws["bind"] = self.engine_instance self.session_maker = self.session_maker_class(**session_kws) return self.session_maker async def session_handler( self, session: "Session", request: "Request", response: "HTTPResponse" ) -> None: # pragma: no cover """Handles the session after a request is processed. Applies the commit strategy and ensures the session is closed. Args: session (sqlalchemy.orm.Session): The database session. request (sanic.Request): The incoming HTTP request. response (sanic.HTTPResponse): The outgoing HTTP response. """ loop = asyncio.get_event_loop() try: if (self.commit_mode == "autocommit" and 200 <= response.status < 300) or ( # noqa: PLR2004 self.commit_mode == "autocommit_include_redirect" and 200 <= response.status < 400 # noqa: PLR2004 ): await loop.run_in_executor(None, session.commit) else: await loop.run_in_executor(None, session.rollback) except Exception: # noqa: BLE001 logger.debug("Session commit/rollback failed during cleanup", exc_info=True) finally: try: await loop.run_in_executor(None, session.close) except Exception: # noqa: BLE001 logger.debug("Session close failed during cleanup", exc_info=True) with contextlib.suppress(AttributeError, KeyError): delattr(request.ctx, self.session_key) def get_engine_from_request(self, request: Request) -> "AsyncEngine": """Retrieve the engine from the request context. Args: request (sanic.Request): The incoming request. Returns: AsyncEngine: The SQLAlchemy engine. """ return cast("AsyncEngine", getattr(request.app.ctx, self.engine_key, self.get_engine())) # pragma: no cover def get_sessionmaker_from_request(self, request: Request) -> sessionmaker[Session]: """Retrieve the session maker from the request context. Args: request (sanic.Request): The incoming request. Returns: SessionMakerT: The session maker. """ return cast("sessionmaker[Session]", getattr(request.app.ctx, self.session_maker_key, None)) # pragma: no cover def get_session_from_request(self, request: Request) -> "Session": """Retrieve the session from the request context. Args: request (sanic.Request): The incoming request. Returns: SessionT: The session associated with the request. """ return cast("Session", getattr(request.ctx, self.session_key, None)) # pragma: no cover async def close_engine(self) -> None: # pragma: no cover """Close the engine.""" if self.engine_instance is not None: loop = asyncio.get_event_loop() await loop.run_in_executor(None, self.engine_instance.dispose) async def on_shutdown(self) -> None: # pragma: no cover """Handles the shutdown event by disposing of the SQLAlchemy engine. Ensures that all connections are properly closed during application shutdown. """ await self.close_engine() if hasattr(self.app.ctx, self.engine_key): # pyright: ignore[reportUnknownMemberType,reportUnknownArgumentType,reportOptionalMemberAccess] delattr(self.app.ctx, self.engine_key) # pyright: ignore[reportUnknownMemberType,reportUnknownArgumentType,reportOptionalMemberAccess] if hasattr(self.app.ctx, self.session_maker_key): # pyright: ignore[reportUnknownMemberType,reportUnknownArgumentType,reportOptionalMemberAccess] delattr(self.app.ctx, self.session_maker_key) # pyright: ignore[reportUnknownMemberType,reportUnknownArgumentType,reportOptionalMemberAccess] python-advanced-alchemy-1.9.3/advanced_alchemy/extensions/sanic/extension.py000066400000000000000000000316711516556515500274070ustar00rootroot00000000000000from collections.abc import AsyncGenerator, Generator, Sequence from contextlib import asynccontextmanager, contextmanager from typing import TYPE_CHECKING, Any, Callable, Optional, Union, cast, overload from sanic import Request, Sanic from sqlalchemy.ext.asyncio import AsyncSession from advanced_alchemy._listeners import set_async_context from advanced_alchemy.exceptions import ImproperConfigurationError, MissingDependencyError from advanced_alchemy.extensions.sanic.config import SQLAlchemyAsyncConfig, SQLAlchemySyncConfig from advanced_alchemy.routing.context import reset_routing_context try: from sanic_ext import Extend from sanic_ext.extensions.base import Extension SANIC_INSTALLED = True except ModuleNotFoundError: # pragma: no cover SANIC_INSTALLED = False # pyright: ignore[reportConstantRedefinition] Extension = type("Extension", (), {}) # type: ignore Extend = type("Extend", (), {}) # type: ignore if TYPE_CHECKING: from sanic import Sanic from sqlalchemy import Engine from sqlalchemy.ext.asyncio import AsyncEngine from sqlalchemy.orm import Session __all__ = ("AdvancedAlchemy",) class AdvancedAlchemy(Extension): # type: ignore[no-untyped-call] # pyright: ignore[reportGeneralTypeIssues,reportUntypedBaseClass] """Sanic extension for integrating Advanced Alchemy with SQLAlchemy. Args: config: One or more configurations for SQLAlchemy. app: The Sanic application instance. """ name = "AdvancedAlchemy" def __init__( self, *, sqlalchemy_config: Union[ "SQLAlchemyAsyncConfig", "SQLAlchemySyncConfig", Sequence[Union["SQLAlchemyAsyncConfig", "SQLAlchemySyncConfig"]], ], sanic_app: Optional["Sanic[Any, Any]"] = None, ) -> None: if not SANIC_INSTALLED: # pragma: no cover msg = "Could not locate either Sanic or Sanic Extensions. Both libraries must be installed to use Advanced Alchemy. Try: pip install sanic[ext]" raise MissingDependencyError(msg) self._config = sqlalchemy_config if isinstance(sqlalchemy_config, Sequence) else [sqlalchemy_config] self._mapped_configs: dict[str, Union[SQLAlchemyAsyncConfig, SQLAlchemySyncConfig]] = self.map_configs() self._app = sanic_app self._initialized = False if self._app is not None: self.register(self._app) def register(self, sanic_app: "Sanic[Any, Any]") -> None: """Initialize the extension with the given Sanic app.""" self._app = sanic_app Extend.register(self) # pyright: ignore[reportUnknownMemberType,reportAttributeAccessIssue] self._initialized = True @property def sanic_app(self) -> "Sanic[Any, Any]": """The Sanic app. Raises: ImproperConfigurationError: If the app is not initialized. """ if self._app is None: # pragma: no cover msg = "AdvancedAlchemy has not been initialized with a Sanic app." raise ImproperConfigurationError(msg) return self._app @property def sqlalchemy_config(self) -> Sequence[Union["SQLAlchemyAsyncConfig", "SQLAlchemySyncConfig"]]: """Current Advanced Alchemy configuration.""" return self._config def startup(self, bootstrap: "Extend") -> None: # pyright: ignore[reportUnknownParameterType,reportInvalidTypeForm] """Advanced Alchemy Sanic extension startup hook. Args: bootstrap (sanic_ext.Extend): The Sanic extension bootstrap. """ for config in self.sqlalchemy_config: config.init_app(self.sanic_app, bootstrap) # pyright: ignore[reportUnknownMemberType,reportUnknownArgumentType] def map_configs(self) -> dict[str, Union["SQLAlchemyAsyncConfig", "SQLAlchemySyncConfig"]]: """Maps the configs to the session bind keys. Returns: A dictionary mapping bind keys to SQLAlchemy configurations. """ mapped_configs: dict[str, Union[SQLAlchemyAsyncConfig, SQLAlchemySyncConfig]] = {} for config in self.sqlalchemy_config: if config.bind_key is None: config.bind_key = "default" mapped_configs[config.bind_key] = config return mapped_configs def get_config(self, key: Optional[str] = None) -> Union["SQLAlchemyAsyncConfig", "SQLAlchemySyncConfig"]: """Get the config for the given key. Returns: The config for the given key. Raises: ImproperConfigurationError: If the config is not found. """ if key is None: key = "default" if key == "default" and len(self.sqlalchemy_config) == 1: key = self.sqlalchemy_config[0].bind_key or "default" config = self._mapped_configs.get(key) if config is None: # pragma: no cover msg = f"Config with key {key} not found" raise ImproperConfigurationError(msg) return config def get_async_config(self, key: Optional[str] = None) -> "SQLAlchemyAsyncConfig": """Get the async config for the given key. Returns: The async config for the given key. Raises: ImproperConfigurationError: If the config is not an async config. """ config = self.get_config(key) if not isinstance(config, SQLAlchemyAsyncConfig): # pragma: no cover msg = "Expected an async config, but got a sync config" raise ImproperConfigurationError(msg) return config def get_sync_config(self, key: Optional[str] = None) -> "SQLAlchemySyncConfig": """Get the sync config for the given key. Returns: The sync config for the given key. Raises: ImproperConfigurationError: If the config is not an sync config. """ config = self.get_config(key) if not isinstance(config, SQLAlchemySyncConfig): # pragma: no cover msg = "Expected a sync config, but got an async config" raise ImproperConfigurationError(msg) return config @asynccontextmanager async def with_async_session( self, key: Optional[str] = None ) -> AsyncGenerator["AsyncSession", None]: # pragma: no cover """Context manager for getting an async session. Yields: An AsyncSession instance. """ config = self.get_async_config(key) async with config.get_session() as session: yield session @contextmanager def with_sync_session(self, key: Optional[str] = None) -> Generator["Session", None]: # pragma: no cover """Context manager for getting a sync session. Yields: A Session instance. """ config = self.get_sync_config(key) with config.get_session() as session: yield session @overload @staticmethod def _get_session_from_request(request: "Request", config: "SQLAlchemyAsyncConfig") -> "AsyncSession": ... @overload @staticmethod def _get_session_from_request(request: "Request", config: "SQLAlchemySyncConfig") -> "Session": ... @staticmethod def _get_session_from_request( request: "Request", config: Union["SQLAlchemyAsyncConfig", "SQLAlchemySyncConfig"], # pragma: no cover ) -> Union["Session", "AsyncSession"]: # pragma: no cover """Get the session for the request and config. Returns: The session for the request and config. """ session = getattr(request.ctx, config.session_key, None) if session is None: reset_routing_context() session = config.get_session() setattr(request.ctx, config.session_key, session) set_async_context(isinstance(session, AsyncSession)) return cast("Union[Session, AsyncSession]", session) def get_session( self, request: "Request", key: Optional[str] = None ) -> Union["Session", "AsyncSession"]: # pragma: no cover """Get the session for the given key. Returns: The session for the given key. """ config = self.get_config(key) return self._get_session_from_request(request, config) def get_async_session(self, request: "Request", key: Optional[str] = None) -> "AsyncSession": # pragma: no cover """Get the async session for the given key. Returns: The async session for the given key. """ config = self.get_async_config(key) return self._get_session_from_request(request, config) def get_sync_session(self, request: "Request", key: Optional[str] = None) -> "Session": # pragma: no cover """Get the sync session for the given key. Returns: The sync session for the given key. """ config = self.get_sync_config(key) return self._get_session_from_request(request, config) def provide_session( self, key: Optional[str] = None ) -> Callable[["Request"], Union["Session", "AsyncSession"]]: # pragma: no cover """Get session provider for the given key. Returns: The session provider for the given key. """ config = self.get_config(key) def _get_session(request: "Request") -> Union["Session", "AsyncSession"]: return self._get_session_from_request(request, config) return _get_session def provide_async_session( self, key: Optional[str] = None ) -> Callable[["Request"], "AsyncSession"]: # pragma: no cover """Get async session provider for the given key. Returns: The async session provider for the given key. """ config = self.get_async_config(key) def _get_session(request: Request) -> "AsyncSession": return self._get_session_from_request(request, config) return _get_session def provide_sync_session(self, key: Optional[str] = None) -> Callable[[Request], "Session"]: # pragma: no cover """Get sync session provider for the given key. Returns: The sync session provider for the given key. """ config = self.get_sync_config(key) def _get_session(request: Request) -> "Session": return self._get_session_from_request(request, config) return _get_session def get_engine(self, key: Optional[str] = None) -> Union["Engine", "AsyncEngine"]: # pragma: no cover """Get the engine for the given key. Returns: The engine for the given key. """ config = self.get_config(key) return config.get_engine() def get_async_engine(self, key: Optional[str] = None) -> "AsyncEngine": # pragma: no cover """Get the async engine for the given key. Returns: The async engine for the given key. """ config = self.get_async_config(key) return config.get_engine() def get_sync_engine(self, key: Optional[str] = None) -> "Engine": # pragma: no cover """Get the sync engine for the given key. Returns: The sync engine for the given key. """ config = self.get_sync_config(key) return config.get_engine() def provide_engine( self, key: Optional[str] = None ) -> Callable[[], Union["Engine", "AsyncEngine"]]: # pragma: no cover """Get the engine for the given key. Returns: A callable that returns the engine. """ config = self.get_config(key) def _get_engine() -> Union["Engine", "AsyncEngine"]: return config.get_engine() return _get_engine def provide_async_engine(self, key: Optional[str] = None) -> Callable[[], "AsyncEngine"]: # pragma: no cover """Get the async engine for the given key. Returns: A callable that returns the engine. """ config = self.get_async_config(key) def _get_engine() -> "AsyncEngine": return config.get_engine() return _get_engine def provide_sync_engine(self, key: Optional[str] = None) -> Callable[[], "Engine"]: # pragma: no cover """Get the sync engine for the given key. Returns: A callable that returns the engine. """ config = self.get_sync_config(key) def _get_engine() -> "Engine": return config.get_engine() return _get_engine def add_session_dependency( self, session_type: type[Union["Session", "AsyncSession"]], key: Optional[str] = None ) -> None: """Add a session dependency to the Sanic app.""" self.sanic_app.ext.add_dependency(session_type, self.provide_session(key)) # pyright: ignore[reportUnknownMemberType] def add_engine_dependency( self, engine_type: type[Union["Engine", "AsyncEngine"]], key: Optional[str] = None ) -> None: """Add an engine dependency to the Sanic app.""" self.sanic_app.ext.add_dependency(engine_type, self.provide_engine(key)) # pyright: ignore[reportUnknownMemberType] python-advanced-alchemy-1.9.3/advanced_alchemy/extensions/starlette/000077500000000000000000000000001516556515500257235ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/advanced_alchemy/extensions/starlette/__init__.py000066400000000000000000000017741516556515500300450ustar00rootroot00000000000000"""Starlette extension for Advanced Alchemy. This module provides Starlette integration for Advanced Alchemy, including session management and service utilities. """ from advanced_alchemy import base, exceptions, filters, mixins, operations, repository, service, types, utils from advanced_alchemy.alembic.commands import AlembicCommands from advanced_alchemy.config import AlembicAsyncConfig, AlembicSyncConfig, AsyncSessionConfig, SyncSessionConfig from advanced_alchemy.extensions.starlette.config import EngineConfig, SQLAlchemyAsyncConfig, SQLAlchemySyncConfig from advanced_alchemy.extensions.starlette.extension import AdvancedAlchemy __all__ = ( "AdvancedAlchemy", "AlembicAsyncConfig", "AlembicCommands", "AlembicSyncConfig", "AsyncSessionConfig", "EngineConfig", "SQLAlchemyAsyncConfig", "SQLAlchemySyncConfig", "SyncSessionConfig", "base", "exceptions", "filters", "mixins", "operations", "repository", "service", "types", "utils", ) python-advanced-alchemy-1.9.3/advanced_alchemy/extensions/starlette/config.py000066400000000000000000000523331516556515500275500ustar00rootroot00000000000000"""Configuration classes for Starlette integration. This module provides configuration classes for integrating SQLAlchemy with Starlette applications, including both synchronous and asynchronous database configurations. """ import contextlib import logging from dataclasses import dataclass, field from importlib.util import find_spec from typing import TYPE_CHECKING, Any, Callable, Optional, Union, cast from sqlalchemy.exc import OperationalError from starlette.concurrency import run_in_threadpool # pyright: ignore[reportUnknownVariableType] from starlette.requests import Request from typing_extensions import Literal from advanced_alchemy._serialization import decode_json, encode_json from advanced_alchemy.base import metadata_registry from advanced_alchemy.config import EngineConfig as _EngineConfig from advanced_alchemy.config.asyncio import SQLAlchemyAsyncConfig as _SQLAlchemyAsyncConfig from advanced_alchemy.config.sync import SQLAlchemySyncConfig as _SQLAlchemySyncConfig from advanced_alchemy.routing.context import reset_routing_context from advanced_alchemy.service import schema_dump if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session from starlette.applications import Starlette from starlette.middleware.base import RequestResponseEndpoint from starlette.responses import Response from starlette.types import ASGIApp, Receive, Scope, Send logger = logging.getLogger("advanced_alchemy.extensions.starlette") FASTAPI_CLI_INSTALLED = bool(find_spec("fastapi_cli")) def _echo(message: str) -> None: # pragma: no cover """Echo a message using either rich toolkit or click echo.""" if FASTAPI_CLI_INSTALLED: from fastapi_cli.utils.cli import get_rich_toolkit with get_rich_toolkit() as toolkit: toolkit.print(message, tag="INFO") else: from click import echo echo(message) def _make_unique_state_key(app: "Starlette", key: str) -> str: # pragma: no cover """Generates a unique state key for the Starlette application. Ensures that the key does not already exist in the application's state. Args: app (starlette.applications.Starlette): The Starlette application instance. key (str): The base key name. Returns: str: A unique key name. """ i = 0 while True: if not hasattr(app.state, key): return key key = f"{key}_{i}" i += i class SessionMiddleware: """Pure ASGI middleware for database session lifecycle management. Unlike BaseHTTPMiddleware, this intercepts the ``send`` callable directly to capture the response status code at the moment it is sent. This ensures ``response_status`` is available on ``request.state`` before generator dependency cleanup runs, and avoids known Starlette issues with BaseHTTPMiddleware and generator dependencies. """ def __init__(self, app: "ASGIApp", config: Union["SQLAlchemyAsyncConfig", "SQLAlchemySyncConfig"]) -> None: self.app = app self.config = config async def __call__(self, scope: "Scope", receive: "Receive", send: "Send") -> None: if scope["type"] != "http": await self.app(scope, receive, send) return request = Request(scope) reset_routing_context() status_code = 500 response_started = False async def send_wrapper(message: Any) -> None: nonlocal status_code, response_started if message["type"] == "http.response.start": status_code = message["status"] response_started = True setattr(request.state, f"{self.config.session_key}_response_status", status_code) await send(message) exc_to_raise: Optional[BaseException] = None try: await self.app(scope, receive, send_wrapper) except Exception as exc: # noqa: BLE001 exc_to_raise = exc if not response_started: setattr(request.state, f"{self.config.session_key}_response_status", 500) finally: session = getattr(request.state, self.config.session_key, None) is_generator_managed = getattr(request.state, f"{self.config.session_key}_generator_managed", False) if session is not None and not is_generator_managed: await self._handle_session_cleanup(session, request, status_code) if exc_to_raise is not None: raise exc_to_raise async def _handle_session_cleanup( self, session: Any, request: "Request", status_code: int ) -> None: # pragma: no cover """Clean up a non-generator-managed session after the response.""" config = self.config should_commit = (config.commit_mode == "autocommit" and 200 <= status_code < 300) or ( # noqa: PLR2004 config.commit_mode == "autocommit_include_redirect" and 200 <= status_code < 400 # noqa: PLR2004 ) try: if isinstance(config, SQLAlchemyAsyncConfig): if should_commit: await session.commit() else: await session.rollback() elif should_commit: await run_in_threadpool(session.commit) else: await run_in_threadpool(session.rollback) except Exception: # noqa: BLE001 logger.debug("Session commit/rollback failed during middleware cleanup", exc_info=True) finally: try: if isinstance(config, SQLAlchemyAsyncConfig): await session.close() else: await run_in_threadpool(session.close) except Exception: # noqa: BLE001 logger.debug("Session close failed during middleware cleanup", exc_info=True) with contextlib.suppress(AttributeError, KeyError): delattr(request.state, config.session_key) def serializer(value: Any) -> str: """Serialize JSON field values. Args: value: Any JSON serializable value. Returns: str: JSON string representation of the value. """ return encode_json(schema_dump(value)) @dataclass class EngineConfig(_EngineConfig): """Configuration for SQLAlchemy's Engine. This class extends the base EngineConfig with Starlette-specific JSON serialization options. For details see: https://docs.sqlalchemy.org/en/20/core/engines.html Attributes: json_deserializer: Callable for converting JSON strings to Python objects. json_serializer: Callable for converting Python objects to JSON strings. """ json_deserializer: Callable[[str], Any] = decode_json """For dialects that support the :class:`~sqlalchemy.types.JSON` datatype, this is a Python callable that will convert a JSON string to a Python object. But default, this uses the built-in serializers.""" json_serializer: Callable[[Any], str] = serializer """For dialects that support the JSON datatype, this is a Python callable that will render a given object as JSON. By default, By default, the built-in serializer is used.""" @dataclass class SQLAlchemyAsyncConfig(_SQLAlchemyAsyncConfig): """SQLAlchemy Async config for Starlette.""" app: "Optional[Starlette]" = None """The Starlette application instance.""" commit_mode: Literal["manual", "autocommit", "autocommit_include_redirect"] = "manual" """The commit mode to use for database sessions.""" engine_key: str = "db_engine" """Key to use for the dependency injection of database engines.""" session_key: str = "db_session" """Key to use for the dependency injection of database sessions.""" session_maker_key: str = "session_maker_class" """Key under which to store the SQLAlchemy :class:`sessionmaker ` in the application state instance. """ engine_config: EngineConfig = field(default_factory=EngineConfig) # pyright: ignore[reportIncompatibleVariableOverride] """Configuration for the SQLAlchemy engine. The configuration options are documented in the SQLAlchemy documentation. """ async def create_all_metadata(self) -> None: # pragma: no cover """Create all metadata tables in the database.""" if self.engine_instance is None: self.engine_instance = self.get_engine() async with self.engine_instance.begin() as conn: try: await conn.run_sync( metadata_registry.get(None if self.bind_key == "default" else self.bind_key).create_all ) await conn.commit() except OperationalError as exc: _echo(f" * Could not create target metadata. Reason: {exc}") else: _echo(" * Created target metadata.") def init_app(self, app: "Starlette") -> None: """Initialize the Starlette application with this configuration. Args: app: The Starlette application instance. """ self.app = app self.bind_key = self.bind_key or "default" _ = self.create_session_maker() self.session_key = _make_unique_state_key(app, f"advanced_alchemy_async_session_{self.session_key}") self.engine_key = _make_unique_state_key(app, f"advanced_alchemy_async_engine_{self.engine_key}") self.session_maker_key = _make_unique_state_key( app, f"advanced_alchemy_async_session_maker_{self.session_maker_key}" ) app.add_middleware(SessionMiddleware, config=self) # pyright: ignore[reportUnknownMemberType] async def on_startup(self) -> None: """Initialize the Starlette application with this configuration.""" if self.create_all: await self.create_all_metadata() def create_session_maker(self) -> Callable[[], "AsyncSession"]: """Get a session maker. If none exists yet, create one. Returns: Callable[[], Session]: Session factory used by the plugin. """ if self.session_maker: return self.session_maker session_kws = self.session_config_dict if self.engine_instance is None: self.engine_instance = self.get_engine() if session_kws.get("bind") is None: session_kws["bind"] = self.engine_instance self.session_maker = self.session_maker_class(**session_kws) return self.session_maker async def session_handler( self, session: "AsyncSession", request: "Request", response: "Response" ) -> None: # pragma: no cover """Handles the session after a request is processed. Applies the commit strategy and ensures the session is closed. Args: session (sqlalchemy.ext.asyncio.AsyncSession): The database session. request (starlette.requests.Request): The incoming HTTP request. response (starlette.responses.Response): The outgoing HTTP response. """ try: if (self.commit_mode == "autocommit" and 200 <= response.status_code < 300) or ( # noqa: PLR2004 self.commit_mode == "autocommit_include_redirect" and 200 <= response.status_code < 400 # noqa: PLR2004 ): await session.commit() else: await session.rollback() except Exception: # noqa: BLE001 logger.debug("Session commit/rollback failed during cleanup", exc_info=True) finally: try: await session.close() except Exception: # noqa: BLE001 logger.debug("Session close failed during cleanup", exc_info=True) with contextlib.suppress(AttributeError, KeyError): delattr(request.state, self.session_key) async def middleware_dispatch( self, request: "Request", call_next: "RequestResponseEndpoint" ) -> "Response": # pragma: no cover """Middleware dispatch function to handle requests and responses. Processes the request, invokes the next middleware or route handler, and applies the session handler after the response is generated. For generator-managed sessions (e.g., from provide_service()), the middleware stores the response status but skips cleanup, allowing the generator to handle commit/rollback/close operations properly. Args: request (starlette.requests.Request): The incoming HTTP request. call_next (starlette.middleware.base.RequestResponseEndpoint): The next middleware or route handler. Returns: starlette.responses.Response: The HTTP response. """ # Reset routing context for request-scoped isolation reset_routing_context() response = await call_next(request) # Store response status for generator dependencies to access during cleanup setattr(request.state, f"{self.session_key}_response_status", response.status_code) session = cast("Optional[AsyncSession]", getattr(request.state, self.session_key, None)) # Check if session is managed by a generator dependency (e.g., provide_service) is_generator_managed = getattr(request.state, f"{self.session_key}_generator_managed", False) if session is not None and not is_generator_managed: # Only handle cleanup for non-generator-managed sessions await self.session_handler(session=session, request=request, response=response) return response async def close_engine(self) -> None: # pragma: no cover """Close the engine.""" if self.engine_instance is not None: await self.engine_instance.dispose() async def on_shutdown(self) -> None: # pragma: no cover """Handles the shutdown event by disposing of the SQLAlchemy engine. Ensures that all connections are properly closed during application shutdown. """ await self.close_engine() if self.app is not None: with contextlib.suppress(AttributeError, KeyError): delattr(self.app.state, self.engine_key) delattr(self.app.state, self.session_maker_key) delattr(self.app.state, self.session_key) @dataclass class SQLAlchemySyncConfig(_SQLAlchemySyncConfig): """SQLAlchemy Sync config for Starlette.""" app: "Optional[Starlette]" = None """The Starlette application instance.""" commit_mode: Literal["manual", "autocommit", "autocommit_include_redirect"] = "manual" """The commit mode to use for database sessions.""" engine_key: str = "db_engine" """Key to use for the dependency injection of database engines.""" session_key: str = "db_session" """Key to use for the dependency injection of database sessions.""" session_maker_key: str = "session_maker_class" """Key under which to store the SQLAlchemy :class:`sessionmaker ` in the application state instance. """ engine_config: EngineConfig = field(default_factory=EngineConfig) # pyright: ignore[reportIncompatibleVariableOverride] """Configuration for the SQLAlchemy engine. The configuration options are documented in the SQLAlchemy documentation. """ async def create_all_metadata(self) -> None: # pragma: no cover """Create all metadata tables in the database.""" if self.engine_instance is None: self.engine_instance = self.get_engine() with self.engine_instance.begin() as conn: try: await run_in_threadpool( lambda: metadata_registry.get(None if self.bind_key == "default" else self.bind_key).create_all( conn ) ) except OperationalError as exc: _echo(f" * Could not create target metadata. Reason: {exc}") def init_app(self, app: "Starlette") -> None: """Initialize the Starlette application with this configuration. Args: app: The Starlette application instance. """ self.app = app self.bind_key = self.bind_key or "default" self.session_key = _make_unique_state_key(app, f"advanced_alchemy_sync_session_{self.session_key}") self.engine_key = _make_unique_state_key(app, f"advanced_alchemy_sync_engine_{self.engine_key}") self.session_maker_key = _make_unique_state_key( app, f"advanced_alchemy_sync_session_maker_{self.session_maker_key}" ) _ = self.create_session_maker() app.add_middleware(SessionMiddleware, config=self) # pyright: ignore[reportUnknownMemberType] async def on_startup(self) -> None: """Initialize the Starlette application with this configuration.""" if self.create_all: await self.create_all_metadata() def create_session_maker(self) -> Callable[[], "Session"]: """Get a session maker. If none exists yet, create one. Returns: Callable[[], Session]: Session factory used by the plugin. """ if self.session_maker: return self.session_maker session_kws = self.session_config_dict if self.engine_instance is None: self.engine_instance = self.get_engine() if session_kws.get("bind") is None: session_kws["bind"] = self.engine_instance self.session_maker = self.session_maker_class(**session_kws) return self.session_maker async def session_handler( self, session: "Session", request: "Request", response: "Response" ) -> None: # pragma: no cover """Handles the session after a request is processed. Applies the commit strategy and ensures the session is closed. Args: session (sqlalchemy.orm.Session | sqlalchemy.ext.asyncio.AsyncSession): The database session. request (starlette.requests.Request): The incoming HTTP request. response (starlette.responses.Response): The outgoing HTTP response. """ try: if (self.commit_mode == "autocommit" and 200 <= response.status_code < 300) or ( # noqa: PLR2004 self.commit_mode == "autocommit_include_redirect" and 200 <= response.status_code < 400 # noqa: PLR2004 ): await run_in_threadpool(session.commit) else: await run_in_threadpool(session.rollback) except Exception: # noqa: BLE001 logger.debug("Session commit/rollback failed during cleanup", exc_info=True) finally: try: await run_in_threadpool(session.close) except Exception: # noqa: BLE001 logger.debug("Session close failed during cleanup", exc_info=True) with contextlib.suppress(AttributeError, KeyError): delattr(request.state, self.session_key) async def middleware_dispatch( self, request: "Request", call_next: "RequestResponseEndpoint" ) -> "Response": # pragma: no cover """Middleware dispatch function to handle requests and responses. Processes the request, invokes the next middleware or route handler, and applies the session handler after the response is generated. For generator-managed sessions (e.g., from provide_service()), the middleware stores the response status but skips cleanup, allowing the generator to handle commit/rollback/close operations properly. Args: request (starlette.requests.Request): The incoming HTTP request. call_next (starlette.middleware.base.RequestResponseEndpoint): The next middleware or route handler. Returns: starlette.responses.Response: The HTTP response. """ # Reset routing context for request-scoped isolation reset_routing_context() response = await call_next(request) # Store response status for generator dependencies to access during cleanup setattr(request.state, f"{self.session_key}_response_status", response.status_code) session = cast("Optional[Session]", getattr(request.state, self.session_key, None)) # Check if session is managed by a generator dependency (e.g., provide_service) is_generator_managed = getattr(request.state, f"{self.session_key}_generator_managed", False) if session is not None and not is_generator_managed: # Only handle cleanup for non-generator-managed sessions await self.session_handler(session=session, request=request, response=response) return response async def close_engine(self) -> None: # pragma: no cover """Close the engines.""" if self.engine_instance is not None: await run_in_threadpool(self.engine_instance.dispose) async def on_shutdown(self) -> None: # pragma: no cover """Handles the shutdown event by disposing of the SQLAlchemy engine. Ensures that all connections are properly closed during application shutdown. """ await self.close_engine() if self.app is not None: with contextlib.suppress(AttributeError, KeyError): delattr(self.app.state, self.engine_key) delattr(self.app.state, self.session_maker_key) delattr(self.app.state, self.session_key) python-advanced-alchemy-1.9.3/advanced_alchemy/extensions/starlette/extension.py000066400000000000000000000351171516556515500303200ustar00rootroot00000000000000import contextlib from collections.abc import AsyncGenerator, Callable, Generator, Sequence from contextlib import asynccontextmanager, contextmanager from typing import ( TYPE_CHECKING, Any, Optional, Union, cast, overload, ) from sqlalchemy.ext.asyncio import AsyncSession from starlette.requests import Request from advanced_alchemy._listeners import set_async_context from advanced_alchemy.exceptions import ImproperConfigurationError from advanced_alchemy.extensions.starlette.config import SQLAlchemyAsyncConfig, SQLAlchemySyncConfig if TYPE_CHECKING: from sqlalchemy import Engine from sqlalchemy.ext.asyncio import AsyncEngine from sqlalchemy.orm import Session from starlette.applications import Starlette class AdvancedAlchemy: """AdvancedAlchemy integration for Starlette applications. This class manages SQLAlchemy sessions and engine lifecycle within a Starlette application. It provides middleware for handling transactions based on commit strategies. Args: config (advanced_alchemy.config.asyncio.SQLAlchemyAsyncConfig | advanced_alchemy.config.sync.SQLAlchemySyncConfig): The SQLAlchemy configuration. app (starlette.applications.Starlette | None): The Starlette application instance. Defaults to None. """ def __init__( self, config: Union[ SQLAlchemyAsyncConfig, SQLAlchemySyncConfig, Sequence[Union[SQLAlchemyAsyncConfig, SQLAlchemySyncConfig]] ], app: Optional["Starlette"] = None, ) -> None: self._config = config if isinstance(config, Sequence) else [config] self._mapped_configs: dict[str, Union[SQLAlchemyAsyncConfig, SQLAlchemySyncConfig]] = self.map_configs() self._app = cast("Optional[Starlette]", None) if app is not None: self.init_app(app) @property def config(self) -> Sequence[Union[SQLAlchemyAsyncConfig, SQLAlchemySyncConfig]]: """Current Advanced Alchemy configuration.""" return self._config def init_app(self, app: "Starlette") -> None: """Initializes the Starlette application with SQLAlchemy engine and sessionmaker. Sets up middleware and shutdown handlers for managing the database engine. Args: app (starlette.applications.Starlette): The Starlette application instance. Raises: advanced_alchemy.exceptions.ImproperConfigurationError: If the application is not initialized. """ self._app = app unique_bind_keys = {config.bind_key for config in self.config} if len(unique_bind_keys) != len(self.config): # pragma: no cover msg = "Please ensure that each config has a unique name for the `bind_key` attribute. The default is `default` and can only be bound to a single engine." raise ImproperConfigurationError(msg) for config in self.config: config.init_app(app) app.state.advanced_alchemy = self original_lifespan = app.router.lifespan_context @asynccontextmanager async def wrapped_lifespan(app: "Starlette") -> AsyncGenerator[Any, None]: # pragma: no cover async with self.lifespan(app), original_lifespan(app) as state: # type: ignore[misc] yield state app.router.lifespan_context = wrapped_lifespan @asynccontextmanager async def lifespan(self, app: "Starlette") -> AsyncGenerator[Any, None]: # pragma: no cover """Context manager for lifespan events. Args: app: The starlette application. Yields: None """ await self.on_startup() try: yield finally: await self.on_shutdown() @property def app(self) -> "Starlette": # pragma: no cover """Returns the Starlette application instance. Raises: advanced_alchemy.exceptions.ImproperConfigurationError: If the application is not initialized. Returns: starlette.applications.Starlette: The Starlette application instance. """ if self._app is None: # pragma: no cover msg = "Application not initialized. Did you forget to call init_app?" raise ImproperConfigurationError(msg) return self._app async def on_startup(self) -> None: # pragma: no cover """Initializes the database.""" for config in self.config: await config.on_startup() async def on_shutdown(self) -> None: # pragma: no cover """Handles the shutdown event by disposing of the SQLAlchemy engine. Ensures that all connections are properly closed during application shutdown. """ for config in self.config: await config.on_shutdown() with contextlib.suppress(AttributeError, KeyError): delattr(self.app.state, "advanced_alchemy") def map_configs(self) -> dict[str, Union[SQLAlchemyAsyncConfig, SQLAlchemySyncConfig]]: """Maps the configs to the session bind keys. Returns: A dictionary of config bind keys to configs. """ mapped_configs: dict[str, Union[SQLAlchemyAsyncConfig, SQLAlchemySyncConfig]] = {} for config in self.config: if config.bind_key is None: config.bind_key = "default" mapped_configs[config.bind_key] = config return mapped_configs def get_config(self, key: Optional[str] = None) -> Union[SQLAlchemyAsyncConfig, SQLAlchemySyncConfig]: """Get the config for the given key. Args: key: The key to get the config for. Raises: advanced_alchemy.exceptions.ImproperConfigurationError: If the config is not found. Returns: The config for the given key. """ if key is None: key = "default" if key == "default" and len(self.config) == 1: key = self.config[0].bind_key or "default" config = self._mapped_configs.get(key) if config is None: # pragma: no cover msg = f"Config with key {key} not found" raise ImproperConfigurationError(msg) return config def get_async_config(self, key: Optional[str] = None) -> SQLAlchemyAsyncConfig: """Get the async config for the given key. Raises: advanced_alchemy.exceptions.ImproperConfigurationError: If the config is not found. Returns: The async config for the given key. """ config = self.get_config(key) if not isinstance(config, SQLAlchemyAsyncConfig): # pragma: no cover msg = "Expected an async config, but got a sync config" raise ImproperConfigurationError(msg) return config def get_sync_config(self, key: Optional[str] = None) -> SQLAlchemySyncConfig: """Get the sync config for the given key. Raises: advanced_alchemy.exceptions.ImproperConfigurationError: If the config is not found. Returns: The sync config for the given key. """ config = self.get_config(key) if not isinstance(config, SQLAlchemySyncConfig): # pragma: no cover msg = "Expected a sync config, but got an async config" raise ImproperConfigurationError(msg) return config @asynccontextmanager async def with_async_session( self, key: Optional[str] = None ) -> AsyncGenerator["AsyncSession", None]: # pragma: no cover """Context manager for getting an async session. Yields: The async session for the given key. """ config = self.get_async_config(key) async with config.get_session() as session: yield session @contextmanager def with_sync_session(self, key: Optional[str] = None) -> Generator["Session", None]: # pragma: no cover """Context manager for getting a sync session. Yields: The sync session for the given key. """ config = self.get_sync_config(key) with config.get_session() as session: yield session @overload @staticmethod def _get_session_from_request(request: Request, config: SQLAlchemyAsyncConfig) -> "AsyncSession": ... @overload @staticmethod def _get_session_from_request(request: Request, config: SQLAlchemySyncConfig) -> "Session": ... @staticmethod def _get_session_from_request( request: Request, config: Union[SQLAlchemyAsyncConfig, SQLAlchemySyncConfig], # pragma: no cover ) -> Union["Session", "AsyncSession"]: # pragma: no cover """Get the session for the given key. Args: request: The request object. config: The config object. Returns: The session for the given key. """ session = getattr(request.state, config.session_key, None) if session is None: session = config.create_session_maker()() setattr(request.state, config.session_key, session) set_async_context(isinstance(session, AsyncSession)) return session def get_session( self, request: Request, key: Optional[str] = None ) -> Union["Session", "AsyncSession"]: # pragma: no cover """Get the session for the given key. Args: request: The request object. key: The key to get the session for. Returns: The session for the given key. """ config = self.get_config(key) return self._get_session_from_request(request, config) def get_async_session(self, request: Request, key: Optional[str] = None) -> "AsyncSession": # pragma: no cover """Get the async session for the given key. Args: request: The request object. key: The key to get the session for. Returns: The async session for the given key. """ config = self.get_async_config(key) return self._get_session_from_request(request, config) def get_sync_session(self, request: Request, key: Optional[str] = None) -> "Session": # pragma: no cover """Get the sync session for the given key. Args: request: The request object. key: The key to get the session for. Returns: The sync session for the given key. """ config = self.get_sync_config(key) return self._get_session_from_request(request, config) def provide_session( self, key: Optional[str] = None ) -> Callable[[Request], Union["Session", "AsyncSession"]]: # pragma: no cover """Get the session for the given key. Args: key: The key to get the session for. Returns: The session for the given key. """ config = self.get_config(key) def _get_session(request: Request) -> Union["Session", "AsyncSession"]: set_async_context(isinstance(config, SQLAlchemyAsyncConfig)) return self._get_session_from_request(request, config) return _get_session def provide_async_session( self, key: Optional[str] = None ) -> Callable[[Request], "AsyncSession"]: # pragma: no cover """Get the async session for the given key. Args: key: The key to get the session for. Returns: The async session for the given key. """ config = self.get_async_config(key) def _get_session(request: Request) -> "AsyncSession": set_async_context(True) return self._get_session_from_request(request, config) return _get_session def provide_sync_session(self, key: Optional[str] = None) -> Callable[[Request], "Session"]: # pragma: no cover """Get the sync session for the given key. Args: key: The key to get the session for. Returns: The sync session for the given key. """ config = self.get_sync_config(key) def _get_session(request: Request) -> "Session": set_async_context(False) return self._get_session_from_request(request, config) return _get_session def get_engine(self, key: Optional[str] = None) -> Union["Engine", "AsyncEngine"]: # pragma: no cover """Get the engine for the given key. Args: key: The key to get the engine for. Returns: The engine for the given key. """ config = self.get_config(key) return config.get_engine() def get_async_engine(self, key: Optional[str] = None) -> "AsyncEngine": # pragma: no cover """Get the async engine for the given key. Args: key: The key to get the engine for. Returns: The async engine for the given key. """ config = self.get_async_config(key) return config.get_engine() def get_sync_engine(self, key: Optional[str] = None) -> "Engine": # pragma: no cover """Get the sync engine for the given key. Args: key: The key to get the engine for. Returns: The sync engine for the given key. """ config = self.get_sync_config(key) return config.get_engine() def provide_engine( self, key: Optional[str] = None ) -> Callable[[], Union["Engine", "AsyncEngine"]]: # pragma: no cover """Get the engine for the given key. Args: key: The key to get the engine for. Returns: The engine for the given key. """ config = self.get_config(key) def _get_engine() -> Union["Engine", "AsyncEngine"]: return config.get_engine() return _get_engine def provide_async_engine(self, key: Optional[str] = None) -> Callable[[], "AsyncEngine"]: # pragma: no cover """Get the async engine for the given key. Args: key: The key to get the engine for. Returns: The async engine for the given key. """ config = self.get_async_config(key) def _get_engine() -> "AsyncEngine": return config.get_engine() return _get_engine def provide_sync_engine(self, key: Optional[str] = None) -> Callable[[], "Engine"]: # pragma: no cover """Get the sync engine for the given key. Args: key: The key to get the engine for. Returns: The sync engine for the given key. """ config = self.get_sync_config(key) def _get_engine() -> "Engine": return config.get_engine() return _get_engine python-advanced-alchemy-1.9.3/advanced_alchemy/filters.py000066400000000000000000001255601516556515500235500ustar00rootroot00000000000000"""SQLAlchemy filter constructs for advanced query operations. This module provides a comprehensive collection of filter datastructures designed to enhance SQLAlchemy query construction. It implements type-safe, reusable filter patterns for common database query operations. Features: Type-safe filter construction, datetime range filtering, collection-based filtering, pagination support, search operations, and customizable ordering. Note: All filter classes implement the :class:`StatementFilter` ABC, ensuring consistent interface across different filter types. See Also: - :class:`sqlalchemy.sql.expression.Select`: Core SQLAlchemy select expression - :class:`sqlalchemy.orm.Query`: SQLAlchemy ORM query interface - :mod:`advanced_alchemy.base`: Base model definitions """ import datetime import logging from abc import ABC, abstractmethod from collections.abc import Collection from dataclasses import dataclass from operator import attrgetter from typing import ( TYPE_CHECKING, Any, Callable, ClassVar, Generic, Literal, Optional, Union, cast, ) from sqlalchemy import ( BinaryExpression, ColumnElement, Date, Delete, Select, Update, and_, any_, exists, false, not_, or_, select, text, true, ) from sqlalchemy.sql import operators as op from sqlalchemy.sql.dml import ReturningDelete, ReturningUpdate from typing_extensions import TypeAlias, TypedDict, TypeVar from advanced_alchemy.base import ModelProtocol if TYPE_CHECKING: from sqlalchemy.orm import InstrumentedAttribute __all__ = ( "BeforeAfter", "CollectionFilter", "ComparisonFilter", "ExistsFilter", "FilterGroup", "FilterMap", "FilterTypes", "InAnyFilter", "LimitOffset", "LogicalOperatorMap", "MultiFilter", "NotExistsFilter", "NotInCollectionFilter", "NotInSearchFilter", "NotNullFilter", "NullFilter", "OnBeforeAfter", "OrderBy", "PaginationFilter", "SearchFilter", "StatementFilter", "StatementFilterT", "StatementTypeT", ) T = TypeVar("T") ModelT = TypeVar("ModelT", bound=ModelProtocol) StatementFilterT = TypeVar("StatementFilterT", bound="StatementFilter") StatementTypeT = TypeVar( "StatementTypeT", bound=Union[ ReturningDelete[tuple[Any]], ReturningUpdate[tuple[Any]], Select[tuple[Any]], Select[Any], Update, Delete ], ) logger = logging.getLogger("advanced_alchemy") # Define TypedDicts for filter and logical maps class FilterMap(TypedDict): before_after: "type[BeforeAfter]" on_before_after: "type[OnBeforeAfter]" collection: "type[CollectionFilter[Any]]" not_in_collection: "type[NotInCollectionFilter[Any]]" limit_offset: "type[LimitOffset]" null: "type[NullFilter]" not_null: "type[NotNullFilter]" order_by: "type[OrderBy]" search: "type[SearchFilter]" not_in_search: "type[NotInSearchFilter]" comparison: "type[ComparisonFilter]" exists: "type[ExistsFilter]" not_exists: "type[NotExistsFilter]" filter_group: "type[FilterGroup]" class LogicalOperatorMap(TypedDict): and_: Callable[..., ColumnElement[bool]] or_: Callable[..., ColumnElement[bool]] class StatementFilter(ABC): """Abstract base class for SQLAlchemy statement filters. This class defines the interface for all filter types in the system. Each filter implementation must provide a method to append its filtering logic to an existing SQLAlchemy statement. """ @abstractmethod def append_to_statement( self, statement: StatementTypeT, model: type[ModelT], *args: Any, **kwargs: Any ) -> StatementTypeT: """Append filter conditions to a SQLAlchemy statement. Args: statement: The SQLAlchemy statement to modify model: The SQLAlchemy model class *args: Additional positional arguments **kwargs: Additional keyword arguments Returns: StatementTypeT: Modified SQLAlchemy statement with filter conditions applied Raises: NotImplementedError: If the concrete class doesn't implement this method Note: This method must be implemented by all concrete filter classes. See Also: :meth:`sqlalchemy.sql.expression.Select.where`: SQLAlchemy where clause """ return statement @staticmethod def _get_instrumented_attr( model: Any, key: "Union[str, ColumnElement[Any], InstrumentedAttribute[Any]]" ) -> "Union[ColumnElement[Any], InstrumentedAttribute[Any]]": """Get SQLAlchemy instrumented attribute from model. Args: model: SQLAlchemy model class or instance key: Attribute name or instrumented attribute Returns: InstrumentedAttribute[Any]: SQLAlchemy instrumented attribute See Also: :class:`sqlalchemy.orm.attributes.InstrumentedAttribute`: SQLAlchemy attribute """ return cast("InstrumentedAttribute[Any]", getattr(model, key)) if isinstance(key, str) else key @dataclass class BeforeAfter(StatementFilter): """DateTime range filter with exclusive bounds. This filter creates date/time range conditions using < and > operators, excluding the boundary values. If either `before` or `after` is None, that boundary condition is not applied. See Also: --------- :class:`OnBeforeAfter` : Inclusive datetime range filtering """ field_name: "Union[str, ColumnElement[Any], InstrumentedAttribute[Any]]" """Field name, model attribute, or func expression.""" before: Optional[datetime.datetime] """Filter results where field is earlier than this value.""" after: Optional[datetime.datetime] """Filter results where field is later than this value.""" def append_to_statement(self, statement: StatementTypeT, model: type[ModelT]) -> StatementTypeT: """Apply datetime range conditions to statement. Parameters ---------- statement : StatementTypeT The SQLAlchemy statement to modify model : type[ModelT] The SQLAlchemy model class Returns: -------- StatementTypeT Modified statement with datetime range conditions """ field = self._get_instrumented_attr(model, self.field_name) if self.before is not None: statement = cast("StatementTypeT", statement.where(field < self.before)) if self.after is not None: statement = cast("StatementTypeT", statement.where(field > self.after)) return statement @dataclass class OnBeforeAfter(StatementFilter): """DateTime range filter with inclusive bounds. This filter creates date/time range conditions using <= and >= operators, including the boundary values. If either `on_or_before` or `on_or_after` is None, that boundary condition is not applied. See Also: --------- :class:`BeforeAfter` : Exclusive datetime range filtering """ field_name: "Union[str, ColumnElement[Any], InstrumentedAttribute[Any]]" """Field name, model attribute, or func expression.""" on_or_before: Optional[datetime.datetime] """Filter results where field is on or earlier than this value.""" on_or_after: Optional[datetime.datetime] """Filter results where field is on or later than this value.""" def append_to_statement(self, statement: StatementTypeT, model: type[ModelT]) -> StatementTypeT: """Apply inclusive datetime range conditions to statement. Parameters ---------- statement : StatementTypeT The SQLAlchemy statement to modify model : type[ModelT] The SQLAlchemy model class Returns: -------- StatementTypeT Modified statement with inclusive datetime range conditions """ field = self._get_instrumented_attr(model, self.field_name) if self.on_or_before is not None: statement = cast("StatementTypeT", statement.where(field <= self.on_or_before)) if self.on_or_after is not None: statement = cast("StatementTypeT", statement.where(field >= self.on_or_after)) return statement class InAnyFilter(StatementFilter, ABC): """Base class for filters using IN or ANY operators. This abstract class provides common functionality for filters that check membership in a collection using either the SQL IN operator or the ANY operator. """ @dataclass class CollectionFilter(InAnyFilter, Generic[T]): """Data required to construct a WHERE ... IN (...) clause. This filter restricts records based on a field's presence in a collection of values. The filter supports both ``IN`` and ``ANY`` operators for collection membership testing. Use ``prefer_any=True`` in ``append_to_statement`` to use the ``ANY`` operator. """ field_name: "Union[str, ColumnElement[Any], InstrumentedAttribute[Any]]" """Field name, model attribute, or func expression.""" values: Union[Collection[T], None] """Values for the ``IN`` clause. If this is None, no filter is applied. An empty list will force an empty result set (WHERE 1=-1)""" def append_to_statement( self, statement: StatementTypeT, model: type[ModelT], prefer_any: bool = False, ) -> StatementTypeT: """Apply a WHERE ... IN or WHERE ... ANY (...) clause to the statement. Parameters ---------- statement : StatementTypeT The SQLAlchemy statement to modify model : type[ModelT] The SQLAlchemy model class prefer_any : bool, optional If True, uses the SQLAlchemy :func:`any_` operator instead of :func:`in_` for the filter condition Returns: -------- StatementTypeT Modified statement with the appropriate IN conditions """ field = self._get_instrumented_attr(model, self.field_name) if self.values is None: return statement if not self.values: # Return empty result set by forcing a false condition return cast("StatementTypeT", statement.where(text("1=-1"))) if prefer_any: return cast("StatementTypeT", statement.where(any_(self.values) == field)) # type: ignore[arg-type] return cast("StatementTypeT", statement.where(field.in_(self.values))) @dataclass class NotInCollectionFilter(InAnyFilter, Generic[T]): """Data required to construct a WHERE ... NOT IN (...) clause. This filter restricts records based on a field's absence in a collection of values. The filter supports both ``NOT IN`` and ``!= ANY`` operators for collection exclusion. Use ``prefer_any=True`` in ``append_to_statement`` to use the ``ANY`` operator. Parameters ---------- field_name : str Name of the model attribute to filter on values : abc.Collection[T] | None Values for the ``NOT IN`` clause. If this is None or empty, the filter is not applied. """ field_name: "Union[str, ColumnElement[Any], InstrumentedAttribute[Any]]" """Field name, model attribute, or func expression.""" values: Union[Collection[T], None] """Values for the ``NOT IN`` clause. If None or empty, no filter is applied.""" def append_to_statement( self, statement: StatementTypeT, model: type[ModelT], prefer_any: bool = False, ) -> StatementTypeT: """Apply a WHERE ... NOT IN or WHERE ... != ANY(...) clause to the statement. Parameters ---------- statement : StatementTypeT The SQLAlchemy statement to modify model : type[ModelT] The SQLAlchemy model class prefer_any : bool, optional If True, uses the SQLAlchemy :func:`any_` operator instead of :func:`notin_` for the filter condition Returns: -------- StatementTypeT Modified statement with the appropriate NOT IN conditions """ field = self._get_instrumented_attr(model, self.field_name) if not self.values: # If None or empty, we do not modify the statement return statement if prefer_any: return cast("StatementTypeT", statement.where(any_(self.values) != field)) # type: ignore[arg-type] return cast("StatementTypeT", statement.where(field.notin_(self.values))) @dataclass class NullFilter(StatementFilter): """Filter for NULL values (IS NULL). This filter creates IS NULL conditions for database fields. Use this to find records where a field has no value. Example: Basic NULL filtering:: from advanced_alchemy.filters import NullFilter # Find records where email_verified_at is NULL null_filter = NullFilter("email_verified_at") unverified = await repo.list(null_filter) With multiple filters:: from advanced_alchemy.filters import ( NullFilter, CollectionFilter, ) # Find unverified users in specific roles filters = [ NullFilter("email_verified_at"), CollectionFilter("role", ["admin", "moderator"]), ] results = await repo.list(*filters) See Also: - :class:`NotNullFilter`: Filter for NOT NULL values - :class:`CollectionFilter`: Filter by collection membership - :meth:`sqlalchemy.sql.expression.ColumnOperators.is_`: IS NULL operator """ field_name: "Union[str, ColumnElement[Any], InstrumentedAttribute[Any]]" """Field name, model attribute, or func expression.""" def append_to_statement(self, statement: StatementTypeT, model: type[ModelT]) -> StatementTypeT: """Apply IS NULL condition to the statement. Args: statement: The SQLAlchemy statement to modify model: The SQLAlchemy model class Returns: StatementTypeT: Modified statement with IS NULL condition applied """ field = self._get_instrumented_attr(model, self.field_name) return cast("StatementTypeT", statement.where(field.is_(None))) @dataclass class NotNullFilter(StatementFilter): """Filter for NOT NULL values (IS NOT NULL). This filter creates IS NOT NULL conditions for database fields. Use this to find records where a field has a value. Example: Basic NOT NULL filtering:: from advanced_alchemy.filters import NotNullFilter # Find records where email_verified_at is NOT NULL not_null_filter = NotNullFilter("email_verified_at") verified = await repo.list(not_null_filter) With multiple filters:: from advanced_alchemy.filters import ( NotNullFilter, CollectionFilter, ) # Find verified users in specific roles filters = [ NotNullFilter("email_verified_at"), CollectionFilter("role", ["admin", "moderator"]), ] results = await repo.list(*filters) See Also: - :class:`NullFilter`: Filter for NULL values - :class:`CollectionFilter`: Filter by collection membership - :meth:`sqlalchemy.sql.expression.ColumnOperators.is_not`: IS NOT NULL operator """ field_name: "Union[str, ColumnElement[Any], InstrumentedAttribute[Any]]" """Field name, model attribute, or func expression.""" def append_to_statement(self, statement: StatementTypeT, model: type[ModelT]) -> StatementTypeT: """Apply IS NOT NULL condition to the statement. Args: statement: The SQLAlchemy statement to modify model: The SQLAlchemy model class Returns: StatementTypeT: Modified statement with IS NOT NULL condition applied """ field = self._get_instrumented_attr(model, self.field_name) return cast("StatementTypeT", statement.where(field.is_not(None))) class PaginationFilter(StatementFilter, ABC): """Abstract base class for pagination filters. Subclasses should implement pagination logic, such as limit/offset or cursor-based pagination. """ @dataclass class LimitOffset(PaginationFilter): """Limit and offset pagination filter. Implements traditional pagination using SQL LIMIT and OFFSET clauses. Only applies to SELECT statements; other statement types are returned unmodified. Note: This filter only modifies SELECT statements. For other statement types (UPDATE, DELETE), the statement is returned unchanged. See Also: - :meth:`sqlalchemy.sql.expression.Select.limit`: SQLAlchemy LIMIT clause - :meth:`sqlalchemy.sql.expression.Select.offset`: SQLAlchemy OFFSET clause """ limit: int """Maximum number of rows to return.""" offset: int """Number of rows to skip before returning results.""" def append_to_statement(self, statement: StatementTypeT, model: type[ModelT]) -> StatementTypeT: """Apply LIMIT/OFFSET pagination to the statement. Args: statement: The SQLAlchemy statement to modify model: The SQLAlchemy model class Returns: StatementTypeT: Modified statement with limit and offset applied Note: Only modifies SELECT statements. Other statement types are returned as-is. See Also: :class:`sqlalchemy.sql.expression.Select`: SQLAlchemy SELECT statement """ if isinstance(statement, Select): statement = cast("StatementTypeT", statement.limit(self.limit).offset(self.offset)) return statement @dataclass class OrderBy(StatementFilter): """Order by a specific field. Appends an ORDER BY clause to SELECT statements, sorting records by the specified field in ascending or descending order. Note: This filter only modifies SELECT statements. For other statement types, the statement is returned unchanged. See Also: - :meth:`sqlalchemy.sql.expression.Select.order_by`: SQLAlchemy ORDER BY clause - :meth:`sqlalchemy.sql.expression.ColumnElement.asc`: Ascending order - :meth:`sqlalchemy.sql.expression.ColumnElement.desc`: Descending order """ field_name: "Union[str, ColumnElement[Any], InstrumentedAttribute[Any]]" """Field name, model attribute, or func expression (e.g., ``func.random()``).""" sort_order: Literal["asc", "desc"] = "asc" """Sort direction ("asc" or "desc").""" def append_to_statement(self, statement: StatementTypeT, model: type[ModelT]) -> StatementTypeT: """Append an ORDER BY clause to the statement. Args: statement: The SQLAlchemy statement to modify model: The SQLAlchemy model class Returns: StatementTypeT: Modified statement with an ORDER BY clause Note: Only modifies SELECT statements. Other statement types are returned as-is. See Also: :meth:`sqlalchemy.sql.expression.Select.order_by`: SQLAlchemy ORDER BY """ if isinstance(statement, Select): field = self._get_instrumented_attr(model, self.field_name) if self.sort_order == "desc": statement = cast("StatementTypeT", statement.order_by(field.desc())) else: statement = cast("StatementTypeT", statement.order_by(field.asc())) return statement @dataclass class SearchFilter(StatementFilter): """Case-sensitive or case-insensitive substring matching filter. Implements text search using SQL LIKE or ILIKE operators. Can search across multiple fields using OR conditions. Note: The search pattern automatically adds wildcards before and after the search value, equivalent to SQL pattern '%value%'. See Also: - :class:`.NotInSearchFilter`: Opposite filter using NOT LIKE/ILIKE - :meth:`sqlalchemy.sql.expression.ColumnOperators.like`: Case-sensitive LIKE - :meth:`sqlalchemy.sql.expression.ColumnOperators.ilike`: Case-insensitive LIKE """ field_name: Union[str, set[str]] """Name or set of names of model attributes to search on.""" value: str """Text to match within the field(s).""" ignore_case: Optional[bool] = False """Whether to use case-insensitive matching.""" @property def _operator(self) -> Callable[..., ColumnElement[bool]]: """Return the SQL operator for combining multiple search clauses. Returns: Callable[..., ColumnElement[bool]]: The `or_` operator for OR conditions See Also: :func:`sqlalchemy.sql.expression.or_`: SQLAlchemy OR operator """ return or_ @property def _func(self) -> "attrgetter[Callable[[str], BinaryExpression[bool]]]": """Return the appropriate LIKE or ILIKE operator as a function. Returns: attrgetter: Bound method for LIKE or ILIKE operations See Also: - :meth:`sqlalchemy.sql.expression.ColumnOperators.like`: LIKE operator - :meth:`sqlalchemy.sql.expression.ColumnOperators.ilike`: ILIKE operator """ return attrgetter("ilike" if self.ignore_case else "like") @property def normalized_field_names(self) -> set[str]: """Convert field_name to a set if it's a single string. Returns: set[str]: Set of field names to be searched """ return {self.field_name} if isinstance(self.field_name, str) else self.field_name def get_search_clauses(self, model: type[ModelT]) -> list[BinaryExpression[bool]]: """Generate the LIKE/ILIKE clauses for all specified fields. Args: model: The SQLAlchemy model class Returns: list[BinaryExpression[bool]]: List of text matching expressions See Also: :class:`sqlalchemy.sql.expression.BinaryExpression`: SQLAlchemy expression """ search_clause: list[BinaryExpression[bool]] = [] for field_name in self.normalized_field_names: try: field = self._get_instrumented_attr(model, field_name) search_text = f"%{self.value}%" search_clause.append(self._func(field)(search_text)) except AttributeError: msg = f"Skipping search for field {field_name}. It is not found in model {model.__name__}" logger.debug(msg) continue return search_clause def append_to_statement(self, statement: StatementTypeT, model: type[ModelT]) -> StatementTypeT: """Append a LIKE/ILIKE clause to the statement. Args: statement: The SQLAlchemy statement to modify model: The SQLAlchemy model class Returns: StatementTypeT: Modified statement with text search clauses See Also: :meth:`sqlalchemy.sql.expression.Select.where`: SQLAlchemy WHERE clause """ search_clauses = self.get_search_clauses(model) if not search_clauses: return statement where_clause = self._operator(*search_clauses) return cast("StatementTypeT", statement.where(where_clause)) # Regular typed dictionary for operators_map operators_map: dict[str, Callable[[Any, Any], ColumnElement[bool]]] = { "eq": op.eq, "ne": op.ne, "gt": op.gt, "ge": op.ge, "lt": op.lt, "le": op.le, "in": op.in_op, "notin": op.notin_op, "between": lambda c, v: c.between(v[0], v[1]), "like": op.like_op, "ilike": op.ilike_op, "startswith": op.startswith_op, "istartswith": lambda c, v: c.ilike(v + "%"), "endswith": op.endswith_op, "iendswith": lambda c, v: c.ilike(v + "%"), "dateeq": lambda c, v: cast("Date", c) == v, } VALID_OPERATORS = set(operators_map.keys()) """Set of valid operators that can be used in ComparisonFilter.""" @dataclass class ComparisonFilter(StatementFilter): """Simple comparison filter for equality and inequality operations. This filter applies basic comparison operators (=, !=, >, >=, <, <=) to a field. It provides a generic way to perform common comparison operations. Args: field_name: Name of the model attribute to filter on operator: Comparison operator to use (must be one of: 'eq', 'ne', 'gt', 'ge', 'lt', 'le', 'in', 'notin', 'between', 'like', 'ilike', 'startswith', 'istartswith', 'endswith', 'iendswith', 'dateeq') value: Value to compare against Raises: ValueError: If an invalid operator is provided """ field_name: "Union[str, ColumnElement[Any], InstrumentedAttribute[Any]]" """Field name, model attribute, or func expression.""" operator: str """Comparison operator to use (one of 'eq', 'ne', 'gt', 'ge', 'lt', 'le').""" value: Any """Value to compare against.""" def append_to_statement(self, statement: StatementTypeT, model: type[ModelT]) -> StatementTypeT: """Apply a comparison operation to the statement. Args: statement: The SQLAlchemy statement to modify model: The SQLAlchemy model class Returns: StatementTypeT: Modified statement with the comparison condition Raises: ValueError: If an invalid operator is provided """ field = self._get_instrumented_attr(model, self.field_name) operator_func = operators_map.get(self.operator) if operator_func is None: msg = f"Invalid operator '{self.operator}'. Must be one of: {', '.join(sorted(VALID_OPERATORS))}" raise ValueError(msg) condition = operator_func(field, self.value) return cast("StatementTypeT", statement.where(condition)) @dataclass class NotInSearchFilter(SearchFilter): """Filter for excluding records that match a substring. Implements negative text search using SQL NOT LIKE or NOT ILIKE operators. Can exclude across multiple fields using AND conditions. Args: field_name: Name or set of names of model attributes to search on value: Text to exclude from the field(s) ignore_case: If True, uses NOT ILIKE for case-insensitive matching Note: Uses AND for multiple fields, meaning records matching any field will be excluded. See Also: - :class:`.SearchFilter`: Opposite filter using LIKE/ILIKE - :meth:`sqlalchemy.sql.expression.ColumnOperators.notlike`: NOT LIKE operator - :meth:`sqlalchemy.sql.expression.ColumnOperators.notilike`: NOT ILIKE operator """ @property def _operator(self) -> Callable[..., ColumnElement[bool]]: """Return the SQL operator for combining multiple negated search clauses. Returns: Callable[..., ColumnElement[bool]]: The `and_` operator for AND conditions See Also: :func:`sqlalchemy.sql.expression.and_`: SQLAlchemy AND operator """ return and_ @property def _func(self) -> "attrgetter[Callable[[str], BinaryExpression[bool]]]": """Return the appropriate NOT LIKE or NOT ILIKE operator as a function. Returns: attrgetter: Bound method for NOT LIKE or NOT ILIKE operations See Also: - :meth:`sqlalchemy.sql.expression.ColumnOperators.notlike`: NOT LIKE - :meth:`sqlalchemy.sql.expression.ColumnOperators.notilike`: NOT ILIKE """ return attrgetter("not_ilike" if self.ignore_case else "not_like") @dataclass class ExistsFilter(StatementFilter): """Filter for EXISTS subqueries. This filter creates an EXISTS condition using a list of column expressions. The expressions can be combined using either AND or OR logic. The filter applies a correlated subquery that returns only the rows from the main query that match the specified conditions. For example, if searching movies with `Movie.genre == "Action"`, only rows where the genre is "Action" will be returned. Parameters ---------- values : list[ColumnElement[bool]] values: List of SQLAlchemy column expressions to use in the EXISTS clause operator : Literal["and", "or"], optional operator: If "and", combines conditions with AND, otherwise uses OR. Defaults to "and". Example: -------- Basic usage with AND conditions:: from sqlalchemy import select from advanced_alchemy.filters import ExistsFilter filter = ExistsFilter( values=[User.email.like("%@example.com%")], ) statement = filter.append_to_statement( select(Organization), Organization ) This will return only organizations where the user's email contains "@example.com". Using OR conditions:: filter = ExistsFilter( values=[User.role == "admin", User.role == "owner"], operator="or", ) This will return organizations where the user's role is either "admin" OR "owner". See Also: -------- :class:`NotExistsFilter`: The inverse of this filter :func:`sqlalchemy.sql.expression.exists`: SQLAlchemy EXISTS expression """ values: list[ColumnElement[bool]] """List of SQLAlchemy column expressions to use in the EXISTS clause.""" operator: Literal["and", "or"] = "and" """If "and", combines conditions with the AND operator, otherwise uses OR.""" @property def _and(self) -> Callable[..., ColumnElement[bool]]: """Access the SQLAlchemy `and_` operator. Returns: Callable[..., ColumnElement[bool]]: The `and_` operator for AND conditions See Also: :func:`sqlalchemy.sql.expression.and_`: SQLAlchemy AND operator """ return and_ @property def _or(self) -> Callable[..., ColumnElement[bool]]: """Access the SQLAlchemy `or_` operator. Returns: Callable[..., ColumnElement[bool]]: The `or_` operator for OR conditions See Also: :func:`sqlalchemy.sql.expression.or_`: SQLAlchemy OR operator """ return or_ def _get_combined_conditions(self) -> ColumnElement[bool]: """Combine the filter conditions using the specified operator. Returns: ColumnElement[bool]: A SQLAlchemy column expression combining all conditions with AND or OR """ op = self._and if self.operator == "and" else self._or return op(*self.values) def get_exists_clause(self, model: type[ModelT]) -> ColumnElement[bool]: """Generate the EXISTS clause for the statement. Args: model : type[ModelT] The SQLAlchemy model class to correlate with Returns: ColumnElement[bool]: A correlated EXISTS expression for use in a WHERE clause """ # Handle empty values list case if not self.values: # Use explicitly imported 'false' from sqlalchemy # Return SQLAlchemy FALSE expression return false() # Combine all values with AND or OR (using the operator specified in the filter) # This creates a single boolean expression from multiple conditions combined_conditions = self._get_combined_conditions() # Create a correlated subquery with the combined conditions try: subquery = select(1).where(combined_conditions) correlated_subquery = subquery.correlate(model.__table__) return exists(correlated_subquery) except Exception: # noqa: BLE001 return false() def append_to_statement(self, statement: StatementTypeT, model: type[ModelT]) -> StatementTypeT: """Append EXISTS condition to the statement. Args: statement : StatementTypeT The SQLAlchemy statement to modify model : type[ModelT] The SQLAlchemy model class Returns: StatementTypeT: Modified statement with EXISTS condition """ # We apply the exists clause regardless of whether self.values is empty, # as get_exists_clause handles the empty case by returning false(). exists_clause = self.get_exists_clause(model) return cast("StatementTypeT", statement.where(exists_clause)) @dataclass class NotExistsFilter(StatementFilter): """Filter for NOT EXISTS subqueries. This filter creates a NOT EXISTS condition using a list of column expressions. The expressions can be combined using either AND or OR logic. The filter applies a correlated subquery that returns only the rows from the main query that DO NOT match the specified conditions. For example, if searching movies with `Movie.genre == "Action"`, only rows where the genre is NOT "Action" will be returned. Parameters ---------- values : list[ColumnElement[bool]] values: List of SQLAlchemy column expressions to use in the NOT EXISTS clause operator : Literal["and", "or"], optional operator: If "and", combines conditions with AND, otherwise uses OR. Defaults to "and". Example: -------- Basic usage with AND conditions:: from sqlalchemy import select from advanced_alchemy.filters import NotExistsFilter filter = NotExistsFilter( values=[User.email.like("%@example.com%")], ) statement = filter.append_to_statement( select(Organization), Organization ) This will return only organizations where the user's email does NOT contain "@example.com". Using OR conditions:: filter = NotExistsFilter( values=[User.role == "admin", User.role == "owner"], operator="or", ) This will return organizations where the user's role is NEITHER "admin" NOR "owner". See Also: -------- :class:`ExistsFilter`: The inverse of this filter :func:`sqlalchemy.sql.expression.not_`: SQLAlchemy NOT operator :func:`sqlalchemy.sql.expression.exists`: SQLAlchemy EXISTS expression """ values: list[ColumnElement[bool]] """List of SQLAlchemy column expressions to use in the NOT EXISTS clause.""" operator: Literal["and", "or"] = "and" """If "and", combines conditions with the AND operator, otherwise uses OR.""" @property def _and(self) -> Callable[..., ColumnElement[bool]]: """Access the SQLAlchemy `and_` operator. Returns: Callable[..., ColumnElement[bool]]: The `and_` operator for AND conditions See Also: :func:`sqlalchemy.sql.expression.and_`: SQLAlchemy AND operator """ return and_ @property def _or(self) -> Callable[..., ColumnElement[bool]]: """Access the SQLAlchemy `or_` operator. Returns: Callable[..., ColumnElement[bool]]: The `or_` operator for OR conditions See Also: :func:`sqlalchemy.sql.expression.or_`: SQLAlchemy OR operator """ return or_ def _get_combined_conditions(self) -> ColumnElement[bool]: op = self._and if self.operator == "and" else self._or return op(*self.values) def get_exists_clause(self, model: type[ModelT]) -> ColumnElement[bool]: """Generate the NOT EXISTS clause for the statement. Args: model : type[ModelT] The SQLAlchemy model class to correlate with Returns: ColumnElement[bool]: A correlated NOT EXISTS expression for use in a WHERE clause """ # Handle empty values list case if not self.values: # Return SQLAlchemy TRUE expression return true() # Combine conditions and create correlated subquery combined_conditions = self._get_combined_conditions() subquery = select(1).where(combined_conditions) correlated_subquery = subquery.correlate(model.__table__) return not_(exists(correlated_subquery)) def append_to_statement(self, statement: StatementTypeT, model: type[ModelT]) -> StatementTypeT: """Append NOT EXISTS condition to the statement. Args: statement : StatementTypeT The SQLAlchemy statement to modify model : type[ModelT] The SQLAlchemy model class Returns: StatementTypeT: Modified statement with NOT EXISTS condition """ # We apply the exists clause regardless of whether self.values is empty, # as get_exists_clause handles the empty case by returning true. exists_clause = self.get_exists_clause(model) return cast("StatementTypeT", statement.where(exists_clause)) @dataclass class FilterGroup(StatementFilter): """A group of filters combined with a logical operator. This class combines multiple filters with a logical operator (AND/OR). It provides a way to create complex nested filter conditions. """ logical_operator: Callable[..., ColumnElement[bool]] """Logical operator to combine the filters.""" filters: list[StatementFilter] """List of filters to combine.""" def append_to_statement( self, statement: StatementTypeT, model: type[ModelT], ) -> "StatementTypeT": """Apply all filters combined with the logical operator. Args: statement: The SQLAlchemy statement to modify model: The SQLAlchemy model class Returns: StatementTypeT: Modified statement with combined filters """ if not self.filters: return statement # Create a list of expressions from each filter expressions = [] for filter_obj in self.filters: # Each filter needs to be applied to a clean version of the statement # to get just its expression filter_statement = filter_obj.append_to_statement(select(), model) # Extract the whereclause from the filter's statement if hasattr(filter_statement, "whereclause") and filter_statement.whereclause is not None: expressions.append(filter_statement.whereclause) # pyright: ignore if expressions: # Combine all expressions with the logical operator combined = self.logical_operator(*expressions) return cast("StatementTypeT", statement.where(combined)) return statement @dataclass class MultiFilter(StatementFilter): """Apply multiple filters to a query based on a JSON/dict input. This filter provides a way to construct complex filter trees from a structured dictionary input, supporting nested logical groups and various filter types. """ filters: dict[str, Any] """JSON/dict structure representing the filters.""" # TypedDict class variables _filter_map: ClassVar[FilterMap] = { "before_after": BeforeAfter, "on_before_after": OnBeforeAfter, "collection": CollectionFilter, "not_in_collection": NotInCollectionFilter, "limit_offset": LimitOffset, "null": NullFilter, "not_null": NotNullFilter, "order_by": OrderBy, "search": SearchFilter, "not_in_search": NotInSearchFilter, "filter_group": FilterGroup, "comparison": ComparisonFilter, "exists": ExistsFilter, "not_exists": NotExistsFilter, } _logical_map: ClassVar[LogicalOperatorMap] = { "and_": and_, "or_": or_, } def append_to_statement( self, statement: StatementTypeT, model: type[ModelT], ) -> StatementTypeT: """Apply the filters to the statement based on the filter definitions. Args: statement: The SQLAlchemy statement to modify model: The SQLAlchemy model class Returns: StatementTypeT: Modified statement with all filters applied """ for filter_type, conditions in self.filters.items(): operator = self._logical_map.get(filter_type) if operator and isinstance(conditions, list): # Create filters from the conditions valid_filters = [] for cond in conditions: # pyright: ignore filter_instance = self._create_filter(cond) # pyright: ignore if filter_instance is not None: valid_filters.append(filter_instance) # pyright: ignore # Only create a filter group if we have valid filters if valid_filters: filter_group = FilterGroup( logical_operator=operator, # type: ignore filters=valid_filters, # pyright: ignore ) statement = filter_group.append_to_statement(statement, model) return statement def _create_filter(self, condition: dict[str, Any]) -> Optional[StatementFilter]: """Create a filter instance from a condition dictionary. Args: condition: Dictionary defining a filter Returns: Optional[StatementFilter]: Filter instance if successfully created, None otherwise """ # Check if condition is a nested logical group logical_keys = set(self._logical_map.keys()) intersect = logical_keys.intersection(condition.keys()) if intersect: # It's a nested filter group for key in intersect: operator = self._logical_map.get(key) if operator and isinstance(condition.get(key), list): nested_filters = [] for cond in condition[key]: filter_instance = self._create_filter(cond) if filter_instance is not None: nested_filters.append(filter_instance) # pyright: ignore if nested_filters: return FilterGroup(logical_operator=operator, filters=nested_filters) # type: ignore else: # Regular filter filter_type = condition.get("type") if filter_type is not None and isinstance(filter_type, str): filter_class = self._filter_map.get(filter_type) if filter_class is not None: try: # Create a copy of the condition without the type key filter_args = {k: v for k, v in condition.items() if k != "type"} return filter_class(**filter_args) # type: ignore except Exception: # noqa: BLE001 return None return None # Define FilterTypes using direct class references FilterTypes: TypeAlias = Union[ BeforeAfter, OnBeforeAfter, CollectionFilter[Any], LimitOffset, NullFilter, NotNullFilter, OrderBy, SearchFilter, NotInCollectionFilter[Any], NotInSearchFilter, ExistsFilter, NotExistsFilter, ComparisonFilter, MultiFilter, FilterGroup, ] """Aggregate type alias of the types supported for collection filtering.""" python-advanced-alchemy-1.9.3/advanced_alchemy/mixins/000077500000000000000000000000001516556515500230245ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/advanced_alchemy/mixins/__init__.py000066400000000000000000000012541516556515500251370ustar00rootroot00000000000000from advanced_alchemy.mixins.audit import AuditColumns from advanced_alchemy.mixins.bigint import BigIntPrimaryKey, IdentityPrimaryKey from advanced_alchemy.mixins.nanoid import NanoIDPrimaryKey from advanced_alchemy.mixins.sentinel import SentinelMixin from advanced_alchemy.mixins.slug import SlugKey from advanced_alchemy.mixins.unique import UniqueMixin from advanced_alchemy.mixins.uuid import UUIDPrimaryKey, UUIDv6PrimaryKey, UUIDv7PrimaryKey __all__ = ( "AuditColumns", "BigIntPrimaryKey", "IdentityPrimaryKey", "NanoIDPrimaryKey", "SentinelMixin", "SlugKey", "UUIDPrimaryKey", "UUIDv6PrimaryKey", "UUIDv7PrimaryKey", "UniqueMixin", ) python-advanced-alchemy-1.9.3/advanced_alchemy/mixins/audit.py000066400000000000000000000020041516556515500245000ustar00rootroot00000000000000import datetime from sqlalchemy.orm import Mapped, declarative_mixin, mapped_column, validates from advanced_alchemy.types import DateTimeUTC @declarative_mixin class AuditColumns: """Created/Updated At Fields Mixin.""" created_at: Mapped[datetime.datetime] = mapped_column( DateTimeUTC(timezone=True), default=lambda: datetime.datetime.now(datetime.timezone.utc), sort_order=3002, ) """Date/time of instance creation.""" updated_at: Mapped[datetime.datetime] = mapped_column( DateTimeUTC(timezone=True), default=lambda: datetime.datetime.now(datetime.timezone.utc), onupdate=lambda: datetime.datetime.now(datetime.timezone.utc), sort_order=3003, ) """Date/time of instance last update.""" @validates("created_at", "updated_at") def validate_tz_info(self, _: str, value: datetime.datetime) -> datetime.datetime: if value.tzinfo is None: value = value.replace(tzinfo=datetime.timezone.utc) return value python-advanced-alchemy-1.9.3/advanced_alchemy/mixins/bigint.py000066400000000000000000000042061516556515500246540ustar00rootroot00000000000000from typing import Any, Optional from sqlalchemy import Identity, Sequence from sqlalchemy.orm import Mapped, declarative_mixin, declared_attr, mapped_column from advanced_alchemy.types import BigIntIdentity def _get_schema(cls: "BigIntPrimaryKey") -> Optional[str]: # pragma: nocover """Get the schema for the class if set via __table_args__, __table__, or __table_kwargs__.""" table_args = getattr(cls, "__table_args__", None) if isinstance(table_args, dict) and "schema" in table_args: return table_args["schema"] # type: ignore if isinstance(table_args, tuple) and table_args and isinstance(table_args[-1], dict) and "schema" in table_args[-1]: return table_args[-1]["schema"] # type: ignore if hasattr(cls, "__table__") and hasattr(cls.__table__, "schema"): # pyright: ignore return cls.__table__.schema # type: ignore[no-any-return] table_kwargs = getattr(cls, "__table_kwargs__", None) if isinstance(table_kwargs, dict) and "schema" in table_kwargs: return table_kwargs["schema"] # type: ignore return None @declarative_mixin class BigIntPrimaryKey: """BigInt Primary Key Field Mixin.""" @declared_attr def id(cls) -> Mapped[int]: """BigInt Primary key column.""" seq_kwargs: dict[str, Any] = {"optional": False} if schema := _get_schema(cls): seq_kwargs["schema"] = schema return mapped_column( BigIntIdentity, Sequence(f"{cls.__tablename__}_id_seq", **seq_kwargs), # type: ignore[attr-defined] primary_key=True, sort_order=-100, ) @declarative_mixin class IdentityPrimaryKey: """Primary Key Field Mixin using database IDENTITY feature. This mixin uses the database's native IDENTITY feature rather than a sequence. This can be more efficient for databases that support IDENTITY natively. """ @declared_attr def id(cls) -> Mapped[int]: """Primary key column using IDENTITY.""" return mapped_column( BigIntIdentity, Identity(start=1, increment=1), primary_key=True, sort_order=-100, ) python-advanced-alchemy-1.9.3/advanced_alchemy/mixins/nanoid.py000066400000000000000000000021201516556515500246410ustar00rootroot00000000000000import logging from typing import TYPE_CHECKING, Any from sqlalchemy.orm import Mapped, declarative_mixin, mapped_column from advanced_alchemy.mixins.sentinel import SentinelMixin from advanced_alchemy.types import NANOID_INSTALLED if NANOID_INSTALLED and not TYPE_CHECKING: from fastnanoid import ( # type: ignore[import-not-found,unused-ignore] # pyright: ignore[reportMissingImports] generate as nanoid, ) else: from uuid import uuid4 as nanoid # type: ignore[assignment,unused-ignore] logger = logging.getLogger("advanced_alchemy") @declarative_mixin class NanoIDPrimaryKey(SentinelMixin): """Nano ID Primary Key Field Mixin.""" def __init_subclass__(cls, **kwargs: Any) -> None: super().__init_subclass__(**kwargs) if not NANOID_INSTALLED and not cls.__module__.startswith("advanced_alchemy"): # pragma: no cover logger.warning("`fastnanoid` not installed, falling back to `uuid4` for NanoID generation.") id: Mapped[str] = mapped_column(default=nanoid, primary_key=True, sort_order=-100) """Nano ID Primary key column.""" python-advanced-alchemy-1.9.3/advanced_alchemy/mixins/sentinel.py000066400000000000000000000020741516556515500252220ustar00rootroot00000000000000from typing import TypedDict from sqlalchemy.orm import Mapped, MappedAsDataclass, declarative_mixin, declared_attr, mapped_column from sqlalchemy.sql.schema import _InsertSentinelColumnDefault # pyright: ignore [reportPrivateUsage] from typing_extensions import NotRequired class SentinelKwargs(TypedDict): init: NotRequired[bool] @declarative_mixin class SentinelMixin: """Mixin to add a sentinel column for SQLAlchemy models.""" __abstract__ = True _sentinel_kwargs: SentinelKwargs = {} def __init_subclass__(cls) -> None: super().__init_subclass__() if issubclass(cls, MappedAsDataclass): cls._sentinel_kwargs["init"] = False @declared_attr def _sentinel(cls) -> Mapped[int]: return mapped_column( name="sa_orm_sentinel", insert_default=_InsertSentinelColumnDefault(), _omit_from_statements=True, insert_sentinel=True, use_existing_column=True, nullable=True, sort_order=3001, **cls._sentinel_kwargs, ) python-advanced-alchemy-1.9.3/advanced_alchemy/mixins/slug.py000066400000000000000000000026171516556515500243560ustar00rootroot00000000000000from typing import TYPE_CHECKING, Any from sqlalchemy import Index, String, UniqueConstraint from sqlalchemy.orm import Mapped, declarative_mixin, declared_attr, mapped_column if TYPE_CHECKING: from sqlalchemy.orm.decl_base import _TableArgsType as TableArgsType # pyright: ignore[reportPrivateUsage] @declarative_mixin class SlugKey: """Slug unique Field Model Mixin.""" @declared_attr def slug(cls) -> Mapped[str]: """Slug field.""" return mapped_column( String(length=100), nullable=False, ) @staticmethod def _create_unique_slug_index(*_: Any, **kwargs: Any) -> bool: return bool(kwargs["dialect"].name.startswith("spanner")) @staticmethod def _create_unique_slug_constraint(*_: Any, **kwargs: Any) -> bool: return not kwargs["dialect"].name.startswith("spanner") @declared_attr.directive @classmethod def __table_args__(cls) -> "TableArgsType": return ( UniqueConstraint( cls.slug, name=f"uq_{cls.__tablename__}_slug", # type: ignore[attr-defined] ).ddl_if(callable_=cls._create_unique_slug_constraint), Index( f"ix_{cls.__tablename__}_slug_unique", # type: ignore[attr-defined] cls.slug, unique=True, ).ddl_if(callable_=cls._create_unique_slug_index), ) python-advanced-alchemy-1.9.3/advanced_alchemy/mixins/unique.py000066400000000000000000000131321516556515500247040ustar00rootroot00000000000000from contextlib import contextmanager from typing import TYPE_CHECKING, Any, Optional, Union from sqlalchemy import ColumnElement, select from sqlalchemy.orm import declarative_mixin from typing_extensions import Self from advanced_alchemy.exceptions import wrap_sqlalchemy_exception if TYPE_CHECKING: from collections.abc import Hashable, Iterator from sqlalchemy import Select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio.scoping import async_scoped_session from sqlalchemy.orm import Session from sqlalchemy.orm.scoping import scoped_session __all__ = ("UniqueMixin",) @declarative_mixin class UniqueMixin: """Mixin for instantiating objects while ensuring uniqueness on some field(s). This is a slightly modified implementation derived from https://github.com/sqlalchemy/sqlalchemy/wiki/UniqueObject """ @classmethod @contextmanager def _prevent_autoflush( cls, session: "Union[AsyncSession, async_scoped_session[AsyncSession], Session, scoped_session[Session]]", ) -> "Iterator[None]": with session.no_autoflush, wrap_sqlalchemy_exception(): yield @classmethod def _check_uniqueness( cls, cache: "Optional[dict[tuple[type[Self], Hashable], Self]]", session: "Union[AsyncSession, async_scoped_session[AsyncSession], Session, scoped_session[Session]]", key: "tuple[type[Self], Hashable]", *args: Any, **kwargs: Any, ) -> "tuple[dict[tuple[type[Self], Hashable], Self], Select[tuple[Self]], Optional[Self]]": if cache is None: cache = {} setattr(session, "_unique_cache", cache) statement = select(cls).where(cls.unique_filter(*args, **kwargs)).limit(2) return cache, statement, cache.get(key) @classmethod async def as_unique_async( cls, session: "Union[AsyncSession, async_scoped_session[AsyncSession]]", *args: Any, **kwargs: Any, ) -> Self: """Instantiate and return a unique object within the provided session based on the given arguments. If an object with the same unique identifier already exists in the session, it is returned from the cache. Args: session (AsyncSession | async_scoped_session[AsyncSession]): SQLAlchemy async session *args (Any): Values used to instantiate the instance if no duplicate exists **kwargs (Any): Values used to instantiate the instance if no duplicate exists Returns: Self: The unique object instance. """ key = cls, cls.unique_hash(*args, **kwargs) cache, statement, obj = cls._check_uniqueness( getattr(session, "_unique_cache", None), session, key, *args, **kwargs, ) if obj: return obj with cls._prevent_autoflush(session): if (obj := (await session.execute(statement)).scalar_one_or_none()) is None: session.add(obj := cls(*args, **kwargs)) cache[key] = obj return obj @classmethod def as_unique_sync( cls, session: "Union[Session, scoped_session[Session]]", *args: Any, **kwargs: Any, ) -> Self: """Instantiate and return a unique object within the provided session based on the given arguments. If an object with the same unique identifier already exists in the session, it is returned from the cache. Args: session (Session | scoped_session[Session]): SQLAlchemy sync session *args (Any): Values used to instantiate the instance if no duplicate exists **kwargs (Any): Values used to instantiate the instance if no duplicate exists Returns: Self: The unique object instance. """ key = cls, cls.unique_hash(*args, **kwargs) cache, statement, obj = cls._check_uniqueness( getattr(session, "_unique_cache", None), session, key, *args, **kwargs, ) if obj: return obj with cls._prevent_autoflush(session): if (obj := session.execute(statement).scalar_one_or_none()) is None: session.add(obj := cls(*args, **kwargs)) cache[key] = obj return obj @classmethod def unique_hash(cls, *args: Any, **kwargs: Any) -> "Hashable": """Generate a unique key based on the provided arguments. This method should be implemented in the subclass. Args: *args (Any): Values passed to the alternate classmethod constructors **kwargs (Any): Values passed to the alternate classmethod constructors Raises: NotImplementedError: If not implemented in the subclass. Returns: Hashable: Any hashable object. """ msg = "Implement this in subclass" raise NotImplementedError(msg) @classmethod def unique_filter(cls, *args: Any, **kwargs: Any) -> "ColumnElement[bool]": """Generate a filter condition for ensuring uniqueness. This method should be implemented in the subclass. Args: *args (Any): Values passed to the alternate classmethod constructors **kwargs (Any): Values passed to the alternate classmethod constructors Raises: NotImplementedError: If not implemented in the subclass. Returns: ColumnElement[bool]: Filter condition to establish the uniqueness. """ msg = "Implement this in subclass" raise NotImplementedError(msg) python-advanced-alchemy-1.9.3/advanced_alchemy/mixins/uuid.py000066400000000000000000000037621516556515500243540ustar00rootroot00000000000000import logging from typing import TYPE_CHECKING, Any from uuid import UUID, uuid4 from sqlalchemy.orm import Mapped, declarative_mixin, mapped_column from advanced_alchemy.mixins.sentinel import SentinelMixin from advanced_alchemy.types import UUID_UTILS_INSTALLED if UUID_UTILS_INSTALLED and not TYPE_CHECKING: from uuid_utils.compat import ( # type: ignore[no-redef,unused-ignore] # pyright: ignore[reportMissingImports] uuid4, uuid6, uuid7, ) else: from uuid import uuid4 # type: ignore[no-redef,unused-ignore] uuid6 = uuid4 # type: ignore[assignment, unused-ignore] uuid7 = uuid4 # type: ignore[assignment, unused-ignore] logger = logging.getLogger("advanced_alchemy") @declarative_mixin class UUIDPrimaryKey(SentinelMixin): """UUID Primary Key Field Mixin.""" id: Mapped[UUID] = mapped_column(default=uuid4, primary_key=True, sort_order=-100) """UUID Primary key column.""" @declarative_mixin class UUIDv6PrimaryKey(SentinelMixin): """UUID v6 Primary Key Field Mixin.""" def __init_subclass__(cls, **kwargs: Any) -> None: super().__init_subclass__(**kwargs) if not UUID_UTILS_INSTALLED and not cls.__module__.startswith("advanced_alchemy"): # pragma: no cover logger.warning("`uuid-utils` not installed, falling back to `uuid4` for UUID v6 generation.") id: Mapped[UUID] = mapped_column(default=uuid6, primary_key=True, sort_order=-100) """UUID Primary key column.""" @declarative_mixin class UUIDv7PrimaryKey(SentinelMixin): """UUID v7 Primary Key Field Mixin.""" def __init_subclass__(cls, **kwargs: Any) -> None: super().__init_subclass__(**kwargs) if not UUID_UTILS_INSTALLED and not cls.__module__.startswith("advanced_alchemy"): # pragma: no cover logger.warning("`uuid-utils` not installed, falling back to `uuid4` for UUID v7 generation.") id: Mapped[UUID] = mapped_column(default=uuid7, primary_key=True, sort_order=-100) """UUID Primary key column.""" python-advanced-alchemy-1.9.3/advanced_alchemy/operations.py000066400000000000000000000466601516556515500242660ustar00rootroot00000000000000"""Advanced database operations for SQLAlchemy. This module provides high-performance database operations that extend beyond basic CRUD functionality. It implements specialized database operations optimized for bulk data handling and schema management. The operations module is designed to work seamlessly with SQLAlchemy Core and ORM, providing efficient implementations for common database operations patterns. Features -------- - Cross-database ON CONFLICT/ON DUPLICATE KEY UPDATE operations - MERGE statement support for Oracle and PostgreSQL 15+ Security -------- This module constructs SQL statements using database identifiers (table and column names) that MUST come from trusted sources only. All identifiers should originate from: - SQLAlchemy model metadata (e.g., Model.__table__) - Hardcoded strings in application code - Validated configuration files Never pass user input directly as table names, column names, or other SQL identifiers. Data values are properly parameterized using bindparam() to prevent SQL injection. Notes: ------ This module is designed to be database-agnostic where possible, with specialized optimizations for specific database backends where appropriate. See Also: --------- - :mod:`sqlalchemy.sql.expression` : SQLAlchemy Core expression language - :mod:`sqlalchemy.orm` : SQLAlchemy ORM functionality - :mod:`advanced_alchemy.extensions` : Additional database extensions """ import re from typing import TYPE_CHECKING, Any, Optional, Union, cast from uuid import UUID from sqlalchemy import Insert, Table, bindparam, literal_column, select, text from sqlalchemy.ext.compiler import compiles from sqlalchemy.sql import ClauseElement from sqlalchemy.sql.expression import Executable if TYPE_CHECKING: # pragma: no cover - typing only from sqlalchemy.sql.compiler import SQLCompiler from sqlalchemy.sql.elements import ColumnElement __all__ = ("MergeStatement", "OnConflictUpsert", "validate_identifier") # Pattern for valid SQL identifiers (conservative - alphanumeric and underscore only) _IDENTIFIER_PATTERN = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$") def validate_identifier(name: str, identifier_type: str = "identifier") -> str: """Validate a SQL identifier to ensure it's safe for use in SQL statements. This function provides validation for SQL identifiers (table names, column names, etc.) to ensure they contain only safe characters. While the operations in this module should only receive identifiers from trusted sources, this validation adds an extra layer of security. Note: SQL keywords (like 'select', 'insert', etc.) are allowed as they can be properly quoted/escaped by SQLAlchemy when used as identifiers. Args: name: The identifier to validate identifier_type: Type of identifier for error messages (e.g., "column", "table") Returns: The validated identifier Raises: ValueError: If the identifier is empty or contains invalid characters Examples: >>> validate_identifier("user_id") 'user_id' >>> validate_identifier("users_table", "table") 'users_table' >>> validate_identifier("select") # SQL keywords are allowed 'select' >>> validate_identifier( ... "drop table users; --" ... ) # Raises ValueError - contains invalid characters """ if not name: msg = f"Empty {identifier_type} name provided" raise ValueError(msg) if not _IDENTIFIER_PATTERN.match(name): msg = f"Invalid {identifier_type} name: '{name}'. Only alphanumeric characters and underscores are allowed." raise ValueError(msg) return name class MergeStatement(Executable, ClauseElement): """A MERGE statement for Oracle and PostgreSQL 15+. This provides a high-level interface for MERGE operations that can handle both matched and unmatched conditions. """ inherit_cache = True def __init__( self, table: Table, source: Union[ClauseElement, str], on_condition: ClauseElement, when_matched_update: Optional[dict[str, Any]] = None, when_not_matched_insert: Optional[dict[str, Any]] = None, ) -> None: """Initialize a MERGE statement. Args: table: Target table for the merge operation source: Source data (can be a subquery or table) on_condition: Condition for matching rows when_matched_update: Values to update when rows match when_not_matched_insert: Values to insert when rows don't match """ self.table = table self.source = source self.on_condition = on_condition self.when_matched_update = when_matched_update or {} self.when_not_matched_insert = when_not_matched_insert or {} # PostgreSQL version constant POSTGRES_MERGE_VERSION = 15 @compiles(MergeStatement) def compile_merge_default(element: MergeStatement, compiler: "SQLCompiler", **kwargs: Any) -> str: """Default compilation - raises error for unsupported dialects.""" _ = element, kwargs # Unused parameters dialect_name = compiler.dialect.name msg = f"MERGE statement not supported for dialect '{dialect_name}'" raise NotImplementedError(msg) @compiles(MergeStatement, "oracle") def compile_merge_oracle(element: MergeStatement, compiler: "SQLCompiler", **kwargs: Any) -> str: """Compile MERGE statement for Oracle.""" table_name = element.table.name if isinstance(element.source, str): source_str = element.source if source_str.upper().startswith("SELECT") and "FROM DUAL" not in source_str.upper(): source_str = f"{source_str} FROM DUAL" source_clause = f"({source_str})" else: compiled_source = compiler.process(element.source, **kwargs) source_clause = f"({compiled_source})" merge_sql = f"MERGE INTO {table_name} tgt USING {source_clause} src ON (" merge_sql += compiler.process(element.on_condition, **kwargs) merge_sql += ")" if element.when_matched_update: merge_sql += " WHEN MATCHED THEN UPDATE SET " updates = [] for column, value in element.when_matched_update.items(): if hasattr(value, "_compiler_dispatch"): compiled_value = compiler.process(value, **kwargs) else: compiled_value = compiler.process(value, **kwargs) updates.append(f"{column} = {compiled_value}") # pyright: ignore merge_sql += ", ".join(updates) # pyright: ignore if element.when_not_matched_insert: columns = list(element.when_not_matched_insert.keys()) values = list(element.when_not_matched_insert.values()) merge_sql += " WHEN NOT MATCHED THEN INSERT (" merge_sql += ", ".join(columns) merge_sql += ") VALUES (" compiled_values = [] for value in values: if hasattr(value, "_compiler_dispatch"): compiled_value = compiler.process(value, **kwargs) else: compiled_value = compiler.process(value, **kwargs) compiled_values.append(compiled_value) # pyright: ignore merge_sql += ", ".join(compiled_values) # pyright: ignore merge_sql += ")" return merge_sql @compiles(MergeStatement, "postgresql") def compile_merge_postgresql(element: MergeStatement, compiler: "SQLCompiler", **kwargs: Any) -> str: """Compile MERGE statement for PostgreSQL 15+.""" dialect = compiler.dialect if ( hasattr(dialect, "server_version_info") and dialect.server_version_info and dialect.server_version_info[0] < POSTGRES_MERGE_VERSION ): msg = "MERGE statement requires PostgreSQL 15 or higher" raise NotImplementedError(msg) table_name = element.table.name if isinstance(element.source, str): # Wrap raw string source and alias as src source_clause = f"({element.source}) AS src" else: # Ensure the compiled source is parenthesized and has a stable alias 'src' compiled_source = compiler.process(element.source, **kwargs) compiled_trim = compiled_source.strip() if compiled_trim.startswith("("): # Already parenthesized; check for alias after closing paren has_outer_alias = ( re.search(r"\)\s+(AS\s+)?[a-zA-Z_][a-zA-Z0-9_]*\s*$", compiled_trim, re.IGNORECASE) is not None ) source_clause = compiled_trim if has_outer_alias else f"{compiled_trim} AS src" else: # Not parenthesized: wrap and alias source_clause = f"({compiled_trim}) AS src" merge_sql = f"MERGE INTO {table_name} AS tgt USING {source_clause} ON (" merge_sql += compiler.process(element.on_condition, **kwargs) merge_sql += ")" if element.when_matched_update: merge_sql += " WHEN MATCHED THEN UPDATE SET " updates = [] for column, value in element.when_matched_update.items(): if hasattr(value, "_compiler_dispatch"): compiled_value = compiler.process(value, **kwargs) else: compiled_value = compiler.process(value, **kwargs) updates.append(f"{column} = {compiled_value}") # pyright: ignore merge_sql += ", ".join(updates) # pyright: ignore if element.when_not_matched_insert: columns = list(element.when_not_matched_insert.keys()) values = list(element.when_not_matched_insert.values()) merge_sql += " WHEN NOT MATCHED THEN INSERT (" merge_sql += ", ".join(columns) merge_sql += ") VALUES (" compiled_values = [] for value in values: if hasattr(value, "_compiler_dispatch"): compiled_value = compiler.process(value, **kwargs) else: compiled_value = compiler.process(value, **kwargs) compiled_values.append(compiled_value) # pyright: ignore merge_sql += ", ".join(compiled_values) # pyright: ignore merge_sql += ")" return merge_sql class OnConflictUpsert: """Cross-database upsert operation using dialect-specific constructs. This class provides a unified interface for upsert operations across different database backends using their native ON CONFLICT or ON DUPLICATE KEY UPDATE mechanisms. """ @staticmethod def supports_native_upsert(dialect_name: str) -> bool: """Check if the dialect supports native upsert operations. Args: dialect_name: Name of the database dialect Returns: True if native upsert is supported, False otherwise """ return dialect_name in {"postgresql", "cockroachdb", "sqlite", "mysql", "mariadb", "duckdb"} @staticmethod def create_upsert( table: Table, values: dict[str, Any], conflict_columns: list[str], update_columns: Optional[list[str]] = None, dialect_name: Optional[str] = None, validate_identifiers: bool = False, ) -> Insert: """Create a dialect-specific upsert statement. Args: table: Target table for the upsert values: Values to insert/update conflict_columns: Columns that define the conflict condition update_columns: Columns to update on conflict (defaults to all non-conflict columns) dialect_name: Database dialect name (auto-detected if not provided) validate_identifiers: If True, validate column names for safety (default: False) Returns: A SQLAlchemy Insert statement with upsert logic Raises: NotImplementedError: If the dialect doesn't support native upsert ValueError: If validate_identifiers is True and invalid identifiers are found """ if validate_identifiers: for col in conflict_columns: validate_identifier(col, "conflict column") if update_columns: for col in update_columns: validate_identifier(col, "update column") for col in values: validate_identifier(col, "column") if update_columns is None: update_columns = [col for col in values if col not in conflict_columns] if dialect_name in {"postgresql", "sqlite", "duckdb"}: from sqlalchemy.dialects.postgresql import insert as pg_insert pg_insert_stmt = pg_insert(table).values(values) return pg_insert_stmt.on_conflict_do_update( index_elements=conflict_columns, set_={col: pg_insert_stmt.excluded[col] for col in update_columns} ) if dialect_name == "cockroachdb": from sqlalchemy.dialects.postgresql import insert as pg_insert pg_insert_stmt = pg_insert(table).values(values) return pg_insert_stmt.on_conflict_do_update( index_elements=conflict_columns, set_={col: pg_insert_stmt.excluded[col] for col in update_columns} ) if dialect_name in {"mysql", "mariadb"}: from sqlalchemy.dialects.mysql import insert as mysql_insert mysql_insert_stmt = mysql_insert(table).values(values) return mysql_insert_stmt.on_duplicate_key_update( **{col: mysql_insert_stmt.inserted[col] for col in update_columns} ) msg = f"Native upsert not supported for dialect '{dialect_name}'" raise NotImplementedError(msg) @staticmethod def create_merge_upsert( # noqa: C901, PLR0915 table: Table, values: dict[str, Any], conflict_columns: list[str], update_columns: Optional[list[str]] = None, dialect_name: Optional[str] = None, validate_identifiers: bool = False, ) -> tuple[MergeStatement, dict[str, Any]]: """Create a MERGE-based upsert for Oracle/PostgreSQL 15+. For Oracle databases, this method automatically generates values for primary key columns that have callable defaults (such as UUID generation functions). This is necessary because Oracle MERGE statements cannot use Python callable defaults directly in the INSERT clause. Args: table: Target table for the upsert values: Values to insert/update conflict_columns: Columns that define the matching condition update_columns: Columns to update on match (defaults to all non-conflict columns) dialect_name: Database dialect name (used to determine Oracle-specific syntax) validate_identifiers: If True, validate column names for safety (default: False) Returns: A tuple of (MergeStatement, additional_params) where additional_params contains any generated values (like Oracle UUID primary keys) Raises: ValueError: If validate_identifiers is True and invalid identifiers are found """ if validate_identifiers: for col in conflict_columns: validate_identifier(col, "conflict column") if update_columns: for col in update_columns: validate_identifier(col, "update column") for col in values: validate_identifier(col, "column") if update_columns is None: update_columns = [col for col in values if col not in conflict_columns] additional_params: dict[str, Any] = {} source: Union[ClauseElement, str] insert_columns: list[str] when_not_matched_insert: dict[str, Any] if dialect_name == "oracle": labeled_columns: list[ColumnElement[Any]] = [] for key, value in values.items(): column = table.c[key] labeled_columns.append(bindparam(key, value=value, type_=column.type).label(key)) pk_col_with_seq = None for pk_column in table.primary_key.columns: if pk_column.name in values or pk_column.default is None: continue if callable(getattr(pk_column.default, "arg", None)): try: default_value = pk_column.default.arg(None) # type: ignore[attr-defined] if isinstance(default_value, UUID): default_value = default_value.hex additional_params[pk_column.name] = default_value labeled_columns.append( bindparam(pk_column.name, value=default_value, type_=pk_column.type).label(pk_column.name) ) except (TypeError, AttributeError, ValueError): continue elif hasattr(pk_column.default, "next_value"): pk_col_with_seq = pk_column # Oracle requires FROM DUAL for SELECT statements without tables source_query = select(*labeled_columns) # Add FROM DUAL for Oracle source_query = source_query.select_from(text("DUAL")) source = source_query.subquery("src") insert_columns = [label_col.name for label_col in labeled_columns] when_not_matched_insert = {col_name: literal_column(f"src.{col_name}") for col_name in insert_columns} if pk_col_with_seq is not None: insert_columns.append(pk_col_with_seq.name) when_not_matched_insert[pk_col_with_seq.name] = cast("Any", pk_col_with_seq.default).next_value() elif dialect_name in {"postgresql", "cockroachdb"}: labeled_columns = [] for key, value in values.items(): column = table.c[key] bp = bindparam(f"src_{key}", value=value, type_=column.type) labeled_columns.append(bp.label(key)) source = select(*labeled_columns).subquery("src") insert_columns = list(values.keys()) when_not_matched_insert = {col: literal_column(f"src.{col}") for col in insert_columns} else: placeholders = ", ".join([f"%({key})s" for key in values]) col_names = ", ".join(values.keys()) source = f"(SELECT * FROM (VALUES ({placeholders})) AS src({col_names}))" # noqa: S608 insert_columns = list(values.keys()) when_not_matched_insert = {col: bindparam(col) for col in insert_columns} on_conditions = [f"tgt.{col} = src.{col}" for col in conflict_columns] on_condition = text(" AND ".join(on_conditions)) if dialect_name in {"postgresql", "cockroachdb", "oracle"}: when_matched_update: dict[str, Any] = { col: literal_column(f"src.{col}") for col in update_columns if col in values } else: when_matched_update = {col: bindparam(col) for col in update_columns if col in values} # For Oracle, we need to ensure the keys in when_not_matched_insert match the insert_columns if dialect_name == "oracle": final_insert_mapping = {} for col_name in insert_columns: if col_name in when_not_matched_insert: final_insert_mapping[col_name] = when_not_matched_insert[col_name] when_not_matched_insert = final_insert_mapping merge_stmt = MergeStatement( table=table, source=source, on_condition=on_condition, when_matched_update=when_matched_update, when_not_matched_insert=when_not_matched_insert, ) return merge_stmt, additional_params # pyright: ignore[reportUnknownVariableType] # Note: Oracle-specific helper removed; inline logic now handles defaults python-advanced-alchemy-1.9.3/advanced_alchemy/py.typed000066400000000000000000000000001516556515500232020ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/advanced_alchemy/repository/000077500000000000000000000000001516556515500237345ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/advanced_alchemy/repository/__init__.py000066400000000000000000000030251516556515500260450ustar00rootroot00000000000000from advanced_alchemy.exceptions import ErrorMessages from advanced_alchemy.repository._async import ( SQLAlchemyAsyncQueryRepository, SQLAlchemyAsyncRepository, SQLAlchemyAsyncRepositoryProtocol, SQLAlchemyAsyncSlugRepository, SQLAlchemyAsyncSlugRepositoryProtocol, ) from advanced_alchemy.repository._sync import ( SQLAlchemySyncQueryRepository, SQLAlchemySyncRepository, SQLAlchemySyncRepositoryProtocol, SQLAlchemySyncSlugRepository, SQLAlchemySyncSlugRepositoryProtocol, ) from advanced_alchemy.repository._util import ( DEFAULT_ERROR_MESSAGE_TEMPLATES, FilterableRepository, FilterableRepositoryProtocol, LoadSpec, get_instrumented_attr, model_from_dict, ) from advanced_alchemy.repository.typing import ModelOrRowMappingT, ModelT, OrderingPair from advanced_alchemy.utils.dataclass import Empty, EmptyType __all__ = ( "DEFAULT_ERROR_MESSAGE_TEMPLATES", "Empty", "EmptyType", "ErrorMessages", "FilterableRepository", "FilterableRepositoryProtocol", "LoadSpec", "ModelOrRowMappingT", "ModelT", "OrderingPair", "SQLAlchemyAsyncQueryRepository", "SQLAlchemyAsyncRepository", "SQLAlchemyAsyncRepositoryProtocol", "SQLAlchemyAsyncSlugRepository", "SQLAlchemyAsyncSlugRepositoryProtocol", "SQLAlchemySyncQueryRepository", "SQLAlchemySyncRepository", "SQLAlchemySyncRepositoryProtocol", "SQLAlchemySyncSlugRepository", "SQLAlchemySyncSlugRepositoryProtocol", "get_instrumented_attr", "model_from_dict", ) python-advanced-alchemy-1.9.3/advanced_alchemy/repository/_async.py000066400000000000000000004620671516556515500256010ustar00rootroot00000000000000import contextlib import datetime import random import string from collections.abc import Iterable, Sequence from functools import partial from typing import ( TYPE_CHECKING, Any, Final, List, Literal, Optional, Protocol, Union, cast, runtime_checkable, ) from sqlalchemy import ( Delete, Result, Row, Select, TextClause, Update, and_, any_, delete, inspect, or_, over, select, text, tuple_, update, ) from sqlalchemy import func as sql_func from sqlalchemy.exc import MissingGreenlet, NoInspectionAvailable from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio.scoping import async_scoped_session from sqlalchemy.orm import InstrumentedAttribute from sqlalchemy.orm.strategy_options import _AbstractLoad # pyright: ignore[reportPrivateUsage] from sqlalchemy.sql import ColumnElement from sqlalchemy.sql.dml import ReturningDelete, ReturningUpdate from sqlalchemy.sql.selectable import ForUpdateArg, ForUpdateParameter from advanced_alchemy.base import model_to_dict from advanced_alchemy.exceptions import ErrorMessages, NotFoundError, RepositoryError, wrap_sqlalchemy_exception from advanced_alchemy.filters import StatementFilter, StatementTypeT from advanced_alchemy.repository._util import ( DEFAULT_ERROR_MESSAGE_TEMPLATES, DEFAULT_SAFE_TYPES, FilterableRepository, FilterableRepositoryProtocol, LoadSpec, _build_list_cache_key, # pyright: ignore column_has_defaults, compare_values, extract_pk_value_from_instance, get_abstract_loader_options, get_instrumented_attr, get_primary_key_info, is_composite_pk, pk_values_present, validate_composite_pk_value, was_attribute_set, ) from advanced_alchemy.repository.typing import MISSING, ModelT, OrderingPair, PrimaryKeyType, T from advanced_alchemy.service.typing import schema_dump from advanced_alchemy.utils.dataclass import Empty, EmptyType from advanced_alchemy.utils.text import slugify if TYPE_CHECKING: from sqlalchemy.engine.interfaces import _CoreSingleExecuteParams # pyright: ignore[reportPrivateUsage] from advanced_alchemy.cache.manager import CacheManager DEFAULT_INSERTMANYVALUES_MAX_PARAMETERS: Final = 950 POSTGRES_VERSION_SUPPORTING_MERGE: Final = 15 @runtime_checkable class SQLAlchemyAsyncRepositoryProtocol(FilterableRepositoryProtocol[ModelT], Protocol[ModelT]): """Base Protocol""" id_attribute: str match_fields: Optional[Union[List[str], str]] = None statement: Select[tuple[ModelT]] session: Union[AsyncSession, async_scoped_session[AsyncSession]] auto_expunge: bool auto_refresh: bool auto_commit: bool order_by: Optional[Union[List[OrderingPair], OrderingPair]] = None error_messages: Optional[ErrorMessages] = None wrap_exceptions: bool = True @property def pk_attr_names(self) -> tuple[str, ...]: ... @property def has_composite_pk(self) -> bool: ... def get_primary_key_value(self, instance: ModelT) -> PrimaryKeyType: ... def has_primary_key_values(self, instance: ModelT) -> bool: ... def __init__( self, *, statement: Optional[Select[tuple[ModelT]]] = None, session: Union[AsyncSession, async_scoped_session[AsyncSession]], auto_expunge: bool = False, auto_refresh: bool = True, auto_commit: bool = False, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, order_by: Optional[Union[List[OrderingPair], OrderingPair]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, wrap_exceptions: bool = True, **kwargs: Any, ) -> None: ... @classmethod def get_id_attribute_value( cls, item: Union[ModelT, type[ModelT]], id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]] = None, ) -> Any: ... @classmethod def set_id_attribute_value( cls, item_id: Any, item: ModelT, id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]] = None, ) -> ModelT: ... @staticmethod def check_not_found(item_or_none: Optional[ModelT]) -> ModelT: ... async def add( self, data: ModelT, *, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, auto_refresh: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, bind_group: Optional[str] = None, ) -> ModelT: ... async def add_many( self, data: List[ModelT], *, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, bind_group: Optional[str] = None, ) -> Sequence[ModelT]: ... async def delete( self, item_id: PrimaryKeyType, *, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, bind_group: Optional[str] = None, ) -> ModelT: ... async def delete_many( self, item_ids: List[PrimaryKeyType], *, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]] = None, chunk_size: Optional[int] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, bind_group: Optional[str] = None, ) -> Sequence[ModelT]: ... async def delete_where( self, *filters: Union[StatementFilter, ColumnElement[bool]], auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, load: Optional[LoadSpec] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, execution_options: Optional[dict[str, Any]] = None, sanity_check: bool = True, bind_group: Optional[str] = None, **kwargs: Any, ) -> Sequence[ModelT]: ... async def exists( self, *filters: Union[StatementFilter, ColumnElement[bool]], load: Optional[LoadSpec] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, execution_options: Optional[dict[str, Any]] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> bool: ... async def get( self, item_id: PrimaryKeyType, *, auto_expunge: Optional[bool] = None, statement: Optional[Select[tuple[ModelT]]] = None, id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, with_for_update: ForUpdateParameter = None, bind_group: Optional[str] = None, ) -> ModelT: ... async def get_one( self, *filters: Union[StatementFilter, ColumnElement[bool]], auto_expunge: Optional[bool] = None, statement: Optional[Select[tuple[ModelT]]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, with_for_update: ForUpdateParameter = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> ModelT: ... async def get_one_or_none( self, *filters: Union[StatementFilter, ColumnElement[bool]], auto_expunge: Optional[bool] = None, statement: Optional[Select[tuple[ModelT]]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, with_for_update: ForUpdateParameter = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> Optional[ModelT]: ... async def get_or_upsert( self, *filters: Union[StatementFilter, ColumnElement[bool]], match_fields: Optional[Union[List[str], str]] = None, upsert: bool = True, attribute_names: Optional[Iterable[str]] = None, with_for_update: ForUpdateParameter = None, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, auto_refresh: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[ModelT, bool]: ... async def get_and_update( self, *filters: Union[StatementFilter, ColumnElement[bool]], match_fields: Optional[Union[List[str], str]] = None, attribute_names: Optional[Iterable[str]] = None, with_for_update: ForUpdateParameter = None, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, auto_refresh: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[ModelT, bool]: ... async def count( self, *filters: Union[StatementFilter, ColumnElement[bool]], statement: Optional[Select[tuple[ModelT]]] = None, load: Optional[LoadSpec] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, execution_options: Optional[dict[str, Any]] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> int: ... async def update( self, data: ModelT, *, attribute_names: Optional[Iterable[str]] = None, with_for_update: ForUpdateParameter = None, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, auto_refresh: Optional[bool] = None, id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, bind_group: Optional[str] = None, ) -> ModelT: ... async def update_many( self, data: List[ModelT], *, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, bind_group: Optional[str] = None, ) -> List[ModelT]: ... def _get_update_many_statement( self, model_type: type[ModelT], supports_returning: bool, loader_options: Optional[List[_AbstractLoad]], execution_options: Optional[dict[str, Any]], ) -> Union[Update, ReturningUpdate[tuple[ModelT]]]: ... async def upsert( self, data: ModelT, *, attribute_names: Optional[Iterable[str]] = None, with_for_update: ForUpdateParameter = None, auto_expunge: Optional[bool] = None, auto_commit: Optional[bool] = None, auto_refresh: Optional[bool] = None, match_fields: Optional[Union[List[str], str]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, bind_group: Optional[str] = None, ) -> ModelT: ... async def upsert_many( self, data: List[ModelT], *, auto_expunge: Optional[bool] = None, auto_commit: Optional[bool] = None, no_merge: bool = False, match_fields: Optional[Union[List[str], str]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, bind_group: Optional[str] = None, ) -> List[ModelT]: ... async def list_and_count( self, *filters: Union[StatementFilter, ColumnElement[bool]], auto_expunge: Optional[bool] = None, statement: Optional[Select[tuple[ModelT]]] = None, count_with_window_function: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, order_by: Optional[Union[List[OrderingPair], OrderingPair]] = None, use_cache: bool = True, bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[List[ModelT], int]: ... async def list( self, *filters: Union[StatementFilter, ColumnElement[bool]], auto_expunge: Optional[bool] = None, statement: Optional[Select[tuple[ModelT]]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, order_by: Optional[Union[List[OrderingPair], OrderingPair]] = None, use_cache: bool = True, bind_group: Optional[str] = None, **kwargs: Any, ) -> List[ModelT]: ... @classmethod async def check_health(cls, session: Union[AsyncSession, async_scoped_session[AsyncSession]]) -> bool: ... @runtime_checkable class SQLAlchemyAsyncSlugRepositoryProtocol(SQLAlchemyAsyncRepositoryProtocol[ModelT], Protocol[ModelT]): """Protocol for SQLAlchemy repositories that support slug-based operations. Extends the base repository protocol to add slug-related functionality. Type Parameters: ModelT: The SQLAlchemy model type this repository handles. """ async def get_by_slug( self, slug: str, *, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> Optional[ModelT]: """Get a model instance by its slug. Args: slug: The slug value to search for. error_messages: Optional custom error message templates. load: Specification for eager loading of relationships. execution_options: Options for statement execution. bind_group: Optional routing group to use for the operation. **kwargs: Additional filtering criteria. Returns: ModelT | None: The found model instance or None if not found. """ ... async def get_available_slug( self, value_to_slugify: str, **kwargs: Any, ) -> str: """Generate a unique slug for a given value. Args: value_to_slugify: The string to convert to a slug. **kwargs: Additional parameters for slug generation. Returns: str: A unique slug derived from the input value. """ ... class SQLAlchemyAsyncRepository(SQLAlchemyAsyncRepositoryProtocol[ModelT], FilterableRepository[ModelT]): """Async SQLAlchemy repository implementation. Provides a complete implementation of async database operations using SQLAlchemy, including CRUD operations, filtering, and relationship loading. Type Parameters: ModelT: The SQLAlchemy model type this repository handles. .. seealso:: :class:`~advanced_alchemy.repository._util.FilterableRepository` """ id_attribute: str = "id" """Name of the unique identifier for the model.""" loader_options: Optional[LoadSpec] = None """Default loader options for the repository.""" error_messages: Optional[ErrorMessages] = None """Default error messages for the repository.""" wrap_exceptions: bool = True """Wrap SQLAlchemy exceptions in a ``RepositoryError``. When set to ``False``, the original exception will be raised.""" inherit_lazy_relationships: bool = True """Optionally ignore the default ``lazy`` configuration for model relationships. This is useful for when you want to replace instead of merge the model's loaded relationships with the ones specified in the ``load`` or ``default_loader_options`` configuration.""" merge_loader_options: bool = True """Merges the default loader options with the loader options specified in the ``load`` argument. This is useful for when you want to totally replace instead of merge the model's loaded relationships with the ones specified in the ``load`` or ``default_loader_options`` configuration.""" execution_options: Optional[dict[str, Any]] = None """Default execution options for the repository.""" match_fields: Optional[Union[List[str], str]] = None """List of dialects that prefer to use ``field.id = ANY(:1)`` instead of ``field.id IN (...)``.""" uniquify: bool = False """Optionally apply the ``unique()`` method to results before returning. This is useful for certain SQLAlchemy uses cases such as applying ``contains_eager`` to a query containing a one-to-many relationship """ count_with_window_function: bool = True """Use an analytical window function to count results. This allows the count to be performed in a single query. """ _cache_manager: Optional["CacheManager"] = None """Cache manager instance for repository-level caching. Set via ``cache_manager`` kwarg or retrieved from ``session.info``.""" _bind_group: Optional[str] = None """Default bind group for routing operations (e.g., to read replicas). Can be overridden per-method.""" def __init__( self, *, statement: Optional[Select[tuple[ModelT]]] = None, session: Union[AsyncSession, async_scoped_session[AsyncSession]], auto_expunge: bool = False, auto_refresh: bool = True, auto_commit: bool = False, order_by: Optional[Union[List[OrderingPair], OrderingPair]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, wrap_exceptions: bool = True, uniquify: Optional[bool] = None, count_with_window_function: Optional[bool] = None, cache_manager: Optional["CacheManager"] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> None: """Repository for SQLAlchemy models. Args: statement: To facilitate customization of the underlying select query. session: Session managing the unit-of-work for the operation. auto_expunge: Remove object from session before returning. auto_refresh: Refresh object from session before returning. auto_commit: Commit objects before returning. order_by: Set default order options for queries. load: Set default relationships to be loaded execution_options: Set default execution options error_messages: A set of custom error messages to use for operations wrap_exceptions: Wrap SQLAlchemy exceptions in a ``RepositoryError``. When set to ``False``, the original exception will be raised. uniquify: Optionally apply the ``unique()`` method to results before returning. count_with_window_function: When false, list and count will use two queries instead of an analytical window function. cache_manager: Optional cache manager for repository-level caching. If not provided, retrieved from ``session.info``. bind_group: Optional default routing group to use for all operations. Can be overridden per-method. **kwargs: Additional arguments. """ self.auto_expunge = auto_expunge self.auto_refresh = auto_refresh self.auto_commit = auto_commit self.order_by = order_by self.session = session self.error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages ) self.wrap_exceptions = wrap_exceptions self._uniquify = uniquify if uniquify is not None else self.uniquify self.count_with_window_function = ( count_with_window_function if count_with_window_function is not None else self.count_with_window_function ) self._default_loader_options, self._loader_options_have_wildcards = get_abstract_loader_options( loader_options=load if load is not None else self.loader_options, inherit_lazy_relationships=self.inherit_lazy_relationships, merge_with_default=self.merge_loader_options, ) execution_options = execution_options if execution_options is not None else self.execution_options self._default_execution_options = execution_options or {} self.statement = select(self.model_type) if statement is None else statement self._dialect = self.session.bind.dialect if self.session.bind is not None else self.session.get_bind().dialect self._prefer_any = any(self._dialect.name == engine_type for engine_type in self.prefer_any_dialects or ()) # Cache manager: from explicit param or session.info (set by SQLAlchemyAsyncConfig) self._cache_manager = cache_manager if cache_manager is not None else session.info.get("cache_manager") # Default bind group for all operations (can be overridden per-method) self._bind_group = bind_group # Cache primary key columns for composite key support self._pk_columns, self._pk_attr_names = get_primary_key_info(self.model_type) def _get_uniquify(self, uniquify: Optional[bool] = None) -> bool: """Get the uniquify value, preferring the method parameter over instance setting. Args: uniquify: Optional override for the uniquify setting. Returns: bool: The uniquify value to use. """ return bool(uniquify) if uniquify is not None else self._uniquify def _resolve_bind_group(self, bind_group: Optional[str] = None) -> Optional[str]: """Resolve the bind_group to use, preferring method parameter over instance default. Args: bind_group: Optional override for the bind_group setting. Returns: The bind_group to use, or None if not set. """ return bind_group if bind_group is not None else self._bind_group def _queue_cache_invalidation(self, entity_id: Any, bind_group: Optional[str] = None) -> None: """Queue a cache invalidation for an entity. The invalidation will be processed after the transaction commits. If the transaction rolls back, the pending invalidation is discarded. This uses cache listeners which must be set up via setup_cache_listeners() during application initialization, or via scoped listeners in SQLAlchemyConfig. Args: entity_id: The primary key value of the entity to invalidate. bind_group: Optional routing group for multi-master configurations. When provided, only the cache entry for that bind_group is invalidated. """ if self._cache_manager is not None: from advanced_alchemy._listeners import get_cache_tracker # Check if model_type has __tablename__ (may not exist in mock scenarios) model_name = getattr(self.model_type, "__tablename__", None) if model_name is None: return tracker = get_cache_tracker(self.session, self._cache_manager) if tracker is not None: tracker.add_invalidation(cast("str", model_name), entity_id, bind_group) def _type_must_use_in_instead_of_any(self, matched_values: "List[Any]", field_type: "Any" = None) -> bool: """Determine if field.in_() should be used instead of any_() for compatibility. Uses SQLAlchemy's type introspection to detect types that may have DBAPI serialization issues with the ANY() operator. Checks if actual values match the column's expected python_type - mismatches indicate complex types that need the safer IN() operator. Falls back to Python type checking when SQLAlchemy type information is unavailable. Args: matched_values: Values to be used in the filter field_type: Optional SQLAlchemy TypeEngine from the column Returns: bool: True if field.in_() should be used instead of any_() """ if not matched_values: return False if field_type is not None: try: expected_python_type = getattr(field_type, "python_type", None) if expected_python_type is not None: for value in matched_values: if value is not None and not isinstance(value, expected_python_type): return True except (AttributeError, NotImplementedError): return True return any(value is not None and type(value) not in DEFAULT_SAFE_TYPES for value in matched_values) def _get_unique_values(self, values: "List[Any]") -> "List[Any]": """Get unique values from a list, handling unhashable types safely. Args: values: List of values to deduplicate Returns: list[Any]: List of unique values preserving order """ if not values: return [] try: # Fast path for hashable types seen: set[Any] = set() unique_values: List[Any] = [] for value in values: if value not in seen: unique_values.append(value) seen.add(value) except TypeError: # Fallback for unhashable types (e.g., dicts from JSONB) unique_values = [] for value in values: if value not in unique_values: unique_values.append(value) return unique_values @staticmethod def _get_error_messages( error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, default_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, ) -> Optional[ErrorMessages]: if error_messages == Empty: error_messages = None if default_messages == Empty: default_messages = None messages = cast("ErrorMessages", dict(DEFAULT_ERROR_MESSAGE_TEMPLATES)) if default_messages and isinstance(default_messages, dict): messages.update(default_messages) if error_messages: messages.update(cast("ErrorMessages", error_messages)) # type: ignore[unused-ignore,redundant-cast] return messages @classmethod def get_id_attribute_value( cls, item: Union[ModelT, type[ModelT]], id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]] = None, ) -> Any: """Get value of attribute named as :attr:`id_attribute` on ``item``. Args: item: Anything that should have an attribute named as :attr:`id_attribute` value. id_attribute: Allows customization of the unique identifier to use for model fetching. Defaults to `None`, but can reference any surrogate or candidate key for the table. Returns: The value of attribute on ``item`` named as :attr:`id_attribute`. """ if isinstance(id_attribute, InstrumentedAttribute): id_attribute = id_attribute.key return getattr(item, id_attribute if id_attribute is not None else cls.id_attribute) @classmethod def set_id_attribute_value( cls, item_id: Any, item: ModelT, id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]] = None, ) -> ModelT: """Return the ``item`` after the ID is set to the appropriate attribute. Args: item_id: Value of ID to be set on instance item: Anything that should have an attribute named as :attr:`id_attribute` value. id_attribute: Allows customization of the unique identifier to use for model fetching. Defaults to `None`, but can reference any surrogate or candidate key for the table. Returns: Item with ``item_id`` set to :attr:`id_attribute` """ if isinstance(id_attribute, InstrumentedAttribute): id_attribute = id_attribute.key setattr(item, id_attribute if id_attribute is not None else cls.id_attribute, item_id) return item @property def pk_attr_names(self) -> tuple[str, ...]: """Get primary key attribute names. Returns: Tuple of ORM attribute names for primary key columns. """ return self._pk_attr_names @property def has_composite_pk(self) -> bool: """Check if model has a composite (multi-column) primary key. Returns: True if the model has 2 or more primary key columns, False otherwise. Examples: >>> repo.has_composite_pk # For model with single PK False >>> repo.has_composite_pk # For model with (user_id, role_id) PK True """ return is_composite_pk(self._pk_columns) def _build_pk_filter(self, pk_value: PrimaryKeyType) -> ColumnElement[bool]: """Build a WHERE clause for primary key lookup. Supports single and composite primary keys with flexible input formats. Args: pk_value: Primary key value(s). - For single PK: scalar value (int, str, UUID, etc.) - For composite PK: tuple of values in column order, or dict mapping attribute names to values Returns: SQLAlchemy WHERE clause expression. Raises: ValueError: If the input format doesn't match the primary key structure. Examples: # Single primary key >>> filter = repo._build_pk_filter(123) >>> # Generates: WHERE id = 123 # Composite primary key (tuple format) >>> filter = repo._build_pk_filter((1, 5)) >>> # Generates: WHERE user_id = 1 AND role_id = 5 # Composite primary key (dict format) >>> filter = repo._build_pk_filter({"user_id": 1, "role_id": 5}) >>> # Generates: WHERE user_id = 1 AND role_id = 5 """ pk_columns = self._pk_columns pk_attr_names = self._pk_attr_names # Fallback for models without mapped primary key (e.g., mock objects) # In this case, use id_attribute for backward compatibility if len(pk_columns) == 0: id_attr = get_instrumented_attr(self.model_type, self.id_attribute) result: ColumnElement[bool] = id_attr == pk_value return result # Single primary key - accept scalar value only if len(pk_columns) == 1: if isinstance(pk_value, tuple): msg = ( f"Model {self.model_type.__name__} has a single primary key column '{pk_attr_names[0]}'. " f"Expected a scalar value, got tuple: {pk_value!r}" ) raise ValueError(msg) if isinstance(pk_value, dict): msg = ( f"Model {self.model_type.__name__} has a single primary key column '{pk_attr_names[0]}'. " f"Expected a scalar value, got dict: {pk_value!r}" ) raise ValueError(msg) single_pk_result: ColumnElement[bool] = pk_columns[0] == pk_value return single_pk_result pk_tuple = validate_composite_pk_value(pk_value, pk_attr_names, self.model_type.__name__) return and_(*[col == val for col, val in zip(pk_columns, pk_tuple)]) def get_primary_key_value(self, instance: ModelT) -> PrimaryKeyType: """Extract the primary key value(s) from a model instance. Args: instance: Model instance to extract primary key from. Returns: - For single PK: scalar value (int, str, UUID, etc.) - For composite PK: tuple of values in column order Examples: # Single primary key >>> user = User(id=123, name="Alice") >>> repo.get_primary_key_value(user) 123 # Composite primary key >>> assignment = UserRole(user_id=1, role_id=5) >>> repo.get_primary_key_value(assignment) (1, 5) """ return extract_pk_value_from_instance(instance, self._pk_attr_names) def has_primary_key_values(self, instance: ModelT) -> bool: """Check if all primary key values are set on an instance. Args: instance: Model instance to check. Returns: True if all PK values are non-None, False otherwise. """ return pk_values_present(instance, self._pk_attr_names) def _normalize_pk_values_to_tuples(self, item_ids: list[PrimaryKeyType]) -> list[tuple[Any, ...]]: """Normalize a list of composite primary key values to tuples. Args: item_ids: List of PK values (dicts or tuples). Returns: List of tuples with values in PK column order. Raises: TypeError: If a value is not a dict or tuple. ValueError: If tuple length doesn't match PK columns, dict is missing keys, or values are None. """ pk_attr_names = self._pk_attr_names model_name = self.model_type.__name__ return [validate_composite_pk_value(pk_value, pk_attr_names, model_name) for pk_value in item_ids] @staticmethod def check_not_found(item_or_none: Optional[ModelT]) -> ModelT: """Raise :exc:`advanced_alchemy.exceptions.NotFoundError` if ``item_or_none`` is ``None``. Args: item_or_none: Item (:class:`T `) to be tested for existence. Raises: NotFoundError: If ``item_or_none`` is ``None`` Returns: The item, if it exists. """ if item_or_none is None: msg = "No item found when one was expected" raise NotFoundError(msg) return item_or_none def _get_execution_options( self, execution_options: Optional[dict[str, Any]] = None, ) -> dict[str, Any]: if execution_options is None: return self._default_execution_options return execution_options def _get_loader_options( self, loader_options: Optional[LoadSpec], ) -> Union[tuple[List[_AbstractLoad], bool], tuple[None, bool]]: if loader_options is None: # use the defaults set at initialization return self._default_loader_options, self._loader_options_have_wildcards or self._uniquify return get_abstract_loader_options( loader_options=loader_options, default_loader_options=self._default_loader_options, default_options_have_wildcards=self._loader_options_have_wildcards or self._uniquify, inherit_lazy_relationships=self.inherit_lazy_relationships, merge_with_default=self.merge_loader_options, ) async def add( self, data: ModelT, *, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, auto_refresh: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, bind_group: Optional[str] = None, ) -> ModelT: """Add ``data`` to the collection. Args: data: Instance to be added to the collection. auto_expunge: Remove object from session before returning. auto_refresh: Refresh object from session before returning. auto_commit: Commit objects before returning. error_messages: An optional dictionary of templates to use for friendlier error messages to clients bind_group: Optional routing group for multi-master configurations. Returns: The added instance. """ _ = bind_group # Reserved for future multi-master routing error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, ) with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): instance = await self._attach_to_session(data) await self._flush_or_commit(auto_commit=auto_commit) await self._refresh(instance, auto_refresh=auto_refresh) self._expunge(instance, auto_expunge=auto_expunge) return instance async def add_many( self, data: List[ModelT], *, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, bind_group: Optional[str] = None, ) -> Sequence[ModelT]: """Add many `data` to the collection. Args: data: list of Instances to be added to the collection. auto_expunge: Remove object from session before returning. auto_commit: Commit objects before returning. error_messages: An optional dictionary of templates to use for friendlier error messages to clients bind_group: Optional routing group for multi-master configurations. Returns: The added instances. """ _ = bind_group # Reserved for future multi-master routing error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, ) with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): self.session.add_all(data) await self._flush_or_commit(auto_commit=auto_commit) for datum in data: self._expunge(datum, auto_expunge=auto_expunge) return data async def delete( self, item_id: PrimaryKeyType, *, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, ) -> ModelT: """Delete instance identified by ``item_id``. Args: item_id: Identifier of instance to be deleted. For single primary keys, pass a scalar value. For composite primary keys, pass a tuple of values in column order or a dict mapping attribute names to values. auto_expunge: Remove object from session before returning. auto_commit: Commit objects before returning. id_attribute: Allows customization of the unique identifier to use for model fetching. Defaults to `id`, but can reference any surrogate or candidate key for the table. Note: Only applies to single-column lookups. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. bind_group: Optional routing group for multi-master configurations. Returns: The deleted instance. Examples: # Single primary key >>> deleted = await user_repo.delete(123) # Composite primary key >>> deleted = await user_role_repo.delete((user_id, role_id)) """ self._uniquify = self._get_uniquify(uniquify) error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, ) with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): resolved_bind_group = self._resolve_bind_group(bind_group) if resolved_bind_group: execution_options = dict(execution_options) if execution_options else {} execution_options["bind_group"] = resolved_bind_group execution_options = self._get_execution_options(execution_options) instance = await self.get( item_id, id_attribute=id_attribute, load=load, execution_options=execution_options, bind_group=bind_group, ) await self.session.delete(instance) await self._flush_or_commit(auto_commit=auto_commit) self._expunge(instance, auto_expunge=auto_expunge) # Queue cache invalidation (processed on commit) self._queue_cache_invalidation(item_id, bind_group) return instance async def delete_many( self, item_ids: List[PrimaryKeyType], *, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]] = None, chunk_size: Optional[int] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, ) -> Sequence[ModelT]: """Delete multiple instances identified by ``item_ids``. Args: item_ids: List of identifiers of instances to be deleted. For single primary keys, pass a list of scalar values. For composite primary keys, pass a list of tuples (values in column order) or a list of dicts (mapping attribute names to values). auto_expunge: Remove objects from session before returning. auto_commit: Commit objects before returning. id_attribute: Allows customization of the unique identifier to use for model fetching. Defaults to `id`, but can reference any surrogate or candidate key for the table. Note: Only applies to single-column lookups. chunk_size: Allows customization of the ``insertmanyvalues_max_parameters`` setting for the driver. Defaults to `950` if left unset. For composite keys, this is automatically divided by the number of PK columns. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. bind_group: Optional routing group for multi-master configurations. Returns: The deleted instances. Examples: # Single primary key >>> deleted = await user_repo.delete_many([1, 2, 3]) # Composite primary key (tuple format) >>> deleted = await user_role_repo.delete_many( ... [ ... (1, 5), ... (1, 6), ... (2, 5), ... ] ... ) # Composite primary key (dict format) >>> deleted = await user_role_repo.delete_many( ... [ ... {"user_id": 1, "role_id": 5}, ... {"user_id": 1, "role_id": 6}, ... ] ... ) """ self._uniquify = self._get_uniquify(uniquify) error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, ) with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): resolved_bind_group = self._resolve_bind_group(bind_group) if resolved_bind_group: execution_options = dict(execution_options) if execution_options else {} execution_options["bind_group"] = resolved_bind_group execution_options = self._get_execution_options(execution_options) loader_options, _loader_options_have_wildcard = self._get_loader_options(load) instances: List[ModelT] = [] # Determine if using composite key path or single column path use_composite_path = id_attribute is None and self.has_composite_pk if use_composite_path: # Composite primary key path using tuple_().in_() # Adjust chunk size for composite keys (divide by number of PK columns) base_chunk_size = self._get_insertmanyvalues_max_parameters(chunk_size) effective_chunk_size = max(1, base_chunk_size // len(self._pk_columns)) normalized_ids = self._normalize_pk_values_to_tuples(item_ids) for idx in range(0, len(normalized_ids), effective_chunk_size): chunk = normalized_ids[idx : min(idx + effective_chunk_size, len(normalized_ids))] pk_filter = ( or_( *[and_(*[col == val for col, val in zip(self._pk_columns, pk_tuple)]) for pk_tuple in chunk] ) if self._dialect.name == "mssql" else tuple_(*self._pk_columns).in_(chunk) ) if self._dialect.delete_executemany_returning: returning_delete_stmt = delete(self.model_type).where(pk_filter).returning(self.model_type) if execution_options: returning_delete_stmt = returning_delete_stmt.execution_options(**execution_options) instances.extend(await self.session.scalars(returning_delete_stmt)) else: # Select first, then delete select_stmt = select(self.model_type).where(pk_filter) if loader_options: select_stmt = select_stmt.options(*loader_options) if execution_options: select_stmt = select_stmt.execution_options(**execution_options) instances.extend(await self.session.scalars(select_stmt)) plain_delete_stmt = delete(self.model_type).where(pk_filter) if execution_options: plain_delete_stmt = plain_delete_stmt.execution_options(**execution_options) await self.session.execute(plain_delete_stmt) else: # Single column path (existing behavior) id_attr = get_instrumented_attr( self.model_type, id_attribute if id_attribute is not None else self.id_attribute, ) if self._prefer_any: chunk_size = len(item_ids) + 1 chunk_size = self._get_insertmanyvalues_max_parameters(chunk_size) for idx in range(0, len(item_ids), chunk_size): chunk = cast("List[Any]", item_ids[idx : min(idx + chunk_size, len(item_ids))]) if self._dialect.delete_executemany_returning: instances.extend( await self.session.scalars( self._get_delete_many_statement( statement_type="delete", model_type=self.model_type, id_attribute=id_attr, id_chunk=chunk, supports_returning=self._dialect.delete_executemany_returning, loader_options=loader_options, execution_options=execution_options, ), ), ) else: instances.extend( await self.session.scalars( self._get_delete_many_statement( statement_type="select", model_type=self.model_type, id_attribute=id_attr, id_chunk=chunk, supports_returning=self._dialect.delete_executemany_returning, loader_options=loader_options, execution_options=execution_options, ), ), ) await self.session.execute( self._get_delete_many_statement( statement_type="delete", model_type=self.model_type, id_attribute=id_attr, id_chunk=chunk, supports_returning=self._dialect.delete_executemany_returning, loader_options=loader_options, execution_options=execution_options, ), ) await self._flush_or_commit(auto_commit=auto_commit) for instance in instances: self._expunge(instance, auto_expunge=auto_expunge) # Queue cache invalidation (processed on commit) # Use get_primary_key_value for composite PK support self._queue_cache_invalidation(self.get_primary_key_value(instance), bind_group) return instances @staticmethod def _get_insertmanyvalues_max_parameters(chunk_size: Optional[int] = None) -> int: return chunk_size if chunk_size is not None else DEFAULT_INSERTMANYVALUES_MAX_PARAMETERS async def delete_where( self, *filters: Union[StatementFilter, ColumnElement[bool]], auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, sanity_check: bool = True, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> Sequence[ModelT]: """Delete instances specified by referenced kwargs and filters. Args: *filters: Types for specific filtering operations. auto_expunge: Remove object from session before returning. auto_commit: Commit objects before returning. error_messages: An optional dictionary of templates to use for friendlier error messages to clients sanity_check: When true, the length of selected instances is compared to the deleted row count load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. bind_group: Optional routing group for multi-master configurations. **kwargs: Arguments to apply to a delete Raises: RepositoryError: If the number of deleted rows does not match the number of selected instances Returns: The deleted instances. """ self._uniquify = self._get_uniquify(uniquify) error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, ) with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): resolved_bind_group = self._resolve_bind_group(bind_group) if resolved_bind_group: execution_options = dict(execution_options) if execution_options else {} execution_options["bind_group"] = resolved_bind_group execution_options = self._get_execution_options(execution_options) loader_options, _loader_options_have_wildcard = self._get_loader_options(load) model_type = self.model_type statement = self._get_base_stmt( statement=delete(model_type), loader_options=loader_options, execution_options=execution_options, ) statement = self._filter_select_by_kwargs(statement=statement, kwargs=kwargs) statement = self._apply_filters(*filters, statement=statement, apply_pagination=False) instances: List[ModelT] = [] if self._dialect.delete_executemany_returning: instances.extend(await self.session.scalars(statement.returning(model_type))) else: instances.extend( await self.list( *filters, load=load, execution_options=execution_options, auto_expunge=auto_expunge, use_cache=False, # Always fetch from DB for delete_where bind_group=bind_group, **kwargs, ), ) result = await self.session.execute(statement) row_count = getattr(result, "rowcount", -2) if sanity_check and row_count >= 0 and len(instances) != row_count: # pyright: ignore # backends will return a -1 if they can't determine impacted rowcount # only compare length of selected instances to results if it's >= 0 await self.session.rollback() raise RepositoryError(detail="Deleted count does not match fetched count. Rollback issued.") await self._flush_or_commit(auto_commit=auto_commit) for instance in instances: self._expunge(instance, auto_expunge=auto_expunge) # Queue cache invalidation (processed on commit) self._queue_cache_invalidation(self.get_primary_key_value(instance), resolved_bind_group) return instances async def exists( self, *filters: Union[StatementFilter, ColumnElement[bool]], error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> bool: """Return true if the object specified by ``kwargs`` exists. Args: *filters: Types for specific filtering operations. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. bind_group: Optional routing group to use for the operation. **kwargs: Identifier of the instance to be retrieved. Returns: True if the instance was found. False if not found.. """ error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, ) existing = await self.count( *filters, load=load, execution_options=execution_options, error_messages=error_messages, bind_group=bind_group, **kwargs, ) return existing > 0 @staticmethod def _get_base_stmt( *, statement: StatementTypeT, loader_options: Optional[List[_AbstractLoad]], execution_options: Optional[dict[str, Any]], ) -> StatementTypeT: """Get base statement with options applied. Args: statement: The select statement to modify loader_options: Options for loading relationships execution_options: Options for statement execution Returns: Modified select statement """ if loader_options: statement = cast("StatementTypeT", statement.options(*loader_options)) if execution_options: statement = cast("StatementTypeT", statement.execution_options(**execution_options)) return statement def _apply_for_update_options( self, statement: Select[tuple[ModelT]], with_for_update: ForUpdateParameter, ) -> Select[tuple[ModelT]]: """Apply FOR UPDATE options to a SELECT statement when requested.""" if with_for_update in (None, False): return statement if with_for_update is True: return statement.with_for_update() if isinstance(with_for_update, ForUpdateArg): with_for_update_kwargs: dict[str, Any] = { "nowait": with_for_update.nowait, "read": with_for_update.read, "skip_locked": with_for_update.skip_locked, "key_share": with_for_update.key_share, } if getattr(with_for_update, "of", None): with_for_update_kwargs["of"] = with_for_update.of return statement.with_for_update(**with_for_update_kwargs) if isinstance(with_for_update, dict): # pyright: ignore return statement.with_for_update(**with_for_update) return statement def _get_delete_many_statement( self, *, model_type: type[ModelT], id_attribute: InstrumentedAttribute[Any], id_chunk: List[Any], supports_returning: bool, statement_type: Literal["delete", "select"] = "delete", loader_options: Optional[List[_AbstractLoad]], execution_options: Optional[dict[str, Any]], ) -> Union[Select[tuple[ModelT]], Delete, ReturningDelete[tuple[ModelT]]]: # Base statement is static statement = self._get_base_stmt( statement=delete(model_type) if statement_type == "delete" else select(model_type), loader_options=loader_options, execution_options=execution_options, ) if execution_options: statement = statement.execution_options(**execution_options) if supports_returning and statement_type != "select": statement = cast("ReturningDelete[tuple[ModelT]]", statement.returning(model_type)) # type: ignore[union-attr,assignment] # pyright: ignore[reportUnknownLambdaType,reportUnknownMemberType,reportAttributeAccessIssue,reportUnknownVariableType] # Use field.in_() if types are incompatible with ANY() or if dialect doesn't prefer ANY() use_in = not self._prefer_any or self._type_must_use_in_instead_of_any(id_chunk, id_attribute.type) if use_in: return statement.where(id_attribute.in_(id_chunk)) # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType] return statement.where(any_(id_chunk) == id_attribute) # type: ignore[arg-type] async def _get_from_db( self, item_id: Any, *, auto_expunge: Optional[bool], statement: Optional[Select[tuple[ModelT]]], id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]], error_messages: Optional[ErrorMessages], load: Optional[LoadSpec], execution_options: Optional[dict[str, Any]], with_for_update: ForUpdateParameter, bind_group: Optional[str] = None, ) -> ModelT: """Fetch an entity from the database without using cache.""" with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): resolved_bind_group = self._resolve_bind_group(bind_group) if resolved_bind_group: execution_options = dict(execution_options) if execution_options else {} execution_options["bind_group"] = resolved_bind_group resolved_execution_options = self._get_execution_options(execution_options) resolved_statement = self.statement if statement is None else statement loader_options, loader_options_have_wildcard = self._get_loader_options(load) resolved_statement = self._get_base_stmt( statement=resolved_statement, loader_options=loader_options, execution_options=resolved_execution_options, ) # Default: use primary key (handles both single and composite PKs) if id_attribute is None: resolved_statement = resolved_statement.where(self._build_pk_filter(item_id)) else: # Custom id_attribute override: lookup by user-specified column resolved_statement = self._filter_select_by_kwargs(resolved_statement, [(id_attribute, item_id)]) resolved_statement = self._apply_for_update_options(resolved_statement, with_for_update) instance = ( await self._execute(resolved_statement, uniquify=loader_options_have_wildcard) ).scalar_one_or_none() instance = self.check_not_found(instance) self._expunge(instance, auto_expunge=auto_expunge) return instance async def _get_cached_creator( self, model_name: str, item_id: Any, *, auto_expunge: Optional[bool], statement: Optional[Select[tuple[ModelT]]], id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]], error_messages: Optional[ErrorMessages], load: Optional[LoadSpec], execution_options: Optional[dict[str, Any]], with_for_update: ForUpdateParameter, bind_group: Optional[str] = None, ) -> ModelT: """Singleflight creator for get(id) caching (async).""" if self._cache_manager is None: return await self._get_from_db( item_id, auto_expunge=auto_expunge, statement=statement, id_attribute=id_attribute, error_messages=error_messages, load=load, execution_options=execution_options, with_for_update=with_for_update, bind_group=bind_group, ) existing = await self._cache_manager.get_entity_async( model_name, item_id, self.model_type, bind_group=bind_group ) if existing is not None: return existing instance = await self._get_from_db( item_id, auto_expunge=auto_expunge, statement=statement, id_attribute=id_attribute, error_messages=error_messages, load=load, execution_options=execution_options, with_for_update=with_for_update, bind_group=bind_group, ) await self._cache_manager.set_entity_async(model_name, item_id, instance, bind_group=bind_group) return instance async def _list_from_db( self, *, filters: Sequence[Union[StatementFilter, ColumnElement[bool]]], auto_expunge: Optional[bool], statement: Optional[Select[tuple[ModelT]]], order_by: Optional[Union[List[OrderingPair], OrderingPair]], error_messages: Optional[ErrorMessages], load: Optional[LoadSpec], execution_options: Optional[dict[str, Any]], kwargs: dict[str, Any], uniquify: Optional[bool], bind_group: Optional[str] = None, ) -> List[ModelT]: """Fetch a list of entities from the database without using cache.""" self._uniquify = self._get_uniquify(uniquify) with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): resolved_bind_group = self._resolve_bind_group(bind_group) if resolved_bind_group: execution_options = dict(execution_options) if execution_options else {} execution_options["bind_group"] = resolved_bind_group resolved_execution_options = self._get_execution_options(execution_options) resolved_statement = self.statement if statement is None else statement loader_options, loader_options_have_wildcard = self._get_loader_options(load) resolved_statement = self._get_base_stmt( statement=resolved_statement, loader_options=loader_options, execution_options=resolved_execution_options, ) if order_by is None: order_by = self.order_by if self.order_by is not None else [] resolved_statement = self._apply_order_by(statement=resolved_statement, order_by=order_by) resolved_statement = self._apply_filters(*filters, statement=resolved_statement) resolved_statement = self._filter_select_by_kwargs(resolved_statement, kwargs) result = await self._execute(resolved_statement, uniquify=loader_options_have_wildcard) instances = list(result.scalars()) for instance in instances: self._expunge(instance, auto_expunge=auto_expunge) return cast("List[ModelT]", instances) async def _list_cached_creator( self, cache_key: str, *, filters: Sequence[Union[StatementFilter, ColumnElement[bool]]], auto_expunge: Optional[bool], statement: Optional[Select[tuple[ModelT]]], order_by: Optional[Union[List[OrderingPair], OrderingPair]], error_messages: Optional[ErrorMessages], load: Optional[LoadSpec], execution_options: Optional[dict[str, Any]], kwargs: dict[str, Any], uniquify: Optional[bool], bind_group: Optional[str] = None, ) -> List[ModelT]: """Singleflight creator for list caching (async).""" if self._cache_manager is None: return await self._list_from_db( filters=filters, auto_expunge=auto_expunge, statement=statement, order_by=order_by, error_messages=error_messages, load=load, execution_options=execution_options, kwargs=kwargs, uniquify=uniquify, bind_group=bind_group, ) existing = await self._cache_manager.get_list_async(cache_key, self.model_type) if existing is not None: return existing instances = await self._list_from_db( filters=filters, auto_expunge=auto_expunge, statement=statement, order_by=order_by, error_messages=error_messages, load=load, execution_options=execution_options, kwargs=kwargs, uniquify=uniquify, bind_group=bind_group, ) await self._cache_manager.set_list_async(cache_key, list(instances)) return list(instances) async def _list_and_count_from_db( self, *, filters: Sequence[Union[StatementFilter, ColumnElement[bool]]], auto_expunge: Optional[bool], statement: Optional[Select[tuple[ModelT]]], count_with_window_function: bool, order_by: Optional[Union[List[OrderingPair], OrderingPair]], error_messages: Optional[ErrorMessages], load: Optional[LoadSpec], execution_options: Optional[dict[str, Any]], kwargs: dict[str, Any], uniquify: Optional[bool], bind_group: Optional[str] = None, ) -> tuple[List[ModelT], int]: """Fetch a list+count payload from the database without using cache.""" self._uniquify = self._get_uniquify(uniquify) resolved_bind_group = self._resolve_bind_group(bind_group) if resolved_bind_group: execution_options = dict(execution_options) if execution_options else {} execution_options["bind_group"] = resolved_bind_group if self._dialect.name in {"spanner", "spanner+spanner"} or not count_with_window_function: return await self._list_and_count_basic( *filters, auto_expunge=auto_expunge, statement=statement, load=load, execution_options=execution_options, order_by=order_by, error_messages=error_messages, **kwargs, ) return await self._list_and_count_window( *filters, auto_expunge=auto_expunge, statement=statement, load=load, execution_options=execution_options, error_messages=error_messages, order_by=order_by, **kwargs, ) async def _list_and_count_cached_creator( self, cache_key: str, *, filters: Sequence[Union[StatementFilter, ColumnElement[bool]]], auto_expunge: Optional[bool], statement: Optional[Select[tuple[ModelT]]], count_with_window_function: bool, order_by: Optional[Union[List[OrderingPair], OrderingPair]], error_messages: Optional[ErrorMessages], load: Optional[LoadSpec], execution_options: Optional[dict[str, Any]], kwargs: dict[str, Any], uniquify: Optional[bool], bind_group: Optional[str] = None, ) -> tuple[List[ModelT], int]: """Singleflight creator for list_and_count caching (async).""" if self._cache_manager is None: return await self._list_and_count_from_db( filters=filters, auto_expunge=auto_expunge, statement=statement, count_with_window_function=count_with_window_function, order_by=order_by, error_messages=error_messages, load=load, execution_options=execution_options, kwargs=kwargs, uniquify=uniquify, bind_group=bind_group, ) existing = await self._cache_manager.get_list_and_count_async(cache_key, self.model_type) if existing is not None: return existing instances, count = await self._list_and_count_from_db( filters=filters, auto_expunge=auto_expunge, statement=statement, count_with_window_function=count_with_window_function, order_by=order_by, error_messages=error_messages, load=load, execution_options=execution_options, kwargs=kwargs, uniquify=uniquify, bind_group=bind_group, ) await self._cache_manager.set_list_and_count_async(cache_key, list(instances), count) return list(instances), count async def get( self, item_id: PrimaryKeyType, *, auto_expunge: Optional[bool] = None, statement: Optional[Select[tuple[ModelT]]] = None, id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, with_for_update: ForUpdateParameter = None, use_cache: bool = True, bind_group: Optional[str] = None, ) -> ModelT: """Get instance identified by `item_id`. Args: item_id: Identifier of the instance to be retrieved. For single primary keys, pass a scalar value (int, str, UUID, etc.). For composite primary keys, pass a tuple of values in column order or a dict mapping attribute names to values. auto_expunge: Remove object from session before returning. statement: To facilitate customization of the underlying select query. id_attribute: Allows customization of the unique identifier to use for model fetching. Defaults to `id`, but can reference any surrogate or candidate key for the table. Note: Only applies to single-column lookups. For composite primary keys, this parameter is ignored and the primary key columns are used automatically. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. with_for_update: Optional FOR UPDATE clause / parameters to apply to the SELECT statement. use_cache: Whether to use caching for this query (default True). bind_group: Optional routing group to use for the operation. Returns: The retrieved instance. Examples: # Single primary key >>> user = await user_repo.get(123) # Composite primary key (tuple format) >>> assignment = await user_role_repo.get((user_id, role_id)) # Composite primary key (dict format) >>> assignment = await user_role_repo.get( ... { ... "user_id": 1, ... "role_id": 5, ... } ... ) Raises: NotFoundError: If no instance is found with the given primary key. ValueError: If the input format doesn't match the primary key structure. """ self._uniquify = self._get_uniquify(uniquify) resolved_error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, ) resolved_auto_expunge = self.auto_expunge if auto_expunge is None else auto_expunge resolved_id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]] = id_attribute if isinstance(resolved_id_attribute, InstrumentedAttribute): resolved_id_attribute = resolved_id_attribute.key cache_manager = self._cache_manager # Resolve bind_group for cache key namespacing resolved_bind_group = self._resolve_bind_group(bind_group) if ( use_cache and cache_manager is not None and bool(resolved_auto_expunge) and statement is None and load is None and with_for_update is None and (resolved_id_attribute is None or resolved_id_attribute == self.id_attribute) and not self._default_loader_options and not self._default_execution_options and execution_options is None ): model_name = cast("str", self.model_type.__tablename__) # type: ignore[attr-defined] cached = await cache_manager.get_entity_async( model_name, item_id, self.model_type, bind_group=resolved_bind_group ) if cached is not None: return cached # Include bind_group in singleflight key to prevent cross-shard cache pollution singleflight_key = ( f"{model_name}:{resolved_bind_group}:get:{item_id}" if resolved_bind_group else f"{model_name}:get:{item_id}" ) return await cache_manager.singleflight_async( singleflight_key, partial( self._get_cached_creator, model_name, item_id, auto_expunge=auto_expunge, statement=statement, id_attribute=resolved_id_attribute, error_messages=resolved_error_messages, load=load, execution_options=execution_options, with_for_update=with_for_update, bind_group=resolved_bind_group, ), ) return await self._get_from_db( item_id, auto_expunge=auto_expunge, statement=statement, id_attribute=id_attribute, error_messages=resolved_error_messages, load=load, execution_options=execution_options, with_for_update=with_for_update, bind_group=bind_group, ) async def get_one( self, *filters: Union[StatementFilter, ColumnElement[bool]], auto_expunge: Optional[bool] = None, statement: Optional[Select[tuple[ModelT]]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, with_for_update: ForUpdateParameter = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> ModelT: """Get instance identified by ``kwargs``. Args: *filters: Types for specific filtering operations. auto_expunge: Remove object from session before returning. statement: To facilitate customization of the underlying select query. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. with_for_update: Optional FOR UPDATE clause / parameters to apply to the SELECT statement. bind_group: Optional routing group to use for the operation. **kwargs: Identifier of the instance to be retrieved. Returns: The retrieved instance. """ self._uniquify = self._get_uniquify(uniquify) error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, ) with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): if bind_group: execution_options = dict(execution_options) if execution_options else {} execution_options["bind_group"] = bind_group execution_options = self._get_execution_options(execution_options) statement = self.statement if statement is None else statement loader_options, loader_options_have_wildcard = self._get_loader_options(load) statement = self._get_base_stmt( statement=statement, loader_options=loader_options, execution_options=execution_options, ) statement = self._apply_filters(*filters, apply_pagination=False, statement=statement) statement = self._filter_select_by_kwargs(statement, kwargs) statement = self._apply_for_update_options(statement, with_for_update) instance = (await self._execute(statement, uniquify=loader_options_have_wildcard)).scalar_one_or_none() instance = self.check_not_found(instance) self._expunge(instance, auto_expunge=auto_expunge) return instance async def get_one_or_none( self, *filters: Union[StatementFilter, ColumnElement[bool]], auto_expunge: Optional[bool] = None, statement: Optional[Select[tuple[ModelT]]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, with_for_update: ForUpdateParameter = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> Union[ModelT, None]: """Get instance identified by ``kwargs`` or None if not found. Args: *filters: Types for specific filtering operations. auto_expunge: Remove object from session before returning. statement: To facilitate customization of the underlying select query. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. with_for_update: Optional FOR UPDATE clause / parameters to apply to the SELECT statement. bind_group: Optional routing group to use for the operation. **kwargs: Identifier of the instance to be retrieved. Returns: The retrieved instance or None """ self._uniquify = self._get_uniquify(uniquify) error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, ) with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): if bind_group: execution_options = dict(execution_options) if execution_options else {} execution_options["bind_group"] = bind_group execution_options = self._get_execution_options(execution_options) statement = self.statement if statement is None else statement loader_options, loader_options_have_wildcard = self._get_loader_options(load) statement = self._get_base_stmt( statement=statement, loader_options=loader_options, execution_options=execution_options, ) statement = self._apply_filters(*filters, apply_pagination=False, statement=statement) statement = self._filter_select_by_kwargs(statement, kwargs) statement = self._apply_for_update_options(statement, with_for_update) instance = cast( "Result[tuple[ModelT]]", (await self._execute(statement, uniquify=loader_options_have_wildcard)), ).scalar_one_or_none() if instance: self._expunge(instance, auto_expunge=auto_expunge) return instance async def get_or_upsert( self, *filters: Union[StatementFilter, ColumnElement[bool]], match_fields: Optional[Union[List[str], str]] = None, upsert: bool = True, attribute_names: Optional[Iterable[str]] = None, with_for_update: ForUpdateParameter = None, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, auto_refresh: Union[bool, None] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[ModelT, bool]: """Get instance identified by ``kwargs`` or create if it doesn't exist. Args: *filters: Types for specific filtering operations. match_fields: a list of keys to use to match the existing model. When empty, all fields are matched. upsert: When using match_fields and actual model values differ from `kwargs`, automatically perform an update operation on the model. attribute_names: an iterable of attribute names to pass into the ``update`` method. with_for_update: indicating FOR UPDATE should be used, or may be a dictionary containing flags to indicate a more specific set of FOR UPDATE flags for the SELECT auto_expunge: Remove object from session before returning. auto_refresh: Refresh object from session before returning. auto_commit: Commit objects before returning. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. bind_group: Optional routing group for multi-master configurations. **kwargs: Identifier of the instance to be retrieved. Returns: a tuple that includes the instance and whether it needed to be created. When using match_fields and actual model values differ from ``kwargs``, the model value will be updated. """ self._uniquify = self._get_uniquify(uniquify) error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, ) with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): if match_fields := self._get_match_fields(match_fields=match_fields): match_filter = { field_name: kwargs.get(field_name) for field_name in match_fields if kwargs.get(field_name) is not None } else: match_filter = kwargs existing = await self.get_one_or_none( *filters, **match_filter, load=load, execution_options=execution_options, bind_group=bind_group, ) if not existing: return ( await self.add( self.model_type(**kwargs), auto_commit=auto_commit, auto_expunge=auto_expunge, auto_refresh=auto_refresh, bind_group=bind_group, ), True, ) if upsert: for field_name, new_field_value in kwargs.items(): field = getattr(existing, field_name, MISSING) if field is not MISSING and not compare_values(field, new_field_value): # pragma: no cover setattr(existing, field_name, new_field_value) existing = await self._attach_to_session(existing, strategy="merge") await self._flush_or_commit(auto_commit=auto_commit) await self._refresh( existing, attribute_names=attribute_names, with_for_update=with_for_update, auto_refresh=auto_refresh, ) self._expunge(existing, auto_expunge=auto_expunge) return existing, False async def get_and_update( self, *filters: Union[StatementFilter, ColumnElement[bool]], match_fields: Optional[Union[List[str], str]] = None, attribute_names: Optional[Iterable[str]] = None, with_for_update: ForUpdateParameter = None, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, auto_refresh: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[ModelT, bool]: """Get instance identified by ``kwargs`` and update the model if the arguments are different. Args: *filters: Types for specific filtering operations. match_fields: a list of keys to use to match the existing model. When empty, all fields are matched. attribute_names: an iterable of attribute names to pass into the ``update`` method. with_for_update: indicating FOR UPDATE should be used, or may be a dictionary containing flags to indicate a more specific set of FOR UPDATE flags for the SELECT auto_expunge: Remove object from session before returning. auto_refresh: Refresh object from session before returning. auto_commit: Commit objects before returning. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. bind_group: Optional routing group for multi-master configurations. **kwargs: Identifier of the instance to be retrieved. Returns: a tuple that includes the instance and whether it needed to be updated. When using match_fields and actual model values differ from ``kwargs``, the model value will be updated. """ self._uniquify = self._get_uniquify(uniquify) error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, ) with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): if match_fields := self._get_match_fields(match_fields=match_fields): match_filter = { field_name: kwargs.get(field_name) for field_name in match_fields if kwargs.get(field_name) is not None } else: match_filter = kwargs existing = await self.get_one( *filters, **match_filter, load=load, execution_options=execution_options, bind_group=bind_group ) updated = False for field_name, new_field_value in kwargs.items(): field = getattr(existing, field_name, MISSING) if field is not MISSING and not compare_values(field, new_field_value): # pragma: no cover updated = True setattr(existing, field_name, new_field_value) existing = await self._attach_to_session(existing, strategy="merge") await self._flush_or_commit(auto_commit=auto_commit) await self._refresh( existing, attribute_names=attribute_names, with_for_update=with_for_update, auto_refresh=auto_refresh, ) self._expunge(existing, auto_expunge=auto_expunge) return existing, updated async def count( self, *filters: Union[StatementFilter, ColumnElement[bool]], statement: Optional[Select[tuple[ModelT]]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> int: """Get the count of records returned by a query. Args: *filters: Types for specific filtering operations. statement: To facilitate customization of the underlying select query. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. bind_group: Optional routing group to use for the operation. **kwargs: Instance attribute value filters. Returns: Count of records returned by query, ignoring pagination. """ self._uniquify = self._get_uniquify(uniquify) error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, ) with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): if bind_group: execution_options = dict(execution_options) if execution_options else {} execution_options["bind_group"] = bind_group execution_options = self._get_execution_options(execution_options) statement = self.statement if statement is None else statement loader_options, loader_options_have_wildcard = self._get_loader_options(load) statement = self._get_base_stmt( statement=statement, loader_options=loader_options, execution_options=execution_options, ) statement = self._apply_filters(*filters, apply_pagination=False, statement=statement) statement = self._filter_select_by_kwargs(statement, kwargs) results = await self._execute( statement=self._get_count_stmt( statement=statement, loader_options=loader_options, execution_options=execution_options ), uniquify=loader_options_have_wildcard, ) return cast("int", results.scalar_one()) async def update( self, data: ModelT, *, attribute_names: Optional[Iterable[str]] = None, with_for_update: ForUpdateParameter = None, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, auto_refresh: Optional[bool] = None, id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, ) -> ModelT: """Update instance with the attribute values present on `data`. Args: data: An instance that should have a value for `self.id_attribute` that exists in the collection. attribute_names: an iterable of attribute names to pass into the ``update`` method. with_for_update: indicating FOR UPDATE should be used, or may be a dictionary containing flags to indicate a more specific set of FOR UPDATE flags for the SELECT auto_expunge: Remove object from session before returning. auto_refresh: Refresh object from session before returning. auto_commit: Commit objects before returning. id_attribute: Allows customization of the unique identifier to use for model fetching. Defaults to `id`, but can reference any surrogate or candidate key for the table. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. bind_group: Optional routing group for multi-master configurations. Returns: The updated instance. """ self._uniquify = self._get_uniquify(uniquify) error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, ) with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): # For composite PKs (when no id_attribute override), extract the full PK value if id_attribute is None and self.has_composite_pk: item_id = self.get_primary_key_value(data) else: item_id = self.get_id_attribute_value( data, id_attribute=id_attribute, ) existing_instance = await self.get( item_id, id_attribute=id_attribute, load=load, execution_options=execution_options, with_for_update=with_for_update, bind_group=bind_group, ) mapper = None with ( self.session.no_autoflush, contextlib.suppress(MissingGreenlet, NoInspectionAvailable), ): mapper = inspect(data) if mapper is not None: for column in mapper.mapper.columns: field_name = column.key new_field_value = getattr(data, field_name, MISSING) if new_field_value is not MISSING: # Skip setting columns with defaults/onupdate to None during updates # This prevents overwriting columns that should use their defaults if new_field_value is None and column_has_defaults(column): continue # Only copy attributes that were explicitly set on the input instance # This prevents overwriting existing values with uninitialized None values if not was_attribute_set(data, mapper, field_name): continue existing_field_value = getattr(existing_instance, field_name, MISSING) if existing_field_value is not MISSING and not compare_values( existing_field_value, new_field_value ): setattr(existing_instance, field_name, new_field_value) # Handle relationships by merging objects into session first for relationship in mapper.mapper.relationships: if relationship.viewonly or relationship.lazy in { # pragma: no cover "write_only", "dynamic", "raise", "raise_on_sql", }: # Skip relationships with incompatible lazy loading strategies continue # Only copy relationships that were explicitly set on the input instance # This prevents overwriting existing relationships with uninitialized # None/[] values from SQLAlchemy's auto-initialization if not was_attribute_set(data, mapper, relationship.key): continue if (new_value := getattr(data, relationship.key, MISSING)) is not MISSING: # Skip relationships that cannot be handled by generic merge operations if isinstance(new_value, list): merged_values = [ # pyright: ignore await self.session.merge(item, load=False) # pyright: ignore for item in new_value # pyright: ignore ] setattr(existing_instance, relationship.key, merged_values) elif new_value is not None: merged_value = await self.session.merge(new_value, load=False) setattr(existing_instance, relationship.key, merged_value) else: setattr(existing_instance, relationship.key, new_value) instance = await self._attach_to_session(existing_instance, strategy="merge") await self._flush_or_commit(auto_commit=auto_commit) await self._refresh( instance, attribute_names=attribute_names, with_for_update=with_for_update, auto_refresh=auto_refresh, ) self._expunge(instance, auto_expunge=auto_expunge) # Queue cache invalidation (processed on commit) self._queue_cache_invalidation(self.get_primary_key_value(instance), bind_group) return instance async def update_many( self, data: List[ModelT], *, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, ) -> List[ModelT]: """Update one or more instances with the attribute values present on `data`. This function has an optimized bulk update based on the configured SQL dialect: - For backends supporting `RETURNING` with `executemany`, a single bulk update with returning clause is executed. - For other backends, it does a bulk update and then returns the updated data after a refresh. Args: data: A list of instances to update. Each should have a value for `self.id_attribute` that exists in the collection. auto_expunge: Remove object from session before returning. auto_commit: Commit objects before returning. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. bind_group: Optional routing group for multi-master configurations. Returns: The updated instances. """ self._uniquify = self._get_uniquify(uniquify) error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, ) supports_updated_at = hasattr(self.model_type, "updated_at") data_to_update: List[dict[str, Any]] = [] for v in data: update_payload = model_to_dict(v) if hasattr(v, "__mapper__") else schema_dump(cast("dict[str, Any]", v)) if supports_updated_at and (update_payload.get("updated_at") is None): update_payload["updated_at"] = datetime.datetime.now(datetime.timezone.utc) data_to_update.append(update_payload) with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): resolved_bind_group = self._resolve_bind_group(bind_group) if resolved_bind_group: execution_options = dict(execution_options) if execution_options else {} execution_options["bind_group"] = resolved_bind_group execution_options = self._get_execution_options(execution_options) loader_options = self._get_loader_options(load)[0] supports_returning = self._dialect.update_executemany_returning and self._dialect.name != "oracle" statement = self._get_update_many_statement( self.model_type, supports_returning, loader_options=loader_options, execution_options=execution_options, ) if supports_returning: instances = list( await self.session.scalars( statement, cast("_CoreSingleExecuteParams", data_to_update), # this is not correct but the only way # currently to deal with an SQLAlchemy typing issue. See # https://github.com/sqlalchemy/sqlalchemy/discussions/9925 execution_options=execution_options, ), ) await self._flush_or_commit(auto_commit=auto_commit) for instance in instances: self._expunge(instance, auto_expunge=auto_expunge) return instances await self.session.execute(statement, data_to_update, execution_options=execution_options) await self._flush_or_commit(auto_commit=auto_commit) # For non-RETURNING backends, fetch updated instances from database if self.has_composite_pk: # Build composite PK filter using OR of AND conditions pk_filters: List[ColumnElement[bool]] = [] for item in data_to_update: pk_tuple = tuple(item[attr] for attr in self._pk_attr_names) pk_filters.append(and_(*[col == val for col, val in zip(self._pk_columns, pk_tuple)])) updated_instances = await self.list( or_(*pk_filters), load=loader_options, execution_options=execution_options, bind_group=bind_group, ) else: updated_ids: List[Any] = [item[self.id_attribute] for item in data_to_update] updated_instances = await self.list( getattr(self.model_type, self.id_attribute).in_(updated_ids), load=loader_options, execution_options=execution_options, bind_group=bind_group, ) for instance in updated_instances: self._expunge(instance, auto_expunge=auto_expunge) # Queue cache invalidation (processed on commit) self._queue_cache_invalidation(self.get_primary_key_value(instance), bind_group) return updated_instances def _get_update_many_statement( self, model_type: type[ModelT], supports_returning: bool, loader_options: Union[List[_AbstractLoad], None], execution_options: Union[dict[str, Any], None], ) -> Union[Update, ReturningUpdate[tuple[ModelT]]]: # Base update statement is static statement = self._get_base_stmt( statement=update(table=model_type), loader_options=loader_options, execution_options=execution_options ) if supports_returning: return statement.returning(model_type) return statement async def list_and_count( self, *filters: Union[StatementFilter, ColumnElement[bool]], statement: Optional[Select[tuple[ModelT]]] = None, auto_expunge: Optional[bool] = None, count_with_window_function: Optional[bool] = None, order_by: Optional[Union[List[OrderingPair], OrderingPair]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, use_cache: bool = True, bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[List[ModelT], int]: """List records with total count. Args: *filters: Types for specific filtering operations. statement: To facilitate customization of the underlying select query. auto_expunge: Remove object from session before returning. count_with_window_function: When false, list and count will use two queries instead of an analytical window function. order_by: Set default order options for queries. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. use_cache: Whether to use the cache for this query. Defaults to ``True``. bind_group: Optional routing group to use for the operation. **kwargs: Instance attribute value filters. Returns: Count of records returned by query, ignoring pagination. """ count_with_window_function = ( count_with_window_function if count_with_window_function is not None else self.count_with_window_function ) self._uniquify = self._get_uniquify(uniquify) resolved_error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, ) resolved_auto_expunge = self.auto_expunge if auto_expunge is None else auto_expunge resolved_execution_options = self._get_execution_options(execution_options) resolved_order_by = order_by if order_by is not None else (self.order_by if self.order_by is not None else []) cache_manager = self._cache_manager if not ( use_cache and bool(resolved_auto_expunge) and cache_manager is not None and statement is None and load is None and not self._default_loader_options ): return await self._list_and_count_from_db( filters=filters, auto_expunge=auto_expunge, statement=statement, count_with_window_function=count_with_window_function, order_by=order_by, error_messages=resolved_error_messages, load=load, execution_options=execution_options, kwargs=kwargs, uniquify=uniquify, bind_group=bind_group, ) model_name = cast("str", self.model_type.__tablename__) # type: ignore[attr-defined] version_token = await cache_manager.get_model_version_async(model_name) cache_key = _build_list_cache_key( model_name=model_name, version_token=version_token, method="list_and_count", filters=filters, kwargs=kwargs, order_by=resolved_order_by, execution_options=resolved_execution_options, uniquify=self._uniquify, count_with_window_function=count_with_window_function, ) if cache_key is None: return await self._list_and_count_from_db( filters=filters, auto_expunge=auto_expunge, statement=statement, count_with_window_function=count_with_window_function, order_by=order_by, error_messages=resolved_error_messages, load=load, execution_options=execution_options, kwargs=kwargs, uniquify=uniquify, bind_group=bind_group, ) cached = await cache_manager.get_list_and_count_async(cache_key, self.model_type) if cached is not None: return cached return await cache_manager.singleflight_async( cache_key, partial( self._list_and_count_cached_creator, cache_key, filters=filters, auto_expunge=auto_expunge, statement=statement, count_with_window_function=count_with_window_function, order_by=order_by, error_messages=resolved_error_messages, load=load, execution_options=execution_options, kwargs=kwargs, uniquify=uniquify, bind_group=bind_group, ), ) def _expunge(self, instance: "ModelT", auto_expunge: "Optional[bool]") -> None: """Remove instance from session if auto_expunge is enabled. Args: instance: The model instance to expunge auto_expunge: Whether to expunge the instance. If None, uses self.auto_expunge Note: Deleted objects that have been committed are automatically moved to the detached state by SQLAlchemy. Objects returned from DELETE...RETURNING statements are initially persistent but become detached after commit. We skip expunge for objects that are already detached or marked for deletion to avoid InvalidRequestError. """ if auto_expunge is None: auto_expunge = self.auto_expunge if not auto_expunge: return # Check object state before expunging state = inspect(instance) if state is not None and (state.deleted or state.detached): # Skip expunge for objects that are deleted or already detached # - state.deleted: Object marked for deletion, will be detached on commit # - state.detached: Object already removed from session (e.g., from DELETE...RETURNING) return self.session.expunge(instance) return async def _flush_or_commit(self, auto_commit: Optional[bool]) -> None: if auto_commit is None: auto_commit = self.auto_commit return await self.session.commit() if auto_commit else await self.session.flush() async def _refresh( self, instance: ModelT, auto_refresh: Optional[bool], attribute_names: Optional[Iterable[str]] = None, with_for_update: ForUpdateParameter = None, ) -> None: if auto_refresh is None: auto_refresh = self.auto_refresh return ( await self.session.refresh( instance=instance, attribute_names=attribute_names, with_for_update=with_for_update, ) if auto_refresh else None ) async def _list_and_count_window( self, *filters: Union[StatementFilter, ColumnElement[bool]], auto_expunge: Optional[bool] = None, statement: Optional[Select[tuple[ModelT]]] = None, order_by: Optional[Union[List[OrderingPair], OrderingPair]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[List[ModelT], int]: """List records with total count. Args: *filters: Types for specific filtering operations. auto_expunge: Remove object from session before returning. statement: To facilitate customization of the underlying select query. order_by: List[OrderingPair] | OrderingPair | None = None, error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set relationships to be loaded execution_options: Set default execution options bind_group: Optional routing group to use for the operation. **kwargs: Instance attribute value filters. Returns: Count of records returned by query using an analytical window function, ignoring pagination. """ error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, ) with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): if bind_group: execution_options = dict(execution_options) if execution_options else {} execution_options["bind_group"] = bind_group execution_options = self._get_execution_options(execution_options) statement = self.statement if statement is None else statement loader_options, loader_options_have_wildcard = self._get_loader_options(load) statement = self._get_base_stmt( statement=statement, loader_options=loader_options, execution_options=execution_options, ) if order_by is None: order_by = self.order_by if self.order_by is not None else [] statement = self._apply_order_by(statement=statement, order_by=order_by) statement = self._apply_filters(*filters, statement=statement) statement = self._filter_select_by_kwargs(statement, kwargs) result = await self._execute( statement.add_columns(over(sql_func.count())), uniquify=loader_options_have_wildcard ) count: int = 0 instances: List[ModelT] = [] for i, (instance, count_value) in enumerate(result): self._expunge(instance, auto_expunge=auto_expunge) instances.append(instance) if i == 0: count = count_value return instances, count async def _list_and_count_basic( self, *filters: Union[StatementFilter, ColumnElement[bool]], auto_expunge: Optional[bool] = None, statement: Optional[Select[tuple[ModelT]]] = None, order_by: Optional[Union[List[OrderingPair], OrderingPair]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[List[ModelT], int]: """List records with total count. Args: *filters: Types for specific filtering operations. auto_expunge: Remove object from session before returning. statement: To facilitate customization of the underlying select query. order_by: Set default order options for queries. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set relationships to be loaded execution_options: Set default execution options bind_group: Optional routing group to use for the operation. **kwargs: Instance attribute value filters. Returns: Count of records returned by query using 2 queries, ignoring pagination. """ error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, ) with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): if bind_group: execution_options = dict(execution_options) if execution_options else {} execution_options["bind_group"] = bind_group execution_options = self._get_execution_options(execution_options) statement = self.statement if statement is None else statement loader_options, loader_options_have_wildcard = self._get_loader_options(load) statement = self._get_base_stmt( statement=statement, loader_options=loader_options, execution_options=execution_options, ) if order_by is None: order_by = self.order_by if self.order_by is not None else [] statement = self._apply_order_by(statement=statement, order_by=order_by) statement = self._apply_filters(*filters, statement=statement) statement = self._filter_select_by_kwargs(statement, kwargs) count_result = await self.session.execute( self._get_count_stmt( statement, loader_options=loader_options, execution_options=execution_options, ), ) count = count_result.scalar_one() if count == 0: return [], 0 result = await self._execute(statement, uniquify=loader_options_have_wildcard) instances: List[ModelT] = [] for (instance,) in result: self._expunge(instance, auto_expunge=auto_expunge) instances.append(instance) return instances, count @staticmethod def _get_count_stmt( statement: Select[tuple[ModelT]], loader_options: Optional[List[_AbstractLoad]], # noqa: ARG004 execution_options: Optional[dict[str, Any]], # noqa: ARG004 ) -> Select[tuple[int]]: # Count statement transformations are static return ( statement.with_only_columns(sql_func.count(text("1")), maintain_column_froms=True) .limit(None) .offset(None) .order_by(None) ) async def upsert( self, data: ModelT, *, attribute_names: Optional[Iterable[str]] = None, with_for_update: ForUpdateParameter = None, auto_expunge: Optional[bool] = None, auto_commit: Optional[bool] = None, auto_refresh: Optional[bool] = None, match_fields: Optional[Union[List[str], str]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, ) -> ModelT: """Modify or create instance. Updates instance with the attribute values present on `data`, or creates a new instance if one doesn't exist. Args: data: Instance to update existing, or be created. Identifier used to determine if an existing instance exists is the value of an attribute on `data` named as value of `self.id_attribute`. attribute_names: an iterable of attribute names to pass into the ``update`` method. with_for_update: indicating FOR UPDATE should be used, or may be a dictionary containing flags to indicate a more specific set of FOR UPDATE flags for the SELECT auto_expunge: Remove object from session before returning. auto_refresh: Refresh object from session before returning. auto_commit: Commit objects before returning. match_fields: a list of keys to use to match the existing model. When empty, all fields are matched. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. bind_group: Optional routing group for multi-master configurations. Returns: The updated or created instance. """ self._uniquify = self._get_uniquify(uniquify) error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, ) if match_fields := self._get_match_fields(match_fields=match_fields): match_filter = { field_name: getattr(data, field_name, None) for field_name in match_fields if getattr(data, field_name, None) is not None } elif self.has_composite_pk and self.has_primary_key_values(data): # For composite PKs, match on all PK columns match_filter = {attr: getattr(data, attr) for attr in self._pk_attr_names} elif getattr(data, self.id_attribute, None) is not None: match_filter = {self.id_attribute: getattr(data, self.id_attribute, None)} else: # Exclude all PK columns when matching by non-PK fields exclude_cols = set(self._pk_attr_names) if self.has_composite_pk else {self.id_attribute} match_filter = model_to_dict(data, exclude=exclude_cols) existing = await self.get_one_or_none( load=load, execution_options=execution_options, bind_group=bind_group, **match_filter ) if not existing: return await self.add( data, auto_commit=auto_commit, auto_expunge=auto_expunge, auto_refresh=auto_refresh, bind_group=bind_group, ) with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): # Exclude all PK columns when copying field values exclude_cols = set(self._pk_attr_names) if self.has_composite_pk else {self.id_attribute} for field_name, new_field_value in model_to_dict(data, exclude=exclude_cols).items(): field = getattr(existing, field_name, MISSING) if field is not MISSING and not compare_values(field, new_field_value): # pragma: no cover setattr(existing, field_name, new_field_value) instance = await self._attach_to_session(existing, strategy="merge") await self._flush_or_commit(auto_commit=auto_commit) await self._refresh( instance, attribute_names=attribute_names, with_for_update=with_for_update, auto_refresh=auto_refresh, ) self._expunge(instance, auto_expunge=auto_expunge) # Queue cache invalidation (processed on commit) self._queue_cache_invalidation(self.get_primary_key_value(instance), bind_group) return instance async def upsert_many( self, data: List[ModelT], *, auto_expunge: Optional[bool] = None, auto_commit: Optional[bool] = None, no_merge: bool = False, match_fields: Optional[Union[List[str], str]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, ) -> List[ModelT]: """Modify or create multiple instances. Update instances with the attribute values present on `data`, or create a new instance if one doesn't exist. !!! tip In most cases, you will want to set `match_fields` to the combination of attributes, excluded the primary key, that define uniqueness for a row. Args: data: Instance to update existing, or be created. Identifier used to determine if an existing instance exists is the value of an attribute on ``data`` named as value of :attr:`id_attribute`. auto_expunge: Remove object from session before returning. auto_commit: Commit objects before returning. no_merge: Skip the usage of optimized Merge statements match_fields: a list of keys to use to match the existing model. When empty, automatically uses ``self.id_attribute`` (`id` by default) to match . error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. bind_group: Optional routing group to use for the operation. Returns: The updated or created instance. """ self._uniquify = self._get_uniquify(uniquify) error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, ) instances: List[ModelT] = [] data_to_update: List[ModelT] = [] data_to_insert: List[ModelT] = [] match_fields = self._get_match_fields(match_fields=match_fields) if match_fields is None: # Default to all PK columns for composite PKs, otherwise just id_attribute match_fields = list(self._pk_attr_names) if self.has_composite_pk else [self.id_attribute] match_filter: List[Union[StatementFilter, ColumnElement[bool]]] = [] if match_fields: for field_name in match_fields: field = get_instrumented_attr(self.model_type, field_name) matched_values = [ field_data for datum in data if (field_data := getattr(datum, field_name)) is not None ] # Use field.in_() if types are incompatible with ANY() or if dialect doesn't prefer ANY() use_in = not self._prefer_any or self._type_must_use_in_instead_of_any(matched_values, field.type) match_filter.append(field.in_(matched_values) if use_in else any_(matched_values) == field) # type: ignore[arg-type] with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): existing_objs = await self.list( *match_filter, load=load, execution_options=execution_options, auto_expunge=False, bind_group=bind_group, ) for field_name in match_fields: field = get_instrumented_attr(self.model_type, field_name) # Safe deduplication that handles unhashable types (e.g., JSONB dicts) all_values = [getattr(datum, field_name) for datum in existing_objs if datum] matched_values = self._get_unique_values(all_values) # Use field.in_() if types are incompatible with ANY() or if dialect doesn't prefer ANY() use_in = not self._prefer_any or self._type_must_use_in_instead_of_any(matched_values, field.type) match_filter.append(field.in_(matched_values) if use_in else any_(matched_values) == field) # type: ignore[arg-type] existing_ids = self._get_object_ids(existing_objs=existing_objs) data = self._merge_on_match_fields(data, existing_objs, match_fields) for datum in data: # Use extracted PK value which handles composite PKs (returns tuple) datum_pk = self.get_primary_key_value(datum) if datum_pk in existing_ids: data_to_update.append(datum) else: data_to_insert.append(datum) if data_to_insert: instances.extend( await self.add_many(data_to_insert, auto_commit=False, auto_expunge=False, bind_group=bind_group), ) if data_to_update: instances.extend( await self.update_many( data_to_update, auto_commit=False, auto_expunge=False, load=load, execution_options=execution_options, bind_group=bind_group, ), ) await self._flush_or_commit(auto_commit=auto_commit) for instance in instances: self._expunge(instance, auto_expunge=auto_expunge) return instances def _get_object_ids(self, existing_objs: List[ModelT]) -> List[PrimaryKeyType]: """Extract primary key values from a list of model instances. For composite PKs, returns tuples; for single PKs, returns scalar values. """ return [self.get_primary_key_value(datum) for datum in existing_objs if self.has_primary_key_values(datum)] def _get_match_fields( self, match_fields: Optional[Union[List[str], str]] = None, id_attribute: Optional[str] = None, ) -> Optional[List[str]]: id_attribute = id_attribute or self.id_attribute match_fields = match_fields or self.match_fields if isinstance(match_fields, str): match_fields = [match_fields] return match_fields def _merge_on_match_fields( self, data: List[ModelT], existing_data: List[ModelT], match_fields: Optional[Union[List[str], str]] = None, ) -> List[ModelT]: match_fields = self._get_match_fields(match_fields=match_fields) if match_fields is None: # Default to all PK columns for composite PKs, otherwise just id_attribute match_fields = list(self._pk_attr_names) if self.has_composite_pk else [self.id_attribute] for existing_datum in existing_data: for datum in data: match = all( getattr(datum, field_name) == getattr(existing_datum, field_name) for field_name in match_fields ) if match and self.has_primary_key_values(existing_datum): # Copy all PK values from existing to datum (handles composite PKs) for pk_attr in self._pk_attr_names: setattr(datum, pk_attr, getattr(existing_datum, pk_attr)) return data async def list( self, *filters: Union[StatementFilter, ColumnElement[bool]], auto_expunge: Optional[bool] = None, statement: Optional[Select[tuple[ModelT]]] = None, order_by: Optional[Union[List[OrderingPair], OrderingPair]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, use_cache: bool = True, bind_group: Optional[str] = None, **kwargs: Any, ) -> List[ModelT]: """Get a list of instances, optionally filtered. Args: *filters: Types for specific filtering operations. auto_expunge: Remove object from session before returning. statement: To facilitate customization of the underlying select query. order_by: Set default order options for queries. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. use_cache: Whether to use the cache for this query. Defaults to ``True``. bind_group: Optional routing group to use for the operation. **kwargs: Instance attribute value filters. Returns: The list of instances, after filtering applied. """ self._uniquify = self._get_uniquify(uniquify) resolved_error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, ) resolved_auto_expunge = self.auto_expunge if auto_expunge is None else auto_expunge resolved_execution_options = self._get_execution_options(execution_options) resolved_order_by = order_by if order_by is not None else (self.order_by if self.order_by is not None else []) cache_manager = self._cache_manager if not ( use_cache and bool(resolved_auto_expunge) and cache_manager is not None and statement is None and load is None and not self._default_loader_options ): return await self._list_from_db( filters=filters, auto_expunge=auto_expunge, statement=statement, order_by=order_by, error_messages=resolved_error_messages, load=load, execution_options=execution_options, kwargs=kwargs, uniquify=uniquify, bind_group=bind_group, ) model_name = cast("str", self.model_type.__tablename__) # type: ignore[attr-defined] version_token = await cache_manager.get_model_version_async(model_name) cache_key = _build_list_cache_key( model_name=model_name, version_token=version_token, method="list", filters=filters, kwargs=kwargs, order_by=resolved_order_by, execution_options=resolved_execution_options, uniquify=self._uniquify, ) if cache_key is None: return await self._list_from_db( filters=filters, auto_expunge=auto_expunge, statement=statement, order_by=order_by, error_messages=resolved_error_messages, load=load, execution_options=execution_options, kwargs=kwargs, uniquify=uniquify, bind_group=bind_group, ) cached = await cache_manager.get_list_async(cache_key, self.model_type) if cached is not None: return cached return await cache_manager.singleflight_async( cache_key, partial( self._list_cached_creator, cache_key, filters=filters, auto_expunge=auto_expunge, statement=statement, order_by=order_by, error_messages=resolved_error_messages, load=load, execution_options=execution_options, kwargs=kwargs, uniquify=uniquify, bind_group=bind_group, ), ) @classmethod async def check_health(cls, session: Union[AsyncSession, async_scoped_session[AsyncSession]]) -> bool: """Perform a health check on the database. Args: session: through which we run a check statement Returns: ``True`` if healthy. """ with wrap_sqlalchemy_exception(): return ( # type: ignore[no-any-return] await session.execute(cls._get_health_check_statement(session)) ).scalar_one() == 1 @staticmethod def _get_health_check_statement(session: Union[AsyncSession, async_scoped_session[AsyncSession]]) -> TextClause: if session.bind and session.bind.dialect.name == "oracle": return text("SELECT 1 FROM DUAL") return text("SELECT 1") async def _attach_to_session( self, model: ModelT, strategy: Literal["add", "merge"] = "add", load: bool = True ) -> ModelT: """Attach detached instance to the session. Args: model: The instance to be attached to the session. strategy: How the instance should be attached. - "add": New instance added to session - "merge": Instance merged with existing, or new one added. load: Boolean, when False, merge switches into a "high performance" mode which causes it to forego emitting history events as well as all database access. This flag is used for cases such as transferring graphs of objects into a session from a second level cache, or to transfer just-loaded objects into the session owned by a worker thread or process without re-querying the database. Raises: ValueError: If `strategy` is not one of the expected values. Returns: Instance attached to the session - if `"merge"` strategy, may not be same instance that was provided. """ if strategy == "add": self.session.add(model) return model if strategy == "merge": return await self.session.merge(model, load=load) msg = "Unexpected value for `strategy`, must be `'add'` or `'merge'`" # type: ignore[unreachable] raise ValueError(msg) async def _execute( self, statement: Select[Any], uniquify: bool = False, ) -> Result[Any]: result = await self.session.execute(statement) if uniquify or self._uniquify: result = result.unique() return result class SQLAlchemyAsyncSlugRepository( SQLAlchemyAsyncRepository[ModelT], SQLAlchemyAsyncSlugRepositoryProtocol[ModelT], ): """Extends the repository to include slug model features..""" async def get_by_slug( self, slug: str, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> Optional[ModelT]: """Select record by slug value. Returns: The model instance or None if not found. """ return await self.get_one_or_none( slug=slug, load=load, execution_options=execution_options, error_messages=error_messages, uniquify=uniquify, bind_group=bind_group, ) async def get_available_slug( self, value_to_slugify: str, **kwargs: Any, ) -> str: """Get a unique slug for the supplied value. If the value is found to exist, a random 4 digit character is appended to the end. Override this method to change the default behavior Args: value_to_slugify (str): A string that should be converted to a unique slug. **kwargs: stuff Returns: str: a unique slug for the supplied value. This is safe for URLs and other unique identifiers. """ slug = slugify(value_to_slugify) if await self._is_slug_unique(slug): return slug random_string = "".join(random.choices(string.ascii_lowercase + string.digits, k=4)) # noqa: S311 return f"{slug}-{random_string}" async def _is_slug_unique( self, slug: str, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, **kwargs: Any, ) -> bool: return await self.exists(slug=slug, load=load, execution_options=execution_options, **kwargs) is False class SQLAlchemyAsyncQueryRepository: """SQLAlchemy Query Repository. This is a loosely typed helper to query for when you need to select data in ways that don't align to the normal repository pattern. """ error_messages: Optional[ErrorMessages] = None wrap_exceptions: bool = True def __init__( self, *, session: Union[AsyncSession, async_scoped_session[AsyncSession]], error_messages: Optional[ErrorMessages] = None, wrap_exceptions: bool = True, **kwargs: Any, ) -> None: """Repository pattern for SQLAlchemy models. Args: session: Session managing the unit-of-work for the operation. error_messages: A set of error messages to use for operations. wrap_exceptions: Whether to wrap exceptions in a SQLAlchemy exception. **kwargs: Additional arguments (ignored). """ self.session = session self.error_messages = error_messages self.wrap_exceptions = wrap_exceptions self._dialect = self.session.bind.dialect if self.session.bind is not None else self.session.get_bind().dialect async def get_one( self, statement: Select[tuple[Any]], bind_group: Optional[str] = None, **kwargs: Any, ) -> Row[Any]: """Get instance identified by ``kwargs``. Args: statement: To facilitate customization of the underlying select query. bind_group: The bind group to use for the operation. **kwargs: Instance attribute value filters. Returns: The retrieved instance. """ with wrap_sqlalchemy_exception(error_messages=self.error_messages, wrap_exceptions=self.wrap_exceptions): statement = self._filter_statement_by_kwargs(statement, **kwargs) execution_options = {"bind_group": bind_group} if bind_group else None instance = (await self.execute(statement, execution_options=execution_options)).scalar_one_or_none() return self.check_not_found(instance) async def get_one_or_none( self, statement: Select[Any], bind_group: Optional[str] = None, **kwargs: Any, ) -> Optional[Row[Any]]: """Get instance identified by ``kwargs`` or None if not found. Args: statement: To facilitate customization of the underlying select query. bind_group: The bind group to use for the operation. **kwargs: Instance attribute value filters. Returns: The retrieved instance or None """ with wrap_sqlalchemy_exception(error_messages=self.error_messages, wrap_exceptions=self.wrap_exceptions): statement = self._filter_statement_by_kwargs(statement, **kwargs) execution_options = {"bind_group": bind_group} if bind_group else None instance = (await self.execute(statement, execution_options=execution_options)).scalar_one_or_none() return instance or None async def count(self, statement: Select[Any], bind_group: Optional[str] = None, **kwargs: Any) -> int: """Get the count of records returned by a query. Args: statement: To facilitate customization of the underlying select query. bind_group: The bind group to use for the operation. **kwargs: Instance attribute value filters. Returns: Count of records returned by query, ignoring pagination. """ with wrap_sqlalchemy_exception(error_messages=self.error_messages, wrap_exceptions=self.wrap_exceptions): statement = statement.with_only_columns(sql_func.count(text("1")), maintain_column_froms=True).order_by( None, ) statement = self._filter_statement_by_kwargs(statement, **kwargs) execution_options = {"bind_group": bind_group} if bind_group else None results = await self.execute(statement, execution_options=execution_options) return results.scalar_one() # type: ignore async def list_and_count( self, statement: Select[Any], count_with_window_function: Optional[bool] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[List[Row[Any]], int]: """List records with total count. Args: statement: To facilitate customization of the underlying select query. count_with_window_function: Force list and count to use two queries instead of an analytical window function. bind_group: The bind group to use for the operation. **kwargs: Instance attribute value filters. Returns: Count of records returned by query, ignoring pagination. """ if self._dialect.name in {"spanner", "spanner+spanner"} or count_with_window_function: return await self._list_and_count_basic(statement=statement, bind_group=bind_group, **kwargs) return await self._list_and_count_window(statement=statement, bind_group=bind_group, **kwargs) async def _list_and_count_window( self, statement: Select[Any], bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[List[Row[Any]], int]: """List records with total count. Args: *filters: Types for specific filtering operations. statement: To facilitate customization of the underlying select query. bind_group: The bind group to use for the operation. **kwargs: Instance attribute value filters. Returns: Count of records returned by query using an analytical window function, ignoring pagination. """ with wrap_sqlalchemy_exception(error_messages=self.error_messages, wrap_exceptions=self.wrap_exceptions): statement = statement.add_columns(over(sql_func.count(text("1")))) statement = self._filter_statement_by_kwargs(statement, **kwargs) execution_options = {"bind_group": bind_group} if bind_group else None result = await self.execute(statement, execution_options=execution_options) count: int = 0 instances: List[Row[Any]] = [] for i, (instance, count_value) in enumerate(result): instances.append(instance) if i == 0: count = count_value return instances, count @staticmethod def _get_count_stmt(statement: Select[Any]) -> Select[Any]: return statement.with_only_columns(sql_func.count(text("1")), maintain_column_froms=True).order_by(None) # pyright: ignore[reportUnknownVariable] async def _list_and_count_basic( self, statement: Select[Any], bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[List[Row[Any]], int]: """List records with total count. Args: statement: To facilitate customization of the underlying select query. . bind_group: The bind group to use for the operation. **kwargs: Instance attribute value filters. Returns: Count of records returned by query using 2 queries, ignoring pagination. """ with wrap_sqlalchemy_exception(error_messages=self.error_messages, wrap_exceptions=self.wrap_exceptions): statement = self._filter_statement_by_kwargs(statement, **kwargs) execution_options = {"bind_group": bind_group} if bind_group else None count_result = await self.session.execute( self._get_count_stmt(statement), execution_options=execution_options or {} ) count = count_result.scalar_one() result = await self.execute(statement, execution_options=execution_options) instances: List[Row[Any]] = [] for (instance,) in result: instances.append(instance) return instances, count async def list(self, statement: Select[Any], bind_group: Optional[str] = None, **kwargs: Any) -> List[Row[Any]]: """Get a list of instances, optionally filtered. Args: statement: To facilitate customization of the underlying select query. bind_group: The bind group to use for the operation. **kwargs: Instance attribute value filters. Returns: The list of instances, after filtering applied. """ with wrap_sqlalchemy_exception(error_messages=self.error_messages, wrap_exceptions=self.wrap_exceptions): statement = self._filter_statement_by_kwargs(statement, **kwargs) execution_options = {"bind_group": bind_group} if bind_group else None result = await self.execute(statement, execution_options=execution_options) return list(result.all()) def _filter_statement_by_kwargs( self, statement: Select[Any], /, **kwargs: Any, ) -> Select[Any]: """Filter the collection by kwargs. Args: statement: statement to filter **kwargs: key/value pairs such that objects remaining in the statement after filtering have the property that their attribute named `key` has value equal to `value`. Returns: The filtered statement. """ with wrap_sqlalchemy_exception(error_messages=self.error_messages): return statement.filter_by(**kwargs) # the following is all sqlalchemy implementation detail, and shouldn't be directly accessed @staticmethod def check_not_found(item_or_none: Optional[T]) -> T: """Raise :class:`NotFoundError` if ``item_or_none`` is ``None``. Args: item_or_none: Item to be tested for existence. Raises: NotFoundError: If ``item_or_none`` is ``None`` Returns: The item, if it exists. """ if item_or_none is None: msg = "No item found when one was expected" raise NotFoundError(msg) return item_or_none async def execute( self, statement: Union[ ReturningDelete[tuple[Any]], ReturningUpdate[tuple[Any]], Select[tuple[Any]], Update, Delete, Select[Any] ], execution_options: Optional[dict[str, Any]] = None, ) -> Result[Any]: return await self.session.execute(statement, execution_options=execution_options or {}) python-advanced-alchemy-1.9.3/advanced_alchemy/repository/_sync.py000066400000000000000000004575741516556515500254470ustar00rootroot00000000000000# Do not edit this file directly. It has been autogenerated from # advanced_alchemy/repository/_async.py import contextlib import datetime import random import string from collections.abc import Iterable, Sequence from functools import partial from typing import ( TYPE_CHECKING, Any, Final, List, Literal, Optional, Protocol, Union, cast, runtime_checkable, ) from sqlalchemy import ( Delete, Result, Row, Select, TextClause, Update, and_, any_, delete, inspect, or_, over, select, text, tuple_, update, ) from sqlalchemy import func as sql_func from sqlalchemy.exc import MissingGreenlet, NoInspectionAvailable from sqlalchemy.orm import InstrumentedAttribute, Session from sqlalchemy.orm.scoping import scoped_session from sqlalchemy.orm.strategy_options import _AbstractLoad # pyright: ignore[reportPrivateUsage] from sqlalchemy.sql import ColumnElement from sqlalchemy.sql.dml import ReturningDelete, ReturningUpdate from sqlalchemy.sql.selectable import ForUpdateArg, ForUpdateParameter from advanced_alchemy.base import model_to_dict from advanced_alchemy.exceptions import ErrorMessages, NotFoundError, RepositoryError, wrap_sqlalchemy_exception from advanced_alchemy.filters import StatementFilter, StatementTypeT from advanced_alchemy.repository._util import ( DEFAULT_ERROR_MESSAGE_TEMPLATES, DEFAULT_SAFE_TYPES, FilterableRepository, FilterableRepositoryProtocol, LoadSpec, _build_list_cache_key, # pyright: ignore column_has_defaults, compare_values, extract_pk_value_from_instance, get_abstract_loader_options, get_instrumented_attr, get_primary_key_info, is_composite_pk, pk_values_present, validate_composite_pk_value, was_attribute_set, ) from advanced_alchemy.repository.typing import MISSING, ModelT, OrderingPair, PrimaryKeyType, T from advanced_alchemy.service.typing import schema_dump from advanced_alchemy.utils.dataclass import Empty, EmptyType from advanced_alchemy.utils.text import slugify if TYPE_CHECKING: from sqlalchemy.engine.interfaces import _CoreSingleExecuteParams # pyright: ignore[reportPrivateUsage] from advanced_alchemy.cache.manager import CacheManager DEFAULT_INSERTMANYVALUES_MAX_PARAMETERS: Final = 950 POSTGRES_VERSION_SUPPORTING_MERGE: Final = 15 @runtime_checkable class SQLAlchemySyncRepositoryProtocol(FilterableRepositoryProtocol[ModelT], Protocol[ModelT]): """Base Protocol""" id_attribute: str match_fields: Optional[Union[List[str], str]] = None statement: Select[tuple[ModelT]] session: Union[Session, scoped_session[Session]] auto_expunge: bool auto_refresh: bool auto_commit: bool order_by: Optional[Union[List[OrderingPair], OrderingPair]] = None error_messages: Optional[ErrorMessages] = None wrap_exceptions: bool = True @property def pk_attr_names(self) -> tuple[str, ...]: ... @property def has_composite_pk(self) -> bool: ... def get_primary_key_value(self, instance: ModelT) -> PrimaryKeyType: ... def has_primary_key_values(self, instance: ModelT) -> bool: ... def __init__( self, *, statement: Optional[Select[tuple[ModelT]]] = None, session: Union[Session, scoped_session[Session]], auto_expunge: bool = False, auto_refresh: bool = True, auto_commit: bool = False, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, order_by: Optional[Union[List[OrderingPair], OrderingPair]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, wrap_exceptions: bool = True, **kwargs: Any, ) -> None: ... @classmethod def get_id_attribute_value( cls, item: Union[ModelT, type[ModelT]], id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]] = None, ) -> Any: ... @classmethod def set_id_attribute_value( cls, item_id: Any, item: ModelT, id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]] = None, ) -> ModelT: ... @staticmethod def check_not_found(item_or_none: Optional[ModelT]) -> ModelT: ... def add( self, data: ModelT, *, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, auto_refresh: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, bind_group: Optional[str] = None, ) -> ModelT: ... def add_many( self, data: List[ModelT], *, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, bind_group: Optional[str] = None, ) -> Sequence[ModelT]: ... def delete( self, item_id: PrimaryKeyType, *, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, bind_group: Optional[str] = None, ) -> ModelT: ... def delete_many( self, item_ids: List[PrimaryKeyType], *, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]] = None, chunk_size: Optional[int] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, bind_group: Optional[str] = None, ) -> Sequence[ModelT]: ... def delete_where( self, *filters: Union[StatementFilter, ColumnElement[bool]], auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, load: Optional[LoadSpec] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, execution_options: Optional[dict[str, Any]] = None, sanity_check: bool = True, bind_group: Optional[str] = None, **kwargs: Any, ) -> Sequence[ModelT]: ... def exists( self, *filters: Union[StatementFilter, ColumnElement[bool]], load: Optional[LoadSpec] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, execution_options: Optional[dict[str, Any]] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> bool: ... def get( self, item_id: PrimaryKeyType, *, auto_expunge: Optional[bool] = None, statement: Optional[Select[tuple[ModelT]]] = None, id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, with_for_update: ForUpdateParameter = None, bind_group: Optional[str] = None, ) -> ModelT: ... def get_one( self, *filters: Union[StatementFilter, ColumnElement[bool]], auto_expunge: Optional[bool] = None, statement: Optional[Select[tuple[ModelT]]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, with_for_update: ForUpdateParameter = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> ModelT: ... def get_one_or_none( self, *filters: Union[StatementFilter, ColumnElement[bool]], auto_expunge: Optional[bool] = None, statement: Optional[Select[tuple[ModelT]]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, with_for_update: ForUpdateParameter = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> Optional[ModelT]: ... def get_or_upsert( self, *filters: Union[StatementFilter, ColumnElement[bool]], match_fields: Optional[Union[List[str], str]] = None, upsert: bool = True, attribute_names: Optional[Iterable[str]] = None, with_for_update: ForUpdateParameter = None, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, auto_refresh: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[ModelT, bool]: ... def get_and_update( self, *filters: Union[StatementFilter, ColumnElement[bool]], match_fields: Optional[Union[List[str], str]] = None, attribute_names: Optional[Iterable[str]] = None, with_for_update: ForUpdateParameter = None, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, auto_refresh: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[ModelT, bool]: ... def count( self, *filters: Union[StatementFilter, ColumnElement[bool]], statement: Optional[Select[tuple[ModelT]]] = None, load: Optional[LoadSpec] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, execution_options: Optional[dict[str, Any]] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> int: ... def update( self, data: ModelT, *, attribute_names: Optional[Iterable[str]] = None, with_for_update: ForUpdateParameter = None, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, auto_refresh: Optional[bool] = None, id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, bind_group: Optional[str] = None, ) -> ModelT: ... def update_many( self, data: List[ModelT], *, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, bind_group: Optional[str] = None, ) -> List[ModelT]: ... def _get_update_many_statement( self, model_type: type[ModelT], supports_returning: bool, loader_options: Optional[List[_AbstractLoad]], execution_options: Optional[dict[str, Any]], ) -> Union[Update, ReturningUpdate[tuple[ModelT]]]: ... def upsert( self, data: ModelT, *, attribute_names: Optional[Iterable[str]] = None, with_for_update: ForUpdateParameter = None, auto_expunge: Optional[bool] = None, auto_commit: Optional[bool] = None, auto_refresh: Optional[bool] = None, match_fields: Optional[Union[List[str], str]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, bind_group: Optional[str] = None, ) -> ModelT: ... def upsert_many( self, data: List[ModelT], *, auto_expunge: Optional[bool] = None, auto_commit: Optional[bool] = None, no_merge: bool = False, match_fields: Optional[Union[List[str], str]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, bind_group: Optional[str] = None, ) -> List[ModelT]: ... def list_and_count( self, *filters: Union[StatementFilter, ColumnElement[bool]], auto_expunge: Optional[bool] = None, statement: Optional[Select[tuple[ModelT]]] = None, count_with_window_function: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, order_by: Optional[Union[List[OrderingPair], OrderingPair]] = None, use_cache: bool = True, bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[List[ModelT], int]: ... def list( self, *filters: Union[StatementFilter, ColumnElement[bool]], auto_expunge: Optional[bool] = None, statement: Optional[Select[tuple[ModelT]]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, order_by: Optional[Union[List[OrderingPair], OrderingPair]] = None, use_cache: bool = True, bind_group: Optional[str] = None, **kwargs: Any, ) -> List[ModelT]: ... @classmethod def check_health(cls, session: Union[Session, scoped_session[Session]]) -> bool: ... @runtime_checkable class SQLAlchemySyncSlugRepositoryProtocol(SQLAlchemySyncRepositoryProtocol[ModelT], Protocol[ModelT]): """Protocol for SQLAlchemy repositories that support slug-based operations. Extends the base repository protocol to add slug-related functionality. Type Parameters: ModelT: The SQLAlchemy model type this repository handles. """ def get_by_slug( self, slug: str, *, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> Optional[ModelT]: """Get a model instance by its slug. Args: slug: The slug value to search for. error_messages: Optional custom error message templates. load: Specification for eager loading of relationships. execution_options: Options for statement execution. bind_group: Optional routing group to use for the operation. **kwargs: Additional filtering criteria. Returns: ModelT | None: The found model instance or None if not found. """ ... def get_available_slug( self, value_to_slugify: str, **kwargs: Any, ) -> str: """Generate a unique slug for a given value. Args: value_to_slugify: The string to convert to a slug. **kwargs: Additional parameters for slug generation. Returns: str: A unique slug derived from the input value. """ ... class SQLAlchemySyncRepository(SQLAlchemySyncRepositoryProtocol[ModelT], FilterableRepository[ModelT]): """Async SQLAlchemy repository implementation. Provides a complete implementation of async database operations using SQLAlchemy, including CRUD operations, filtering, and relationship loading. Type Parameters: ModelT: The SQLAlchemy model type this repository handles. .. seealso:: :class:`~advanced_alchemy.repository._util.FilterableRepository` """ id_attribute: str = "id" """Name of the unique identifier for the model.""" loader_options: Optional[LoadSpec] = None """Default loader options for the repository.""" error_messages: Optional[ErrorMessages] = None """Default error messages for the repository.""" wrap_exceptions: bool = True """Wrap SQLAlchemy exceptions in a ``RepositoryError``. When set to ``False``, the original exception will be raised.""" inherit_lazy_relationships: bool = True """Optionally ignore the default ``lazy`` configuration for model relationships. This is useful for when you want to replace instead of merge the model's loaded relationships with the ones specified in the ``load`` or ``default_loader_options`` configuration.""" merge_loader_options: bool = True """Merges the default loader options with the loader options specified in the ``load`` argument. This is useful for when you want to totally replace instead of merge the model's loaded relationships with the ones specified in the ``load`` or ``default_loader_options`` configuration.""" execution_options: Optional[dict[str, Any]] = None """Default execution options for the repository.""" match_fields: Optional[Union[List[str], str]] = None """List of dialects that prefer to use ``field.id = ANY(:1)`` instead of ``field.id IN (...)``.""" uniquify: bool = False """Optionally apply the ``unique()`` method to results before returning. This is useful for certain SQLAlchemy uses cases such as applying ``contains_eager`` to a query containing a one-to-many relationship """ count_with_window_function: bool = True """Use an analytical window function to count results. This allows the count to be performed in a single query. """ _cache_manager: Optional["CacheManager"] = None """Cache manager instance for repository-level caching. Set via ``cache_manager`` kwarg or retrieved from ``session.info``.""" _bind_group: Optional[str] = None """Default bind group for routing operations (e.g., to read replicas). Can be overridden per-method.""" def __init__( self, *, statement: Optional[Select[tuple[ModelT]]] = None, session: Union[Session, scoped_session[Session]], auto_expunge: bool = False, auto_refresh: bool = True, auto_commit: bool = False, order_by: Optional[Union[List[OrderingPair], OrderingPair]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, wrap_exceptions: bool = True, uniquify: Optional[bool] = None, count_with_window_function: Optional[bool] = None, cache_manager: Optional["CacheManager"] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> None: """Repository for SQLAlchemy models. Args: statement: To facilitate customization of the underlying select query. session: Session managing the unit-of-work for the operation. auto_expunge: Remove object from session before returning. auto_refresh: Refresh object from session before returning. auto_commit: Commit objects before returning. order_by: Set default order options for queries. load: Set default relationships to be loaded execution_options: Set default execution options error_messages: A set of custom error messages to use for operations wrap_exceptions: Wrap SQLAlchemy exceptions in a ``RepositoryError``. When set to ``False``, the original exception will be raised. uniquify: Optionally apply the ``unique()`` method to results before returning. count_with_window_function: When false, list and count will use two queries instead of an analytical window function. cache_manager: Optional cache manager for repository-level caching. If not provided, retrieved from ``session.info``. bind_group: Optional default routing group to use for all operations. Can be overridden per-method. **kwargs: Additional arguments. """ self.auto_expunge = auto_expunge self.auto_refresh = auto_refresh self.auto_commit = auto_commit self.order_by = order_by self.session = session self.error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages ) self.wrap_exceptions = wrap_exceptions self._uniquify = uniquify if uniquify is not None else self.uniquify self.count_with_window_function = ( count_with_window_function if count_with_window_function is not None else self.count_with_window_function ) self._default_loader_options, self._loader_options_have_wildcards = get_abstract_loader_options( loader_options=load if load is not None else self.loader_options, inherit_lazy_relationships=self.inherit_lazy_relationships, merge_with_default=self.merge_loader_options, ) execution_options = execution_options if execution_options is not None else self.execution_options self._default_execution_options = execution_options or {} self.statement = select(self.model_type) if statement is None else statement self._dialect = self.session.bind.dialect if self.session.bind is not None else self.session.get_bind().dialect self._prefer_any = any(self._dialect.name == engine_type for engine_type in self.prefer_any_dialects or ()) # Cache manager: from explicit param or session.info (set by SQLAlchemyAsyncConfig) self._cache_manager = cache_manager if cache_manager is not None else session.info.get("cache_manager") # Default bind group for all operations (can be overridden per-method) self._bind_group = bind_group # Cache primary key columns for composite key support self._pk_columns, self._pk_attr_names = get_primary_key_info(self.model_type) def _get_uniquify(self, uniquify: Optional[bool] = None) -> bool: """Get the uniquify value, preferring the method parameter over instance setting. Args: uniquify: Optional override for the uniquify setting. Returns: bool: The uniquify value to use. """ return bool(uniquify) if uniquify is not None else self._uniquify def _resolve_bind_group(self, bind_group: Optional[str] = None) -> Optional[str]: """Resolve the bind_group to use, preferring method parameter over instance default. Args: bind_group: Optional override for the bind_group setting. Returns: The bind_group to use, or None if not set. """ return bind_group if bind_group is not None else self._bind_group def _queue_cache_invalidation(self, entity_id: Any, bind_group: Optional[str] = None) -> None: """Queue a cache invalidation for an entity. The invalidation will be processed after the transaction commits. If the transaction rolls back, the pending invalidation is discarded. This uses cache listeners which must be set up via setup_cache_listeners() during application initialization, or via scoped listeners in SQLAlchemyConfig. Args: entity_id: The primary key value of the entity to invalidate. bind_group: Optional routing group for multi-master configurations. When provided, only the cache entry for that bind_group is invalidated. """ if self._cache_manager is not None: from advanced_alchemy._listeners import get_cache_tracker # Check if model_type has __tablename__ (may not exist in mock scenarios) model_name = getattr(self.model_type, "__tablename__", None) if model_name is None: return tracker = get_cache_tracker(self.session, self._cache_manager) if tracker is not None: tracker.add_invalidation(cast("str", model_name), entity_id, bind_group) def _type_must_use_in_instead_of_any(self, matched_values: "List[Any]", field_type: "Any" = None) -> bool: """Determine if field.in_() should be used instead of any_() for compatibility. Uses SQLAlchemy's type introspection to detect types that may have DBAPI serialization issues with the ANY() operator. Checks if actual values match the column's expected python_type - mismatches indicate complex types that need the safer IN() operator. Falls back to Python type checking when SQLAlchemy type information is unavailable. Args: matched_values: Values to be used in the filter field_type: Optional SQLAlchemy TypeEngine from the column Returns: bool: True if field.in_() should be used instead of any_() """ if not matched_values: return False if field_type is not None: try: expected_python_type = getattr(field_type, "python_type", None) if expected_python_type is not None: for value in matched_values: if value is not None and not isinstance(value, expected_python_type): return True except (AttributeError, NotImplementedError): return True return any(value is not None and type(value) not in DEFAULT_SAFE_TYPES for value in matched_values) def _get_unique_values(self, values: "List[Any]") -> "List[Any]": """Get unique values from a list, handling unhashable types safely. Args: values: List of values to deduplicate Returns: list[Any]: List of unique values preserving order """ if not values: return [] try: # Fast path for hashable types seen: set[Any] = set() unique_values: List[Any] = [] for value in values: if value not in seen: unique_values.append(value) seen.add(value) except TypeError: # Fallback for unhashable types (e.g., dicts from JSONB) unique_values = [] for value in values: if value not in unique_values: unique_values.append(value) return unique_values @staticmethod def _get_error_messages( error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, default_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, ) -> Optional[ErrorMessages]: if error_messages == Empty: error_messages = None if default_messages == Empty: default_messages = None messages = cast("ErrorMessages", dict(DEFAULT_ERROR_MESSAGE_TEMPLATES)) if default_messages and isinstance(default_messages, dict): messages.update(default_messages) if error_messages: messages.update(cast("ErrorMessages", error_messages)) # type: ignore[unused-ignore,redundant-cast] return messages @classmethod def get_id_attribute_value( cls, item: Union[ModelT, type[ModelT]], id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]] = None, ) -> Any: """Get value of attribute named as :attr:`id_attribute` on ``item``. Args: item: Anything that should have an attribute named as :attr:`id_attribute` value. id_attribute: Allows customization of the unique identifier to use for model fetching. Defaults to `None`, but can reference any surrogate or candidate key for the table. Returns: The value of attribute on ``item`` named as :attr:`id_attribute`. """ if isinstance(id_attribute, InstrumentedAttribute): id_attribute = id_attribute.key return getattr(item, id_attribute if id_attribute is not None else cls.id_attribute) @classmethod def set_id_attribute_value( cls, item_id: Any, item: ModelT, id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]] = None, ) -> ModelT: """Return the ``item`` after the ID is set to the appropriate attribute. Args: item_id: Value of ID to be set on instance item: Anything that should have an attribute named as :attr:`id_attribute` value. id_attribute: Allows customization of the unique identifier to use for model fetching. Defaults to `None`, but can reference any surrogate or candidate key for the table. Returns: Item with ``item_id`` set to :attr:`id_attribute` """ if isinstance(id_attribute, InstrumentedAttribute): id_attribute = id_attribute.key setattr(item, id_attribute if id_attribute is not None else cls.id_attribute, item_id) return item @property def pk_attr_names(self) -> tuple[str, ...]: """Get primary key attribute names. Returns: Tuple of ORM attribute names for primary key columns. """ return self._pk_attr_names @property def has_composite_pk(self) -> bool: """Check if model has a composite (multi-column) primary key. Returns: True if the model has 2 or more primary key columns, False otherwise. Examples: >>> repo.has_composite_pk # For model with single PK False >>> repo.has_composite_pk # For model with (user_id, role_id) PK True """ return is_composite_pk(self._pk_columns) def _build_pk_filter(self, pk_value: PrimaryKeyType) -> ColumnElement[bool]: """Build a WHERE clause for primary key lookup. Supports single and composite primary keys with flexible input formats. Args: pk_value: Primary key value(s). - For single PK: scalar value (int, str, UUID, etc.) - For composite PK: tuple of values in column order, or dict mapping attribute names to values Returns: SQLAlchemy WHERE clause expression. Raises: ValueError: If the input format doesn't match the primary key structure. Examples: # Single primary key >>> filter = repo._build_pk_filter(123) >>> # Generates: WHERE id = 123 # Composite primary key (tuple format) >>> filter = repo._build_pk_filter((1, 5)) >>> # Generates: WHERE user_id = 1 AND role_id = 5 # Composite primary key (dict format) >>> filter = repo._build_pk_filter({"user_id": 1, "role_id": 5}) >>> # Generates: WHERE user_id = 1 AND role_id = 5 """ pk_columns = self._pk_columns pk_attr_names = self._pk_attr_names # Fallback for models without mapped primary key (e.g., mock objects) # In this case, use id_attribute for backward compatibility if len(pk_columns) == 0: id_attr = get_instrumented_attr(self.model_type, self.id_attribute) result: ColumnElement[bool] = id_attr == pk_value return result # Single primary key - accept scalar value only if len(pk_columns) == 1: if isinstance(pk_value, tuple): msg = ( f"Model {self.model_type.__name__} has a single primary key column '{pk_attr_names[0]}'. " f"Expected a scalar value, got tuple: {pk_value!r}" ) raise ValueError(msg) if isinstance(pk_value, dict): msg = ( f"Model {self.model_type.__name__} has a single primary key column '{pk_attr_names[0]}'. " f"Expected a scalar value, got dict: {pk_value!r}" ) raise ValueError(msg) single_pk_result: ColumnElement[bool] = pk_columns[0] == pk_value return single_pk_result pk_tuple = validate_composite_pk_value(pk_value, pk_attr_names, self.model_type.__name__) return and_(*[col == val for col, val in zip(pk_columns, pk_tuple)]) def get_primary_key_value(self, instance: ModelT) -> PrimaryKeyType: """Extract the primary key value(s) from a model instance. Args: instance: Model instance to extract primary key from. Returns: - For single PK: scalar value (int, str, UUID, etc.) - For composite PK: tuple of values in column order Examples: # Single primary key >>> user = User(id=123, name="Alice") >>> repo.get_primary_key_value(user) 123 # Composite primary key >>> assignment = UserRole(user_id=1, role_id=5) >>> repo.get_primary_key_value(assignment) (1, 5) """ return extract_pk_value_from_instance(instance, self._pk_attr_names) def has_primary_key_values(self, instance: ModelT) -> bool: """Check if all primary key values are set on an instance. Args: instance: Model instance to check. Returns: True if all PK values are non-None, False otherwise. """ return pk_values_present(instance, self._pk_attr_names) def _normalize_pk_values_to_tuples(self, item_ids: list[PrimaryKeyType]) -> list[tuple[Any, ...]]: """Normalize a list of composite primary key values to tuples. Args: item_ids: List of PK values (dicts or tuples). Returns: List of tuples with values in PK column order. Raises: TypeError: If a value is not a dict or tuple. ValueError: If tuple length doesn't match PK columns, dict is missing keys, or values are None. """ pk_attr_names = self._pk_attr_names model_name = self.model_type.__name__ return [validate_composite_pk_value(pk_value, pk_attr_names, model_name) for pk_value in item_ids] @staticmethod def check_not_found(item_or_none: Optional[ModelT]) -> ModelT: """Raise :exc:`advanced_alchemy.exceptions.NotFoundError` if ``item_or_none`` is ``None``. Args: item_or_none: Item (:class:`T `) to be tested for existence. Raises: NotFoundError: If ``item_or_none`` is ``None`` Returns: The item, if it exists. """ if item_or_none is None: msg = "No item found when one was expected" raise NotFoundError(msg) return item_or_none def _get_execution_options( self, execution_options: Optional[dict[str, Any]] = None, ) -> dict[str, Any]: if execution_options is None: return self._default_execution_options return execution_options def _get_loader_options( self, loader_options: Optional[LoadSpec], ) -> Union[tuple[List[_AbstractLoad], bool], tuple[None, bool]]: if loader_options is None: # use the defaults set at initialization return self._default_loader_options, self._loader_options_have_wildcards or self._uniquify return get_abstract_loader_options( loader_options=loader_options, default_loader_options=self._default_loader_options, default_options_have_wildcards=self._loader_options_have_wildcards or self._uniquify, inherit_lazy_relationships=self.inherit_lazy_relationships, merge_with_default=self.merge_loader_options, ) def add( self, data: ModelT, *, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, auto_refresh: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, bind_group: Optional[str] = None, ) -> ModelT: """Add ``data`` to the collection. Args: data: Instance to be added to the collection. auto_expunge: Remove object from session before returning. auto_refresh: Refresh object from session before returning. auto_commit: Commit objects before returning. error_messages: An optional dictionary of templates to use for friendlier error messages to clients bind_group: Optional routing group for multi-master configurations. Returns: The added instance. """ _ = bind_group # Reserved for future multi-master routing error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, ) with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): instance = self._attach_to_session(data) self._flush_or_commit(auto_commit=auto_commit) self._refresh(instance, auto_refresh=auto_refresh) self._expunge(instance, auto_expunge=auto_expunge) return instance def add_many( self, data: List[ModelT], *, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, bind_group: Optional[str] = None, ) -> Sequence[ModelT]: """Add many `data` to the collection. Args: data: list of Instances to be added to the collection. auto_expunge: Remove object from session before returning. auto_commit: Commit objects before returning. error_messages: An optional dictionary of templates to use for friendlier error messages to clients bind_group: Optional routing group for multi-master configurations. Returns: The added instances. """ _ = bind_group # Reserved for future multi-master routing error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, ) with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): self.session.add_all(data) self._flush_or_commit(auto_commit=auto_commit) for datum in data: self._expunge(datum, auto_expunge=auto_expunge) return data def delete( self, item_id: PrimaryKeyType, *, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, ) -> ModelT: """Delete instance identified by ``item_id``. Args: item_id: Identifier of instance to be deleted. For single primary keys, pass a scalar value. For composite primary keys, pass a tuple of values in column order or a dict mapping attribute names to values. auto_expunge: Remove object from session before returning. auto_commit: Commit objects before returning. id_attribute: Allows customization of the unique identifier to use for model fetching. Defaults to `id`, but can reference any surrogate or candidate key for the table. Note: Only applies to single-column lookups. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. bind_group: Optional routing group for multi-master configurations. Returns: The deleted instance. Examples: # Single primary key >>> deleted = await user_repo.delete(123) # Composite primary key >>> deleted = await user_role_repo.delete((user_id, role_id)) """ self._uniquify = self._get_uniquify(uniquify) error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, ) with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): resolved_bind_group = self._resolve_bind_group(bind_group) if resolved_bind_group: execution_options = dict(execution_options) if execution_options else {} execution_options["bind_group"] = resolved_bind_group execution_options = self._get_execution_options(execution_options) instance = self.get( item_id, id_attribute=id_attribute, load=load, execution_options=execution_options, bind_group=bind_group, ) self.session.delete(instance) self._flush_or_commit(auto_commit=auto_commit) self._expunge(instance, auto_expunge=auto_expunge) # Queue cache invalidation (processed on commit) self._queue_cache_invalidation(item_id, bind_group) return instance def delete_many( self, item_ids: List[PrimaryKeyType], *, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]] = None, chunk_size: Optional[int] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, ) -> Sequence[ModelT]: """Delete multiple instances identified by ``item_ids``. Args: item_ids: List of identifiers of instances to be deleted. For single primary keys, pass a list of scalar values. For composite primary keys, pass a list of tuples (values in column order) or a list of dicts (mapping attribute names to values). auto_expunge: Remove objects from session before returning. auto_commit: Commit objects before returning. id_attribute: Allows customization of the unique identifier to use for model fetching. Defaults to `id`, but can reference any surrogate or candidate key for the table. Note: Only applies to single-column lookups. chunk_size: Allows customization of the ``insertmanyvalues_max_parameters`` setting for the driver. Defaults to `950` if left unset. For composite keys, this is automatically divided by the number of PK columns. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. bind_group: Optional routing group for multi-master configurations. Returns: The deleted instances. Examples: # Single primary key >>> deleted = await user_repo.delete_many([1, 2, 3]) # Composite primary key (tuple format) >>> deleted = await user_role_repo.delete_many( ... [ ... (1, 5), ... (1, 6), ... (2, 5), ... ] ... ) # Composite primary key (dict format) >>> deleted = await user_role_repo.delete_many( ... [ ... {"user_id": 1, "role_id": 5}, ... {"user_id": 1, "role_id": 6}, ... ] ... ) """ self._uniquify = self._get_uniquify(uniquify) error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, ) with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): resolved_bind_group = self._resolve_bind_group(bind_group) if resolved_bind_group: execution_options = dict(execution_options) if execution_options else {} execution_options["bind_group"] = resolved_bind_group execution_options = self._get_execution_options(execution_options) loader_options, _loader_options_have_wildcard = self._get_loader_options(load) instances: List[ModelT] = [] # Determine if using composite key path or single column path use_composite_path = id_attribute is None and self.has_composite_pk if use_composite_path: # Composite primary key path using tuple_().in_() # Adjust chunk size for composite keys (divide by number of PK columns) base_chunk_size = self._get_insertmanyvalues_max_parameters(chunk_size) effective_chunk_size = max(1, base_chunk_size // len(self._pk_columns)) normalized_ids = self._normalize_pk_values_to_tuples(item_ids) for idx in range(0, len(normalized_ids), effective_chunk_size): chunk = normalized_ids[idx : min(idx + effective_chunk_size, len(normalized_ids))] pk_filter = ( or_( *[and_(*[col == val for col, val in zip(self._pk_columns, pk_tuple)]) for pk_tuple in chunk] ) if self._dialect.name == "mssql" else tuple_(*self._pk_columns).in_(chunk) ) if self._dialect.delete_executemany_returning: returning_delete_stmt = delete(self.model_type).where(pk_filter).returning(self.model_type) if execution_options: returning_delete_stmt = returning_delete_stmt.execution_options(**execution_options) instances.extend(self.session.scalars(returning_delete_stmt)) else: # Select first, then delete select_stmt = select(self.model_type).where(pk_filter) if loader_options: select_stmt = select_stmt.options(*loader_options) if execution_options: select_stmt = select_stmt.execution_options(**execution_options) instances.extend(self.session.scalars(select_stmt)) plain_delete_stmt = delete(self.model_type).where(pk_filter) if execution_options: plain_delete_stmt = plain_delete_stmt.execution_options(**execution_options) self.session.execute(plain_delete_stmt) else: # Single column path (existing behavior) id_attr = get_instrumented_attr( self.model_type, id_attribute if id_attribute is not None else self.id_attribute, ) if self._prefer_any: chunk_size = len(item_ids) + 1 chunk_size = self._get_insertmanyvalues_max_parameters(chunk_size) for idx in range(0, len(item_ids), chunk_size): chunk = cast("List[Any]", item_ids[idx : min(idx + chunk_size, len(item_ids))]) if self._dialect.delete_executemany_returning: instances.extend( self.session.scalars( self._get_delete_many_statement( statement_type="delete", model_type=self.model_type, id_attribute=id_attr, id_chunk=chunk, supports_returning=self._dialect.delete_executemany_returning, loader_options=loader_options, execution_options=execution_options, ), ), ) else: instances.extend( self.session.scalars( self._get_delete_many_statement( statement_type="select", model_type=self.model_type, id_attribute=id_attr, id_chunk=chunk, supports_returning=self._dialect.delete_executemany_returning, loader_options=loader_options, execution_options=execution_options, ), ), ) self.session.execute( self._get_delete_many_statement( statement_type="delete", model_type=self.model_type, id_attribute=id_attr, id_chunk=chunk, supports_returning=self._dialect.delete_executemany_returning, loader_options=loader_options, execution_options=execution_options, ), ) self._flush_or_commit(auto_commit=auto_commit) for instance in instances: self._expunge(instance, auto_expunge=auto_expunge) # Queue cache invalidation (processed on commit) # Use get_primary_key_value for composite PK support self._queue_cache_invalidation(self.get_primary_key_value(instance), bind_group) return instances @staticmethod def _get_insertmanyvalues_max_parameters(chunk_size: Optional[int] = None) -> int: return chunk_size if chunk_size is not None else DEFAULT_INSERTMANYVALUES_MAX_PARAMETERS def delete_where( self, *filters: Union[StatementFilter, ColumnElement[bool]], auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, sanity_check: bool = True, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> Sequence[ModelT]: """Delete instances specified by referenced kwargs and filters. Args: *filters: Types for specific filtering operations. auto_expunge: Remove object from session before returning. auto_commit: Commit objects before returning. error_messages: An optional dictionary of templates to use for friendlier error messages to clients sanity_check: When true, the length of selected instances is compared to the deleted row count load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. bind_group: Optional routing group for multi-master configurations. **kwargs: Arguments to apply to a delete Raises: RepositoryError: If the number of deleted rows does not match the number of selected instances Returns: The deleted instances. """ self._uniquify = self._get_uniquify(uniquify) error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, ) with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): resolved_bind_group = self._resolve_bind_group(bind_group) if resolved_bind_group: execution_options = dict(execution_options) if execution_options else {} execution_options["bind_group"] = resolved_bind_group execution_options = self._get_execution_options(execution_options) loader_options, _loader_options_have_wildcard = self._get_loader_options(load) model_type = self.model_type statement = self._get_base_stmt( statement=delete(model_type), loader_options=loader_options, execution_options=execution_options, ) statement = self._filter_select_by_kwargs(statement=statement, kwargs=kwargs) statement = self._apply_filters(*filters, statement=statement, apply_pagination=False) instances: List[ModelT] = [] if self._dialect.delete_executemany_returning: instances.extend(self.session.scalars(statement.returning(model_type))) else: instances.extend( self.list( *filters, load=load, execution_options=execution_options, auto_expunge=auto_expunge, use_cache=False, # Always fetch from DB for delete_where bind_group=bind_group, **kwargs, ), ) result = self.session.execute(statement) row_count = getattr(result, "rowcount", -2) if sanity_check and row_count >= 0 and len(instances) != row_count: # pyright: ignore # backends will return a -1 if they can't determine impacted rowcount # only compare length of selected instances to results if it's >= 0 self.session.rollback() raise RepositoryError(detail="Deleted count does not match fetched count. Rollback issued.") self._flush_or_commit(auto_commit=auto_commit) for instance in instances: self._expunge(instance, auto_expunge=auto_expunge) # Queue cache invalidation (processed on commit) self._queue_cache_invalidation(self.get_primary_key_value(instance), resolved_bind_group) return instances def exists( self, *filters: Union[StatementFilter, ColumnElement[bool]], error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> bool: """Return true if the object specified by ``kwargs`` exists. Args: *filters: Types for specific filtering operations. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. bind_group: Optional routing group to use for the operation. **kwargs: Identifier of the instance to be retrieved. Returns: True if the instance was found. False if not found.. """ error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, ) existing = self.count( *filters, load=load, execution_options=execution_options, error_messages=error_messages, bind_group=bind_group, **kwargs, ) return existing > 0 @staticmethod def _get_base_stmt( *, statement: StatementTypeT, loader_options: Optional[List[_AbstractLoad]], execution_options: Optional[dict[str, Any]], ) -> StatementTypeT: """Get base statement with options applied. Args: statement: The select statement to modify loader_options: Options for loading relationships execution_options: Options for statement execution Returns: Modified select statement """ if loader_options: statement = cast("StatementTypeT", statement.options(*loader_options)) if execution_options: statement = cast("StatementTypeT", statement.execution_options(**execution_options)) return statement def _apply_for_update_options( self, statement: Select[tuple[ModelT]], with_for_update: ForUpdateParameter, ) -> Select[tuple[ModelT]]: """Apply FOR UPDATE options to a SELECT statement when requested.""" if with_for_update in (None, False): return statement if with_for_update is True: return statement.with_for_update() if isinstance(with_for_update, ForUpdateArg): with_for_update_kwargs: dict[str, Any] = { "nowait": with_for_update.nowait, "read": with_for_update.read, "skip_locked": with_for_update.skip_locked, "key_share": with_for_update.key_share, } if getattr(with_for_update, "of", None): with_for_update_kwargs["of"] = with_for_update.of return statement.with_for_update(**with_for_update_kwargs) if isinstance(with_for_update, dict): # pyright: ignore return statement.with_for_update(**with_for_update) return statement def _get_delete_many_statement( self, *, model_type: type[ModelT], id_attribute: InstrumentedAttribute[Any], id_chunk: List[Any], supports_returning: bool, statement_type: Literal["delete", "select"] = "delete", loader_options: Optional[List[_AbstractLoad]], execution_options: Optional[dict[str, Any]], ) -> Union[Select[tuple[ModelT]], Delete, ReturningDelete[tuple[ModelT]]]: # Base statement is static statement = self._get_base_stmt( statement=delete(model_type) if statement_type == "delete" else select(model_type), loader_options=loader_options, execution_options=execution_options, ) if execution_options: statement = statement.execution_options(**execution_options) if supports_returning and statement_type != "select": statement = cast("ReturningDelete[tuple[ModelT]]", statement.returning(model_type)) # type: ignore[union-attr,assignment] # pyright: ignore[reportUnknownLambdaType,reportUnknownMemberType,reportAttributeAccessIssue,reportUnknownVariableType] # Use field.in_() if types are incompatible with ANY() or if dialect doesn't prefer ANY() use_in = not self._prefer_any or self._type_must_use_in_instead_of_any(id_chunk, id_attribute.type) if use_in: return statement.where(id_attribute.in_(id_chunk)) # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType] return statement.where(any_(id_chunk) == id_attribute) # type: ignore[arg-type] def _get_from_db( self, item_id: Any, *, auto_expunge: Optional[bool], statement: Optional[Select[tuple[ModelT]]], id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]], error_messages: Optional[ErrorMessages], load: Optional[LoadSpec], execution_options: Optional[dict[str, Any]], with_for_update: ForUpdateParameter, bind_group: Optional[str] = None, ) -> ModelT: """Fetch an entity from the database without using cache.""" with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): resolved_bind_group = self._resolve_bind_group(bind_group) if resolved_bind_group: execution_options = dict(execution_options) if execution_options else {} execution_options["bind_group"] = resolved_bind_group resolved_execution_options = self._get_execution_options(execution_options) resolved_statement = self.statement if statement is None else statement loader_options, loader_options_have_wildcard = self._get_loader_options(load) resolved_statement = self._get_base_stmt( statement=resolved_statement, loader_options=loader_options, execution_options=resolved_execution_options, ) # Default: use primary key (handles both single and composite PKs) if id_attribute is None: resolved_statement = resolved_statement.where(self._build_pk_filter(item_id)) else: # Custom id_attribute override: lookup by user-specified column resolved_statement = self._filter_select_by_kwargs(resolved_statement, [(id_attribute, item_id)]) resolved_statement = self._apply_for_update_options(resolved_statement, with_for_update) instance = (self._execute(resolved_statement, uniquify=loader_options_have_wildcard)).scalar_one_or_none() instance = self.check_not_found(instance) self._expunge(instance, auto_expunge=auto_expunge) return instance def _get_cached_creator( self, model_name: str, item_id: Any, *, auto_expunge: Optional[bool], statement: Optional[Select[tuple[ModelT]]], id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]], error_messages: Optional[ErrorMessages], load: Optional[LoadSpec], execution_options: Optional[dict[str, Any]], with_for_update: ForUpdateParameter, bind_group: Optional[str] = None, ) -> ModelT: """Singleflight creator for get(id) caching (async).""" if self._cache_manager is None: return self._get_from_db( item_id, auto_expunge=auto_expunge, statement=statement, id_attribute=id_attribute, error_messages=error_messages, load=load, execution_options=execution_options, with_for_update=with_for_update, bind_group=bind_group, ) existing = self._cache_manager.get_entity_sync(model_name, item_id, self.model_type, bind_group=bind_group) if existing is not None: return existing instance = self._get_from_db( item_id, auto_expunge=auto_expunge, statement=statement, id_attribute=id_attribute, error_messages=error_messages, load=load, execution_options=execution_options, with_for_update=with_for_update, bind_group=bind_group, ) self._cache_manager.set_entity_sync(model_name, item_id, instance, bind_group=bind_group) return instance def _list_from_db( self, *, filters: Sequence[Union[StatementFilter, ColumnElement[bool]]], auto_expunge: Optional[bool], statement: Optional[Select[tuple[ModelT]]], order_by: Optional[Union[List[OrderingPair], OrderingPair]], error_messages: Optional[ErrorMessages], load: Optional[LoadSpec], execution_options: Optional[dict[str, Any]], kwargs: dict[str, Any], uniquify: Optional[bool], bind_group: Optional[str] = None, ) -> List[ModelT]: """Fetch a list of entities from the database without using cache.""" self._uniquify = self._get_uniquify(uniquify) with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): resolved_bind_group = self._resolve_bind_group(bind_group) if resolved_bind_group: execution_options = dict(execution_options) if execution_options else {} execution_options["bind_group"] = resolved_bind_group resolved_execution_options = self._get_execution_options(execution_options) resolved_statement = self.statement if statement is None else statement loader_options, loader_options_have_wildcard = self._get_loader_options(load) resolved_statement = self._get_base_stmt( statement=resolved_statement, loader_options=loader_options, execution_options=resolved_execution_options, ) if order_by is None: order_by = self.order_by if self.order_by is not None else [] resolved_statement = self._apply_order_by(statement=resolved_statement, order_by=order_by) resolved_statement = self._apply_filters(*filters, statement=resolved_statement) resolved_statement = self._filter_select_by_kwargs(resolved_statement, kwargs) result = self._execute(resolved_statement, uniquify=loader_options_have_wildcard) instances = list(result.scalars()) for instance in instances: self._expunge(instance, auto_expunge=auto_expunge) return cast("List[ModelT]", instances) def _list_cached_creator( self, cache_key: str, *, filters: Sequence[Union[StatementFilter, ColumnElement[bool]]], auto_expunge: Optional[bool], statement: Optional[Select[tuple[ModelT]]], order_by: Optional[Union[List[OrderingPair], OrderingPair]], error_messages: Optional[ErrorMessages], load: Optional[LoadSpec], execution_options: Optional[dict[str, Any]], kwargs: dict[str, Any], uniquify: Optional[bool], bind_group: Optional[str] = None, ) -> List[ModelT]: """Singleflight creator for list caching (async).""" if self._cache_manager is None: return self._list_from_db( filters=filters, auto_expunge=auto_expunge, statement=statement, order_by=order_by, error_messages=error_messages, load=load, execution_options=execution_options, kwargs=kwargs, uniquify=uniquify, bind_group=bind_group, ) existing = self._cache_manager.get_list_sync(cache_key, self.model_type) if existing is not None: return existing instances = self._list_from_db( filters=filters, auto_expunge=auto_expunge, statement=statement, order_by=order_by, error_messages=error_messages, load=load, execution_options=execution_options, kwargs=kwargs, uniquify=uniquify, bind_group=bind_group, ) self._cache_manager.set_list_sync(cache_key, list(instances)) return list(instances) def _list_and_count_from_db( self, *, filters: Sequence[Union[StatementFilter, ColumnElement[bool]]], auto_expunge: Optional[bool], statement: Optional[Select[tuple[ModelT]]], count_with_window_function: bool, order_by: Optional[Union[List[OrderingPair], OrderingPair]], error_messages: Optional[ErrorMessages], load: Optional[LoadSpec], execution_options: Optional[dict[str, Any]], kwargs: dict[str, Any], uniquify: Optional[bool], bind_group: Optional[str] = None, ) -> tuple[List[ModelT], int]: """Fetch a list+count payload from the database without using cache.""" self._uniquify = self._get_uniquify(uniquify) resolved_bind_group = self._resolve_bind_group(bind_group) if resolved_bind_group: execution_options = dict(execution_options) if execution_options else {} execution_options["bind_group"] = resolved_bind_group if self._dialect.name in {"spanner", "spanner+spanner"} or not count_with_window_function: return self._list_and_count_basic( *filters, auto_expunge=auto_expunge, statement=statement, load=load, execution_options=execution_options, order_by=order_by, error_messages=error_messages, **kwargs, ) return self._list_and_count_window( *filters, auto_expunge=auto_expunge, statement=statement, load=load, execution_options=execution_options, error_messages=error_messages, order_by=order_by, **kwargs, ) def _list_and_count_cached_creator( self, cache_key: str, *, filters: Sequence[Union[StatementFilter, ColumnElement[bool]]], auto_expunge: Optional[bool], statement: Optional[Select[tuple[ModelT]]], count_with_window_function: bool, order_by: Optional[Union[List[OrderingPair], OrderingPair]], error_messages: Optional[ErrorMessages], load: Optional[LoadSpec], execution_options: Optional[dict[str, Any]], kwargs: dict[str, Any], uniquify: Optional[bool], bind_group: Optional[str] = None, ) -> tuple[List[ModelT], int]: """Singleflight creator for list_and_count caching (async).""" if self._cache_manager is None: return self._list_and_count_from_db( filters=filters, auto_expunge=auto_expunge, statement=statement, count_with_window_function=count_with_window_function, order_by=order_by, error_messages=error_messages, load=load, execution_options=execution_options, kwargs=kwargs, uniquify=uniquify, bind_group=bind_group, ) existing = self._cache_manager.get_list_and_count_sync(cache_key, self.model_type) if existing is not None: return existing instances, count = self._list_and_count_from_db( filters=filters, auto_expunge=auto_expunge, statement=statement, count_with_window_function=count_with_window_function, order_by=order_by, error_messages=error_messages, load=load, execution_options=execution_options, kwargs=kwargs, uniquify=uniquify, bind_group=bind_group, ) self._cache_manager.set_list_and_count_sync(cache_key, list(instances), count) return list(instances), count def get( self, item_id: PrimaryKeyType, *, auto_expunge: Optional[bool] = None, statement: Optional[Select[tuple[ModelT]]] = None, id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, with_for_update: ForUpdateParameter = None, use_cache: bool = True, bind_group: Optional[str] = None, ) -> ModelT: """Get instance identified by `item_id`. Args: item_id: Identifier of the instance to be retrieved. For single primary keys, pass a scalar value (int, str, UUID, etc.). For composite primary keys, pass a tuple of values in column order or a dict mapping attribute names to values. auto_expunge: Remove object from session before returning. statement: To facilitate customization of the underlying select query. id_attribute: Allows customization of the unique identifier to use for model fetching. Defaults to `id`, but can reference any surrogate or candidate key for the table. Note: Only applies to single-column lookups. For composite primary keys, this parameter is ignored and the primary key columns are used automatically. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. with_for_update: Optional FOR UPDATE clause / parameters to apply to the SELECT statement. use_cache: Whether to use caching for this query (default True). bind_group: Optional routing group to use for the operation. Returns: The retrieved instance. Examples: # Single primary key >>> user = await user_repo.get(123) # Composite primary key (tuple format) >>> assignment = await user_role_repo.get((user_id, role_id)) # Composite primary key (dict format) >>> assignment = await user_role_repo.get( ... { ... "user_id": 1, ... "role_id": 5, ... } ... ) Raises: NotFoundError: If no instance is found with the given primary key. ValueError: If the input format doesn't match the primary key structure. """ self._uniquify = self._get_uniquify(uniquify) resolved_error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, ) resolved_auto_expunge = self.auto_expunge if auto_expunge is None else auto_expunge resolved_id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]] = id_attribute if isinstance(resolved_id_attribute, InstrumentedAttribute): resolved_id_attribute = resolved_id_attribute.key cache_manager = self._cache_manager # Resolve bind_group for cache key namespacing resolved_bind_group = self._resolve_bind_group(bind_group) if ( use_cache and cache_manager is not None and bool(resolved_auto_expunge) and statement is None and load is None and with_for_update is None and (resolved_id_attribute is None or resolved_id_attribute == self.id_attribute) and not self._default_loader_options and not self._default_execution_options and execution_options is None ): model_name = cast("str", self.model_type.__tablename__) # type: ignore[attr-defined] cached = cache_manager.get_entity_sync(model_name, item_id, self.model_type, bind_group=resolved_bind_group) if cached is not None: return cached # Include bind_group in singleflight key to prevent cross-shard cache pollution singleflight_key = ( f"{model_name}:{resolved_bind_group}:get:{item_id}" if resolved_bind_group else f"{model_name}:get:{item_id}" ) return cache_manager.singleflight_sync( singleflight_key, partial( self._get_cached_creator, model_name, item_id, auto_expunge=auto_expunge, statement=statement, id_attribute=resolved_id_attribute, error_messages=resolved_error_messages, load=load, execution_options=execution_options, with_for_update=with_for_update, bind_group=resolved_bind_group, ), ) return self._get_from_db( item_id, auto_expunge=auto_expunge, statement=statement, id_attribute=id_attribute, error_messages=resolved_error_messages, load=load, execution_options=execution_options, with_for_update=with_for_update, bind_group=bind_group, ) def get_one( self, *filters: Union[StatementFilter, ColumnElement[bool]], auto_expunge: Optional[bool] = None, statement: Optional[Select[tuple[ModelT]]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, with_for_update: ForUpdateParameter = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> ModelT: """Get instance identified by ``kwargs``. Args: *filters: Types for specific filtering operations. auto_expunge: Remove object from session before returning. statement: To facilitate customization of the underlying select query. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. with_for_update: Optional FOR UPDATE clause / parameters to apply to the SELECT statement. bind_group: Optional routing group to use for the operation. **kwargs: Identifier of the instance to be retrieved. Returns: The retrieved instance. """ self._uniquify = self._get_uniquify(uniquify) error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, ) with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): if bind_group: execution_options = dict(execution_options) if execution_options else {} execution_options["bind_group"] = bind_group execution_options = self._get_execution_options(execution_options) statement = self.statement if statement is None else statement loader_options, loader_options_have_wildcard = self._get_loader_options(load) statement = self._get_base_stmt( statement=statement, loader_options=loader_options, execution_options=execution_options, ) statement = self._apply_filters(*filters, apply_pagination=False, statement=statement) statement = self._filter_select_by_kwargs(statement, kwargs) statement = self._apply_for_update_options(statement, with_for_update) instance = (self._execute(statement, uniquify=loader_options_have_wildcard)).scalar_one_or_none() instance = self.check_not_found(instance) self._expunge(instance, auto_expunge=auto_expunge) return instance def get_one_or_none( self, *filters: Union[StatementFilter, ColumnElement[bool]], auto_expunge: Optional[bool] = None, statement: Optional[Select[tuple[ModelT]]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, with_for_update: ForUpdateParameter = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> Union[ModelT, None]: """Get instance identified by ``kwargs`` or None if not found. Args: *filters: Types for specific filtering operations. auto_expunge: Remove object from session before returning. statement: To facilitate customization of the underlying select query. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. with_for_update: Optional FOR UPDATE clause / parameters to apply to the SELECT statement. bind_group: Optional routing group to use for the operation. **kwargs: Identifier of the instance to be retrieved. Returns: The retrieved instance or None """ self._uniquify = self._get_uniquify(uniquify) error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, ) with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): if bind_group: execution_options = dict(execution_options) if execution_options else {} execution_options["bind_group"] = bind_group execution_options = self._get_execution_options(execution_options) statement = self.statement if statement is None else statement loader_options, loader_options_have_wildcard = self._get_loader_options(load) statement = self._get_base_stmt( statement=statement, loader_options=loader_options, execution_options=execution_options, ) statement = self._apply_filters(*filters, apply_pagination=False, statement=statement) statement = self._filter_select_by_kwargs(statement, kwargs) statement = self._apply_for_update_options(statement, with_for_update) instance = cast( "Result[tuple[ModelT]]", (self._execute(statement, uniquify=loader_options_have_wildcard)), ).scalar_one_or_none() if instance: self._expunge(instance, auto_expunge=auto_expunge) return instance def get_or_upsert( self, *filters: Union[StatementFilter, ColumnElement[bool]], match_fields: Optional[Union[List[str], str]] = None, upsert: bool = True, attribute_names: Optional[Iterable[str]] = None, with_for_update: ForUpdateParameter = None, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, auto_refresh: Union[bool, None] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[ModelT, bool]: """Get instance identified by ``kwargs`` or create if it doesn't exist. Args: *filters: Types for specific filtering operations. match_fields: a list of keys to use to match the existing model. When empty, all fields are matched. upsert: When using match_fields and actual model values differ from `kwargs`, automatically perform an update operation on the model. attribute_names: an iterable of attribute names to pass into the ``update`` method. with_for_update: indicating FOR UPDATE should be used, or may be a dictionary containing flags to indicate a more specific set of FOR UPDATE flags for the SELECT auto_expunge: Remove object from session before returning. auto_refresh: Refresh object from session before returning. auto_commit: Commit objects before returning. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. bind_group: Optional routing group for multi-master configurations. **kwargs: Identifier of the instance to be retrieved. Returns: a tuple that includes the instance and whether it needed to be created. When using match_fields and actual model values differ from ``kwargs``, the model value will be updated. """ self._uniquify = self._get_uniquify(uniquify) error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, ) with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): if match_fields := self._get_match_fields(match_fields=match_fields): match_filter = { field_name: kwargs.get(field_name) for field_name in match_fields if kwargs.get(field_name) is not None } else: match_filter = kwargs existing = self.get_one_or_none( *filters, **match_filter, load=load, execution_options=execution_options, bind_group=bind_group, ) if not existing: return ( self.add( self.model_type(**kwargs), auto_commit=auto_commit, auto_expunge=auto_expunge, auto_refresh=auto_refresh, bind_group=bind_group, ), True, ) if upsert: for field_name, new_field_value in kwargs.items(): field = getattr(existing, field_name, MISSING) if field is not MISSING and not compare_values(field, new_field_value): # pragma: no cover setattr(existing, field_name, new_field_value) existing = self._attach_to_session(existing, strategy="merge") self._flush_or_commit(auto_commit=auto_commit) self._refresh( existing, attribute_names=attribute_names, with_for_update=with_for_update, auto_refresh=auto_refresh, ) self._expunge(existing, auto_expunge=auto_expunge) return existing, False def get_and_update( self, *filters: Union[StatementFilter, ColumnElement[bool]], match_fields: Optional[Union[List[str], str]] = None, attribute_names: Optional[Iterable[str]] = None, with_for_update: ForUpdateParameter = None, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, auto_refresh: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[ModelT, bool]: """Get instance identified by ``kwargs`` and update the model if the arguments are different. Args: *filters: Types for specific filtering operations. match_fields: a list of keys to use to match the existing model. When empty, all fields are matched. attribute_names: an iterable of attribute names to pass into the ``update`` method. with_for_update: indicating FOR UPDATE should be used, or may be a dictionary containing flags to indicate a more specific set of FOR UPDATE flags for the SELECT auto_expunge: Remove object from session before returning. auto_refresh: Refresh object from session before returning. auto_commit: Commit objects before returning. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. bind_group: Optional routing group for multi-master configurations. **kwargs: Identifier of the instance to be retrieved. Returns: a tuple that includes the instance and whether it needed to be updated. When using match_fields and actual model values differ from ``kwargs``, the model value will be updated. """ self._uniquify = self._get_uniquify(uniquify) error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, ) with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): if match_fields := self._get_match_fields(match_fields=match_fields): match_filter = { field_name: kwargs.get(field_name) for field_name in match_fields if kwargs.get(field_name) is not None } else: match_filter = kwargs existing = self.get_one( *filters, **match_filter, load=load, execution_options=execution_options, bind_group=bind_group ) updated = False for field_name, new_field_value in kwargs.items(): field = getattr(existing, field_name, MISSING) if field is not MISSING and not compare_values(field, new_field_value): # pragma: no cover updated = True setattr(existing, field_name, new_field_value) existing = self._attach_to_session(existing, strategy="merge") self._flush_or_commit(auto_commit=auto_commit) self._refresh( existing, attribute_names=attribute_names, with_for_update=with_for_update, auto_refresh=auto_refresh, ) self._expunge(existing, auto_expunge=auto_expunge) return existing, updated def count( self, *filters: Union[StatementFilter, ColumnElement[bool]], statement: Optional[Select[tuple[ModelT]]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> int: """Get the count of records returned by a query. Args: *filters: Types for specific filtering operations. statement: To facilitate customization of the underlying select query. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. bind_group: Optional routing group to use for the operation. **kwargs: Instance attribute value filters. Returns: Count of records returned by query, ignoring pagination. """ self._uniquify = self._get_uniquify(uniquify) error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, ) with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): if bind_group: execution_options = dict(execution_options) if execution_options else {} execution_options["bind_group"] = bind_group execution_options = self._get_execution_options(execution_options) statement = self.statement if statement is None else statement loader_options, loader_options_have_wildcard = self._get_loader_options(load) statement = self._get_base_stmt( statement=statement, loader_options=loader_options, execution_options=execution_options, ) statement = self._apply_filters(*filters, apply_pagination=False, statement=statement) statement = self._filter_select_by_kwargs(statement, kwargs) results = self._execute( statement=self._get_count_stmt( statement=statement, loader_options=loader_options, execution_options=execution_options ), uniquify=loader_options_have_wildcard, ) return cast("int", results.scalar_one()) def update( self, data: ModelT, *, attribute_names: Optional[Iterable[str]] = None, with_for_update: ForUpdateParameter = None, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, auto_refresh: Optional[bool] = None, id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, ) -> ModelT: """Update instance with the attribute values present on `data`. Args: data: An instance that should have a value for `self.id_attribute` that exists in the collection. attribute_names: an iterable of attribute names to pass into the ``update`` method. with_for_update: indicating FOR UPDATE should be used, or may be a dictionary containing flags to indicate a more specific set of FOR UPDATE flags for the SELECT auto_expunge: Remove object from session before returning. auto_refresh: Refresh object from session before returning. auto_commit: Commit objects before returning. id_attribute: Allows customization of the unique identifier to use for model fetching. Defaults to `id`, but can reference any surrogate or candidate key for the table. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. bind_group: Optional routing group for multi-master configurations. Returns: The updated instance. """ self._uniquify = self._get_uniquify(uniquify) error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, ) with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): # For composite PKs (when no id_attribute override), extract the full PK value if id_attribute is None and self.has_composite_pk: item_id = self.get_primary_key_value(data) else: item_id = self.get_id_attribute_value( data, id_attribute=id_attribute, ) existing_instance = self.get( item_id, id_attribute=id_attribute, load=load, execution_options=execution_options, with_for_update=with_for_update, bind_group=bind_group, ) mapper = None with ( self.session.no_autoflush, contextlib.suppress(MissingGreenlet, NoInspectionAvailable), ): mapper = inspect(data) if mapper is not None: for column in mapper.mapper.columns: field_name = column.key new_field_value = getattr(data, field_name, MISSING) if new_field_value is not MISSING: # Skip setting columns with defaults/onupdate to None during updates # This prevents overwriting columns that should use their defaults if new_field_value is None and column_has_defaults(column): continue # Only copy attributes that were explicitly set on the input instance # This prevents overwriting existing values with uninitialized None values if not was_attribute_set(data, mapper, field_name): continue existing_field_value = getattr(existing_instance, field_name, MISSING) if existing_field_value is not MISSING and not compare_values( existing_field_value, new_field_value ): setattr(existing_instance, field_name, new_field_value) # Handle relationships by merging objects into session first for relationship in mapper.mapper.relationships: if relationship.viewonly or relationship.lazy in { # pragma: no cover "write_only", "dynamic", "raise", "raise_on_sql", }: # Skip relationships with incompatible lazy loading strategies continue # Only copy relationships that were explicitly set on the input instance # This prevents overwriting existing relationships with uninitialized # None/[] values from SQLAlchemy's auto-initialization if not was_attribute_set(data, mapper, relationship.key): continue if (new_value := getattr(data, relationship.key, MISSING)) is not MISSING: # Skip relationships that cannot be handled by generic merge operations if isinstance(new_value, list): merged_values = [ # pyright: ignore self.session.merge(item, load=False) # pyright: ignore for item in new_value # pyright: ignore ] setattr(existing_instance, relationship.key, merged_values) elif new_value is not None: merged_value = self.session.merge(new_value, load=False) setattr(existing_instance, relationship.key, merged_value) else: setattr(existing_instance, relationship.key, new_value) instance = self._attach_to_session(existing_instance, strategy="merge") self._flush_or_commit(auto_commit=auto_commit) self._refresh( instance, attribute_names=attribute_names, with_for_update=with_for_update, auto_refresh=auto_refresh, ) self._expunge(instance, auto_expunge=auto_expunge) # Queue cache invalidation (processed on commit) self._queue_cache_invalidation(self.get_primary_key_value(instance), bind_group) return instance def update_many( self, data: List[ModelT], *, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, ) -> List[ModelT]: """Update one or more instances with the attribute values present on `data`. This function has an optimized bulk update based on the configured SQL dialect: - For backends supporting `RETURNING` with `executemany`, a single bulk update with returning clause is executed. - For other backends, it does a bulk update and then returns the updated data after a refresh. Args: data: A list of instances to update. Each should have a value for `self.id_attribute` that exists in the collection. auto_expunge: Remove object from session before returning. auto_commit: Commit objects before returning. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. bind_group: Optional routing group for multi-master configurations. Returns: The updated instances. """ self._uniquify = self._get_uniquify(uniquify) error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, ) supports_updated_at = hasattr(self.model_type, "updated_at") data_to_update: List[dict[str, Any]] = [] for v in data: update_payload = model_to_dict(v) if hasattr(v, "__mapper__") else schema_dump(cast("dict[str, Any]", v)) if supports_updated_at and (update_payload.get("updated_at") is None): update_payload["updated_at"] = datetime.datetime.now(datetime.timezone.utc) data_to_update.append(update_payload) with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): resolved_bind_group = self._resolve_bind_group(bind_group) if resolved_bind_group: execution_options = dict(execution_options) if execution_options else {} execution_options["bind_group"] = resolved_bind_group execution_options = self._get_execution_options(execution_options) loader_options = self._get_loader_options(load)[0] supports_returning = self._dialect.update_executemany_returning and self._dialect.name != "oracle" statement = self._get_update_many_statement( self.model_type, supports_returning, loader_options=loader_options, execution_options=execution_options, ) if supports_returning: instances = list( self.session.scalars( statement, cast("_CoreSingleExecuteParams", data_to_update), # this is not correct but the only way # currently to deal with an SQLAlchemy typing issue. See # https://github.com/sqlalchemy/sqlalchemy/discussions/9925 execution_options=execution_options, ), ) self._flush_or_commit(auto_commit=auto_commit) for instance in instances: self._expunge(instance, auto_expunge=auto_expunge) return instances self.session.execute(statement, data_to_update, execution_options=execution_options) self._flush_or_commit(auto_commit=auto_commit) # For non-RETURNING backends, fetch updated instances from database if self.has_composite_pk: # Build composite PK filter using OR of AND conditions pk_filters: List[ColumnElement[bool]] = [] for item in data_to_update: pk_tuple = tuple(item[attr] for attr in self._pk_attr_names) pk_filters.append(and_(*[col == val for col, val in zip(self._pk_columns, pk_tuple)])) updated_instances = self.list( or_(*pk_filters), load=loader_options, execution_options=execution_options, bind_group=bind_group, ) else: updated_ids: List[Any] = [item[self.id_attribute] for item in data_to_update] updated_instances = self.list( getattr(self.model_type, self.id_attribute).in_(updated_ids), load=loader_options, execution_options=execution_options, bind_group=bind_group, ) for instance in updated_instances: self._expunge(instance, auto_expunge=auto_expunge) # Queue cache invalidation (processed on commit) self._queue_cache_invalidation(self.get_primary_key_value(instance), bind_group) return updated_instances def _get_update_many_statement( self, model_type: type[ModelT], supports_returning: bool, loader_options: Union[List[_AbstractLoad], None], execution_options: Union[dict[str, Any], None], ) -> Union[Update, ReturningUpdate[tuple[ModelT]]]: # Base update statement is static statement = self._get_base_stmt( statement=update(table=model_type), loader_options=loader_options, execution_options=execution_options ) if supports_returning: return statement.returning(model_type) return statement def list_and_count( self, *filters: Union[StatementFilter, ColumnElement[bool]], statement: Optional[Select[tuple[ModelT]]] = None, auto_expunge: Optional[bool] = None, count_with_window_function: Optional[bool] = None, order_by: Optional[Union[List[OrderingPair], OrderingPair]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, use_cache: bool = True, bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[List[ModelT], int]: """List records with total count. Args: *filters: Types for specific filtering operations. statement: To facilitate customization of the underlying select query. auto_expunge: Remove object from session before returning. count_with_window_function: When false, list and count will use two queries instead of an analytical window function. order_by: Set default order options for queries. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. use_cache: Whether to use the cache for this query. Defaults to ``True``. bind_group: Optional routing group to use for the operation. **kwargs: Instance attribute value filters. Returns: Count of records returned by query, ignoring pagination. """ count_with_window_function = ( count_with_window_function if count_with_window_function is not None else self.count_with_window_function ) self._uniquify = self._get_uniquify(uniquify) resolved_error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, ) resolved_auto_expunge = self.auto_expunge if auto_expunge is None else auto_expunge resolved_execution_options = self._get_execution_options(execution_options) resolved_order_by = order_by if order_by is not None else (self.order_by if self.order_by is not None else []) cache_manager = self._cache_manager if not ( use_cache and bool(resolved_auto_expunge) and cache_manager is not None and statement is None and load is None and not self._default_loader_options ): return self._list_and_count_from_db( filters=filters, auto_expunge=auto_expunge, statement=statement, count_with_window_function=count_with_window_function, order_by=order_by, error_messages=resolved_error_messages, load=load, execution_options=execution_options, kwargs=kwargs, uniquify=uniquify, bind_group=bind_group, ) model_name = cast("str", self.model_type.__tablename__) # type: ignore[attr-defined] version_token = cache_manager.get_model_version_sync(model_name) cache_key = _build_list_cache_key( model_name=model_name, version_token=version_token, method="list_and_count", filters=filters, kwargs=kwargs, order_by=resolved_order_by, execution_options=resolved_execution_options, uniquify=self._uniquify, count_with_window_function=count_with_window_function, ) if cache_key is None: return self._list_and_count_from_db( filters=filters, auto_expunge=auto_expunge, statement=statement, count_with_window_function=count_with_window_function, order_by=order_by, error_messages=resolved_error_messages, load=load, execution_options=execution_options, kwargs=kwargs, uniquify=uniquify, bind_group=bind_group, ) cached = cache_manager.get_list_and_count_sync(cache_key, self.model_type) if cached is not None: return cached return cache_manager.singleflight_sync( cache_key, partial( self._list_and_count_cached_creator, cache_key, filters=filters, auto_expunge=auto_expunge, statement=statement, count_with_window_function=count_with_window_function, order_by=order_by, error_messages=resolved_error_messages, load=load, execution_options=execution_options, kwargs=kwargs, uniquify=uniquify, bind_group=bind_group, ), ) def _expunge(self, instance: "ModelT", auto_expunge: "Optional[bool]") -> None: """Remove instance from session if auto_expunge is enabled. Args: instance: The model instance to expunge auto_expunge: Whether to expunge the instance. If None, uses self.auto_expunge Note: Deleted objects that have been committed are automatically moved to the detached state by SQLAlchemy. Objects returned from DELETE...RETURNING statements are initially persistent but become detached after commit. We skip expunge for objects that are already detached or marked for deletion to avoid InvalidRequestError. """ if auto_expunge is None: auto_expunge = self.auto_expunge if not auto_expunge: return # Check object state before expunging state = inspect(instance) if state is not None and (state.deleted or state.detached): # Skip expunge for objects that are deleted or already detached # - state.deleted: Object marked for deletion, will be detached on commit # - state.detached: Object already removed from session (e.g., from DELETE...RETURNING) return self.session.expunge(instance) return def _flush_or_commit(self, auto_commit: Optional[bool]) -> None: if auto_commit is None: auto_commit = self.auto_commit return self.session.commit() if auto_commit else self.session.flush() def _refresh( self, instance: ModelT, auto_refresh: Optional[bool], attribute_names: Optional[Iterable[str]] = None, with_for_update: ForUpdateParameter = None, ) -> None: if auto_refresh is None: auto_refresh = self.auto_refresh return ( self.session.refresh( instance=instance, attribute_names=attribute_names, with_for_update=with_for_update, ) if auto_refresh else None ) def _list_and_count_window( self, *filters: Union[StatementFilter, ColumnElement[bool]], auto_expunge: Optional[bool] = None, statement: Optional[Select[tuple[ModelT]]] = None, order_by: Optional[Union[List[OrderingPair], OrderingPair]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[List[ModelT], int]: """List records with total count. Args: *filters: Types for specific filtering operations. auto_expunge: Remove object from session before returning. statement: To facilitate customization of the underlying select query. order_by: List[OrderingPair] | OrderingPair | None = None, error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set relationships to be loaded execution_options: Set default execution options bind_group: Optional routing group to use for the operation. **kwargs: Instance attribute value filters. Returns: Count of records returned by query using an analytical window function, ignoring pagination. """ error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, ) with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): if bind_group: execution_options = dict(execution_options) if execution_options else {} execution_options["bind_group"] = bind_group execution_options = self._get_execution_options(execution_options) statement = self.statement if statement is None else statement loader_options, loader_options_have_wildcard = self._get_loader_options(load) statement = self._get_base_stmt( statement=statement, loader_options=loader_options, execution_options=execution_options, ) if order_by is None: order_by = self.order_by if self.order_by is not None else [] statement = self._apply_order_by(statement=statement, order_by=order_by) statement = self._apply_filters(*filters, statement=statement) statement = self._filter_select_by_kwargs(statement, kwargs) result = self._execute(statement.add_columns(over(sql_func.count())), uniquify=loader_options_have_wildcard) count: int = 0 instances: List[ModelT] = [] for i, (instance, count_value) in enumerate(result): self._expunge(instance, auto_expunge=auto_expunge) instances.append(instance) if i == 0: count = count_value return instances, count def _list_and_count_basic( self, *filters: Union[StatementFilter, ColumnElement[bool]], auto_expunge: Optional[bool] = None, statement: Optional[Select[tuple[ModelT]]] = None, order_by: Optional[Union[List[OrderingPair], OrderingPair]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[List[ModelT], int]: """List records with total count. Args: *filters: Types for specific filtering operations. auto_expunge: Remove object from session before returning. statement: To facilitate customization of the underlying select query. order_by: Set default order options for queries. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set relationships to be loaded execution_options: Set default execution options bind_group: Optional routing group to use for the operation. **kwargs: Instance attribute value filters. Returns: Count of records returned by query using 2 queries, ignoring pagination. """ error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, ) with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): if bind_group: execution_options = dict(execution_options) if execution_options else {} execution_options["bind_group"] = bind_group execution_options = self._get_execution_options(execution_options) statement = self.statement if statement is None else statement loader_options, loader_options_have_wildcard = self._get_loader_options(load) statement = self._get_base_stmt( statement=statement, loader_options=loader_options, execution_options=execution_options, ) if order_by is None: order_by = self.order_by if self.order_by is not None else [] statement = self._apply_order_by(statement=statement, order_by=order_by) statement = self._apply_filters(*filters, statement=statement) statement = self._filter_select_by_kwargs(statement, kwargs) count_result = self.session.execute( self._get_count_stmt( statement, loader_options=loader_options, execution_options=execution_options, ), ) count = count_result.scalar_one() if count == 0: return [], 0 result = self._execute(statement, uniquify=loader_options_have_wildcard) instances: List[ModelT] = [] for (instance,) in result: self._expunge(instance, auto_expunge=auto_expunge) instances.append(instance) return instances, count @staticmethod def _get_count_stmt( statement: Select[tuple[ModelT]], loader_options: Optional[List[_AbstractLoad]], # noqa: ARG004 execution_options: Optional[dict[str, Any]], # noqa: ARG004 ) -> Select[tuple[int]]: # Count statement transformations are static return ( statement.with_only_columns(sql_func.count(text("1")), maintain_column_froms=True) .limit(None) .offset(None) .order_by(None) ) def upsert( self, data: ModelT, *, attribute_names: Optional[Iterable[str]] = None, with_for_update: ForUpdateParameter = None, auto_expunge: Optional[bool] = None, auto_commit: Optional[bool] = None, auto_refresh: Optional[bool] = None, match_fields: Optional[Union[List[str], str]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, ) -> ModelT: """Modify or create instance. Updates instance with the attribute values present on `data`, or creates a new instance if one doesn't exist. Args: data: Instance to update existing, or be created. Identifier used to determine if an existing instance exists is the value of an attribute on `data` named as value of `self.id_attribute`. attribute_names: an iterable of attribute names to pass into the ``update`` method. with_for_update: indicating FOR UPDATE should be used, or may be a dictionary containing flags to indicate a more specific set of FOR UPDATE flags for the SELECT auto_expunge: Remove object from session before returning. auto_refresh: Refresh object from session before returning. auto_commit: Commit objects before returning. match_fields: a list of keys to use to match the existing model. When empty, all fields are matched. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. bind_group: Optional routing group for multi-master configurations. Returns: The updated or created instance. """ self._uniquify = self._get_uniquify(uniquify) error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, ) if match_fields := self._get_match_fields(match_fields=match_fields): match_filter = { field_name: getattr(data, field_name, None) for field_name in match_fields if getattr(data, field_name, None) is not None } elif self.has_composite_pk and self.has_primary_key_values(data): # For composite PKs, match on all PK columns match_filter = {attr: getattr(data, attr) for attr in self._pk_attr_names} elif getattr(data, self.id_attribute, None) is not None: match_filter = {self.id_attribute: getattr(data, self.id_attribute, None)} else: # Exclude all PK columns when matching by non-PK fields exclude_cols = set(self._pk_attr_names) if self.has_composite_pk else {self.id_attribute} match_filter = model_to_dict(data, exclude=exclude_cols) existing = self.get_one_or_none( load=load, execution_options=execution_options, bind_group=bind_group, **match_filter ) if not existing: return self.add( data, auto_commit=auto_commit, auto_expunge=auto_expunge, auto_refresh=auto_refresh, bind_group=bind_group, ) with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): # Exclude all PK columns when copying field values exclude_cols = set(self._pk_attr_names) if self.has_composite_pk else {self.id_attribute} for field_name, new_field_value in model_to_dict(data, exclude=exclude_cols).items(): field = getattr(existing, field_name, MISSING) if field is not MISSING and not compare_values(field, new_field_value): # pragma: no cover setattr(existing, field_name, new_field_value) instance = self._attach_to_session(existing, strategy="merge") self._flush_or_commit(auto_commit=auto_commit) self._refresh( instance, attribute_names=attribute_names, with_for_update=with_for_update, auto_refresh=auto_refresh, ) self._expunge(instance, auto_expunge=auto_expunge) # Queue cache invalidation (processed on commit) self._queue_cache_invalidation(self.get_primary_key_value(instance), bind_group) return instance def upsert_many( self, data: List[ModelT], *, auto_expunge: Optional[bool] = None, auto_commit: Optional[bool] = None, no_merge: bool = False, match_fields: Optional[Union[List[str], str]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, ) -> List[ModelT]: """Modify or create multiple instances. Update instances with the attribute values present on `data`, or create a new instance if one doesn't exist. !!! tip In most cases, you will want to set `match_fields` to the combination of attributes, excluded the primary key, that define uniqueness for a row. Args: data: Instance to update existing, or be created. Identifier used to determine if an existing instance exists is the value of an attribute on ``data`` named as value of :attr:`id_attribute`. auto_expunge: Remove object from session before returning. auto_commit: Commit objects before returning. no_merge: Skip the usage of optimized Merge statements match_fields: a list of keys to use to match the existing model. When empty, automatically uses ``self.id_attribute`` (`id` by default) to match . error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. bind_group: Optional routing group to use for the operation. Returns: The updated or created instance. """ self._uniquify = self._get_uniquify(uniquify) error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, ) instances: List[ModelT] = [] data_to_update: List[ModelT] = [] data_to_insert: List[ModelT] = [] match_fields = self._get_match_fields(match_fields=match_fields) if match_fields is None: # Default to all PK columns for composite PKs, otherwise just id_attribute match_fields = list(self._pk_attr_names) if self.has_composite_pk else [self.id_attribute] match_filter: List[Union[StatementFilter, ColumnElement[bool]]] = [] if match_fields: for field_name in match_fields: field = get_instrumented_attr(self.model_type, field_name) matched_values = [ field_data for datum in data if (field_data := getattr(datum, field_name)) is not None ] # Use field.in_() if types are incompatible with ANY() or if dialect doesn't prefer ANY() use_in = not self._prefer_any or self._type_must_use_in_instead_of_any(matched_values, field.type) match_filter.append(field.in_(matched_values) if use_in else any_(matched_values) == field) # type: ignore[arg-type] with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): existing_objs = self.list( *match_filter, load=load, execution_options=execution_options, auto_expunge=False, bind_group=bind_group, ) for field_name in match_fields: field = get_instrumented_attr(self.model_type, field_name) # Safe deduplication that handles unhashable types (e.g., JSONB dicts) all_values = [getattr(datum, field_name) for datum in existing_objs if datum] matched_values = self._get_unique_values(all_values) # Use field.in_() if types are incompatible with ANY() or if dialect doesn't prefer ANY() use_in = not self._prefer_any or self._type_must_use_in_instead_of_any(matched_values, field.type) match_filter.append(field.in_(matched_values) if use_in else any_(matched_values) == field) # type: ignore[arg-type] existing_ids = self._get_object_ids(existing_objs=existing_objs) data = self._merge_on_match_fields(data, existing_objs, match_fields) for datum in data: # Use extracted PK value which handles composite PKs (returns tuple) datum_pk = self.get_primary_key_value(datum) if datum_pk in existing_ids: data_to_update.append(datum) else: data_to_insert.append(datum) if data_to_insert: instances.extend( self.add_many(data_to_insert, auto_commit=False, auto_expunge=False, bind_group=bind_group), ) if data_to_update: instances.extend( self.update_many( data_to_update, auto_commit=False, auto_expunge=False, load=load, execution_options=execution_options, bind_group=bind_group, ), ) self._flush_or_commit(auto_commit=auto_commit) for instance in instances: self._expunge(instance, auto_expunge=auto_expunge) return instances def _get_object_ids(self, existing_objs: List[ModelT]) -> List[PrimaryKeyType]: """Extract primary key values from a list of model instances. For composite PKs, returns tuples; for single PKs, returns scalar values. """ return [self.get_primary_key_value(datum) for datum in existing_objs if self.has_primary_key_values(datum)] def _get_match_fields( self, match_fields: Optional[Union[List[str], str]] = None, id_attribute: Optional[str] = None, ) -> Optional[List[str]]: id_attribute = id_attribute or self.id_attribute match_fields = match_fields or self.match_fields if isinstance(match_fields, str): match_fields = [match_fields] return match_fields def _merge_on_match_fields( self, data: List[ModelT], existing_data: List[ModelT], match_fields: Optional[Union[List[str], str]] = None, ) -> List[ModelT]: match_fields = self._get_match_fields(match_fields=match_fields) if match_fields is None: # Default to all PK columns for composite PKs, otherwise just id_attribute match_fields = list(self._pk_attr_names) if self.has_composite_pk else [self.id_attribute] for existing_datum in existing_data: for datum in data: match = all( getattr(datum, field_name) == getattr(existing_datum, field_name) for field_name in match_fields ) if match and self.has_primary_key_values(existing_datum): # Copy all PK values from existing to datum (handles composite PKs) for pk_attr in self._pk_attr_names: setattr(datum, pk_attr, getattr(existing_datum, pk_attr)) return data def list( self, *filters: Union[StatementFilter, ColumnElement[bool]], auto_expunge: Optional[bool] = None, statement: Optional[Select[tuple[ModelT]]] = None, order_by: Optional[Union[List[OrderingPair], OrderingPair]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, use_cache: bool = True, bind_group: Optional[str] = None, **kwargs: Any, ) -> List[ModelT]: """Get a list of instances, optionally filtered. Args: *filters: Types for specific filtering operations. auto_expunge: Remove object from session before returning. statement: To facilitate customization of the underlying select query. order_by: Set default order options for queries. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. use_cache: Whether to use the cache for this query. Defaults to ``True``. bind_group: Optional routing group to use for the operation. **kwargs: Instance attribute value filters. Returns: The list of instances, after filtering applied. """ self._uniquify = self._get_uniquify(uniquify) resolved_error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, ) resolved_auto_expunge = self.auto_expunge if auto_expunge is None else auto_expunge resolved_execution_options = self._get_execution_options(execution_options) resolved_order_by = order_by if order_by is not None else (self.order_by if self.order_by is not None else []) cache_manager = self._cache_manager if not ( use_cache and bool(resolved_auto_expunge) and cache_manager is not None and statement is None and load is None and not self._default_loader_options ): return self._list_from_db( filters=filters, auto_expunge=auto_expunge, statement=statement, order_by=order_by, error_messages=resolved_error_messages, load=load, execution_options=execution_options, kwargs=kwargs, uniquify=uniquify, bind_group=bind_group, ) model_name = cast("str", self.model_type.__tablename__) # type: ignore[attr-defined] version_token = cache_manager.get_model_version_sync(model_name) cache_key = _build_list_cache_key( model_name=model_name, version_token=version_token, method="list", filters=filters, kwargs=kwargs, order_by=resolved_order_by, execution_options=resolved_execution_options, uniquify=self._uniquify, ) if cache_key is None: return self._list_from_db( filters=filters, auto_expunge=auto_expunge, statement=statement, order_by=order_by, error_messages=resolved_error_messages, load=load, execution_options=execution_options, kwargs=kwargs, uniquify=uniquify, bind_group=bind_group, ) cached = cache_manager.get_list_sync(cache_key, self.model_type) if cached is not None: return cached return cache_manager.singleflight_sync( cache_key, partial( self._list_cached_creator, cache_key, filters=filters, auto_expunge=auto_expunge, statement=statement, order_by=order_by, error_messages=resolved_error_messages, load=load, execution_options=execution_options, kwargs=kwargs, uniquify=uniquify, bind_group=bind_group, ), ) @classmethod def check_health(cls, session: Union[Session, scoped_session[Session]]) -> bool: """Perform a health check on the database. Args: session: through which we run a check statement Returns: ``True`` if healthy. """ with wrap_sqlalchemy_exception(): return ( # type: ignore[no-any-return] session.execute(cls._get_health_check_statement(session)) ).scalar_one() == 1 @staticmethod def _get_health_check_statement(session: Union[Session, scoped_session[Session]]) -> TextClause: if session.bind and session.bind.dialect.name == "oracle": return text("SELECT 1 FROM DUAL") return text("SELECT 1") def _attach_to_session(self, model: ModelT, strategy: Literal["add", "merge"] = "add", load: bool = True) -> ModelT: """Attach detached instance to the session. Args: model: The instance to be attached to the session. strategy: How the instance should be attached. - "add": New instance added to session - "merge": Instance merged with existing, or new one added. load: Boolean, when False, merge switches into a "high performance" mode which causes it to forego emitting history events as well as all database access. This flag is used for cases such as transferring graphs of objects into a session from a second level cache, or to transfer just-loaded objects into the session owned by a worker thread or process without re-querying the database. Raises: ValueError: If `strategy` is not one of the expected values. Returns: Instance attached to the session - if `"merge"` strategy, may not be same instance that was provided. """ if strategy == "add": self.session.add(model) return model if strategy == "merge": return self.session.merge(model, load=load) msg = "Unexpected value for `strategy`, must be `'add'` or `'merge'`" # type: ignore[unreachable] raise ValueError(msg) def _execute( self, statement: Select[Any], uniquify: bool = False, ) -> Result[Any]: result = self.session.execute(statement) if uniquify or self._uniquify: result = result.unique() return result class SQLAlchemySyncSlugRepository( SQLAlchemySyncRepository[ModelT], SQLAlchemySyncSlugRepositoryProtocol[ModelT], ): """Extends the repository to include slug model features..""" def get_by_slug( self, slug: str, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> Optional[ModelT]: """Select record by slug value. Returns: The model instance or None if not found. """ return self.get_one_or_none( slug=slug, load=load, execution_options=execution_options, error_messages=error_messages, uniquify=uniquify, bind_group=bind_group, ) def get_available_slug( self, value_to_slugify: str, **kwargs: Any, ) -> str: """Get a unique slug for the supplied value. If the value is found to exist, a random 4 digit character is appended to the end. Override this method to change the default behavior Args: value_to_slugify (str): A string that should be converted to a unique slug. **kwargs: stuff Returns: str: a unique slug for the supplied value. This is safe for URLs and other unique identifiers. """ slug = slugify(value_to_slugify) if self._is_slug_unique(slug): return slug random_string = "".join(random.choices(string.ascii_lowercase + string.digits, k=4)) # noqa: S311 return f"{slug}-{random_string}" def _is_slug_unique( self, slug: str, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, **kwargs: Any, ) -> bool: return self.exists(slug=slug, load=load, execution_options=execution_options, **kwargs) is False class SQLAlchemySyncQueryRepository: """SQLAlchemy Query Repository. This is a loosely typed helper to query for when you need to select data in ways that don't align to the normal repository pattern. """ error_messages: Optional[ErrorMessages] = None wrap_exceptions: bool = True def __init__( self, *, session: Union[Session, scoped_session[Session]], error_messages: Optional[ErrorMessages] = None, wrap_exceptions: bool = True, **kwargs: Any, ) -> None: """Repository pattern for SQLAlchemy models. Args: session: Session managing the unit-of-work for the operation. error_messages: A set of error messages to use for operations. wrap_exceptions: Whether to wrap exceptions in a SQLAlchemy exception. **kwargs: Additional arguments (ignored). """ self.session = session self.error_messages = error_messages self.wrap_exceptions = wrap_exceptions self._dialect = self.session.bind.dialect if self.session.bind is not None else self.session.get_bind().dialect def get_one( self, statement: Select[tuple[Any]], bind_group: Optional[str] = None, **kwargs: Any, ) -> Row[Any]: """Get instance identified by ``kwargs``. Args: statement: To facilitate customization of the underlying select query. bind_group: The bind group to use for the operation. **kwargs: Instance attribute value filters. Returns: The retrieved instance. """ with wrap_sqlalchemy_exception(error_messages=self.error_messages, wrap_exceptions=self.wrap_exceptions): statement = self._filter_statement_by_kwargs(statement, **kwargs) execution_options = {"bind_group": bind_group} if bind_group else None instance = (self.execute(statement, execution_options=execution_options)).scalar_one_or_none() return self.check_not_found(instance) def get_one_or_none( self, statement: Select[Any], bind_group: Optional[str] = None, **kwargs: Any, ) -> Optional[Row[Any]]: """Get instance identified by ``kwargs`` or None if not found. Args: statement: To facilitate customization of the underlying select query. bind_group: The bind group to use for the operation. **kwargs: Instance attribute value filters. Returns: The retrieved instance or None """ with wrap_sqlalchemy_exception(error_messages=self.error_messages, wrap_exceptions=self.wrap_exceptions): statement = self._filter_statement_by_kwargs(statement, **kwargs) execution_options = {"bind_group": bind_group} if bind_group else None instance = (self.execute(statement, execution_options=execution_options)).scalar_one_or_none() return instance or None def count(self, statement: Select[Any], bind_group: Optional[str] = None, **kwargs: Any) -> int: """Get the count of records returned by a query. Args: statement: To facilitate customization of the underlying select query. bind_group: The bind group to use for the operation. **kwargs: Instance attribute value filters. Returns: Count of records returned by query, ignoring pagination. """ with wrap_sqlalchemy_exception(error_messages=self.error_messages, wrap_exceptions=self.wrap_exceptions): statement = statement.with_only_columns(sql_func.count(text("1")), maintain_column_froms=True).order_by( None, ) statement = self._filter_statement_by_kwargs(statement, **kwargs) execution_options = {"bind_group": bind_group} if bind_group else None results = self.execute(statement, execution_options=execution_options) return results.scalar_one() # type: ignore def list_and_count( self, statement: Select[Any], count_with_window_function: Optional[bool] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[List[Row[Any]], int]: """List records with total count. Args: statement: To facilitate customization of the underlying select query. count_with_window_function: Force list and count to use two queries instead of an analytical window function. bind_group: The bind group to use for the operation. **kwargs: Instance attribute value filters. Returns: Count of records returned by query, ignoring pagination. """ if self._dialect.name in {"spanner", "spanner+spanner"} or count_with_window_function: return self._list_and_count_basic(statement=statement, bind_group=bind_group, **kwargs) return self._list_and_count_window(statement=statement, bind_group=bind_group, **kwargs) def _list_and_count_window( self, statement: Select[Any], bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[List[Row[Any]], int]: """List records with total count. Args: *filters: Types for specific filtering operations. statement: To facilitate customization of the underlying select query. bind_group: The bind group to use for the operation. **kwargs: Instance attribute value filters. Returns: Count of records returned by query using an analytical window function, ignoring pagination. """ with wrap_sqlalchemy_exception(error_messages=self.error_messages, wrap_exceptions=self.wrap_exceptions): statement = statement.add_columns(over(sql_func.count(text("1")))) statement = self._filter_statement_by_kwargs(statement, **kwargs) execution_options = {"bind_group": bind_group} if bind_group else None result = self.execute(statement, execution_options=execution_options) count: int = 0 instances: List[Row[Any]] = [] for i, (instance, count_value) in enumerate(result): instances.append(instance) if i == 0: count = count_value return instances, count @staticmethod def _get_count_stmt(statement: Select[Any]) -> Select[Any]: return statement.with_only_columns(sql_func.count(text("1")), maintain_column_froms=True).order_by(None) # pyright: ignore[reportUnknownVariable] def _list_and_count_basic( self, statement: Select[Any], bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[List[Row[Any]], int]: """List records with total count. Args: statement: To facilitate customization of the underlying select query. . bind_group: The bind group to use for the operation. **kwargs: Instance attribute value filters. Returns: Count of records returned by query using 2 queries, ignoring pagination. """ with wrap_sqlalchemy_exception(error_messages=self.error_messages, wrap_exceptions=self.wrap_exceptions): statement = self._filter_statement_by_kwargs(statement, **kwargs) execution_options = {"bind_group": bind_group} if bind_group else None count_result = self.session.execute( self._get_count_stmt(statement), execution_options=execution_options or {} ) count = count_result.scalar_one() result = self.execute(statement, execution_options=execution_options) instances: List[Row[Any]] = [] for (instance,) in result: instances.append(instance) return instances, count def list(self, statement: Select[Any], bind_group: Optional[str] = None, **kwargs: Any) -> List[Row[Any]]: """Get a list of instances, optionally filtered. Args: statement: To facilitate customization of the underlying select query. bind_group: The bind group to use for the operation. **kwargs: Instance attribute value filters. Returns: The list of instances, after filtering applied. """ with wrap_sqlalchemy_exception(error_messages=self.error_messages, wrap_exceptions=self.wrap_exceptions): statement = self._filter_statement_by_kwargs(statement, **kwargs) execution_options = {"bind_group": bind_group} if bind_group else None result = self.execute(statement, execution_options=execution_options) return list(result.all()) def _filter_statement_by_kwargs( self, statement: Select[Any], /, **kwargs: Any, ) -> Select[Any]: """Filter the collection by kwargs. Args: statement: statement to filter **kwargs: key/value pairs such that objects remaining in the statement after filtering have the property that their attribute named `key` has value equal to `value`. Returns: The filtered statement. """ with wrap_sqlalchemy_exception(error_messages=self.error_messages): return statement.filter_by(**kwargs) # the following is all sqlalchemy implementation detail, and shouldn't be directly accessed @staticmethod def check_not_found(item_or_none: Optional[T]) -> T: """Raise :class:`NotFoundError` if ``item_or_none`` is ``None``. Args: item_or_none: Item to be tested for existence. Raises: NotFoundError: If ``item_or_none`` is ``None`` Returns: The item, if it exists. """ if item_or_none is None: msg = "No item found when one was expected" raise NotFoundError(msg) return item_or_none def execute( self, statement: Union[ ReturningDelete[tuple[Any]], ReturningUpdate[tuple[Any]], Select[tuple[Any]], Update, Delete, Select[Any] ], execution_options: Optional[dict[str, Any]] = None, ) -> Result[Any]: return self.session.execute(statement, execution_options=execution_options or {}) python-advanced-alchemy-1.9.3/advanced_alchemy/repository/_typing.py000066400000000000000000000052361516556515500257650ustar00rootroot00000000000000"""Repository typing utilities for optional dependency support. Provides stubs and detection functions for numpy arrays to support pgvector and other array-based types when numpy is not installed. """ from typing import Any # Always define stub functions for type checking and fallback behavior def is_numpy_array_stub(value: Any) -> bool: # pragma: no cover """Check if value has numpy array-like characteristics (fallback implementation). When numpy is not installed, this checks for basic array-like attributes that indicate the value might be an array that needs special comparison handling. Args: value: Value to check. Returns: bool: True if value appears to be array-like. """ return hasattr(value, "__array__") and hasattr(value, "dtype") # pragma: no cover def arrays_equal_stub(a: Any, b: Any) -> bool: """Fallback array equality comparison when numpy is not installed. When numpy is not available, we can't properly compare arrays, so we default to considering them different to trigger updates. This ensures safety but may cause unnecessary updates. Args: a: First value to compare. b: Second value to compare. Returns: bool: Always False when numpy is not available. """ _, _ = a, b # Unused parameters # pragma: no cover return False # pragma: no cover # Try to import real numpy implementation at runtime try: import numpy as np # type: ignore[import-not-found,unused-ignore] # pyright: ignore[reportMissingImports] def is_numpy_array_real(value: Any) -> bool: """Check if value is a numpy array. Args: value: Value to check. Returns: bool: True if value is a numpy ndarray. """ return isinstance(value, np.ndarray) # pyright: ignore def arrays_equal_real(a: Any, b: Any) -> bool: """Compare numpy arrays for equality. Uses numpy.array_equal for proper array comparison. Args: a: First array to compare. b: Second array to compare. Returns: bool: True if arrays are equal. """ return bool(np.array_equal(a, b)) # pyright: ignore is_numpy_array = is_numpy_array_real arrays_equal = arrays_equal_real NUMPY_INSTALLED = True # pyright: ignore[reportConstantRedefinition] except ImportError: # pragma: no cover is_numpy_array = is_numpy_array_stub arrays_equal = arrays_equal_stub NUMPY_INSTALLED = False # pyright: ignore[reportConstantRedefinition] __all__ = ( "NUMPY_INSTALLED", "arrays_equal", "arrays_equal_stub", "is_numpy_array", "is_numpy_array_stub", ) python-advanced-alchemy-1.9.3/advanced_alchemy/repository/_util.py000066400000000000000000001023301516556515500254210ustar00rootroot00000000000000# ruff: noqa: PLR0911 import dataclasses import datetime import decimal import hashlib from collections.abc import Iterable, Mapping, Sequence from typing import Any, Final, Literal, Optional, Protocol, Union, cast, overload from sqlalchemy import ( Column, Delete, Dialect, Select, UnaryExpression, Update, inspect, ) from sqlalchemy.exc import NoInspectionAvailable from sqlalchemy.orm import ( InstrumentedAttribute, MapperProperty, RelationshipProperty, class_mapper, joinedload, lazyload, selectinload, ) from sqlalchemy.orm.strategy_options import ( _AbstractLoad, # pyright: ignore[reportPrivateUsage] # pyright: ignore[reportPrivateUsage] ) from sqlalchemy.sql import ColumnElement, ColumnExpressionArgument from sqlalchemy.sql.base import ExecutableOption from sqlalchemy.sql.dml import ReturningDelete, ReturningUpdate from sqlalchemy.sql.elements import Label from typing_extensions import TypeAlias from advanced_alchemy._serialization import encode_complex_type, encode_json from advanced_alchemy.base import ModelProtocol from advanced_alchemy.exceptions import ErrorMessages from advanced_alchemy.exceptions import wrap_sqlalchemy_exception as _wrap_sqlalchemy_exception from advanced_alchemy.filters import ( InAnyFilter, PaginationFilter, StatementFilter, StatementTypeT, ) from advanced_alchemy.repository._typing import arrays_equal, is_numpy_array from advanced_alchemy.repository.typing import ModelT, OrderingPair, PrimaryKeyType DEFAULT_SAFE_TYPES: Final[set[type[Any]]] = { int, float, str, bool, bytes, decimal.Decimal, datetime.date, datetime.datetime, datetime.time, datetime.timedelta, } WhereClauseT = ColumnExpressionArgument[bool] SingleLoad: TypeAlias = Union[ _AbstractLoad, Literal["*"], InstrumentedAttribute[Any], RelationshipProperty[Any], MapperProperty[Any], ] LoadCollection: TypeAlias = Sequence[Union[SingleLoad, Sequence[SingleLoad]]] ExecutableOptions: TypeAlias = Sequence[ExecutableOption] LoadSpec: TypeAlias = Union[LoadCollection, SingleLoad, ExecutableOption, ExecutableOptions] def _sort_kv_by_key_str(kv: tuple[object, object]) -> str: return str(kv[0]) def _sort_normalized_value(value: Any) -> str: return encode_json(_canonicalize_cache_key_value(value)) def _normalize_cache_key_value(value: Any) -> Any: """Normalize values into a deterministic JSON-serializable form. Used for list/list_and_count cache keys. """ if value is None or isinstance(value, (int, float, str, bool)): return value # Try shared encoder first for scalars (datetime, uuid, bytes, etc) if ( not isinstance(value, (list, tuple, set, frozenset, dict)) and (encoded := encode_complex_type(value)) is not None ): return encoded if isinstance(value, set): value_set = cast("set[Any]", value) # type: ignore[redundant-cast] normalized = [_normalize_cache_key_value(v) for v in value_set] normalized.sort(key=_sort_normalized_value) return normalized if isinstance(value, (list, tuple)): value_seq = cast("Sequence[Any]", value) return [_normalize_cache_key_value(v) for v in value_seq] if isinstance(value, dict): value_dict = cast("dict[object, object]", value) normalized_dict: dict[str, Any] = {} for k, v in sorted(value_dict.items(), key=_sort_kv_by_key_str): normalized_dict[str(k)] = _normalize_cache_key_value(v) return normalized_dict if dataclasses.is_dataclass(value) and not isinstance(value, type): # pyright: ignore[reportUnknownArgumentType] return _normalize_cache_key_value(dataclasses.asdict(value)) if isinstance(value, InstrumentedAttribute): return {"__attr__": value.key} if isinstance(value, ColumnElement): # Safe fallback for non-dataclass expressions in ordering/kwargs. value_expr = cast("ColumnElement[Any]", value) # type: ignore[redundant-cast] return {"__sql__": str(value_expr)} return {"__repr__": repr(value)} # pyright: ignore[reportUnknownArgumentType] def _canonicalize_cache_key_value(value: Any) -> Any: """Convert dict-like objects into a stable, ordered representation.""" if isinstance(value, Mapping): value_map = cast("Mapping[object, object]", value) return [ [str(k), _canonicalize_cache_key_value(v)] for k, v in sorted(value_map.items(), key=_sort_kv_by_key_str) ] if isinstance(value, list): value_list = cast("list[Any]", value) # type: ignore[redundant-cast] return [_canonicalize_cache_key_value(v) for v in value_list] return value def _build_list_cache_key( # pyright: ignore[reportUnusedFunction] *, model_name: str, version_token: str, method: str, filters: Sequence[Union[StatementFilter, ColumnElement[bool]]], kwargs: dict[str, Any], order_by: Optional[Union[list[OrderingPair], OrderingPair]], execution_options: dict[str, Any], uniquify: bool, count_with_window_function: Optional[bool] = None, ) -> Optional[str]: """Build a stable cache key for list/list_and_count operations. Returns None if the query specification includes non-cacheable filter expressions (e.g., raw SQLAlchemy boolean expressions). """ normalized_filters: list[dict[str, Any]] = [] for filter_ in filters: if isinstance(filter_, ColumnElement): return None normalized_filters.append({"type": filter_.__class__.__name__, "data": _normalize_cache_key_value(filter_)}) normalized_order_by: Optional[list[Any]] = None if order_by is not None: order_items = order_by if isinstance(order_by, list) else [order_by] normalized_order_by = [] for item in order_items: if isinstance(item, UnaryExpression): normalized_order_by.append({"expr": str(item)}) else: col, desc = item normalized_order_by.append({"col": _normalize_cache_key_value(col), "desc": bool(desc)}) payload: dict[str, Any] = { "method": method, "model": model_name, "version": version_token, "filters": normalized_filters, "kwargs": _normalize_cache_key_value(kwargs), "order_by": normalized_order_by, "execution_options": _normalize_cache_key_value(execution_options), "uniquify": uniquify, } if count_with_window_function is not None: payload["count_with_window_function"] = bool(count_with_window_function) try: encoded = encode_json(_canonicalize_cache_key_value(payload)).encode("utf-8") except TypeError: # pragma: no cover return None digest = hashlib.sha256(encoded).hexdigest() return f"{model_name}:{method}:{digest}" OrderByT: TypeAlias = Union[ str, InstrumentedAttribute[Any], RelationshipProperty[Any], ] # NOTE: For backward compatibility with Litestar - this is imported from here within the litestar codebase. wrap_sqlalchemy_exception = _wrap_sqlalchemy_exception DEFAULT_ERROR_MESSAGE_TEMPLATES: ErrorMessages = { "integrity": "There was a data validation error during processing", "foreign_key": "A foreign key is missing or invalid", "multiple_rows": "Multiple matching rows found", "duplicate_key": "A record matching the supplied data already exists.", "other": "There was an error during data processing", "check_constraint": "The data failed a check constraint during processing", "not_found": "The requested resource was not found", } """Default error messages for repository errors.""" def get_instrumented_attr( model: type[ModelProtocol], key: Union[str, InstrumentedAttribute[Any]], ) -> InstrumentedAttribute[Any]: """Get an instrumented attribute from a model. Args: model: SQLAlchemy model class. key: Either a string attribute name or an :class:`sqlalchemy.orm.InstrumentedAttribute`. Returns: :class:`sqlalchemy.orm.InstrumentedAttribute`: The instrumented attribute from the model. """ if isinstance(key, str): return cast("InstrumentedAttribute[Any]", getattr(model, key)) return key def get_primary_key_info( model: type[ModelProtocol], ) -> tuple[tuple["Column[Any]", ...], tuple[str, ...]]: """Extract primary key columns and attribute names from a SQLAlchemy model. This function safely inspects a model to retrieve its primary key information, handling cases where the model may not be properly mapped (e.g., mock objects in tests). Args: model: SQLAlchemy model class to inspect. Returns: A tuple of (pk_columns, pk_attr_names) where: - pk_columns: Tuple of Column objects representing the primary key - pk_attr_names: Tuple of ORM attribute names for the primary key columns Returns empty tuples if the model cannot be inspected (e.g., unmapped models). Example: >>> pk_columns, pk_attr_names = get_primary_key_info(UserRole) >>> # For a model with composite key (user_id, role_id): >>> # pk_columns = (Column('user_id', ...), Column('role_id', ...)) >>> # pk_attr_names = ('user_id', 'role_id') """ try: mapper = inspect(model) except NoInspectionAvailable: return (), () else: pk_columns: tuple[Column[Any], ...] = tuple(mapper.primary_key) # type: ignore[union-attr] pk_attr_names: tuple[str, ...] = tuple( mapper.get_property_by_column(col).key # type: ignore[union-attr] for col in pk_columns ) return pk_columns, pk_attr_names def validate_composite_pk_value( pk_value: Any, pk_attr_names: tuple[str, ...], model_name: str, ) -> tuple[Any, ...]: """Validate and normalize a composite primary key value to a tuple. Args: pk_value: Primary key value (must be tuple or dict for composite PKs). pk_attr_names: Tuple of ORM attribute names for the PK columns. model_name: Model class name for error messages. Returns: Validated tuple of PK values in column order. Raises: TypeError: If pk_value is not a tuple or dict. ValueError: If tuple length is wrong, dict is missing keys, or any value is None. """ num_pk_columns = len(pk_attr_names) if isinstance(pk_value, tuple): pk_tuple = cast("tuple[Any, ...]", pk_value) # type: ignore[redundant-cast] if len(pk_tuple) != num_pk_columns: msg = ( f"Composite primary key for {model_name} has " f"{num_pk_columns} columns {list(pk_attr_names)}, " f"but {len(pk_tuple)} values provided: {pk_tuple!r}" ) raise ValueError(msg) # Validate no None values for i, val in enumerate(pk_tuple): if val is None: msg = f"Primary key value for '{pk_attr_names[i]}' cannot be None in composite key for {model_name}" raise ValueError(msg) return pk_tuple if isinstance(pk_value, dict): pk_dict = cast("dict[str, Any]", pk_value) provided_keys = set(pk_dict.keys()) required_keys = set(pk_attr_names) missing_keys = required_keys - provided_keys if missing_keys: msg = ( f"Composite primary key for {model_name} requires " f"attributes {sorted(required_keys)}, but missing: {sorted(missing_keys)}" ) raise ValueError(msg) # Validate no None values and build tuple result_values: list[Any] = [] for attr_name in pk_attr_names: val = pk_dict[attr_name] if val is None: msg = f"Primary key value for '{attr_name}' cannot be None in composite key for {model_name}" raise ValueError(msg) result_values.append(val) return tuple(result_values) # Not a valid type for composite PK pk_type_name = type(pk_value).__name__ msg = ( f"Composite primary key for {model_name} requires tuple or dict, " f"got {pk_type_name}: {pk_value!r}. Expected columns: {list(pk_attr_names)}" ) raise TypeError(msg) def is_composite_pk(pk_columns: tuple[Any, ...]) -> bool: """Check if a primary key has multiple columns. Args: pk_columns: Tuple of primary key Column objects. Returns: True if the model has 2 or more primary key columns, False otherwise. Example: >>> is_composite_pk(repo._pk_columns) # Single PK model False >>> is_composite_pk( ... repo._pk_columns ... ) # Model with (user_id, role_id) PK True """ return len(pk_columns) > 1 def extract_pk_value_from_instance( instance: ModelProtocol, pk_attr_names: tuple[str, ...], ) -> PrimaryKeyType: """Extract the primary key value(s) from a model instance. Args: instance: Model instance to extract primary key from. pk_attr_names: Tuple of ORM attribute names for the PK columns. Returns: - For single PK: scalar value (int, str, UUID, etc.) - For composite PK: tuple of values in column order Example: # Single primary key >>> user = User(id=123, name="Alice") >>> extract_pk_value_from_instance(user, ("id",)) 123 # Composite primary key >>> assignment = UserRole(user_id=1, role_id=5) >>> extract_pk_value_from_instance( ... assignment, ("user_id", "role_id") ... ) (1, 5) """ if len(pk_attr_names) == 1: return getattr(instance, pk_attr_names[0]) return tuple(getattr(instance, attr_name) for attr_name in pk_attr_names) def pk_values_present( instance: ModelProtocol, pk_attr_names: tuple[str, ...], ) -> bool: """Check if all primary key values are set on an instance. Args: instance: Model instance to check. pk_attr_names: Tuple of ORM attribute names for the PK columns. Returns: True if all PK values are non-None, False otherwise. Example: >>> user = User(id=123) >>> pk_values_present(user, ("id",)) True >>> user = User(id=None) >>> pk_values_present(user, ("id",)) False """ return all(getattr(instance, attr_name, None) is not None for attr_name in pk_attr_names) def normalize_pk_to_tuple( pk_value: PrimaryKeyType, pk_attr_names: tuple[str, ...], model_name: str, ) -> tuple[Any, ...]: """Normalize a primary key value to tuple format. This function converts various PK input formats (scalar, tuple, dict) to a consistent tuple format for internal processing. Args: pk_value: Primary key value (scalar, tuple, or dict). pk_attr_names: Tuple of ORM attribute names for the PK columns. model_name: Model class name for error messages. Returns: Tuple representation of the primary key. Raises: ValueError: If composite PK is passed a scalar value. Example: # Single PK - wraps scalar in tuple >>> normalize_pk_to_tuple(123, ("id",), "User") (123,) # Composite PK - tuple passes through >>> normalize_pk_to_tuple( ... (1, 5), ("user_id", "role_id"), "UserRole" ... ) (1, 5) # Composite PK - dict converted to tuple >>> normalize_pk_to_tuple( ... {"user_id": 1, "role_id": 5}, ... ("user_id", "role_id"), ... "UserRole", ... ) (1, 5) """ if len(pk_attr_names) == 1: # Single PK - wrap scalar in tuple return (pk_value,) if isinstance(pk_value, tuple): return cast("tuple[Any, ...]", pk_value) # type: ignore[redundant-cast] if isinstance(pk_value, dict): pk_dict = cast("dict[str, Any]", pk_value) return tuple(pk_dict[attr_name] for attr_name in pk_attr_names) # Scalar passed for composite PK - error pk_type_name = type(pk_value).__name__ msg = f"Composite primary key for {model_name} requires tuple or dict, got {pk_type_name}: {pk_value!r}" raise ValueError(msg) def _convert_relationship_value( value: Any, related_model: type[ModelT], is_collection: bool, ) -> Any: """Convert a relationship value, handling dicts, lists, and instances. Args: value: The value to convert (dict, list, model instance, or None). related_model: The SQLAlchemy model class for the relationship. is_collection: Whether this is a collection relationship (uselist=True). Returns: Converted value appropriate for the relationship type. """ if value is None: return None if is_collection: # One-to-many or many-to-many: expect a list if not isinstance(value, (list, tuple)): # Single item provided for collection - wrap in list value = [value] return [ model_from_dict(related_model, **item) if isinstance(item, dict) else item for item in value # pyright: ignore[reportUnknownVariableType] ] # One-to-one or many-to-one: expect single value if isinstance(value, dict): return model_from_dict(related_model, **value) return value def model_from_dict(model: type[ModelT], /, **kwargs: Any) -> ModelT: """Create an ORM model instance from a dictionary of attributes. This function recursively converts nested dictionaries into their corresponding SQLAlchemy model instances for relationship attributes. Args: model: The SQLAlchemy model class to instantiate. **kwargs: Keyword arguments containing model attribute values. For relationship attributes, values can be: - None: Sets the relationship to None - dict: Recursively converted to the related model instance - list[dict]: Each dict converted to related model instances - Model instance: Passed through unchanged Returns: ModelT: A new instance of the model populated with the provided values. Example: Basic usage with nested relationships:: data = { "name": "John Doe", "profile": {"bio": "Developer"}, "addresses": [ {"street": "123 Main St"}, {"street": "456 Oak Ave"}, ], } user = model_from_dict(User, **data) # user.profile is a Profile instance # user.addresses is a list of Address instances """ mapper = class_mapper(model) mapper_attrs = mapper.attrs converted_data: dict[str, Any] = {} # Iterate over kwargs instead of mapper.attrs for better performance # when only a subset of attributes is provided (O(InputKeys) vs O(TotalColumns)) for key, value in kwargs.items(): # Skip keys that aren't mapped attributes (e.g., extra fields) if key not in mapper_attrs: continue attr = mapper_attrs[key] # Check if this attribute is a relationship if isinstance(attr, RelationshipProperty): related_model: type[ModelT] = attr.mapper.class_ converted_data[key] = _convert_relationship_value( value=value, related_model=related_model, is_collection=attr.uselist or False, ) else: # Regular column attribute - pass through converted_data[key] = value return model(**converted_data) def get_abstract_loader_options( loader_options: Union[LoadSpec, None], default_loader_options: Union[list[_AbstractLoad], None] = None, default_options_have_wildcards: bool = False, merge_with_default: bool = True, inherit_lazy_relationships: bool = True, cycle_count: int = 0, ) -> tuple[list[_AbstractLoad], bool]: """Generate SQLAlchemy loader options for eager loading relationships. Args: loader_options :class:`~advanced_alchemy.repository.typing.LoadSpec`|:class:`None` Specification for how to load relationships. Can be: - None: Use defaults - :class:`sqlalchemy.orm.strategy_options._AbstractLoad`: Direct SQLAlchemy loader option - :class:`sqlalchemy.orm.InstrumentedAttribute`: Model relationship attribute - :class:`sqlalchemy.orm.RelationshipProperty`: SQLAlchemy relationship - str: "*" for wildcard loading - :class:`typing.Sequence` of the above default_loader_options: :class:`typing.Sequence` of :class:`sqlalchemy.orm.strategy_options._AbstractLoad` loader options to start with. default_options_have_wildcards: Whether the default options contain wildcards. merge_with_default: Whether to merge the default options with the loader options. inherit_lazy_relationships: Whether to inherit the ``lazy`` configuration from the model's relationships. cycle_count: Number of times this function has been called recursively. Returns: tuple[:class:`list`[:class:`sqlalchemy.orm.strategy_options._AbstractLoad`], bool]: A tuple containing: - :class:`list` of :class:`sqlalchemy.orm.strategy_options._AbstractLoad` SQLAlchemy loader option objects - Boolean indicating if any wildcard loaders are present """ loads: list[_AbstractLoad] = [] if cycle_count == 0 and not inherit_lazy_relationships: loads.append(lazyload("*")) if cycle_count == 0 and merge_with_default and default_loader_options is not None: loads.extend(default_loader_options) options_have_wildcards = default_options_have_wildcards if loader_options is None: return (loads, options_have_wildcards) if isinstance(loader_options, _AbstractLoad): return ([loader_options], options_have_wildcards) if isinstance(loader_options, InstrumentedAttribute): loader_options = [loader_options.property] if isinstance(loader_options, RelationshipProperty): class_ = loader_options.class_attribute return ( [selectinload(class_)] if loader_options.uselist else [joinedload(class_, innerjoin=loader_options.innerjoin)], options_have_wildcards if loader_options.uselist else True, ) if isinstance(loader_options, str) and loader_options == "*": options_have_wildcards = True return ([joinedload("*")], options_have_wildcards) if isinstance(loader_options, (list, tuple)): for attribute in loader_options: # pyright: ignore[reportUnknownVariableType] if isinstance(attribute, (list, tuple)): load_chain, options_have_wildcards = get_abstract_loader_options( loader_options=attribute, # pyright: ignore[reportUnknownArgumentType] default_options_have_wildcards=options_have_wildcards, inherit_lazy_relationships=inherit_lazy_relationships, merge_with_default=merge_with_default, cycle_count=cycle_count + 1, ) loader = load_chain[-1] for sub_load in load_chain[-2::-1]: loader = sub_load.options(loader) loads.append(loader) else: load_chain, options_have_wildcards = get_abstract_loader_options( loader_options=attribute, # pyright: ignore[reportUnknownArgumentType] default_options_have_wildcards=options_have_wildcards, inherit_lazy_relationships=inherit_lazy_relationships, merge_with_default=merge_with_default, cycle_count=cycle_count + 1, ) loads.extend(load_chain) return (loads, options_have_wildcards) class FilterableRepositoryProtocol(Protocol[ModelT]): """Protocol defining the interface for filterable repositories. This protocol defines the required attributes and methods that any filterable repository implementation must provide. """ model_type: type[ModelT] """The SQLAlchemy model class this repository manages.""" class FilterableRepository(FilterableRepositoryProtocol[ModelT]): """Default implementation of a filterable repository. Provides core filtering, ordering and pagination functionality for SQLAlchemy models. """ model_type: type[ModelT] """The SQLAlchemy model class this repository manages.""" prefer_any_dialects: Optional[tuple[str]] = ("postgresql",) """List of dialects that prefer to use ``field.id = ANY(:1)`` instead of ``field.id IN (...)``.""" order_by: Optional[Union[list[OrderingPair], OrderingPair]] = None """List or single :class:`~advanced_alchemy.repository.typing.OrderingPair` to use for sorting.""" _prefer_any: bool = False """Whether to prefer ANY() over IN() in queries.""" _dialect: Dialect """The SQLAlchemy :class:`sqlalchemy.dialects.Dialect` being used.""" @overload def _apply_filters( self, *filters: Union[StatementFilter, ColumnElement[bool]], apply_pagination: bool = True, statement: Select[tuple[ModelT]], ) -> Select[tuple[ModelT]]: ... @overload def _apply_filters( self, *filters: Union[StatementFilter, ColumnElement[bool]], apply_pagination: bool = True, statement: Delete, ) -> Delete: ... @overload def _apply_filters( self, *filters: Union[StatementFilter, ColumnElement[bool]], apply_pagination: bool = True, statement: Union[ReturningDelete[tuple[ModelT]], ReturningUpdate[tuple[ModelT]]], ) -> Union[ReturningDelete[tuple[ModelT]], ReturningUpdate[tuple[ModelT]]]: ... @overload def _apply_filters( self, *filters: Union[StatementFilter, ColumnElement[bool]], apply_pagination: bool = True, statement: Update, ) -> Update: ... def _apply_filters( self, *filters: Union[StatementFilter, ColumnElement[bool]], apply_pagination: bool = True, statement: StatementTypeT, ) -> StatementTypeT: """Apply filters to a SQL statement. Args: *filters: Filter conditions to apply. apply_pagination: Whether to apply pagination filters. statement: The base SQL statement to filter. Returns: StatementTypeT: The filtered SQL statement. """ for filter_ in filters: if isinstance(filter_, (PaginationFilter,)): if apply_pagination: statement = filter_.append_to_statement(statement, self.model_type) elif isinstance(filter_, (InAnyFilter,)): statement = filter_.append_to_statement(statement, self.model_type) elif isinstance(filter_, ColumnElement): statement = cast("StatementTypeT", statement.where(filter_)) else: statement = filter_.append_to_statement(statement, self.model_type) return statement def _filter_select_by_kwargs( self, statement: StatementTypeT, kwargs: Union[dict[Any, Any], Iterable[tuple[Any, Any]]], ) -> StatementTypeT: """Filter a statement using keyword arguments. Args: statement: :class:`sqlalchemy.sql.Select` The SQL statement to filter. kwargs: Dictionary or iterable of tuples containing filter criteria. Keys should be model attribute names, values are what to filter for. Returns: StatementTypeT: The filtered SQL statement. """ for key, val in dict(kwargs).items(): field = get_instrumented_attr(self.model_type, key) statement = cast("StatementTypeT", statement.where(field == val)) return statement def _apply_order_by( self, statement: StatementTypeT, order_by: Union[ OrderingPair, list[OrderingPair], ], ) -> StatementTypeT: """Apply ordering to a SQL statement. Args: statement: The SQL statement to order. order_by: Ordering specification. Either a single tuple or list of tuples where: - First element is the field name or :class:`sqlalchemy.orm.InstrumentedAttribute` to order by - Second element is a boolean indicating descending (True) or ascending (False) Returns: StatementTypeT: The ordered SQL statement. """ if not isinstance(order_by, list): order_by = [order_by] for order_field in order_by: if isinstance(order_field, UnaryExpression): statement = statement.order_by(order_field) # type: ignore else: field = get_instrumented_attr(self.model_type, order_field[0]) statement = self._order_by_attribute(statement, field, order_field[1]) return statement @staticmethod def _order_by_attribute( statement: StatementTypeT, field: InstrumentedAttribute[Any], is_desc: bool, ) -> StatementTypeT: """Apply ordering by a single attribute to a SQL statement. Args: statement: The SQL statement to order. field: The model attribute to order by. is_desc: Whether to order in descending (True) or ascending (False) order. Returns: StatementTypeT: The ordered SQL statement. """ if isinstance(statement, Select): statement = cast("StatementTypeT", statement.order_by(field.desc() if is_desc else field.asc())) return statement def column_has_defaults(column: Any) -> bool: """Check if a column has any type of default value or update handler. This includes: - Python-side defaults (column.default) - Server-side defaults (column.server_default) - Python-side onupdate handlers (column.onupdate) - Server-side onupdate handlers (column.server_onupdate) Args: column: SQLAlchemy column object to check Returns: bool: True if the column has any type of default or update handler """ # Label objects (from column_property) don't have default/onupdate attributes # Return False for these as they represent computed values, not defaulted columns if isinstance(column, Label): return False # Use defensive attribute checking for safety with other column-like objects return ( getattr(column, "default", None) is not None or getattr(column, "server_default", None) is not None or getattr(column, "onupdate", None) is not None or getattr(column, "server_onupdate", None) is not None ) def was_attribute_set(instance: Any, mapper: Any, attr_name: str) -> bool: """Check if an attribute was explicitly set on a model instance. This function distinguishes between attributes that were explicitly set (even to None) versus attributes that are simply uninitialized and defaulting to None. This is crucial for partial updates where only modified fields should be copied. Args: instance: The model instance to check. mapper: The SQLAlchemy mapper/inspector for the instance. attr_name: The name of the attribute to check. Returns: bool: True if the attribute was explicitly set, False if uninitialized. """ try: # Get the attribute state attr_state = mapper.attrs.get(attr_name) if attr_state is None: return False # Check if the attribute has history (was modified) # For a new transient instance, modified attributes will have history history = attr_state.history if history.has_changes(): return True # For attributes with no history, check if they're in the instance dict # This handles the case where an attribute was set during __init__ return hasattr(instance, "__dict__") and attr_name in instance.__dict__ except (AttributeError, KeyError): # pragma: no cover # If we can't determine, assume it was set to be safe return True def compare_values(existing_value: Any, new_value: Any) -> bool: """Safely compare two values, handling numpy arrays and other special types. This function handles the comparison of values that may include numpy arrays (such as pgvector's Vector type) which cannot be directly compared using standard equality operators due to their element-wise comparison behavior. Args: existing_value: The current value to compare. new_value: The new value to compare against. Returns: bool: True if values are equal, False otherwise. """ # Handle None comparisons if existing_value is None and new_value is None: return True if existing_value is None or new_value is None: return False # Handle numpy arrays or array-like objects if is_numpy_array(existing_value) or is_numpy_array(new_value): # Both values must be arrays for them to be considered equal if not (is_numpy_array(existing_value) and is_numpy_array(new_value)): return False return arrays_equal(existing_value, new_value) # Standard equality comparison for all other types try: return bool(existing_value == new_value) except (ValueError, TypeError): # If comparison fails for any reason, consider them different # This is a safe fallback that will trigger updates when unsure return False python-advanced-alchemy-1.9.3/advanced_alchemy/repository/memory/000077500000000000000000000000001516556515500252445ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/advanced_alchemy/repository/memory/__init__.py000066400000000000000000000006241516556515500273570ustar00rootroot00000000000000from advanced_alchemy.repository.memory._async import SQLAlchemyAsyncMockRepository, SQLAlchemyAsyncMockSlugRepository from advanced_alchemy.repository.memory._sync import SQLAlchemySyncMockRepository, SQLAlchemySyncMockSlugRepository __all__ = [ "SQLAlchemyAsyncMockRepository", "SQLAlchemyAsyncMockSlugRepository", "SQLAlchemySyncMockRepository", "SQLAlchemySyncMockSlugRepository", ] python-advanced-alchemy-1.9.3/advanced_alchemy/repository/memory/_async.py000066400000000000000000001073411516556515500271000ustar00rootroot00000000000000import datetime import random import re import string from collections import abc from collections.abc import Iterable from typing import Any, List, Optional, Union, cast, overload from unittest.mock import create_autospec from sqlalchemy import ( ColumnElement, Dialect, Select, StatementLambdaElement, Update, ) from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio.scoping import async_scoped_session from sqlalchemy.orm import InstrumentedAttribute, class_mapper from sqlalchemy.orm.strategy_options import _AbstractLoad # pyright: ignore[reportPrivateUsage] from sqlalchemy.sql.dml import ReturningUpdate from sqlalchemy.sql.selectable import ForUpdateParameter from typing_extensions import Self from advanced_alchemy.exceptions import ErrorMessages, IntegrityError, NotFoundError, RepositoryError from advanced_alchemy.filters import ( BeforeAfter, CollectionFilter, LimitOffset, NotInCollectionFilter, NotInSearchFilter, OnBeforeAfter, OrderBy, SearchFilter, StatementFilter, ) from advanced_alchemy.repository._async import SQLAlchemyAsyncRepositoryProtocol, SQLAlchemyAsyncSlugRepositoryProtocol from advanced_alchemy.repository._util import ( DEFAULT_ERROR_MESSAGE_TEMPLATES, LoadSpec, compare_values, extract_pk_value_from_instance, is_composite_pk, normalize_pk_to_tuple, pk_values_present, ) from advanced_alchemy.repository.memory.base import ( AnyObject, InMemoryStore, SQLAlchemyInMemoryStore, SQLAlchemyMultiStore, ) from advanced_alchemy.repository.typing import MISSING, ModelT, OrderingPair, PrimaryKeyType from advanced_alchemy.utils.dataclass import Empty, EmptyType from advanced_alchemy.utils.text import slugify class SQLAlchemyAsyncMockRepository(SQLAlchemyAsyncRepositoryProtocol[ModelT]): """In memory repository.""" __database__: SQLAlchemyMultiStore[ModelT] = SQLAlchemyMultiStore(SQLAlchemyInMemoryStore) __database_registry__: dict[type[Self], SQLAlchemyMultiStore[ModelT]] = {} loader_options: Optional[LoadSpec] = None """Default loader options for the repository.""" execution_options: Optional[dict[str, Any]] = None """Default execution options for the repository.""" model_type: type[ModelT] id_attribute: Any = "id" match_fields: Optional[Union[List[str], str]] = None uniquify: bool = False _exclude_kwargs: set[str] = { "statement", "session", "auto_expunge", "auto_refresh", "auto_commit", "attribute_names", "with_for_update", "count_with_window_function", "loader_options", "execution_options", "order_by", "load", "error_messages", "wrap_exceptions", "uniquify", "bind_group", } def __init__( self, *, statement: Union[Select[tuple[ModelT]], StatementLambdaElement, None] = None, session: Union[AsyncSession, async_scoped_session[AsyncSession]], auto_expunge: bool = False, auto_refresh: bool = True, auto_commit: bool = False, order_by: Optional[Union[List[OrderingPair], OrderingPair]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, wrap_exceptions: bool = True, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, **kwargs: Any, ) -> None: self.session = session self.statement = create_autospec("Select[Tuple[ModelT]]", instance=True) self.auto_expunge = auto_expunge self.auto_refresh = auto_refresh self.auto_commit = auto_commit self.error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages ) self.wrap_exceptions = wrap_exceptions self.order_by = order_by self._dialect: Dialect = create_autospec(Dialect, instance=True) self._dialect.name = "mock" self.__filtered_store__: InMemoryStore[ModelT] = self.__database__.store_type() self._default_options: Any = [] self._default_execution_options: Any = {} self._loader_options: Any = [] self._loader_options_have_wildcards = False self.uniquify = bool(uniquify) def __init_subclass__(cls) -> None: cls.__database_registry__[cls] = cls.__database__ # type: ignore[index] @property def _pk_columns(self) -> tuple[Any, ...]: """Get primary key columns from the model mapper. Returns: Tuple of Column objects representing the primary key. """ mapper = class_mapper(self.model_type) return tuple(mapper.primary_key) @property def pk_attr_names(self) -> tuple[str, ...]: """Get primary key attribute names from the model mapper. Uses mapper.get_property_by_column() to get ORM attribute names, which may differ from column names when using Column("sql_name"). Returns: Tuple of ORM attribute names for primary key columns. """ mapper = class_mapper(self.model_type) return tuple(mapper.get_property_by_column(col).key for col in self._pk_columns) @property def has_composite_pk(self) -> bool: """Check if model has a composite (multi-column) primary key. Returns: True if the model has 2 or more primary key columns, False otherwise. """ return is_composite_pk(self._pk_columns) def get_primary_key_value(self, instance: ModelT) -> PrimaryKeyType: """Extract the primary key value(s) from a model instance. Args: instance: Model instance to extract primary key from. Returns: - For single PK: scalar value - For composite PK: tuple of values in column order """ return extract_pk_value_from_instance(instance, self.pk_attr_names) def has_primary_key_values(self, instance: ModelT) -> bool: """Check if all primary key values are set on an instance. Args: instance: Model instance to check. Returns: True if all PK values are non-None, False otherwise. """ return pk_values_present(instance, self.pk_attr_names) def _normalize_pk_to_tuple(self, pk_value: PrimaryKeyType) -> tuple[Any, ...]: """Normalize a primary key value to a tuple for consistent storage key generation. Args: pk_value: Primary key value (scalar, tuple, or dict). Returns: Tuple representation of the primary key. """ return normalize_pk_to_tuple(pk_value, self.pk_attr_names, self.model_type.__name__) def _get_store_key(self, pk_value: PrimaryKeyType) -> str: """Generate a store key from a primary key value. Args: pk_value: Primary key value (scalar, tuple, or dict). Returns: String key for the in-memory store. """ pk_tuple = self._normalize_pk_to_tuple(pk_value) if len(pk_tuple) > 1: return str(pk_tuple) return str(pk_tuple[0]) if pk_tuple else "" def _get_store_key_from_instance(self, instance: ModelT) -> str: """Generate a store key from a model instance. Args: instance: Model instance to generate key from. Returns: String key for the in-memory store. """ pk_value = self.get_primary_key_value(instance) return str(pk_value) @staticmethod def _get_error_messages( error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, default_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, ) -> Optional[ErrorMessages]: if error_messages == Empty: error_messages = None if default_messages == Empty: default_messages = None messages = cast("ErrorMessages", dict(DEFAULT_ERROR_MESSAGE_TEMPLATES)) if default_messages: messages.update(cast("ErrorMessages", default_messages)) # type: ignore[unused-ignore,redundant-cast] if error_messages: messages.update(cast("ErrorMessages", error_messages)) # type: ignore[unused-ignore,redundant-cast] return messages @classmethod def __database_add__(cls, identity: Any, data: ModelT) -> ModelT: return cast("ModelT", cls.__database__.add(identity, data)) # type: ignore[redundant-cast] @classmethod def __database_clear__(cls) -> None: for database in cls.__database_registry__.values(): # pyright: ignore[reportGeneralTypeIssues,reportUnknownMemberType] database.remove_all() @overload def __collection__(self) -> InMemoryStore[ModelT]: ... @overload def __collection__(self, identity: type[AnyObject]) -> InMemoryStore[AnyObject]: ... def __collection__( self, identity: Optional[type[AnyObject]] = None, ) -> Union[InMemoryStore[AnyObject], InMemoryStore[ModelT]]: if identity: return self.__database__.store(identity) return self.__filtered_store__ or self.__database__.store(self.model_type) @staticmethod def check_not_found(item_or_none: Union[ModelT, None]) -> ModelT: if item_or_none is None: msg = "No item found when one was expected" raise NotFoundError(msg) return item_or_none @classmethod def get_id_attribute_value( cls, item: Union[ModelT, type[ModelT]], id_attribute: Union[str, InstrumentedAttribute[Any], None] = None, ) -> Any: """Get value of attribute named as :attr:`id_attribute` on ``item``. Args: item: Anything that should have an attribute named as :attr:`id_attribute` value. id_attribute: Allows customization of the unique identifier to use for model fetching. Defaults to `None`, but can reference any surrogate or candidate key for the table. Returns: The value of attribute on ``item`` named as :attr:`id_attribute`. """ if isinstance(id_attribute, InstrumentedAttribute): id_attribute = id_attribute.key return getattr(item, id_attribute if id_attribute is not None else cls.id_attribute) @classmethod def set_id_attribute_value( cls, item_id: Any, item: ModelT, id_attribute: Union[str, InstrumentedAttribute[Any], None] = None, ) -> ModelT: """Return the ``item`` after the ID is set to the appropriate attribute. Args: item_id: Value of ID to be set on instance item: Anything that should have an attribute named as :attr:`id_attribute` value. id_attribute: Allows customization of the unique identifier to use for model fetching. Defaults to `None`, but can reference any surrogate or candidate key for the table. Returns: Item with ``item_id`` set to :attr:`id_attribute` """ if isinstance(id_attribute, InstrumentedAttribute): id_attribute = id_attribute.key setattr(item, id_attribute if id_attribute is not None else cls.id_attribute, item_id) return item def _exclude_unused_kwargs(self, kwargs: dict[str, Any]) -> dict[str, Any]: return {key: value for key, value in kwargs.items() if key not in self._exclude_kwargs} @staticmethod def _apply_limit_offset_pagination(result: List[ModelT], limit: int, offset: int) -> List[ModelT]: return result[offset:limit] def _extract_field_name(self, field: "Union[str, ColumnElement[Any], InstrumentedAttribute[Any]]") -> str: """Extract string field name from various input types. Args: field: Field name, column element, or instrumented attribute Returns: str: String field name for use with getattr() Raises: RepositoryError: If a ColumnElement (func expression) is used with mock repository """ if isinstance(field, str): return field if isinstance(field, InstrumentedAttribute): return field.key msg = f"{type(field)} columns are not supported in mock repositories (in-memory filtering)" raise RepositoryError(msg) def _filter_in_collection( self, result: List[ModelT], field_name: "Union[str, ColumnElement[Any], InstrumentedAttribute[Any]]", values: abc.Collection[Any], ) -> List[ModelT]: field_str = self._extract_field_name(field_name) return [item for item in result if getattr(item, field_str) in values] def _filter_not_in_collection( self, result: List[ModelT], field_name: "Union[str, ColumnElement[Any], InstrumentedAttribute[Any]]", values: abc.Collection[Any], ) -> List[ModelT]: if not values: return result field_str = self._extract_field_name(field_name) return [item for item in result if getattr(item, field_str) not in values] def _filter_on_datetime_field( self, result: List[ModelT], field_name: "Union[str, ColumnElement[Any], InstrumentedAttribute[Any]]", before: Optional[datetime.datetime] = None, after: Optional[datetime.datetime] = None, on_or_before: Optional[datetime.datetime] = None, on_or_after: Optional[datetime.datetime] = None, ) -> List[ModelT]: field_str = self._extract_field_name(field_name) result_: List[ModelT] = [] for item in result: attr: datetime.datetime = getattr(item, field_str) if before is not None and attr < before: result_.append(item) if after is not None and attr > after: result_.append(item) if on_or_before is not None and attr <= on_or_before: result_.append(item) if on_or_after is not None and attr >= on_or_after: result_.append(item) return result_ @staticmethod def _filter_by_like( result: List[ModelT], field_name: Union[str, set[str]], value: str, ignore_case: bool, ) -> List[ModelT]: pattern = re.compile(rf".*{value}.*", re.IGNORECASE) if ignore_case else re.compile(rf".*{value}.*") fields = {field_name} if isinstance(field_name, str) else field_name items: List[ModelT] = [] for field in fields: items.extend( [ item for item in result if isinstance(getattr(item, field), str) and pattern.match(getattr(item, field)) ], ) return list(set(items)) @staticmethod def _filter_by_not_like( result: List[ModelT], field_name: Union[str, set[str]], value: str, ignore_case: bool, ) -> List[ModelT]: pattern = re.compile(rf".*{value}.*", re.IGNORECASE) if ignore_case else re.compile(rf".*{value}.*") fields = {field_name} if isinstance(field_name, str) else field_name items: List[ModelT] = [] for field in fields: items.extend( [ item for item in result if isinstance(getattr(item, field), str) and pattern.match(getattr(item, field)) ], ) return list(set(result).difference(set(items))) def _filter_result_by_kwargs( self, result: Iterable[ModelT], /, kwargs: Union[dict[Any, Any], Iterable[tuple[Any, Any]]], ) -> List[ModelT]: kwargs_: dict[Any, Any] = kwargs if isinstance(kwargs, dict) else dict(*kwargs) # pyright: ignore kwargs_ = self._exclude_unused_kwargs(kwargs_) # pyright: ignore try: return [item for item in result if all(getattr(item, field) == value for field, value in kwargs_.items())] # pyright: ignore except AttributeError as error: raise RepositoryError from error def _order_by( self, result: List[ModelT], field_name: "Union[str, ColumnElement[Any], InstrumentedAttribute[Any]]", sort_desc: bool = False, ) -> List[ModelT]: return sorted(result, key=lambda item: getattr(item, self._extract_field_name(field_name)), reverse=sort_desc) def _apply_filters( self, result: List[ModelT], *filters: Union[StatementFilter, ColumnElement[bool]], apply_pagination: bool = True, ) -> List[ModelT]: for filter_ in filters: if isinstance(filter_, LimitOffset): if apply_pagination: result = self._apply_limit_offset_pagination(result, filter_.limit, filter_.offset) elif isinstance(filter_, BeforeAfter): result = self._filter_on_datetime_field( result, field_name=filter_.field_name, before=filter_.before, after=filter_.after, ) elif isinstance(filter_, OnBeforeAfter): result = self._filter_on_datetime_field( result, field_name=filter_.field_name, on_or_before=filter_.on_or_before, on_or_after=filter_.on_or_after, ) elif isinstance(filter_, NotInCollectionFilter): if filter_.values is not None: # pyright: ignore result = self._filter_not_in_collection(result, filter_.field_name, filter_.values) # pyright: ignore elif isinstance(filter_, CollectionFilter): if filter_.values is not None: # pyright: ignore result = self._filter_in_collection(result, filter_.field_name, filter_.values) # pyright: ignore elif isinstance(filter_, OrderBy): result = self._order_by( result, filter_.field_name, sort_desc=filter_.sort_order == "desc", ) elif isinstance(filter_, NotInSearchFilter): result = self._filter_by_not_like( result, filter_.field_name, value=filter_.value, ignore_case=bool(filter_.ignore_case), ) elif isinstance(filter_, SearchFilter): result = self._filter_by_like( result, filter_.field_name, value=filter_.value, ignore_case=bool(filter_.ignore_case), ) elif not isinstance(filter_, ColumnElement): msg = f"Unexpected filter: {filter_}" raise RepositoryError(msg) return result def _get_match_fields( self, match_fields: Union[List[str], str, None], id_attribute: Optional[str] = None, ) -> Optional[List[str]]: id_attribute = id_attribute or self.id_attribute match_fields = match_fields or self.match_fields if isinstance(match_fields, str): match_fields = [match_fields] return match_fields async def _list_and_count_basic( self, *filters: Union[StatementFilter, ColumnElement[bool]], **kwargs: Any, ) -> tuple[List[ModelT], int]: result = await self.list(*filters, **kwargs) return result, len(result) async def _list_and_count_window( self, *filters: Union[StatementFilter, ColumnElement[bool]], **kwargs: Any, ) -> tuple[List[ModelT], int]: return await self._list_and_count_basic(*filters, **kwargs) def _find_or_raise_not_found(self, id_: PrimaryKeyType) -> ModelT: """Find an item by primary key or raise NotFoundError. Args: id_: Primary key value (scalar, tuple, or dict). Returns: The found model instance. Raises: NotFoundError: If no instance found with the given primary key. """ store_key = self._get_store_key(id_) return self.check_not_found(self.__collection__().get_or_none(store_key)) @staticmethod def _find_one_or_raise_error(result: List[ModelT]) -> ModelT: if not result: msg = "No item found when one was expected" raise IntegrityError(msg) if len(result) > 1: msg = "Multiple objects when one was expected" raise IntegrityError(msg) return result[0] # pyright: ignore def _get_update_many_statement( self, model_type: type[ModelT], supports_returning: bool, loader_options: Optional[List[_AbstractLoad]], execution_options: Optional[dict[str, Any]], ) -> Union[Update, ReturningUpdate[tuple[ModelT]]]: return self.statement # type: ignore[no-any-return] # pyright: ignore[reportReturnType] @classmethod async def check_health(cls, session: Union[AsyncSession, async_scoped_session[AsyncSession]]) -> bool: return True async def get( self, item_id: PrimaryKeyType, *, auto_expunge: Optional[bool] = None, statement: Union[Select[tuple[ModelT]], StatementLambdaElement, None] = None, id_attribute: Union[str, InstrumentedAttribute[Any], None] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, with_for_update: ForUpdateParameter = None, use_cache: bool = True, bind_group: Optional[str] = None, ) -> ModelT: return self._find_or_raise_not_found(item_id) async def get_one( self, *filters: Union[StatementFilter, ColumnElement[bool]], auto_expunge: Optional[bool] = None, statement: Union[Select[tuple[ModelT]], StatementLambdaElement, None] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> ModelT: return self.check_not_found(await self.get_one_or_none(**kwargs)) async def get_one_or_none( self, *filters: Union[StatementFilter, ColumnElement[bool]], auto_expunge: Optional[bool] = None, statement: Union[Select[tuple[ModelT]], StatementLambdaElement, None] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> Union[ModelT, None]: result = self._filter_result_by_kwargs(self.__collection__().list(), kwargs) if len(result) > 1: msg = "Multiple objects when one was expected" raise IntegrityError(msg) return result[0] if result else None async def get_or_upsert( self, *filters: Union[StatementFilter, ColumnElement[bool]], match_fields: Union[List[str], str, None] = None, upsert: bool = True, attribute_names: Optional[Iterable[str]] = None, with_for_update: ForUpdateParameter = None, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, auto_refresh: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[ModelT, bool]: kwargs_ = self._exclude_unused_kwargs(kwargs) if match_fields := self._get_match_fields(match_fields=match_fields): match_filter = { # sourcery skip: remove-none-from-default-get field_name: kwargs_.get(field_name, None) for field_name in match_fields if kwargs_.get(field_name, None) is not None } else: match_filter = kwargs_ existing = await self.get_one_or_none(**match_filter) if not existing: return (await self.add(self.model_type(**kwargs_)), True) if upsert: for field_name, new_field_value in kwargs_.items(): field = getattr(existing, field_name, MISSING) if field is not MISSING and not compare_values(field, new_field_value): # pragma: no cover setattr(existing, field_name, new_field_value) existing = await self.update(existing) return existing, False async def get_and_update( self, *filters: Union[StatementFilter, ColumnElement[bool]], match_fields: Union[List[str], str, None] = None, attribute_names: Optional[Iterable[str]] = None, with_for_update: ForUpdateParameter = None, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, auto_refresh: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[ModelT, bool]: kwargs_ = self._exclude_unused_kwargs(kwargs) if match_fields := self._get_match_fields(match_fields=match_fields): match_filter = { # sourcery skip: remove-none-from-default-get field_name: kwargs_.get(field_name, None) for field_name in match_fields if kwargs_.get(field_name, None) is not None } else: match_filter = kwargs_ existing = await self.get_one(**match_filter) updated = False for field_name, new_field_value in kwargs_.items(): field = getattr(existing, field_name, MISSING) if field is not MISSING and not compare_values(field, new_field_value): # pragma: no cover updated = True setattr(existing, field_name, new_field_value) existing = await self.update(existing) return existing, updated async def exists( self, *filters: "Union[StatementFilter, ColumnElement[bool]]", uniquify: Optional[bool] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> bool: existing = await self.count(*filters, **kwargs) return existing > 0 async def count( self, *filters: "Union[StatementFilter, ColumnElement[bool]]", uniquify: Optional[bool] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> int: result = self._apply_filters(self.__collection__().list(), *filters) return len(self._filter_result_by_kwargs(result, kwargs)) async def add( self, data: ModelT, *, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, auto_refresh: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, bind_group: Optional[str] = None, ) -> ModelT: try: self.__database__.add(self.model_type, data) except KeyError as exc: msg = "Item already exist in collection" raise IntegrityError(msg) from exc return data async def add_many( self, data: List[ModelT], *, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, bind_group: Optional[str] = None, ) -> List[ModelT]: for obj in data: await self.add(obj) # pyright: ignore[reportCallIssue] return data async def update( self, data: ModelT, *, attribute_names: Optional[Iterable[str]] = None, with_for_update: ForUpdateParameter = None, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, auto_refresh: Optional[bool] = None, id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, ) -> ModelT: pk_value = self.get_primary_key_value(data) self._find_or_raise_not_found(pk_value) return self.__collection__().update(data) async def update_many( self, data: List[ModelT], *, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, ) -> List[ModelT]: return [self.__collection__().update(obj) for obj in data if obj in self.__collection__()] async def delete( self, item_id: PrimaryKeyType, *, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, ) -> ModelT: store_key = self._get_store_key(item_id) try: return self._find_or_raise_not_found(item_id) finally: self.__collection__().remove(store_key) async def delete_many( self, item_ids: List[PrimaryKeyType], *, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]] = None, chunk_size: Optional[int] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, ) -> List[ModelT]: deleted: List[ModelT] = [] for id_ in item_ids: store_key = self._get_store_key(id_) if obj := self.__collection__().get_or_none(store_key): deleted.append(obj) self.__collection__().remove(store_key) return deleted async def delete_where( self, *filters: Union[StatementFilter, ColumnElement[bool]], auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, sanity_check: bool = True, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> List[ModelT]: result = self.__collection__().list() result = self._apply_filters(result, *filters) models = self._filter_result_by_kwargs(result, kwargs) item_ids: list[PrimaryKeyType] = [self.get_primary_key_value(model) for model in models] return await self.delete_many(item_ids=item_ids) async def upsert( self, data: ModelT, *, attribute_names: Optional[Iterable[str]] = None, with_for_update: ForUpdateParameter = None, auto_expunge: Optional[bool] = None, auto_commit: Optional[bool] = None, auto_refresh: Optional[bool] = None, match_fields: Optional[Union[List[str], str]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, ) -> ModelT: # sourcery skip: assign-if-exp, reintroduce-else if data in self.__collection__(): return await self.update(data) return await self.add(data) async def upsert_many( self, data: List[ModelT], *, auto_expunge: Optional[bool] = None, auto_commit: Optional[bool] = None, no_merge: bool = False, match_fields: Optional[Union[List[str], str]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, ) -> List[ModelT]: return [await self.upsert(item) for item in data] async def list_and_count( self, *filters: Union[StatementFilter, ColumnElement[bool]], statement: Union[Select[tuple[ModelT]], StatementLambdaElement, None] = None, auto_expunge: Optional[bool] = None, count_with_window_function: Optional[bool] = None, order_by: Optional[Union[List[OrderingPair], OrderingPair]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, use_cache: bool = True, bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[List[ModelT], int]: return await self._list_and_count_basic(*filters, **kwargs) async def list( self, *filters: Union[StatementFilter, ColumnElement[bool]], uniquify: Optional[bool] = None, use_cache: bool = True, bind_group: Optional[str] = None, **kwargs: Any, ) -> List[ModelT]: result = self.__collection__().list() result = self._apply_filters(result, *filters) return self._filter_result_by_kwargs(result, kwargs) class SQLAlchemyAsyncMockSlugRepository( SQLAlchemyAsyncMockRepository[ModelT], SQLAlchemyAsyncSlugRepositoryProtocol[ModelT], ): async def get_by_slug( self, slug: str, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> Union[ModelT, None]: return await self.get_one_or_none(slug=slug) async def get_available_slug( self, value_to_slugify: str, **kwargs: Any, ) -> str: """Get a unique slug for the supplied value. If the value is found to exist, a random 4 digit character is appended to the end. Override this method to change the default behavior Args: value_to_slugify (str): A string that should be converted to a unique slug. **kwargs: stuff Returns: str: a unique slug for the supplied value. This is safe for URLs and other unique identifiers. """ slug = slugify(value_to_slugify) if await self._is_slug_unique(slug): return slug random_string = "".join(random.choices(string.ascii_lowercase + string.digits, k=4)) # noqa: S311 return f"{slug}-{random_string}" async def _is_slug_unique( self, slug: str, **kwargs: Any, ) -> bool: return await self.exists(slug=slug) is False python-advanced-alchemy-1.9.3/advanced_alchemy/repository/memory/_sync.py000066400000000000000000001067701516556515500267440ustar00rootroot00000000000000# Do not edit this file directly. It has been autogenerated from # advanced_alchemy/repository/memory/_async.py import datetime import random import re import string from collections import abc from collections.abc import Iterable from typing import Any, List, Optional, Union, cast, overload from unittest.mock import create_autospec from sqlalchemy import ( ColumnElement, Dialect, Select, StatementLambdaElement, Update, ) from sqlalchemy.orm import InstrumentedAttribute, Session, class_mapper from sqlalchemy.orm.scoping import scoped_session from sqlalchemy.orm.strategy_options import _AbstractLoad # pyright: ignore[reportPrivateUsage] from sqlalchemy.sql.dml import ReturningUpdate from sqlalchemy.sql.selectable import ForUpdateParameter from typing_extensions import Self from advanced_alchemy.exceptions import ErrorMessages, IntegrityError, NotFoundError, RepositoryError from advanced_alchemy.filters import ( BeforeAfter, CollectionFilter, LimitOffset, NotInCollectionFilter, NotInSearchFilter, OnBeforeAfter, OrderBy, SearchFilter, StatementFilter, ) from advanced_alchemy.repository._sync import SQLAlchemySyncRepositoryProtocol, SQLAlchemySyncSlugRepositoryProtocol from advanced_alchemy.repository._util import ( DEFAULT_ERROR_MESSAGE_TEMPLATES, LoadSpec, compare_values, extract_pk_value_from_instance, is_composite_pk, normalize_pk_to_tuple, pk_values_present, ) from advanced_alchemy.repository.memory.base import ( AnyObject, InMemoryStore, SQLAlchemyInMemoryStore, SQLAlchemyMultiStore, ) from advanced_alchemy.repository.typing import MISSING, ModelT, OrderingPair, PrimaryKeyType from advanced_alchemy.utils.dataclass import Empty, EmptyType from advanced_alchemy.utils.text import slugify class SQLAlchemySyncMockRepository(SQLAlchemySyncRepositoryProtocol[ModelT]): """In memory repository.""" __database__: SQLAlchemyMultiStore[ModelT] = SQLAlchemyMultiStore(SQLAlchemyInMemoryStore) __database_registry__: dict[type[Self], SQLAlchemyMultiStore[ModelT]] = {} loader_options: Optional[LoadSpec] = None """Default loader options for the repository.""" execution_options: Optional[dict[str, Any]] = None """Default execution options for the repository.""" model_type: type[ModelT] id_attribute: Any = "id" match_fields: Optional[Union[List[str], str]] = None uniquify: bool = False _exclude_kwargs: set[str] = { "statement", "session", "auto_expunge", "auto_refresh", "auto_commit", "attribute_names", "with_for_update", "count_with_window_function", "loader_options", "execution_options", "order_by", "load", "error_messages", "wrap_exceptions", "uniquify", "bind_group", } def __init__( self, *, statement: Union[Select[tuple[ModelT]], StatementLambdaElement, None] = None, session: Union[Session, scoped_session[Session]], auto_expunge: bool = False, auto_refresh: bool = True, auto_commit: bool = False, order_by: Optional[Union[List[OrderingPair], OrderingPair]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, wrap_exceptions: bool = True, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, **kwargs: Any, ) -> None: self.session = session self.statement = create_autospec("Select[Tuple[ModelT]]", instance=True) self.auto_expunge = auto_expunge self.auto_refresh = auto_refresh self.auto_commit = auto_commit self.error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages ) self.wrap_exceptions = wrap_exceptions self.order_by = order_by self._dialect: Dialect = create_autospec(Dialect, instance=True) self._dialect.name = "mock" self.__filtered_store__: InMemoryStore[ModelT] = self.__database__.store_type() self._default_options: Any = [] self._default_execution_options: Any = {} self._loader_options: Any = [] self._loader_options_have_wildcards = False self.uniquify = bool(uniquify) def __init_subclass__(cls) -> None: cls.__database_registry__[cls] = cls.__database__ # type: ignore[index] @property def _pk_columns(self) -> tuple[Any, ...]: """Get primary key columns from the model mapper. Returns: Tuple of Column objects representing the primary key. """ mapper = class_mapper(self.model_type) return tuple(mapper.primary_key) @property def pk_attr_names(self) -> tuple[str, ...]: """Get primary key attribute names from the model mapper. Uses mapper.get_property_by_column() to get ORM attribute names, which may differ from column names when using Column("sql_name"). Returns: Tuple of ORM attribute names for primary key columns. """ mapper = class_mapper(self.model_type) return tuple(mapper.get_property_by_column(col).key for col in self._pk_columns) @property def has_composite_pk(self) -> bool: """Check if model has a composite (multi-column) primary key. Returns: True if the model has 2 or more primary key columns, False otherwise. """ return is_composite_pk(self._pk_columns) def get_primary_key_value(self, instance: ModelT) -> PrimaryKeyType: """Extract the primary key value(s) from a model instance. Args: instance: Model instance to extract primary key from. Returns: - For single PK: scalar value - For composite PK: tuple of values in column order """ return extract_pk_value_from_instance(instance, self.pk_attr_names) def has_primary_key_values(self, instance: ModelT) -> bool: """Check if all primary key values are set on an instance. Args: instance: Model instance to check. Returns: True if all PK values are non-None, False otherwise. """ return pk_values_present(instance, self.pk_attr_names) def _normalize_pk_to_tuple(self, pk_value: PrimaryKeyType) -> tuple[Any, ...]: """Normalize a primary key value to a tuple for consistent storage key generation. Args: pk_value: Primary key value (scalar, tuple, or dict). Returns: Tuple representation of the primary key. """ return normalize_pk_to_tuple(pk_value, self.pk_attr_names, self.model_type.__name__) def _get_store_key(self, pk_value: PrimaryKeyType) -> str: """Generate a store key from a primary key value. Args: pk_value: Primary key value (scalar, tuple, or dict). Returns: String key for the in-memory store. """ pk_tuple = self._normalize_pk_to_tuple(pk_value) if len(pk_tuple) > 1: return str(pk_tuple) return str(pk_tuple[0]) if pk_tuple else "" def _get_store_key_from_instance(self, instance: ModelT) -> str: """Generate a store key from a model instance. Args: instance: Model instance to generate key from. Returns: String key for the in-memory store. """ pk_value = self.get_primary_key_value(instance) return str(pk_value) @staticmethod def _get_error_messages( error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, default_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, ) -> Optional[ErrorMessages]: if error_messages == Empty: error_messages = None if default_messages == Empty: default_messages = None messages = cast("ErrorMessages", dict(DEFAULT_ERROR_MESSAGE_TEMPLATES)) if default_messages: messages.update(cast("ErrorMessages", default_messages)) # type: ignore[unused-ignore,redundant-cast] if error_messages: messages.update(cast("ErrorMessages", error_messages)) # type: ignore[unused-ignore,redundant-cast] return messages @classmethod def __database_add__(cls, identity: Any, data: ModelT) -> ModelT: return cast("ModelT", cls.__database__.add(identity, data)) # type: ignore[redundant-cast] @classmethod def __database_clear__(cls) -> None: for database in cls.__database_registry__.values(): # pyright: ignore[reportGeneralTypeIssues,reportUnknownMemberType] database.remove_all() @overload def __collection__(self) -> InMemoryStore[ModelT]: ... @overload def __collection__(self, identity: type[AnyObject]) -> InMemoryStore[AnyObject]: ... def __collection__( self, identity: Optional[type[AnyObject]] = None, ) -> Union[InMemoryStore[AnyObject], InMemoryStore[ModelT]]: if identity: return self.__database__.store(identity) return self.__filtered_store__ or self.__database__.store(self.model_type) @staticmethod def check_not_found(item_or_none: Union[ModelT, None]) -> ModelT: if item_or_none is None: msg = "No item found when one was expected" raise NotFoundError(msg) return item_or_none @classmethod def get_id_attribute_value( cls, item: Union[ModelT, type[ModelT]], id_attribute: Union[str, InstrumentedAttribute[Any], None] = None, ) -> Any: """Get value of attribute named as :attr:`id_attribute` on ``item``. Args: item: Anything that should have an attribute named as :attr:`id_attribute` value. id_attribute: Allows customization of the unique identifier to use for model fetching. Defaults to `None`, but can reference any surrogate or candidate key for the table. Returns: The value of attribute on ``item`` named as :attr:`id_attribute`. """ if isinstance(id_attribute, InstrumentedAttribute): id_attribute = id_attribute.key return getattr(item, id_attribute if id_attribute is not None else cls.id_attribute) @classmethod def set_id_attribute_value( cls, item_id: Any, item: ModelT, id_attribute: Union[str, InstrumentedAttribute[Any], None] = None, ) -> ModelT: """Return the ``item`` after the ID is set to the appropriate attribute. Args: item_id: Value of ID to be set on instance item: Anything that should have an attribute named as :attr:`id_attribute` value. id_attribute: Allows customization of the unique identifier to use for model fetching. Defaults to `None`, but can reference any surrogate or candidate key for the table. Returns: Item with ``item_id`` set to :attr:`id_attribute` """ if isinstance(id_attribute, InstrumentedAttribute): id_attribute = id_attribute.key setattr(item, id_attribute if id_attribute is not None else cls.id_attribute, item_id) return item def _exclude_unused_kwargs(self, kwargs: dict[str, Any]) -> dict[str, Any]: return {key: value for key, value in kwargs.items() if key not in self._exclude_kwargs} @staticmethod def _apply_limit_offset_pagination(result: List[ModelT], limit: int, offset: int) -> List[ModelT]: return result[offset:limit] def _extract_field_name(self, field: "Union[str, ColumnElement[Any], InstrumentedAttribute[Any]]") -> str: """Extract string field name from various input types. Args: field: Field name, column element, or instrumented attribute Returns: str: String field name for use with getattr() Raises: RepositoryError: If a ColumnElement (func expression) is used with mock repository """ if isinstance(field, str): return field if isinstance(field, InstrumentedAttribute): return field.key msg = f"{type(field)} columns are not supported in mock repositories (in-memory filtering)" raise RepositoryError(msg) def _filter_in_collection( self, result: List[ModelT], field_name: "Union[str, ColumnElement[Any], InstrumentedAttribute[Any]]", values: abc.Collection[Any], ) -> List[ModelT]: field_str = self._extract_field_name(field_name) return [item for item in result if getattr(item, field_str) in values] def _filter_not_in_collection( self, result: List[ModelT], field_name: "Union[str, ColumnElement[Any], InstrumentedAttribute[Any]]", values: abc.Collection[Any], ) -> List[ModelT]: if not values: return result field_str = self._extract_field_name(field_name) return [item for item in result if getattr(item, field_str) not in values] def _filter_on_datetime_field( self, result: List[ModelT], field_name: "Union[str, ColumnElement[Any], InstrumentedAttribute[Any]]", before: Optional[datetime.datetime] = None, after: Optional[datetime.datetime] = None, on_or_before: Optional[datetime.datetime] = None, on_or_after: Optional[datetime.datetime] = None, ) -> List[ModelT]: field_str = self._extract_field_name(field_name) result_: List[ModelT] = [] for item in result: attr: datetime.datetime = getattr(item, field_str) if before is not None and attr < before: result_.append(item) if after is not None and attr > after: result_.append(item) if on_or_before is not None and attr <= on_or_before: result_.append(item) if on_or_after is not None and attr >= on_or_after: result_.append(item) return result_ @staticmethod def _filter_by_like( result: List[ModelT], field_name: Union[str, set[str]], value: str, ignore_case: bool, ) -> List[ModelT]: pattern = re.compile(rf".*{value}.*", re.IGNORECASE) if ignore_case else re.compile(rf".*{value}.*") fields = {field_name} if isinstance(field_name, str) else field_name items: List[ModelT] = [] for field in fields: items.extend( [ item for item in result if isinstance(getattr(item, field), str) and pattern.match(getattr(item, field)) ], ) return list(set(items)) @staticmethod def _filter_by_not_like( result: List[ModelT], field_name: Union[str, set[str]], value: str, ignore_case: bool, ) -> List[ModelT]: pattern = re.compile(rf".*{value}.*", re.IGNORECASE) if ignore_case else re.compile(rf".*{value}.*") fields = {field_name} if isinstance(field_name, str) else field_name items: List[ModelT] = [] for field in fields: items.extend( [ item for item in result if isinstance(getattr(item, field), str) and pattern.match(getattr(item, field)) ], ) return list(set(result).difference(set(items))) def _filter_result_by_kwargs( self, result: Iterable[ModelT], /, kwargs: Union[dict[Any, Any], Iterable[tuple[Any, Any]]], ) -> List[ModelT]: kwargs_: dict[Any, Any] = kwargs if isinstance(kwargs, dict) else dict(*kwargs) # pyright: ignore kwargs_ = self._exclude_unused_kwargs(kwargs_) # pyright: ignore try: return [item for item in result if all(getattr(item, field) == value for field, value in kwargs_.items())] # pyright: ignore except AttributeError as error: raise RepositoryError from error def _order_by( self, result: List[ModelT], field_name: "Union[str, ColumnElement[Any], InstrumentedAttribute[Any]]", sort_desc: bool = False, ) -> List[ModelT]: return sorted(result, key=lambda item: getattr(item, self._extract_field_name(field_name)), reverse=sort_desc) def _apply_filters( self, result: List[ModelT], *filters: Union[StatementFilter, ColumnElement[bool]], apply_pagination: bool = True, ) -> List[ModelT]: for filter_ in filters: if isinstance(filter_, LimitOffset): if apply_pagination: result = self._apply_limit_offset_pagination(result, filter_.limit, filter_.offset) elif isinstance(filter_, BeforeAfter): result = self._filter_on_datetime_field( result, field_name=filter_.field_name, before=filter_.before, after=filter_.after, ) elif isinstance(filter_, OnBeforeAfter): result = self._filter_on_datetime_field( result, field_name=filter_.field_name, on_or_before=filter_.on_or_before, on_or_after=filter_.on_or_after, ) elif isinstance(filter_, NotInCollectionFilter): if filter_.values is not None: # pyright: ignore result = self._filter_not_in_collection(result, filter_.field_name, filter_.values) # pyright: ignore elif isinstance(filter_, CollectionFilter): if filter_.values is not None: # pyright: ignore result = self._filter_in_collection(result, filter_.field_name, filter_.values) # pyright: ignore elif isinstance(filter_, OrderBy): result = self._order_by( result, filter_.field_name, sort_desc=filter_.sort_order == "desc", ) elif isinstance(filter_, NotInSearchFilter): result = self._filter_by_not_like( result, filter_.field_name, value=filter_.value, ignore_case=bool(filter_.ignore_case), ) elif isinstance(filter_, SearchFilter): result = self._filter_by_like( result, filter_.field_name, value=filter_.value, ignore_case=bool(filter_.ignore_case), ) elif not isinstance(filter_, ColumnElement): msg = f"Unexpected filter: {filter_}" raise RepositoryError(msg) return result def _get_match_fields( self, match_fields: Union[List[str], str, None], id_attribute: Optional[str] = None, ) -> Optional[List[str]]: id_attribute = id_attribute or self.id_attribute match_fields = match_fields or self.match_fields if isinstance(match_fields, str): match_fields = [match_fields] return match_fields def _list_and_count_basic( self, *filters: Union[StatementFilter, ColumnElement[bool]], **kwargs: Any, ) -> tuple[List[ModelT], int]: result = self.list(*filters, **kwargs) return result, len(result) def _list_and_count_window( self, *filters: Union[StatementFilter, ColumnElement[bool]], **kwargs: Any, ) -> tuple[List[ModelT], int]: return self._list_and_count_basic(*filters, **kwargs) def _find_or_raise_not_found(self, id_: PrimaryKeyType) -> ModelT: """Find an item by primary key or raise NotFoundError. Args: id_: Primary key value (scalar, tuple, or dict). Returns: The found model instance. Raises: NotFoundError: If no instance found with the given primary key. """ store_key = self._get_store_key(id_) return self.check_not_found(self.__collection__().get_or_none(store_key)) @staticmethod def _find_one_or_raise_error(result: List[ModelT]) -> ModelT: if not result: msg = "No item found when one was expected" raise IntegrityError(msg) if len(result) > 1: msg = "Multiple objects when one was expected" raise IntegrityError(msg) return result[0] # pyright: ignore def _get_update_many_statement( self, model_type: type[ModelT], supports_returning: bool, loader_options: Optional[List[_AbstractLoad]], execution_options: Optional[dict[str, Any]], ) -> Union[Update, ReturningUpdate[tuple[ModelT]]]: return self.statement # type: ignore[no-any-return] # pyright: ignore[reportReturnType] @classmethod def check_health(cls, session: Union[Session, scoped_session[Session]]) -> bool: return True def get( self, item_id: PrimaryKeyType, *, auto_expunge: Optional[bool] = None, statement: Union[Select[tuple[ModelT]], StatementLambdaElement, None] = None, id_attribute: Union[str, InstrumentedAttribute[Any], None] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, with_for_update: ForUpdateParameter = None, use_cache: bool = True, bind_group: Optional[str] = None, ) -> ModelT: return self._find_or_raise_not_found(item_id) def get_one( self, *filters: Union[StatementFilter, ColumnElement[bool]], auto_expunge: Optional[bool] = None, statement: Union[Select[tuple[ModelT]], StatementLambdaElement, None] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> ModelT: return self.check_not_found(self.get_one_or_none(**kwargs)) def get_one_or_none( self, *filters: Union[StatementFilter, ColumnElement[bool]], auto_expunge: Optional[bool] = None, statement: Union[Select[tuple[ModelT]], StatementLambdaElement, None] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> Union[ModelT, None]: result = self._filter_result_by_kwargs(self.__collection__().list(), kwargs) if len(result) > 1: msg = "Multiple objects when one was expected" raise IntegrityError(msg) return result[0] if result else None def get_or_upsert( self, *filters: Union[StatementFilter, ColumnElement[bool]], match_fields: Union[List[str], str, None] = None, upsert: bool = True, attribute_names: Optional[Iterable[str]] = None, with_for_update: ForUpdateParameter = None, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, auto_refresh: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[ModelT, bool]: kwargs_ = self._exclude_unused_kwargs(kwargs) if match_fields := self._get_match_fields(match_fields=match_fields): match_filter = { # sourcery skip: remove-none-from-default-get field_name: kwargs_.get(field_name, None) for field_name in match_fields if kwargs_.get(field_name, None) is not None } else: match_filter = kwargs_ existing = self.get_one_or_none(**match_filter) if not existing: return (self.add(self.model_type(**kwargs_)), True) if upsert: for field_name, new_field_value in kwargs_.items(): field = getattr(existing, field_name, MISSING) if field is not MISSING and not compare_values(field, new_field_value): # pragma: no cover setattr(existing, field_name, new_field_value) existing = self.update(existing) return existing, False def get_and_update( self, *filters: Union[StatementFilter, ColumnElement[bool]], match_fields: Union[List[str], str, None] = None, attribute_names: Optional[Iterable[str]] = None, with_for_update: ForUpdateParameter = None, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, auto_refresh: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[ModelT, bool]: kwargs_ = self._exclude_unused_kwargs(kwargs) if match_fields := self._get_match_fields(match_fields=match_fields): match_filter = { # sourcery skip: remove-none-from-default-get field_name: kwargs_.get(field_name, None) for field_name in match_fields if kwargs_.get(field_name, None) is not None } else: match_filter = kwargs_ existing = self.get_one(**match_filter) updated = False for field_name, new_field_value in kwargs_.items(): field = getattr(existing, field_name, MISSING) if field is not MISSING and not compare_values(field, new_field_value): # pragma: no cover updated = True setattr(existing, field_name, new_field_value) existing = self.update(existing) return existing, updated def exists( self, *filters: "Union[StatementFilter, ColumnElement[bool]]", uniquify: Optional[bool] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> bool: existing = self.count(*filters, **kwargs) return existing > 0 def count( self, *filters: "Union[StatementFilter, ColumnElement[bool]]", uniquify: Optional[bool] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> int: result = self._apply_filters(self.__collection__().list(), *filters) return len(self._filter_result_by_kwargs(result, kwargs)) def add( self, data: ModelT, *, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, auto_refresh: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, bind_group: Optional[str] = None, ) -> ModelT: try: self.__database__.add(self.model_type, data) except KeyError as exc: msg = "Item already exist in collection" raise IntegrityError(msg) from exc return data def add_many( self, data: List[ModelT], *, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, bind_group: Optional[str] = None, ) -> List[ModelT]: for obj in data: self.add(obj) # pyright: ignore[reportCallIssue] return data def update( self, data: ModelT, *, attribute_names: Optional[Iterable[str]] = None, with_for_update: ForUpdateParameter = None, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, auto_refresh: Optional[bool] = None, id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, ) -> ModelT: pk_value = self.get_primary_key_value(data) self._find_or_raise_not_found(pk_value) return self.__collection__().update(data) def update_many( self, data: List[ModelT], *, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, ) -> List[ModelT]: return [self.__collection__().update(obj) for obj in data if obj in self.__collection__()] def delete( self, item_id: PrimaryKeyType, *, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, ) -> ModelT: store_key = self._get_store_key(item_id) try: return self._find_or_raise_not_found(item_id) finally: self.__collection__().remove(store_key) def delete_many( self, item_ids: List[PrimaryKeyType], *, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]] = None, chunk_size: Optional[int] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, ) -> List[ModelT]: deleted: List[ModelT] = [] for id_ in item_ids: store_key = self._get_store_key(id_) if obj := self.__collection__().get_or_none(store_key): deleted.append(obj) self.__collection__().remove(store_key) return deleted def delete_where( self, *filters: Union[StatementFilter, ColumnElement[bool]], auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, sanity_check: bool = True, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> List[ModelT]: result = self.__collection__().list() result = self._apply_filters(result, *filters) models = self._filter_result_by_kwargs(result, kwargs) item_ids: list[PrimaryKeyType] = [self.get_primary_key_value(model) for model in models] return self.delete_many(item_ids=item_ids) def upsert( self, data: ModelT, *, attribute_names: Optional[Iterable[str]] = None, with_for_update: ForUpdateParameter = None, auto_expunge: Optional[bool] = None, auto_commit: Optional[bool] = None, auto_refresh: Optional[bool] = None, match_fields: Optional[Union[List[str], str]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, ) -> ModelT: # sourcery skip: assign-if-exp, reintroduce-else if data in self.__collection__(): return self.update(data) return self.add(data) def upsert_many( self, data: List[ModelT], *, auto_expunge: Optional[bool] = None, auto_commit: Optional[bool] = None, no_merge: bool = False, match_fields: Optional[Union[List[str], str]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, ) -> List[ModelT]: return [self.upsert(item) for item in data] def list_and_count( self, *filters: Union[StatementFilter, ColumnElement[bool]], statement: Union[Select[tuple[ModelT]], StatementLambdaElement, None] = None, auto_expunge: Optional[bool] = None, count_with_window_function: Optional[bool] = None, order_by: Optional[Union[List[OrderingPair], OrderingPair]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, use_cache: bool = True, bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[List[ModelT], int]: return self._list_and_count_basic(*filters, **kwargs) def list( self, *filters: Union[StatementFilter, ColumnElement[bool]], uniquify: Optional[bool] = None, use_cache: bool = True, bind_group: Optional[str] = None, **kwargs: Any, ) -> List[ModelT]: result = self.__collection__().list() result = self._apply_filters(result, *filters) return self._filter_result_by_kwargs(result, kwargs) class SQLAlchemySyncMockSlugRepository( SQLAlchemySyncMockRepository[ModelT], SQLAlchemySyncSlugRepositoryProtocol[ModelT], ): def get_by_slug( self, slug: str, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> Union[ModelT, None]: return self.get_one_or_none(slug=slug) def get_available_slug( self, value_to_slugify: str, **kwargs: Any, ) -> str: """Get a unique slug for the supplied value. If the value is found to exist, a random 4 digit character is appended to the end. Override this method to change the default behavior Args: value_to_slugify (str): A string that should be converted to a unique slug. **kwargs: stuff Returns: str: a unique slug for the supplied value. This is safe for URLs and other unique identifiers. """ slug = slugify(value_to_slugify) if self._is_slug_unique(slug): return slug random_string = "".join(random.choices(string.ascii_lowercase + string.digits, k=4)) # noqa: S311 return f"{slug}-{random_string}" def _is_slug_unique( self, slug: str, **kwargs: Any, ) -> bool: return self.exists(slug=slug) is False python-advanced-alchemy-1.9.3/advanced_alchemy/repository/memory/base.py000066400000000000000000000313021516556515500265270ustar00rootroot00000000000000import builtins import contextlib from collections import defaultdict from inspect import isclass, signature from typing import TYPE_CHECKING, Any, Generic, List, Union, cast, overload from sqlalchemy import ColumnElement, inspect from sqlalchemy.orm import RelationshipProperty, Session, class_mapper, object_mapper from typing_extensions import TypeVar from advanced_alchemy.exceptions import AdvancedAlchemyError from advanced_alchemy.repository.typing import _MISSING, MISSING, ModelT # pyright: ignore[reportPrivateUsage] if TYPE_CHECKING: from collections.abc import Iterable from sqlalchemy.orm import Mapper CollectionT = TypeVar("CollectionT") T = TypeVar("T") AnyObject = TypeVar("AnyObject", bound="Any") class _NotSet: pass class InMemoryStore(Generic[T]): def __init__(self) -> None: self._store: dict[Any, T] = {} def _resolve_key(self, key: Any) -> Any: """Test different key representations Args: key: The key to test Raises: KeyError: Raised if key is not present Returns: The key representation that is present in the store """ for key_ in (key, str(key)): if key_ in self._store: return key_ raise KeyError def key(self, obj: T) -> Any: return hash(obj) def add(self, obj: T) -> T: if (key := self.key(obj)) not in self._store: self._store[key] = obj return obj raise KeyError def update(self, obj: T) -> T: key = self._resolve_key(self.key(obj)) self._store[key] = obj return obj @overload def get(self, key: Any, default: type[_NotSet] = _NotSet) -> T: ... @overload def get(self, key: Any, default: AnyObject) -> "Union[T, AnyObject]": ... def get( self, key: Any, default: "Union[AnyObject, type[_NotSet]]" = _NotSet ) -> "Union[T, AnyObject]": # pragma: no cover """Get the object identified by `key`, or return `default` if set or raise a `KeyError` otherwise Args: key: The key to test default: Value to return if key is not present. Defaults to _NotSet. Raises: KeyError: Raised if key is not present Returns: The object identified by key """ try: key = self._resolve_key(key) except KeyError as error: if isclass(default) and not issubclass(default, _NotSet): # pyright: ignore[reportUnnecessaryIsInstance] return cast("AnyObject", default) raise KeyError from error return self._store[key] def get_or_none(self, key: Any, default: Any = _NotSet) -> "Union[T, None]": return self.get(key) if default is _NotSet else self.get(key, default) def remove(self, key: Any) -> T: return self._store.pop(self._resolve_key(key)) def list(self) -> List[T]: return list(self._store.values()) def remove_all(self) -> None: self._store = {} def __contains__(self, obj: T) -> bool: try: self._resolve_key(self.key(obj)) except KeyError: return False else: return True def __bool__(self) -> bool: return bool(self._store) class MultiStore(Generic[T]): def __init__(self, store_type: "type[InMemoryStore[T]]") -> None: self.store_type = store_type self._store: defaultdict[Any, InMemoryStore[T]] = defaultdict(store_type) def add(self, identity: Any, obj: T) -> T: return self._store[identity].add(obj) def store(self, identity: Any) -> "InMemoryStore[T]": return self._store[identity] def identity(self, obj: T) -> Any: return type(obj) def remove_all(self) -> None: self._store = defaultdict(self.store_type) class SQLAlchemyInMemoryStore(InMemoryStore[ModelT]): id_attribute: str = "id" def _update_relationship(self, data: ModelT, ref: ModelT) -> None: # pragma: no cover """Set relationship data fields targeting ref class to ref. Example: ```python class Parent(Base): child = relationship("Child") class Child(Base): pass ``` If data and ref are respectively a `Parent` and `Child` instances, then `data.child` will be set to `ref` Args: data: Model instance on which to update relationships ref: Target model instance to set on data relationships """ ref_mapper = object_mapper(ref) for relationship in object_mapper(data).relationships: local = next(iter(relationship.local_columns)) remote = next(iter(relationship.remote_side)) if not local.key or not remote.key: msg = f"Cannot update relationship {relationship} for model {ref_mapper.class_}" raise AdvancedAlchemyError(msg) value = getattr(data, relationship.key) if not value and relationship.mapper.class_ is ref_mapper.class_: if relationship.uselist: for elem in value: if local_value := getattr(data, local.key): setattr(elem, remote.key, local_value) else: setattr(data, relationship.key, ref) def _update_fks(self, data: ModelT) -> None: # pragma: no cover """Update foreign key fields according to their corresponding relationships. This make sure that `data.child_id` == `data.child.id` or `data.children[0].parent_id` == `data.id` Args: data: Instance to be updated """ ref_mapper = object_mapper(data) for relationship in ref_mapper.relationships: if value := getattr(data, relationship.key): local = next(iter(relationship.local_columns)) remote = next(iter(relationship.remote_side)) if not local.key or not remote.key: msg = f"Cannot update relationship {relationship} for model {ref_mapper.class_}" raise AdvancedAlchemyError(msg) if relationship.uselist: for elem in value: if local_value := getattr(data, local.key): setattr(elem, remote.key, local_value) self._update_relationship(elem, data) # Remove duplicates added by orm when updating list items if isinstance(value, list): setattr(data, relationship.key, type(value)(set(value))) # pyright: ignore[reportUnknownArgumentType] else: if remote_value := getattr(value, remote.key): setattr(data, local.key, remote_value) self._update_relationship(value, data) def _set_defaults(self, data: ModelT) -> None: # pragma: no cover """Set fields with dynamic defaults. Args: data: Instance to be updated """ for elem in object_mapper(data).c: default = getattr(elem, "default", MISSING) value = getattr(data, elem.key, MISSING) # If value is MISSING, it may be a declared_attr whose name can't be # determined from the column/relationship element returned if value is not MISSING and not value and not isinstance(default, _MISSING) and default is not None: if default.is_scalar: default_value: Any = default.arg elif default.is_callable: default_callable = default.arg.__func__ if isinstance(default.arg, staticmethod) else default.arg # pyright: ignore[reportUnknownMemberType] if ( # Eager test because inspect.signature() does not # recognize builtins hasattr(builtins, default_callable.__name__) # If present, context contains information about the current # statement and can be used to access values from other columns. # As we can't reproduce such context in Pydantic, we don't want # include a default_factory in that case. or "context" not in signature(default_callable).parameters ): default_value = default.arg({}) # pyright: ignore else: continue else: continue setattr(data, elem.key, default_value) @staticmethod def changed_attrs(data: ModelT) -> "Iterable[str]": # pragma: no cover res: List[str] = [] mapper = inspect(data) if mapper is None: msg = f"Cannot inspect {data.__class__} model" raise AdvancedAlchemyError(msg) attrs = class_mapper(data.__class__).column_attrs for attr in attrs: hist = getattr(mapper.attrs, attr.key).history if hist.has_changes(): res.append(attr.key) return res def key(self, obj: ModelT) -> str: """Generate a store key from a model instance. Supports both single and composite primary keys. Args: obj: Model instance to generate key from. Returns: String key for storage. For composite keys, returns tuple representation. """ mapper = object_mapper(obj) pk_columns = mapper.primary_key if len(pk_columns) == 1: # Single PK - use simple string representation return str(getattr(obj, self.id_attribute)) # Composite PK - use tuple of all PK values pk_values = tuple(getattr(obj, col.name) for col in pk_columns) return str(pk_values) def add(self, obj: ModelT) -> ModelT: self._set_defaults(obj) self._update_fks(obj) return super().add(obj) def update(self, obj: ModelT) -> ModelT: existing = self.get(self.key(obj)) for attr in self.changed_attrs(obj): setattr(existing, attr, getattr(obj, attr)) self._update_fks(existing) return super().update(existing) class SQLAlchemyMultiStore(MultiStore[ModelT]): @staticmethod def _new_instances(instance: ModelT) -> "Iterable[ModelT]": session = Session() session.add(instance) relations = list(session.new) session.expunge_all() return relations def _set_relationships_for_fks(self, data: ModelT) -> None: # pragma: no cover """Set relationships matching newly added foreign keys on the instance. Example: ```python class Parent(Base): id: Mapped[UUID] class Child(Base): id: Mapped[UUID] parent_id: Mapped[UUID] = mapped_column(ForeignKey("parent.id")) parent: Mapped[Parent] = relationship(Parent) ``` If `data` is a Child instance and `parent_id` is set, `parent` will be set to the matching Parent instance if found in the repository Args: data: The model to update """ obj_mapper = object_mapper(data) mappers: dict[str, Mapper[Any]] = {} column_relationships: dict[ColumnElement[Any], RelationshipProperty[Any]] = {} for mapper in obj_mapper.registry.mappers: for table in mapper.tables: mappers[table.name] = mapper for relationship in obj_mapper.relationships: for column in relationship.local_columns: column_relationships[column] = relationship # sourcery skip: assign-if-exp if state := inspect(data): new_attrs: dict[str, Any] = state.dict else: new_attrs = {} for column in obj_mapper.columns: if column.key not in new_attrs or not column.foreign_keys: continue remote_mapper = mappers[next(iter(column.foreign_keys))._table_key()] # noqa: SLF001 # pyright: ignore[reportPrivateUsage] try: obj = self.store(remote_mapper.class_).get(new_attrs.get(column.key)) except KeyError: continue with contextlib.suppress(KeyError): setattr(data, column_relationships[column].key, obj) def add(self, identity: Any, obj: ModelT) -> ModelT: for relation in self._new_instances(obj): instance_type = self.identity(relation) self._set_relationships_for_fks(relation) if relation in self.store(instance_type): continue self.store(instance_type).add(relation) return obj python-advanced-alchemy-1.9.3/advanced_alchemy/repository/typing.py000066400000000000000000000066541516556515500256330ustar00rootroot00000000000000from typing import TYPE_CHECKING, Any, Union from sqlalchemy import UnaryExpression from sqlalchemy.orm import InstrumentedAttribute from typing_extensions import TypeAlias, TypeVar if TYPE_CHECKING: from sqlalchemy import RowMapping, Select from advanced_alchemy import base from advanced_alchemy.repository._async import SQLAlchemyAsyncRepository from advanced_alchemy.repository._sync import SQLAlchemySyncRepository from advanced_alchemy.repository.memory._async import SQLAlchemyAsyncMockRepository from advanced_alchemy.repository.memory._sync import SQLAlchemySyncMockRepository __all__ = ( "MISSING", "ModelOrRowMappingT", "ModelT", "OrderingPair", "PrimaryKeyType", "RowMappingT", "RowT", "SQLAlchemyAsyncRepositoryT", "SQLAlchemySyncRepositoryT", "SelectT", "T", ) T = TypeVar("T") ModelT = TypeVar("ModelT", bound="base.ModelProtocol") """Type variable for SQLAlchemy models. :class:`~advanced_alchemy.base.ModelProtocol` """ SelectT = TypeVar("SelectT", bound="Select[Any]") """Type variable for SQLAlchemy select statements. :class:`~sqlalchemy.sql.Select` """ RowT = TypeVar("RowT", bound=tuple[Any, ...]) """Type variable for rows. :class:`~sqlalchemy.engine.Row` """ RowMappingT = TypeVar("RowMappingT", bound="RowMapping") """Type variable for row mappings. :class:`~sqlalchemy.engine.RowMapping` """ ModelOrRowMappingT = TypeVar("ModelOrRowMappingT", bound="Union[base.ModelProtocol, RowMapping]") """Type variable for models or row mappings. :class:`~advanced_alchemy.base.ModelProtocol` | :class:`~sqlalchemy.engine.RowMapping` """ SQLAlchemySyncRepositoryT = TypeVar( "SQLAlchemySyncRepositoryT", bound="Union[SQLAlchemySyncRepository[Any], SQLAlchemySyncMockRepository[Any]]", default="Any", ) """Type variable for synchronous SQLAlchemy repositories. :class:`~advanced_alchemy.repository.SQLAlchemySyncRepository` """ SQLAlchemyAsyncRepositoryT = TypeVar( "SQLAlchemyAsyncRepositoryT", bound="Union[SQLAlchemyAsyncRepository[Any], SQLAlchemyAsyncMockRepository[Any]]", default="Any", ) """Type variable for asynchronous SQLAlchemy repositories. :class:`~advanced_alchemy.repository.SQLAlchemyAsyncRepository` """ OrderingPair: TypeAlias = Union[tuple[Union[str, InstrumentedAttribute[Any]], bool], UnaryExpression[Any]] """Type alias for ordering pairs. A tuple of (column, ascending) where: - column: Union[str, :class:`sqlalchemy.orm.InstrumentedAttribute`] - ascending: bool - or a :class:`sqlalchemy.sql.elements.UnaryExpression` which is the standard way to express an ordering in SQLAlchemy This type is used to specify ordering criteria for repository queries. """ PrimaryKeyType: TypeAlias = Union[Any, tuple[Any, ...], dict[str, Any]] """Type alias for primary key values. Supports three formats: - Scalar value (int, str, UUID, etc.) for single-column primary keys - Tuple of values for composite primary keys, in column order - Dict mapping attribute names to values for composite primary keys Examples: # Single primary key repo.get(123) repo.get("abc-uuid") # Composite primary key (tuple format - values in PK column order) repo.get((user_id, role_id)) # Composite primary key (dict format - named attributes) repo.get({"user_id": 1, "role_id": 5}) """ class _MISSING: """Placeholder for missing values.""" MISSING = _MISSING() """Missing value placeholder. :class:`~advanced_alchemy.repository.typing._MISSING` """ python-advanced-alchemy-1.9.3/advanced_alchemy/routing/000077500000000000000000000000001516556515500232045ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/advanced_alchemy/routing/__init__.py000066400000000000000000000035561516556515500253260ustar00rootroot00000000000000"""Read/Write routing support for Advanced Alchemy. This module provides automatic routing of read operations to read replicas while directing write operations to the primary database. Example: Basic usage with routing configuration:: from advanced_alchemy.config import SQLAlchemyAsyncConfig from advanced_alchemy.config.routing import RoutingConfig config = SQLAlchemyAsyncConfig( routing_config=RoutingConfig( primary_connection_string="postgresql+asyncpg://user:pass@primary:5432/db", read_replicas=[ "postgresql+asyncpg://user:pass@replica1:5432/db", "postgresql+asyncpg://user:pass@replica2:5432/db", ], ), ) Using context managers for explicit control:: from advanced_alchemy.routing import ( primary_context, replica_context, ) with primary_context(): user = await repo.get(user_id) with replica_context(): users = await repo.list() """ from advanced_alchemy.routing.context import ( force_primary_var, primary_context, replica_context, reset_routing_context, stick_to_primary_var, use_bind_group, ) from advanced_alchemy.routing.maker import RoutingAsyncSessionMaker, RoutingSyncSessionMaker from advanced_alchemy.routing.selectors import RandomSelector, ReplicaSelector, RoundRobinSelector from advanced_alchemy.routing.session import RoutingAsyncSession, RoutingSyncSession __all__ = ( "RandomSelector", "ReplicaSelector", "RoundRobinSelector", "RoutingAsyncSession", "RoutingAsyncSessionMaker", "RoutingSyncSession", "RoutingSyncSessionMaker", "force_primary_var", "primary_context", "replica_context", "reset_routing_context", "stick_to_primary_var", "use_bind_group", ) python-advanced-alchemy-1.9.3/advanced_alchemy/routing/context.py000066400000000000000000000113071516556515500252440ustar00rootroot00000000000000"""Context variables and context managers for read/write routing. This module provides the context-based state management for routing decisions, including the sticky-to-primary behavior after writes. """ from collections.abc import Generator from contextlib import contextmanager from contextvars import ContextVar, Token from typing import Optional __all__ = ( "bind_group_var", "force_primary_var", "primary_context", "replica_context", "reset_routing_context", "stick_to_primary_var", "use_bind_group", ) stick_to_primary_var: ContextVar[bool] = ContextVar("stick_to_primary", default=False) """Context variable tracking if we should stick to primary after a write. When ``True``, all operations (including reads) will use the primary database until the context is reset (typically after commit/rollback). """ force_primary_var: ContextVar[bool] = ContextVar("force_primary", default=False) """Context variable for explicitly forcing all operations to primary. When ``True``, all operations will use the primary database regardless of operation type or stickiness state. """ bind_group_var: ContextVar[Optional[str]] = ContextVar("bind_group", default=None) """Context variable for explicitly selecting a bind group. When set, this overrides the automatic routing logic to use the specified engine group (e.g., "analytics", "reader"). """ @contextmanager def primary_context() -> Generator[None, None, None]: """Force all operations to use primary within this context. Use this context manager when you need to ensure all database operations (including reads) go to the primary database. Example: Force a specific query to use the primary database:: from advanced_alchemy.routing import primary_context with primary_context(): user = await repo.get(user_id) orders = await order_repo.list() Yields: None """ token: Token[bool] = force_primary_var.set(True) try: yield finally: force_primary_var.reset(token) @contextmanager def replica_context() -> Generator[None, None, None]: """Force read operations to use replicas (temporarily disable stickiness). Use this context manager when you want to explicitly allow reads to go to replicas, even if a previous write has set the sticky-to-primary state. .. warning:: Use with caution! This can lead to read-after-write inconsistency if you're reading data that was recently written. Example: Allow reads to use replicas after a write:: from advanced_alchemy.routing import replica_context await repo.add(user) user = await repo.get(user_id) with replica_context(): users = await repo.list() Yields: None """ stick_token: Token[bool] = stick_to_primary_var.set(False) force_token: Token[bool] = force_primary_var.set(False) try: yield finally: stick_to_primary_var.reset(stick_token) force_primary_var.reset(force_token) @contextmanager def use_bind_group(name: str) -> Generator[None, None, None]: """Force operations to use a specific bind group. Use this context manager to route operations to a specific group of engines, such as "analytics" or "reporting". Example: Route a query to the analytics database:: from advanced_alchemy.routing import use_bind_group with use_bind_group("analytics"): data = await repo.list() Args: name: Name of the bind group to use. Yields: None """ token: Token[Optional[str]] = bind_group_var.set(name) try: yield finally: bind_group_var.reset(token) def reset_routing_context() -> None: """Reset all routing context variables to their defaults. This is typically called after a commit or rollback to allow subsequent reads to use replicas again. Example: Manual reset after transaction:: from advanced_alchemy.routing import reset_routing_context await session.commit() reset_routing_context() """ stick_to_primary_var.set(False) force_primary_var.set(False) bind_group_var.set(None) def set_sticky_primary() -> None: """Set the sticky-to-primary flag. This is called internally after write operations to ensure subsequent reads use the primary database. """ stick_to_primary_var.set(True) def should_use_primary() -> bool: """Check if we should route to the primary database. Returns: ``True`` if routing should use primary (due to force or stickiness). """ return force_primary_var.get() or stick_to_primary_var.get() python-advanced-alchemy-1.9.3/advanced_alchemy/routing/maker.py000066400000000000000000000276051516556515500246670ustar00rootroot00000000000000"""Session maker factories for read/write routing. This module provides session maker classes that create routing-aware sessions with properly configured primary and replica engines. """ from typing import Any, Callable, Optional from sqlalchemy import Engine, create_engine from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine from advanced_alchemy.config.routing import RoutingConfig, RoutingStrategy from advanced_alchemy.exceptions import ImproperConfigurationError from advanced_alchemy.routing.selectors import EngineSelector, RandomSelector, RoundRobinSelector from advanced_alchemy.routing.session import RoutingAsyncSession, RoutingSyncSession __all__ = ( "RoutingAsyncSessionMaker", "RoutingSyncSessionMaker", ) class RoutingSyncSessionMaker: """Factory for creating sync routing sessions. This class creates :class:`RoutingSyncSession` instances with properly configured engines and routing selectors. Example: Creating a routing session maker:: maker = RoutingSyncSessionMaker( routing_config=RoutingConfig( engines={ "writer": ["postgresql://primary"], "reader": ["postgresql://replica1"], } ), engine_config={"pool_size": 10}, ) session = maker() """ __slots__ = ( "_default_engine", "_engine_config", "_engines", "_routing_config", "_selectors", "_session_config", ) def __init__( self, routing_config: RoutingConfig, engine_config: Optional[dict[str, Any]] = None, session_config: Optional[dict[str, Any]] = None, create_engine_callable: Callable[[str], Engine] = create_engine, ) -> None: """Initialize the session maker. Args: routing_config: Configuration for read/write routing. engine_config: Configuration options for engine creation. session_config: Configuration options for session creation. create_engine_callable: Callable to create engines (for testing). """ self._routing_config = routing_config self._engine_config = engine_config or {} self._session_config = session_config or {} self._engines: dict[str, list[Engine]] = {} self._selectors: dict[str, EngineSelector[Engine]] = {} # Initialize engines and selectors for all groups for group in routing_config.engines: engines_for_group: list[Engine] = [] for config in routing_config.get_engine_configs(group): engine = self._create_engine(config.connection_string, create_engine_callable) engines_for_group.append(engine) if engines_for_group: self._engines[group] = engines_for_group self._selectors[group] = self._create_selector( engines_for_group, routing_config.routing_strategy, ) # Set default engine (required) default_group = routing_config.default_group if ( default_group not in self._engines or not self._engines[default_group] ) and not routing_config.primary_connection_string: # Only raise if strict legacy check fails too? # Actually, post_init maps primary_connection_string to engines, so we just check engines. msg = ( f"Default group '{default_group}' has no engines configured. " "Ensure 'engines' contains this group or 'primary_connection_string' is set." ) raise ImproperConfigurationError(msg) self._default_engine = self._engines[default_group][0] def _create_engine( self, connection_string: str, create_engine_callable: Callable[[str], Engine], ) -> Engine: """Create an engine with the configured options. Args: connection_string: Database connection string. create_engine_callable: Callable to create the engine. Returns: The created engine. """ try: return create_engine_callable(connection_string, **self._engine_config) except TypeError: config = self._engine_config.copy() config.pop("json_deserializer", None) config.pop("json_serializer", None) return create_engine_callable(connection_string, **config) def _create_selector( self, engines: list[Engine], strategy: RoutingStrategy, ) -> EngineSelector[Engine]: """Create an engine selector for the given strategy. Args: engines: List of engines. strategy: The routing strategy to use. Returns: The appropriate selector instance. """ if strategy == RoutingStrategy.RANDOM: return RandomSelector(engines) return RoundRobinSelector(engines) def __call__(self) -> RoutingSyncSession: """Create a new routing session. Any ``bind`` passed in the session config is ignored because routing controls bind selection. Returns: A new :class:`RoutingSyncSession` instance. """ session_config = self._session_config.copy() session_config.pop("bind", None) return RoutingSyncSession( routing_config=self._routing_config, selectors=self._selectors, default_engine=self._default_engine, **session_config, ) @property def primary_engine(self) -> Engine: """Get the primary (default) engine. Returns: The primary database engine. """ return self._default_engine @property def replica_engines(self) -> list[Engine]: """Get the replica engines (from read_group). Returns: List of replica database engines. """ return self._engines.get(self._routing_config.read_group, []) def close_all(self) -> None: """Close all engines and release connections. Call this when shutting down to properly release database connections. """ for engine_list in self._engines.values(): for engine in engine_list: engine.dispose() class RoutingAsyncSessionMaker: """Factory for creating async routing sessions. This class creates :class:`RoutingAsyncSession` instances with properly configured async engines and routing selectors. Example: Creating an async routing session maker:: maker = RoutingAsyncSessionMaker( routing_config=RoutingConfig( engines={ "writer": ["postgresql+asyncpg://primary"], "reader": ["postgresql+asyncpg://replica1"], } ), engine_config={"pool_size": 10}, ) async with maker() as session: result = await session.execute(select(User)) """ __slots__ = ( "_default_engine", "_engine_config", "_engines", "_routing_config", "_selectors", "_session_config", ) def __init__( self, routing_config: RoutingConfig, engine_config: Optional[dict[str, Any]] = None, session_config: Optional[dict[str, Any]] = None, create_engine_callable: Callable[[str], AsyncEngine] = create_async_engine, ) -> None: """Initialize the async session maker. Args: routing_config: Configuration for read/write routing. engine_config: Configuration options for engine creation. session_config: Configuration options for session creation. create_engine_callable: Callable to create async engines (for testing). """ self._routing_config = routing_config self._engine_config = engine_config or {} self._session_config = session_config or {} self._engines: dict[str, list[AsyncEngine]] = {} self._selectors: dict[str, EngineSelector[AsyncEngine]] = {} # Initialize engines and selectors for all groups for group in routing_config.engines: engines_for_group: list[AsyncEngine] = [] for config in routing_config.get_engine_configs(group): engine = self._create_engine(config.connection_string, create_engine_callable) engines_for_group.append(engine) if engines_for_group: self._engines[group] = engines_for_group self._selectors[group] = self._create_selector( engines_for_group, routing_config.routing_strategy, ) # Set default engine (required) default_group = routing_config.default_group if default_group not in self._engines or not self._engines[default_group]: msg = ( f"Default group '{default_group}' has no engines configured. " "Ensure 'engines' contains this group or 'primary_connection_string' is set." ) raise ImproperConfigurationError(msg) self._default_engine = self._engines[default_group][0] def _create_engine( self, connection_string: str, create_engine_callable: Callable[[str], AsyncEngine], ) -> AsyncEngine: """Create an async engine with the configured options. Args: connection_string: Database connection string. create_engine_callable: Callable to create the engine. Returns: The created async engine. """ try: return create_engine_callable(connection_string, **self._engine_config) except TypeError: config = self._engine_config.copy() config.pop("json_deserializer", None) config.pop("json_serializer", None) return create_engine_callable(connection_string, **config) def _create_selector( self, engines: list[AsyncEngine], strategy: RoutingStrategy, ) -> EngineSelector[AsyncEngine]: """Create an engine selector for the given strategy. Args: engines: List of replica async engines. strategy: The routing strategy to use. Returns: The appropriate selector instance. """ if strategy == RoutingStrategy.RANDOM: return RandomSelector(engines) return RoundRobinSelector(engines) def __call__(self) -> RoutingAsyncSession: """Create a new async routing session. Any ``bind`` passed in the session config is ignored because routing controls bind selection. Returns: A new :class:`RoutingAsyncSession` instance. """ session_config = self._session_config.copy() session_config.pop("bind", None) return RoutingAsyncSession( routing_config=self._routing_config, selectors=self._selectors, default_engine=self._default_engine, **session_config, ) @property def primary_engine(self) -> AsyncEngine: """Get the primary (default) async engine. Returns: The primary database async engine. """ return self._default_engine @property def replica_engines(self) -> list[AsyncEngine]: """Get the replica async engines (from read_group). Returns: List of replica database async engines. """ return self._engines.get(self._routing_config.read_group, []) async def close_all(self) -> None: """Close all engines and release connections. Call this when shutting down to properly release database connections. """ for engine_list in self._engines.values(): for engine in engine_list: await engine.dispose() python-advanced-alchemy-1.9.3/advanced_alchemy/routing/selectors.py000066400000000000000000000105021516556515500255570ustar00rootroot00000000000000"""Replica selectors for read/write routing. This module provides different strategies for selecting which read replica to use for read operations. """ import secrets import threading from abc import ABC, abstractmethod from itertools import cycle from typing import TYPE_CHECKING, Generic, TypeVar, Union if TYPE_CHECKING: from collections.abc import Iterator from sqlalchemy import Engine from sqlalchemy.ext.asyncio import AsyncEngine __all__ = ( "RandomSelector", "ReplicaSelector", "RoundRobinSelector", ) EngineT = TypeVar("EngineT", bound="Union[Engine, AsyncEngine]") class EngineSelector(ABC, Generic[EngineT]): """Abstract base class for engine selection strategies. Subclasses implement different algorithms for choosing which engine to use for operations. Attributes: _engines: List of engines to select from. """ __slots__ = ("_engines",) def __init__(self, engines: list[EngineT]) -> None: """Initialize the selector with a list of engines. Args: engines: List of database engines. """ self._engines = engines def has_engines(self) -> bool: """Check if any engines are configured. Returns: ``True`` if at least one engine is available. """ return len(self._engines) > 0 def has_replicas(self) -> bool: """Check if any replicas are configured (alias for has_engines). Returns: ``True`` if at least one engine is available. """ return self.has_engines() @property def engines(self) -> list[EngineT]: """Get the list of engines. Returns: List of configured engines. """ return self._engines @property def replicas(self) -> list[EngineT]: """Get the list of replica engines (alias for engines). Returns: List of configured engines. """ return self.engines @abstractmethod def next(self) -> EngineT: """Select the next engine to use. Returns: The selected engine. Raises: RuntimeError: If no engines are available. """ ... # Alias for backward compatibility ReplicaSelector = EngineSelector class RoundRobinSelector(EngineSelector[EngineT]): """Round-robin engine selection. Cycles through engines in order, distributing load evenly across all available engines. This selector is thread-safe. Example: Creating a round-robin selector:: selector = RoundRobinSelector(engines) engine1 = selector.next() engine2 = selector.next() engine3 = selector.next() This cycles through engines in order and wraps back to the first. """ __slots__ = ("_cycle", "_lock") def __init__(self, engines: list[EngineT]) -> None: """Initialize the round-robin selector. Args: engines: List of database engines. """ super().__init__(engines) self._cycle: Iterator[EngineT] = cycle(engines) if engines else iter([]) self._lock = threading.Lock() def next(self) -> EngineT: """Select the next engine in round-robin order. Returns: The next engine in the cycle. Raises: RuntimeError: If no engines are configured. """ if not self._engines: msg = "No engines configured for round-robin selection" raise RuntimeError(msg) with self._lock: return next(self._cycle) class RandomSelector(EngineSelector[EngineT]): """Random engine selection. Selects engines randomly, which can help with load distribution when engines have varying capacity or when you want to avoid predictable patterns. Example: Creating a random selector:: selector = RandomSelector(engines) engine = selector.next() """ __slots__ = () def next(self) -> EngineT: """Select a random engine. Returns: A randomly selected engine. Raises: RuntimeError: If no engines are configured. """ if not self._engines: msg = "No engines configured for random selection" raise RuntimeError(msg) return secrets.choice(self._engines) python-advanced-alchemy-1.9.3/advanced_alchemy/routing/session.py000066400000000000000000000215261516556515500252470ustar00rootroot00000000000000"""Routing-aware session classes for read/write routing. This module provides custom SQLAlchemy session classes that implement read/write routing via the ``get_bind()`` method. """ from typing import TYPE_CHECKING, Any, Optional, Union from sqlalchemy import Delete, Insert, Update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session from advanced_alchemy.routing.context import ( bind_group_var, force_primary_var, reset_routing_context, set_sticky_primary, stick_to_primary_var, ) if TYPE_CHECKING: from sqlalchemy import Engine from sqlalchemy.ext.asyncio import AsyncEngine from sqlalchemy.orm import Mapper from advanced_alchemy.config.routing import RoutingConfig from advanced_alchemy.routing.selectors import EngineSelector __all__ = ( "RoutingAsyncSession", "RoutingSyncSession", ) class RoutingSyncSession(Session): """Synchronous session with read/write routing via ``get_bind()``. This session class extends SQLAlchemy's :class:`Session` to provide automatic routing of operations to different engine groups (e.g. writer/reader). The routing decision is made in ``get_bind()`` based on: 1. Execution options (``bind_group``) 2. Context variables (``bind_group``, ``force_primary``) 3. Stickiness state 4. Operation type (Write vs Read) Attributes: _default_engine: The default (write) database engine. _selectors: Map of group names to engine selectors. _routing_config: Configuration for routing behavior. """ _default_engine: "Engine" _selectors: "dict[str, EngineSelector[Engine]]" _routing_config: "RoutingConfig" def __init__( self, routing_config: "RoutingConfig", selectors: "dict[str, EngineSelector[Engine]]", default_engine: "Engine", **kwargs: Any, ) -> None: """Initialize the routing session. Args: routing_config: Configuration for routing behavior. selectors: Map of group names to engine selectors. default_engine: The default (fallback/write) engine. **kwargs: Additional arguments passed to the parent Session. """ kwargs.pop("bind", None) kwargs.pop("binds", None) super().__init__(**kwargs) self._default_engine = default_engine self._selectors = selectors self._routing_config = routing_config def get_bind( self, mapper: Optional[Union["Mapper[Any]", type[Any]]] = None, clause: Optional[Any] = None, **kwargs: Any, ) -> "Engine": """Route to appropriate engine based on operation and context. Args: mapper: Optional mapper for the operation. clause: The SQL clause being executed. **kwargs: Additional keyword arguments. Returns: The selected engine. """ # 1. Check for explicit bind group in execution options if clause is not None and hasattr(clause, "_execution_options"): bind_group = clause._execution_options.get("bind_group") # noqa: SLF001 if bind_group: return self._get_engine_for_group(bind_group) # 2. Check context variable for bind group bind_group = bind_group_var.get() if bind_group: return self._get_engine_for_group(bind_group) # 3. Check if we should force/stick to default (writer) if self._should_use_default_group(clause): return self._get_engine_for_group(self._routing_config.default_group) # 4. Read operation -> use read group return self._get_engine_for_group(self._routing_config.read_group) def _get_engine_for_group(self, group: str) -> "Engine": """Get an engine for the specified group. Args: group: Name of the engine group. Returns: An engine from the group, or the default engine if group not found. """ if group in self._selectors: selector = self._selectors[group] if selector.has_engines(): return selector.next() # Fallback to default engine if group has no selector/engines # or if it's the default group and we want to be safe return self._default_engine def _should_use_default_group(self, clause: Optional[Any]) -> bool: """Determine if the operation should use the default (writer) group. Args: clause: The SQL clause being executed. Returns: ``True`` if default group should be used. """ if not self._routing_config.enabled: return True if force_primary_var.get(): return True if stick_to_primary_var.get(): return True if self._flushing: if self._routing_config.sticky_after_write: set_sticky_primary() return True if clause is not None and isinstance(clause, (Insert, Update, Delete)): if self._routing_config.sticky_after_write: set_sticky_primary() return True return self._has_for_update(clause) def _has_for_update(self, clause: Optional[Any]) -> bool: """Check if the clause has FOR UPDATE. Args: clause: The SQL clause to check. Returns: ``True`` if FOR UPDATE is present. """ if clause is None: return False for_update_arg = getattr(clause, "_for_update_arg", None) return for_update_arg is not None def commit(self) -> None: """Commit the transaction and reset routing state.""" super().commit() if self._routing_config.reset_stickiness_on_commit: reset_routing_context() def rollback(self) -> None: """Rollback the transaction and reset routing state.""" super().rollback() reset_routing_context() class RoutingAsyncSession(AsyncSession): """Async session with read/write routing support. Wraps :class:`RoutingSyncSession` to provide async routing capabilities. """ sync_session_class: "type[Session]" = RoutingSyncSession def __init__( self, routing_config: "RoutingConfig", selectors: "dict[str, EngineSelector[AsyncEngine]]", default_engine: "AsyncEngine", **kwargs: Any, ) -> None: """Initialize the async routing session. Args: routing_config: Configuration for routing behavior. selectors: Map of group names to async engine selectors. default_engine: The default (fallback/write) async engine. **kwargs: Additional arguments passed to the parent AsyncSession. """ kwargs.pop("bind", None) kwargs.pop("binds", None) # Convert async selectors to sync selectors for the wrapped session sync_selectors = {name: _SyncEngineSelectorWrapper(selector) for name, selector in selectors.items()} super().__init__( sync_session_class=RoutingSyncSession, routing_config=routing_config, selectors=sync_selectors, default_engine=default_engine.sync_engine, **kwargs, ) self._default_engine = default_engine self._selectors = selectors self._routing_config = routing_config @property def primary_engine(self) -> "AsyncEngine": """Get the primary (default) async engine. Returns: The default database engine. """ return self._default_engine @property def routing_config(self) -> "RoutingConfig": """Get the routing configuration. Returns: The routing configuration. """ return self._routing_config class _SyncEngineSelectorWrapper: """Wrapper to adapt async engine selector for sync session. This wrapper extracts sync engines from async engines in the selector. """ __slots__ = ("_async_selector",) def __init__(self, async_selector: "EngineSelector[AsyncEngine]") -> None: """Initialize the wrapper. Args: async_selector: The async engine selector to wrap. """ self._async_selector = async_selector def has_engines(self) -> bool: """Check if any engines are configured. Returns: ``True`` if at least one engine is available. """ return self._async_selector.has_engines() def has_replicas(self) -> bool: """Check if any replicas are configured (alias for has_engines). Returns: ``True`` if at least one engine is available. """ return self.has_engines() def next(self) -> "Engine": """Get the next engine's sync engine. Returns: The sync engine for the next selection. """ return self._async_selector.next().sync_engine python-advanced-alchemy-1.9.3/advanced_alchemy/service/000077500000000000000000000000001516556515500231555ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/advanced_alchemy/service/__init__.py000066400000000000000000000053131516556515500252700ustar00rootroot00000000000000from advanced_alchemy.repository import ( DEFAULT_ERROR_MESSAGE_TEMPLATES, Empty, EmptyType, ErrorMessages, LoadSpec, ModelOrRowMappingT, ModelT, OrderingPair, model_from_dict, ) from advanced_alchemy.service._async import ( SQLAlchemyAsyncQueryService, SQLAlchemyAsyncRepositoryReadService, SQLAlchemyAsyncRepositoryService, ) from advanced_alchemy.service._sync import ( SQLAlchemySyncQueryService, SQLAlchemySyncRepositoryReadService, SQLAlchemySyncRepositoryService, ) from advanced_alchemy.service._util import ResultConverter, find_filter from advanced_alchemy.service.pagination import OffsetPagination from advanced_alchemy.service.typing import ( ATTRS_INSTALLED, AttrsInstance, FilterTypeT, ModelDictListT, ModelDictT, ModelDTOT, SupportedSchemaModel, fields, is_attrs_instance, is_attrs_instance_with_field, is_attrs_instance_without_field, is_attrs_schema, is_dict, is_dict_with_field, is_dict_without_field, is_dto_data, is_msgspec_struct, is_msgspec_struct_with_field, is_msgspec_struct_without_field, is_pydantic_model, is_pydantic_model_with_field, is_pydantic_model_without_field, is_schema, is_schema_or_dict, is_schema_or_dict_with_field, is_schema_or_dict_without_field, is_schema_with_field, is_schema_without_field, is_sqlmodel_table_model, schema_dump, ) __all__ = ( "ATTRS_INSTALLED", "DEFAULT_ERROR_MESSAGE_TEMPLATES", "AttrsInstance", "Empty", "EmptyType", "ErrorMessages", "FilterTypeT", "LoadSpec", "ModelDTOT", "ModelDictListT", "ModelDictT", "ModelOrRowMappingT", "ModelT", "OffsetPagination", "OrderingPair", "ResultConverter", "SQLAlchemyAsyncQueryService", "SQLAlchemyAsyncRepositoryReadService", "SQLAlchemyAsyncRepositoryService", "SQLAlchemySyncQueryService", "SQLAlchemySyncRepositoryReadService", "SQLAlchemySyncRepositoryService", "SupportedSchemaModel", "fields", "find_filter", "is_attrs_instance", "is_attrs_instance_with_field", "is_attrs_instance_without_field", "is_attrs_schema", "is_dict", "is_dict_with_field", "is_dict_without_field", "is_dto_data", "is_msgspec_struct", "is_msgspec_struct_with_field", "is_msgspec_struct_without_field", "is_pydantic_model", "is_pydantic_model_with_field", "is_pydantic_model_without_field", "is_schema", "is_schema_or_dict", "is_schema_or_dict_with_field", "is_schema_or_dict_without_field", "is_schema_with_field", "is_schema_without_field", "is_sqlmodel_table_model", "model_from_dict", "schema_dump", ) python-advanced-alchemy-1.9.3/advanced_alchemy/service/_async.py000066400000000000000000001602131516556515500250060ustar00rootroot00000000000000"""Service object implementation for SQLAlchemy. RepositoryService object is generic on the domain model type which should be a SQLAlchemy model. """ from collections.abc import AsyncIterator, Iterable, Sequence from contextlib import asynccontextmanager from functools import cached_property from typing import Any, ClassVar, Generic, List, Optional, Union, cast from sqlalchemy import Select from sqlalchemy import inspect as sa_inspect from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio.scoping import async_scoped_session from sqlalchemy.orm import InstrumentedAttribute from sqlalchemy.sql import ColumnElement from sqlalchemy.sql.selectable import ForUpdateParameter from typing_extensions import Self from advanced_alchemy.base import ModelProtocol, model_to_dict from advanced_alchemy.config.asyncio import SQLAlchemyAsyncConfig from advanced_alchemy.exceptions import AdvancedAlchemyError, ErrorMessages, ImproperConfigurationError, RepositoryError from advanced_alchemy.filters import StatementFilter from advanced_alchemy.repository import ( SQLAlchemyAsyncQueryRepository, ) from advanced_alchemy.repository._util import LoadSpec, model_from_dict from advanced_alchemy.repository.typing import MISSING, ModelT, OrderingPair, PrimaryKeyType, SQLAlchemyAsyncRepositoryT from advanced_alchemy.service._util import ResultConverter from advanced_alchemy.service.typing import ( UNSET, BulkModelDictT, ModelDictListT, ModelDictT, asdict, attrs_nothing, is_attrs_instance, is_dict, is_dto_data, is_msgspec_struct, is_pydantic_model, is_sqlmodel_table_model, ) from advanced_alchemy.utils.dataclass import Empty, EmptyType class SQLAlchemyAsyncQueryService(ResultConverter): """Simple service to execute the basic Query repository..""" def __init__( self, session: Union[AsyncSession, async_scoped_session[AsyncSession]], **repo_kwargs: Any, ) -> None: """Configure the service object. Args: session: Session managing the unit-of-work for the operation. **repo_kwargs: Optional configuration values to pass into the repository """ self.repository = SQLAlchemyAsyncQueryRepository( session=session, **repo_kwargs, ) @classmethod @asynccontextmanager async def new( cls, session: Optional[Union[AsyncSession, async_scoped_session[AsyncSession]]] = None, config: Optional[SQLAlchemyAsyncConfig] = None, ) -> AsyncIterator[Self]: """Context manager that returns instance of service object. Handles construction of the database session._create_select_for_model Raises: AdvancedAlchemyError: If no configuration or session is provided. Yields: The service object instance. """ if not config and not session: raise AdvancedAlchemyError(detail="Please supply an optional configuration or session to use.") if session: yield cls(session=session) elif config: async with config.get_session() as db_session: yield cls(session=db_session) class SQLAlchemyAsyncRepositoryReadService(ResultConverter, Generic[ModelT, SQLAlchemyAsyncRepositoryT]): """Service object that operates on a repository object.""" repository_type: type[SQLAlchemyAsyncRepositoryT] """Type of the repository to use.""" loader_options: ClassVar[Optional[LoadSpec]] = None """Default loader options for the repository.""" execution_options: ClassVar[Optional[dict[str, Any]]] = None """Default execution options for the repository.""" match_fields: ClassVar[Optional[Union[List[str], str]]] = None """List of dialects that prefer to use ``field.id = ANY(:1)`` instead of ``field.id IN (...)``.""" uniquify: ClassVar[bool] = False """Optionally apply the ``unique()`` method to results before returning.""" count_with_window_function: ClassVar[bool] = True """Use an analytical window function to count results. This allows the count to be performed in a single query.""" _repository_instance: SQLAlchemyAsyncRepositoryT def __init__( self, session: Union[AsyncSession, async_scoped_session[AsyncSession]], *, statement: Optional[Select[Any]] = None, auto_expunge: bool = False, auto_refresh: bool = True, auto_commit: bool = False, order_by: Optional[Union[List[OrderingPair], OrderingPair]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, wrap_exceptions: bool = True, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, count_with_window_function: Optional[bool] = None, **repo_kwargs: Any, ) -> None: """Configure the service object. Args: session: Session managing the unit-of-work for the operation. statement: To facilitate customization of the underlying select query. auto_expunge: Remove object from session before returning. auto_refresh: Refresh object from session before returning. auto_commit: Commit objects before returning. order_by: Set default order options for queries. error_messages: A set of custom error messages to use for operations wrap_exceptions: Wrap exceptions in a RepositoryError load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. count_with_window_function: When false, list and count will use two queries instead of an analytical window function. **repo_kwargs: passed as keyword args to repo instantiation. """ load = load if load is not None else self.loader_options execution_options = execution_options if execution_options is not None else self.execution_options count_with_window_function = ( count_with_window_function if count_with_window_function is not None else self.count_with_window_function ) self._repository_instance: SQLAlchemyAsyncRepositoryT = self.repository_type( # type: ignore[assignment] session=session, statement=statement, auto_expunge=auto_expunge, auto_refresh=auto_refresh, auto_commit=auto_commit, order_by=order_by, error_messages=error_messages, wrap_exceptions=wrap_exceptions, load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), count_with_window_function=count_with_window_function, **repo_kwargs, ) def _get_uniquify(self, uniquify: Optional[bool] = None) -> bool: return bool(uniquify or self.uniquify) @property def repository(self) -> SQLAlchemyAsyncRepositoryT: """Return the repository instance. Raises: ImproperConfigurationError: If the repository is not initialized. Returns: The repository instance. """ if not self._repository_instance: msg = "Repository not initialized" raise ImproperConfigurationError(msg) return self._repository_instance @cached_property def model_type(self) -> type[ModelT]: """Return the model type.""" return cast("type[ModelT]", self.repository.model_type) async def count( self, *filters: Union[StatementFilter, ColumnElement[bool]], statement: Optional[Select[tuple[ModelT]]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> int: """Count of records returned by query. Args: *filters: arguments for filtering. statement: To facilitate customization of the underlying select query. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. bind_group: The bind group to use for the operation. **kwargs: key value pairs of filter types. Returns: A count of the collection, filtered, but ignoring pagination. """ return await self.repository.count( *filters, statement=statement, error_messages=error_messages, load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), bind_group=bind_group, **kwargs, ) async def exists( self, *filters: Union[StatementFilter, ColumnElement[bool]], error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> bool: """Wrap repository exists operation. Args: *filters: Types for specific filtering operations. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. bind_group: The bind group to use for the operation. **kwargs: Keyword arguments for attribute based filtering. Returns: Representation of instance with identifier `item_id`. """ return await self.repository.exists( *filters, error_messages=error_messages, load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), bind_group=bind_group, **kwargs, ) async def get( self, item_id: PrimaryKeyType, *, statement: Optional[Select[tuple[ModelT]]] = None, id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]] = None, auto_expunge: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, ) -> ModelT: """Wrap repository scalar operation. Args: item_id: Identifier of instance to be retrieved. For single primary key models, pass a scalar value (int, str, UUID, etc.). For composite primary key models, pass a tuple of values in column order, or a dict mapping attribute names to values. auto_expunge: Remove object from session before returning. statement: To facilitate customization of the underlying select query. id_attribute: Allows customization of the unique identifier to use for model fetching. Defaults to `id`, but can reference any surrogate or candidate key for the table. Only applicable for single primary key models. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. bind_group: The bind group to use for the operation. Returns: Representation of instance with identifier `item_id`. Examples: # Single primary key >>> user = await service.get(123) # Composite primary key (tuple format) >>> assignment = await service.get((user_id, role_id)) # Composite primary key (dict format) >>> assignment = await service.get({"user_id": 1, "role_id": 5}) """ return cast( "ModelT", await self.repository.get( item_id=item_id, auto_expunge=auto_expunge, statement=statement, id_attribute=id_attribute, error_messages=error_messages, load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), bind_group=bind_group, ), ) async def get_one( self, *filters: Union[StatementFilter, ColumnElement[bool]], statement: Optional[Select[tuple[ModelT]]] = None, auto_expunge: Optional[bool] = None, load: Optional[LoadSpec] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, with_for_update: ForUpdateParameter = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> ModelT: """Wrap repository scalar operation. Args: *filters: Types for specific filtering operations. auto_expunge: Remove object from session before returning. statement: To facilitate customization of the underlying select query. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. with_for_update: Optional FOR UPDATE clause / parameters to apply to the SELECT statement. bind_group: The bind group to use for the operation. **kwargs: Identifier of the instance to be retrieved. Returns: Representation of instance with identifier `item_id`. """ return cast( "ModelT", await self.repository.get_one( *filters, auto_expunge=auto_expunge, statement=statement, error_messages=error_messages, load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), with_for_update=with_for_update, bind_group=bind_group, **kwargs, ), ) async def get_one_or_none( self, *filters: Union[StatementFilter, ColumnElement[bool]], statement: Optional[Select[tuple[ModelT]]] = None, auto_expunge: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, with_for_update: ForUpdateParameter = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> Optional[ModelT]: """Wrap repository scalar operation. Args: *filters: Types for specific filtering operations. auto_expunge: Remove object from session before returning. statement: To facilitate customization of the underlying select query. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. with_for_update: Optional FOR UPDATE clause / parameters to apply to the SELECT statement. bind_group: The bind group to use for the operation. **kwargs: Identifier of the instance to be retrieved. Returns: Representation of instance with identifier `item_id`. """ return cast( "Optional[ModelT]", await self.repository.get_one_or_none( *filters, auto_expunge=auto_expunge, statement=statement, error_messages=error_messages, load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), with_for_update=with_for_update, bind_group=bind_group, **kwargs, ), ) async def to_model_on_create(self, data: "ModelDictT[ModelT]") -> "ModelDictT[ModelT]": """Convenience method to allow for custom behavior on create. Args: data: The data to be converted to a model. Returns: The data to be converted to a model. """ return data async def to_model_on_update(self, data: "ModelDictT[ModelT]") -> "ModelDictT[ModelT]": """Convenience method to allow for custom behavior on update. Args: data: The data to be converted to a model. Returns: The data to be converted to a model. """ return data async def to_model_on_delete(self, data: "ModelDictT[ModelT]") -> "ModelDictT[ModelT]": """Convenience method to allow for custom behavior on delete. Args: data: The data to be converted to a model. Returns: The data to be converted to a model. """ return data async def to_model_on_upsert(self, data: "ModelDictT[ModelT]") -> "ModelDictT[ModelT]": """Convenience method to allow for custom behavior on upsert. Args: data: The data to be converted to a model. Returns: The data to be converted to a model. """ return data async def to_model( self, data: "ModelDictT[ModelT]", operation: Optional[str] = None, ) -> ModelT: """Parse and Convert input into a model. Args: data: Representations to be created. operation: Optional operation flag so that you can provide behavior based on CRUD operation Returns: Representation of created instances. """ operation_map = { "create": self.to_model_on_create, "update": self.to_model_on_update, "delete": self.to_model_on_delete, "upsert": self.to_model_on_upsert, } if operation and (op := operation_map.get(operation)): data = await op(data) if isinstance(data, self.model_type): return data if is_dict(data): return model_from_dict(self.model_type, **data) if is_sqlmodel_table_model(data): return model_from_dict(self.model_type, **model_to_dict(cast("ModelProtocol", data))) if is_pydantic_model(data): return model_from_dict( self.model_type, **data.model_dump(exclude_unset=True), ) if is_msgspec_struct(data): return model_from_dict( self.model_type, **{ f: getattr(data, f) for f in data.__struct_fields__ if hasattr(data, f) and getattr(data, f) is not UNSET }, ) if is_dto_data(data): return cast("ModelT", data.create_instance()) if is_attrs_instance(data): # Filter out attrs.NOTHING values for partial updates def filter_unset(attr: Any, value: Any) -> bool: # noqa: ARG001 return value is not attrs_nothing return model_from_dict( self.model_type, **asdict(data, filter=filter_unset), ) # Fallback for objects with __dict__ (e.g., regular classes) if hasattr(data, "__dict__") and not isinstance(data, self.model_type): return model_from_dict( self.model_type, **data.__dict__, ) return cast("ModelT", data) async def list_and_count( self, *filters: Union[StatementFilter, ColumnElement[bool]], statement: Optional[Select[tuple[ModelT]]] = None, auto_expunge: Optional[bool] = None, count_with_window_function: Optional[bool] = None, order_by: Optional[Union[List[OrderingPair], OrderingPair]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, use_cache: bool = True, bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[Sequence[ModelT], int]: """List of records and total count returned by query. Args: *filters: Types for specific filtering operations. statement: To facilitate customization of the underlying select query. auto_expunge: Remove object from session before returning. count_with_window_function: When false, list and count will use two queries instead of an analytical window function. order_by: Set default order options for queries. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. use_cache: Whether to use the repository cache for this query. bind_group: Optional routing group to use for the operation. **kwargs: Instance attribute value filters. Returns: List of instances and count of total collection, ignoring pagination. """ return cast( "tuple[Sequence[ModelT], int]", await self.repository.list_and_count( *filters, statement=statement, auto_expunge=auto_expunge, count_with_window_function=count_with_window_function, order_by=order_by, error_messages=error_messages, load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), use_cache=use_cache, bind_group=bind_group, **kwargs, ), ) @classmethod @asynccontextmanager async def new( cls, session: Optional[Union[AsyncSession, async_scoped_session[AsyncSession]]] = None, statement: Optional[Select[tuple[ModelT]]] = None, config: Optional[SQLAlchemyAsyncConfig] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, count_with_window_function: Optional[bool] = None, ) -> AsyncIterator[Self]: """Context manager that returns instance of service object. Handles construction of the database session._create_select_for_model Raises: AdvancedAlchemyError: If no configuration or session is provided. Yields: The service object instance. """ if not config and not session: raise AdvancedAlchemyError(detail="Please supply an optional configuration or session to use.") if session: yield cls( statement=statement, session=session, error_messages=error_messages, load=load, execution_options=execution_options, uniquify=uniquify, count_with_window_function=count_with_window_function, ) elif config: async with config.get_session() as db_session: yield cls( statement=statement, session=db_session, error_messages=error_messages, load=load, execution_options=execution_options, uniquify=uniquify, count_with_window_function=count_with_window_function, ) async def list( self, *filters: Union[StatementFilter, ColumnElement[bool]], statement: Optional[Select[tuple[ModelT]]] = None, auto_expunge: Optional[bool] = None, order_by: Optional[Union[List[OrderingPair], OrderingPair]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, use_cache: bool = True, bind_group: Optional[str] = None, **kwargs: Any, ) -> Sequence[ModelT]: """Wrap repository scalars operation. Args: *filters: Types for specific filtering operations. auto_expunge: Remove object from session before returning. statement: To facilitate customization of the underlying select query. order_by: Set default order options for queries. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. use_cache: Whether to use the repository cache for this query. bind_group: Optional routing group to use for the operation. **kwargs: Instance attribute value filters. Returns: The list of instances retrieved from the repository. """ return cast( "Sequence[ModelT]", await self.repository.list( *filters, statement=statement, auto_expunge=auto_expunge, order_by=order_by, error_messages=error_messages, load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), use_cache=use_cache, bind_group=bind_group, **kwargs, ), ) class SQLAlchemyAsyncRepositoryService( SQLAlchemyAsyncRepositoryReadService[ModelT, SQLAlchemyAsyncRepositoryT], Generic[ModelT, SQLAlchemyAsyncRepositoryT], ): """Service object that operates on a repository object.""" async def create( self, data: "ModelDictT[ModelT]", *, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, auto_refresh: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, bind_group: Optional[str] = None, ) -> "ModelT": """Wrap repository instance creation. Args: data: Representation to be created. auto_expunge: Remove object from session before returning. auto_refresh: Refresh object from session before returning. auto_commit: Commit objects before returning. error_messages: An optional dictionary of templates to use for friendlier error messages to clients bind_group: Optional routing group to use for the operation. Returns: Representation of created instance. """ data = await self.to_model(data, "create") return cast( "ModelT", await self.repository.add( data=data, auto_commit=auto_commit, auto_expunge=auto_expunge, auto_refresh=auto_refresh, error_messages=error_messages, bind_group=bind_group, ), ) async def create_many( self, data: "BulkModelDictT[ModelT]", *, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, bind_group: Optional[str] = None, ) -> Sequence[ModelT]: """Wrap repository bulk instance creation. Args: data: Representations to be created. auto_expunge: Remove object from session before returning. auto_commit: Commit objects before returning. error_messages: An optional dictionary of templates to use for friendlier error messages to clients bind_group: Optional routing group to use for the operation. Returns: Representation of created instances. """ if is_dto_data(data): data = data.create_instance() data = [(await self.to_model(datum, "create")) for datum in cast("ModelDictListT[ModelT]", data)] return cast( "Sequence[ModelT]", await self.repository.add_many( data=cast("List[ModelT]", data), # pyright: ignore[reportUnnecessaryCast] auto_commit=auto_commit, auto_expunge=auto_expunge, error_messages=error_messages, bind_group=bind_group, ), ) async def update( self, data: "ModelDictT[ModelT]", item_id: Optional[Any] = None, *, attribute_names: Optional[Iterable[str]] = None, with_for_update: ForUpdateParameter = None, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, auto_refresh: Optional[bool] = None, id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, ) -> "ModelT": """Wrap repository update operation. Args: data: Representation to be updated. item_id: Identifier of item to be updated. attribute_names: an iterable of attribute names to pass into the ``update`` method. with_for_update: indicating FOR UPDATE should be used, or may be a dictionary containing flags to indicate a more specific set of FOR UPDATE flags for the SELECT auto_expunge: Remove object from session before returning. auto_refresh: Refresh object from session before returning. auto_commit: Commit objects before returning. id_attribute: Allows customization of the unique identifier to use for model fetching. Defaults to `id`, but can reference any surrogate or candidate key for the table. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. bind_group: Optional routing group to use for the operation. Raises: RepositoryError: If no configuration or session is provided. Returns: Updated representation. """ # ALWAYS convert data through to_model first to ensure operation hooks are called # This ensures custom to_model() implementations receive the operation="update" parameter # and that to_model_on_update() is properly invoked via the operation_map data = await self.to_model(data, "update") if item_id is not None: # When item_id is provided, update existing instance rather than replacing it # This preserves relationships and database-managed fields existing_instance: ModelT = await self.repository.get( item_id, id_attribute=id_attribute, load=load, execution_options=execution_options, with_for_update=with_for_update, bind_group=bind_group, ) # Extract attributes from converted model to update existing instance # Only copy attributes that were explicitly set (present in instance state) instance_state = sa_inspect(data) # pyright: ignore[reportOptionalMemberAccess] for attr in instance_state.mapper.attrs: # type: ignore[union-attr] # pyright: ignore[reportOptionalMemberAccess] # Check if attribute was explicitly set in the instance if attr.key in instance_state.dict: # type: ignore[union-attr] # pyright: ignore[reportOptionalMemberAccess] value = getattr(data, attr.key, MISSING) if value is not MISSING and hasattr(existing_instance, attr.key): setattr(existing_instance, attr.key, value) data = existing_instance elif id_attribute is None and self.repository.has_composite_pk: # For composite PKs, check if all PK values are present if not self.repository.has_primary_key_values(data): pk_names = ", ".join(self.repository.pk_attr_names) msg = f"Could not identify composite PK values. Required attributes: {pk_names}" raise RepositoryError(msg) elif ( self.repository.get_id_attribute_value( # pyright: ignore[reportUnknownMemberType] item=data, id_attribute=id_attribute, ) is None ): # No item_id provided and no ID on model - error msg = ( "Could not identify ID attribute value. One of the following is required: " f"``item_id`` or ``data.{id_attribute or self.repository.id_attribute}``" ) raise RepositoryError(msg) return cast( "ModelT", await self.repository.update( data=data, attribute_names=attribute_names, with_for_update=with_for_update, auto_commit=auto_commit, auto_expunge=auto_expunge, auto_refresh=auto_refresh, id_attribute=id_attribute, error_messages=error_messages, load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), bind_group=bind_group, ), ) async def update_many( self, data: "BulkModelDictT[ModelT]", *, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, ) -> Sequence[ModelT]: """Wrap repository bulk instance update. Args: data: Representations to be updated. auto_expunge: Remove object from session before returning. auto_commit: Commit objects before returning. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. bind_group: Optional routing group to use for the operation. Returns: Representation of updated instances. """ if is_dto_data(data): data = data.create_instance() data = [(await self.to_model(datum, "update")) for datum in cast("ModelDictListT[ModelT]", data)] return cast( "Sequence[ModelT]", await self.repository.update_many( cast("List[ModelT]", data), # pyright: ignore[reportUnnecessaryCast] auto_commit=auto_commit, auto_expunge=auto_expunge, error_messages=error_messages, load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), bind_group=bind_group, ), ) async def upsert( self, data: "ModelDictT[ModelT]", item_id: Optional[Any] = None, *, attribute_names: Optional[Iterable[str]] = None, with_for_update: ForUpdateParameter = None, auto_expunge: Optional[bool] = None, auto_commit: Optional[bool] = None, auto_refresh: Optional[bool] = None, match_fields: Optional[Union[List[str], str]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, ) -> ModelT: """Wrap repository upsert operation. Args: data: Instance to update existing, or be created. Identifier used to determine if an existing instance exists is the value of an attribute on `data` named as value of `self.id_attribute`. item_id: Identifier of the object for upsert. attribute_names: an iterable of attribute names to pass into the ``update`` method. with_for_update: indicating FOR UPDATE should be used, or may be a dictionary containing flags to indicate a more specific set of FOR UPDATE flags for the SELECT auto_expunge: Remove object from session before returning. auto_refresh: Refresh object from session before returning. auto_commit: Commit objects before returning. match_fields: a list of keys to use to match the existing model. When empty, all fields are matched. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. bind_group: Optional routing group to use for the operation. Returns: Updated or created representation. """ data = await self.to_model(data, "upsert") # Handle ID extraction and setting for single-column PKs # For composite PKs, the repository's upsert() handles this directly if item_id is None: if self.repository.has_composite_pk: # For composite PKs, extract the full PK if present if self.repository.has_primary_key_values(data): item_id = self.repository.get_primary_key_value(data) else: item_id = self.repository.get_id_attribute_value(item=data) # pyright: ignore[reportUnknownMemberType] if item_id is not None and not self.repository.has_composite_pk: # Only set single-column ID (composite PK values are already on the instance) self.repository.set_id_attribute_value(item_id, data) # pyright: ignore[reportUnknownMemberType] return cast( "ModelT", await self.repository.upsert( data=data, attribute_names=attribute_names, with_for_update=with_for_update, auto_expunge=auto_expunge, auto_commit=auto_commit, auto_refresh=auto_refresh, match_fields=match_fields, error_messages=error_messages, load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), bind_group=bind_group, ), ) async def upsert_many( self, data: "BulkModelDictT[ModelT]", *, auto_expunge: Optional[bool] = None, auto_commit: Optional[bool] = None, no_merge: bool = False, match_fields: Optional[Union[List[str], str]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, ) -> Sequence[ModelT]: """Wrap repository upsert operation. Args: data: Instance to update existing, or be created. auto_expunge: Remove object from session before returning. auto_commit: Commit objects before returning. no_merge: Skip the usage of optimized Merge statements (**reserved for future use**) match_fields: a list of keys to use to match the existing model. When empty, all fields are matched. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. bind_group: Optional routing group to use for the operation. Returns: Updated or created representation. """ if is_dto_data(data): data = data.create_instance() data = [(await self.to_model(datum, "upsert")) for datum in cast("ModelDictListT[ModelT]", data)] return cast( "Sequence[ModelT]", await self.repository.upsert_many( data=cast("List[ModelT]", data), # pyright: ignore[reportUnnecessaryCast] auto_expunge=auto_expunge, auto_commit=auto_commit, no_merge=no_merge, match_fields=match_fields, error_messages=error_messages, load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), bind_group=bind_group, ), ) async def get_or_upsert( self, *filters: Union[StatementFilter, ColumnElement[bool]], match_fields: Optional[Union[List[str], str]] = None, upsert: bool = True, attribute_names: Optional[Iterable[str]] = None, with_for_update: ForUpdateParameter = None, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, auto_refresh: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[ModelT, bool]: """Wrap repository instance creation. Args: *filters: Types for specific filtering operations. match_fields: a list of keys to use to match the existing model. When empty, all fields are matched. upsert: When using match_fields and actual model values differ from `kwargs`, perform an update operation on the model. create: Should a model be created. If no model is found, an exception is raised. attribute_names: an iterable of attribute names to pass into the ``update`` method. with_for_update: indicating FOR UPDATE should be used, or may be a dictionary containing flags to indicate a more specific set of FOR UPDATE flags for the SELECT auto_expunge: Remove object from session before returning. auto_refresh: Refresh object from session before returning. auto_commit: Commit objects before returning. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. bind_group: Optional routing group to use for the operation. **kwargs: Identifier of the instance to be retrieved. Returns: Representation of created instance. """ match_fields = match_fields or self.match_fields validated_model = await self.to_model(kwargs, "create") return cast( "tuple[ModelT, bool]", await self.repository.get_or_upsert( *filters, match_fields=match_fields, upsert=upsert, attribute_names=attribute_names, with_for_update=with_for_update, auto_commit=auto_commit, auto_expunge=auto_expunge, auto_refresh=auto_refresh, error_messages=error_messages, load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), bind_group=bind_group, **model_to_dict(validated_model), ), ) async def get_and_update( self, *filters: Union[StatementFilter, ColumnElement[bool]], match_fields: Optional[Union[List[str], str]] = None, attribute_names: Optional[Iterable[str]] = None, with_for_update: ForUpdateParameter = None, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, auto_refresh: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[ModelT, bool]: """Wrap repository instance creation. Args: *filters: Types for specific filtering operations. match_fields: a list of keys to use to match the existing model. When empty, all fields are matched. attribute_names: an iterable of attribute names to pass into the ``update`` method. with_for_update: indicating FOR UPDATE should be used, or may be a dictionary containing flags to indicate a more specific set of FOR UPDATE flags for the SELECT auto_expunge: Remove object from session before returning. auto_refresh: Refresh object from session before returning. auto_commit: Commit objects before returning. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. bind_group: Optional routing group to use for the operation. **kwargs: Identifier of the instance to be retrieved. Returns: Representation of updated instance. """ match_fields = match_fields or self.match_fields validated_model = await self.to_model(kwargs, "update") return cast( "tuple[ModelT, bool]", await self.repository.get_and_update( *filters, match_fields=match_fields, attribute_names=attribute_names, with_for_update=with_for_update, auto_commit=auto_commit, auto_expunge=auto_expunge, auto_refresh=auto_refresh, error_messages=error_messages, load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), bind_group=bind_group, **model_to_dict(validated_model), ), ) async def delete( self, item_id: PrimaryKeyType, *, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, ) -> ModelT: """Wrap repository delete operation. Args: item_id: Identifier of instance to be deleted. For single primary key models, pass a scalar value (int, str, UUID, etc.). For composite primary key models, pass a tuple of values in column order, or a dict mapping attribute names to values. auto_commit: Commit objects before returning. auto_expunge: Remove object from session before returning. id_attribute: Allows customization of the unique identifier to use for model fetching. Defaults to `id`, but can reference any surrogate or candidate key for the table. Only applicable for single primary key models. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. bind_group: Optional routing group to use for the operation. Returns: Representation of the deleted instance. Examples: # Single primary key >>> deleted_user = await service.delete(123) # Composite primary key (tuple format) >>> deleted = await service.delete((user_id, role_id)) # Composite primary key (dict format) >>> deleted = await service.delete({"user_id": 1, "role_id": 5}) """ return cast( "ModelT", await self.repository.delete( item_id=item_id, auto_commit=auto_commit, auto_expunge=auto_expunge, id_attribute=id_attribute, error_messages=error_messages, load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), bind_group=bind_group, ), ) async def delete_many( self, item_ids: List[PrimaryKeyType], *, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]] = None, chunk_size: Optional[int] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, ) -> Sequence[ModelT]: """Wrap repository bulk instance deletion. Args: item_ids: List of identifiers of instances to be deleted. For single primary key models, pass a list of scalar values. For composite primary key models, pass a list of tuples or dicts. auto_expunge: Remove object from session before returning. auto_commit: Commit objects before returning. id_attribute: Allows customization of the unique identifier to use for model fetching. Defaults to `id`, but can reference any surrogate or candidate key for the table. Only applicable for single primary key models. chunk_size: Allows customization of the ``insertmanyvalues_max_parameters`` setting for the driver. Defaults to `950` if left unset. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. bind_group: Optional routing group to use for the operation. Returns: Representation of removed instances. Examples: # Single primary key >>> deleted = await service.delete_many([1, 2, 3]) # Composite primary key (tuple format) >>> deleted = await service.delete_many( ... [ ... (user_id_1, role_id_1), ... (user_id_2, role_id_2), ... ] ... ) # Composite primary key (dict format) >>> deleted = await service.delete_many( ... [ ... {"user_id": 1, "role_id": 5}, ... {"user_id": 1, "role_id": 6}, ... ] ... ) """ return cast( "Sequence[ModelT]", await self.repository.delete_many( item_ids=item_ids, auto_commit=auto_commit, auto_expunge=auto_expunge, id_attribute=id_attribute, chunk_size=chunk_size, error_messages=error_messages, load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), bind_group=bind_group, ), ) async def delete_where( self, *filters: Union[StatementFilter, ColumnElement[bool]], auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, sanity_check: bool = True, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> Sequence[ModelT]: """Wrap repository scalars operation. Args: *filters: Types for specific filtering operations. auto_expunge: Remove object from session before returning. auto_commit: Commit objects before returning. error_messages: An optional dictionary of templates to use for friendlier error messages to clients sanity_check: When true, the length of selected instances is compared to the deleted row count load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. bind_group: Optional routing group to use for the operation. **kwargs: Instance attribute value filters. Returns: The list of instances deleted from the repository. """ return cast( "Sequence[ModelT]", await self.repository.delete_where( *filters, auto_commit=auto_commit, auto_expunge=auto_expunge, error_messages=error_messages, sanity_check=sanity_check, load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), bind_group=bind_group, **kwargs, ), ) python-advanced-alchemy-1.9.3/advanced_alchemy/service/_sync.py000066400000000000000000001573671516556515500246650ustar00rootroot00000000000000# Do not edit this file directly. It has been autogenerated from # advanced_alchemy/service/_async.py """Service object implementation for SQLAlchemy. RepositoryService object is generic on the domain model type which should be a SQLAlchemy model. """ from collections.abc import Iterable, Iterator, Sequence from contextlib import contextmanager from functools import cached_property from typing import Any, ClassVar, Generic, List, Optional, Union, cast from sqlalchemy import Select from sqlalchemy import inspect as sa_inspect from sqlalchemy.orm import InstrumentedAttribute, Session from sqlalchemy.orm.scoping import scoped_session from sqlalchemy.sql import ColumnElement from sqlalchemy.sql.selectable import ForUpdateParameter from typing_extensions import Self from advanced_alchemy.base import ModelProtocol, model_to_dict from advanced_alchemy.config.sync import SQLAlchemySyncConfig from advanced_alchemy.exceptions import AdvancedAlchemyError, ErrorMessages, ImproperConfigurationError, RepositoryError from advanced_alchemy.filters import StatementFilter from advanced_alchemy.repository import SQLAlchemySyncQueryRepository from advanced_alchemy.repository._util import LoadSpec, model_from_dict from advanced_alchemy.repository.typing import MISSING, ModelT, OrderingPair, PrimaryKeyType, SQLAlchemySyncRepositoryT from advanced_alchemy.service._util import ResultConverter from advanced_alchemy.service.typing import ( UNSET, BulkModelDictT, ModelDictListT, ModelDictT, asdict, attrs_nothing, is_attrs_instance, is_dict, is_dto_data, is_msgspec_struct, is_pydantic_model, is_sqlmodel_table_model, ) from advanced_alchemy.utils.dataclass import Empty, EmptyType class SQLAlchemySyncQueryService(ResultConverter): """Simple service to execute the basic Query repository..""" def __init__( self, session: Union[Session, scoped_session[Session]], **repo_kwargs: Any, ) -> None: """Configure the service object. Args: session: Session managing the unit-of-work for the operation. **repo_kwargs: Optional configuration values to pass into the repository """ self.repository = SQLAlchemySyncQueryRepository( session=session, **repo_kwargs, ) @classmethod @contextmanager def new( cls, session: Optional[Union[Session, scoped_session[Session]]] = None, config: Optional[SQLAlchemySyncConfig] = None, ) -> Iterator[Self]: """Context manager that returns instance of service object. Handles construction of the database session._create_select_for_model Raises: AdvancedAlchemyError: If no configuration or session is provided. Yields: The service object instance. """ if not config and not session: raise AdvancedAlchemyError(detail="Please supply an optional configuration or session to use.") if session: yield cls(session=session) elif config: with config.get_session() as db_session: yield cls(session=db_session) class SQLAlchemySyncRepositoryReadService(ResultConverter, Generic[ModelT, SQLAlchemySyncRepositoryT]): """Service object that operates on a repository object.""" repository_type: type[SQLAlchemySyncRepositoryT] """Type of the repository to use.""" loader_options: ClassVar[Optional[LoadSpec]] = None """Default loader options for the repository.""" execution_options: ClassVar[Optional[dict[str, Any]]] = None """Default execution options for the repository.""" match_fields: ClassVar[Optional[Union[List[str], str]]] = None """List of dialects that prefer to use ``field.id = ANY(:1)`` instead of ``field.id IN (...)``.""" uniquify: ClassVar[bool] = False """Optionally apply the ``unique()`` method to results before returning.""" count_with_window_function: ClassVar[bool] = True """Use an analytical window function to count results. This allows the count to be performed in a single query.""" _repository_instance: SQLAlchemySyncRepositoryT def __init__( self, session: Union[Session, scoped_session[Session]], *, statement: Optional[Select[Any]] = None, auto_expunge: bool = False, auto_refresh: bool = True, auto_commit: bool = False, order_by: Optional[Union[List[OrderingPair], OrderingPair]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, wrap_exceptions: bool = True, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, count_with_window_function: Optional[bool] = None, **repo_kwargs: Any, ) -> None: """Configure the service object. Args: session: Session managing the unit-of-work for the operation. statement: To facilitate customization of the underlying select query. auto_expunge: Remove object from session before returning. auto_refresh: Refresh object from session before returning. auto_commit: Commit objects before returning. order_by: Set default order options for queries. error_messages: A set of custom error messages to use for operations wrap_exceptions: Wrap exceptions in a RepositoryError load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. count_with_window_function: When false, list and count will use two queries instead of an analytical window function. **repo_kwargs: passed as keyword args to repo instantiation. """ load = load if load is not None else self.loader_options execution_options = execution_options if execution_options is not None else self.execution_options count_with_window_function = ( count_with_window_function if count_with_window_function is not None else self.count_with_window_function ) self._repository_instance: SQLAlchemySyncRepositoryT = self.repository_type( # type: ignore[assignment] session=session, statement=statement, auto_expunge=auto_expunge, auto_refresh=auto_refresh, auto_commit=auto_commit, order_by=order_by, error_messages=error_messages, wrap_exceptions=wrap_exceptions, load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), count_with_window_function=count_with_window_function, **repo_kwargs, ) def _get_uniquify(self, uniquify: Optional[bool] = None) -> bool: return bool(uniquify or self.uniquify) @property def repository(self) -> SQLAlchemySyncRepositoryT: """Return the repository instance. Raises: ImproperConfigurationError: If the repository is not initialized. Returns: The repository instance. """ if not self._repository_instance: msg = "Repository not initialized" raise ImproperConfigurationError(msg) return self._repository_instance @cached_property def model_type(self) -> type[ModelT]: """Return the model type.""" return cast("type[ModelT]", self.repository.model_type) def count( self, *filters: Union[StatementFilter, ColumnElement[bool]], statement: Optional[Select[tuple[ModelT]]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> int: """Count of records returned by query. Args: *filters: arguments for filtering. statement: To facilitate customization of the underlying select query. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. bind_group: The bind group to use for the operation. **kwargs: key value pairs of filter types. Returns: A count of the collection, filtered, but ignoring pagination. """ return self.repository.count( *filters, statement=statement, error_messages=error_messages, load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), bind_group=bind_group, **kwargs, ) def exists( self, *filters: Union[StatementFilter, ColumnElement[bool]], error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> bool: """Wrap repository exists operation. Args: *filters: Types for specific filtering operations. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. bind_group: The bind group to use for the operation. **kwargs: Keyword arguments for attribute based filtering. Returns: Representation of instance with identifier `item_id`. """ return self.repository.exists( *filters, error_messages=error_messages, load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), bind_group=bind_group, **kwargs, ) def get( self, item_id: PrimaryKeyType, *, statement: Optional[Select[tuple[ModelT]]] = None, id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]] = None, auto_expunge: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, ) -> ModelT: """Wrap repository scalar operation. Args: item_id: Identifier of instance to be retrieved. For single primary key models, pass a scalar value (int, str, UUID, etc.). For composite primary key models, pass a tuple of values in column order, or a dict mapping attribute names to values. auto_expunge: Remove object from session before returning. statement: To facilitate customization of the underlying select query. id_attribute: Allows customization of the unique identifier to use for model fetching. Defaults to `id`, but can reference any surrogate or candidate key for the table. Only applicable for single primary key models. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. bind_group: The bind group to use for the operation. Returns: Representation of instance with identifier `item_id`. Examples: # Single primary key >>> user = await service.get(123) # Composite primary key (tuple format) >>> assignment = await service.get((user_id, role_id)) # Composite primary key (dict format) >>> assignment = await service.get({"user_id": 1, "role_id": 5}) """ return cast( "ModelT", self.repository.get( item_id=item_id, auto_expunge=auto_expunge, statement=statement, id_attribute=id_attribute, error_messages=error_messages, load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), bind_group=bind_group, ), ) def get_one( self, *filters: Union[StatementFilter, ColumnElement[bool]], statement: Optional[Select[tuple[ModelT]]] = None, auto_expunge: Optional[bool] = None, load: Optional[LoadSpec] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, with_for_update: ForUpdateParameter = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> ModelT: """Wrap repository scalar operation. Args: *filters: Types for specific filtering operations. auto_expunge: Remove object from session before returning. statement: To facilitate customization of the underlying select query. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. with_for_update: Optional FOR UPDATE clause / parameters to apply to the SELECT statement. bind_group: The bind group to use for the operation. **kwargs: Identifier of the instance to be retrieved. Returns: Representation of instance with identifier `item_id`. """ return cast( "ModelT", self.repository.get_one( *filters, auto_expunge=auto_expunge, statement=statement, error_messages=error_messages, load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), with_for_update=with_for_update, bind_group=bind_group, **kwargs, ), ) def get_one_or_none( self, *filters: Union[StatementFilter, ColumnElement[bool]], statement: Optional[Select[tuple[ModelT]]] = None, auto_expunge: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, with_for_update: ForUpdateParameter = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> Optional[ModelT]: """Wrap repository scalar operation. Args: *filters: Types for specific filtering operations. auto_expunge: Remove object from session before returning. statement: To facilitate customization of the underlying select query. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. with_for_update: Optional FOR UPDATE clause / parameters to apply to the SELECT statement. bind_group: The bind group to use for the operation. **kwargs: Identifier of the instance to be retrieved. Returns: Representation of instance with identifier `item_id`. """ return cast( "Optional[ModelT]", self.repository.get_one_or_none( *filters, auto_expunge=auto_expunge, statement=statement, error_messages=error_messages, load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), with_for_update=with_for_update, bind_group=bind_group, **kwargs, ), ) def to_model_on_create(self, data: "ModelDictT[ModelT]") -> "ModelDictT[ModelT]": """Convenience method to allow for custom behavior on create. Args: data: The data to be converted to a model. Returns: The data to be converted to a model. """ return data def to_model_on_update(self, data: "ModelDictT[ModelT]") -> "ModelDictT[ModelT]": """Convenience method to allow for custom behavior on update. Args: data: The data to be converted to a model. Returns: The data to be converted to a model. """ return data def to_model_on_delete(self, data: "ModelDictT[ModelT]") -> "ModelDictT[ModelT]": """Convenience method to allow for custom behavior on delete. Args: data: The data to be converted to a model. Returns: The data to be converted to a model. """ return data def to_model_on_upsert(self, data: "ModelDictT[ModelT]") -> "ModelDictT[ModelT]": """Convenience method to allow for custom behavior on upsert. Args: data: The data to be converted to a model. Returns: The data to be converted to a model. """ return data def to_model( self, data: "ModelDictT[ModelT]", operation: Optional[str] = None, ) -> ModelT: """Parse and Convert input into a model. Args: data: Representations to be created. operation: Optional operation flag so that you can provide behavior based on CRUD operation Returns: Representation of created instances. """ operation_map = { "create": self.to_model_on_create, "update": self.to_model_on_update, "delete": self.to_model_on_delete, "upsert": self.to_model_on_upsert, } if operation and (op := operation_map.get(operation)): data = op(data) if isinstance(data, self.model_type): return data if is_dict(data): return model_from_dict(self.model_type, **data) if is_sqlmodel_table_model(data): return model_from_dict(self.model_type, **model_to_dict(cast("ModelProtocol", data))) if is_pydantic_model(data): return model_from_dict( self.model_type, **data.model_dump(exclude_unset=True), ) if is_msgspec_struct(data): return model_from_dict( self.model_type, **{ f: getattr(data, f) for f in data.__struct_fields__ if hasattr(data, f) and getattr(data, f) is not UNSET }, ) if is_dto_data(data): return cast("ModelT", data.create_instance()) if is_attrs_instance(data): # Filter out attrs.NOTHING values for partial updates def filter_unset(attr: Any, value: Any) -> bool: # noqa: ARG001 return value is not attrs_nothing return model_from_dict( self.model_type, **asdict(data, filter=filter_unset), ) # Fallback for objects with __dict__ (e.g., regular classes) if hasattr(data, "__dict__") and not isinstance(data, self.model_type): return model_from_dict( self.model_type, **data.__dict__, ) return cast("ModelT", data) def list_and_count( self, *filters: Union[StatementFilter, ColumnElement[bool]], statement: Optional[Select[tuple[ModelT]]] = None, auto_expunge: Optional[bool] = None, count_with_window_function: Optional[bool] = None, order_by: Optional[Union[List[OrderingPair], OrderingPair]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, use_cache: bool = True, bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[Sequence[ModelT], int]: """List of records and total count returned by query. Args: *filters: Types for specific filtering operations. statement: To facilitate customization of the underlying select query. auto_expunge: Remove object from session before returning. count_with_window_function: When false, list and count will use two queries instead of an analytical window function. order_by: Set default order options for queries. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. use_cache: Whether to use the repository cache for this query. bind_group: Optional routing group to use for the operation. **kwargs: Instance attribute value filters. Returns: List of instances and count of total collection, ignoring pagination. """ return cast( "tuple[Sequence[ModelT], int]", self.repository.list_and_count( *filters, statement=statement, auto_expunge=auto_expunge, count_with_window_function=count_with_window_function, order_by=order_by, error_messages=error_messages, load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), use_cache=use_cache, bind_group=bind_group, **kwargs, ), ) @classmethod @contextmanager def new( cls, session: Optional[Union[Session, scoped_session[Session]]] = None, statement: Optional[Select[tuple[ModelT]]] = None, config: Optional[SQLAlchemySyncConfig] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, count_with_window_function: Optional[bool] = None, ) -> Iterator[Self]: """Context manager that returns instance of service object. Handles construction of the database session._create_select_for_model Raises: AdvancedAlchemyError: If no configuration or session is provided. Yields: The service object instance. """ if not config and not session: raise AdvancedAlchemyError(detail="Please supply an optional configuration or session to use.") if session: yield cls( statement=statement, session=session, error_messages=error_messages, load=load, execution_options=execution_options, uniquify=uniquify, count_with_window_function=count_with_window_function, ) elif config: with config.get_session() as db_session: yield cls( statement=statement, session=db_session, error_messages=error_messages, load=load, execution_options=execution_options, uniquify=uniquify, count_with_window_function=count_with_window_function, ) def list( self, *filters: Union[StatementFilter, ColumnElement[bool]], statement: Optional[Select[tuple[ModelT]]] = None, auto_expunge: Optional[bool] = None, order_by: Optional[Union[List[OrderingPair], OrderingPair]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, use_cache: bool = True, bind_group: Optional[str] = None, **kwargs: Any, ) -> Sequence[ModelT]: """Wrap repository scalars operation. Args: *filters: Types for specific filtering operations. auto_expunge: Remove object from session before returning. statement: To facilitate customization of the underlying select query. order_by: Set default order options for queries. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. use_cache: Whether to use the repository cache for this query. bind_group: Optional routing group to use for the operation. **kwargs: Instance attribute value filters. Returns: The list of instances retrieved from the repository. """ return cast( "Sequence[ModelT]", self.repository.list( *filters, statement=statement, auto_expunge=auto_expunge, order_by=order_by, error_messages=error_messages, load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), use_cache=use_cache, bind_group=bind_group, **kwargs, ), ) class SQLAlchemySyncRepositoryService( SQLAlchemySyncRepositoryReadService[ModelT, SQLAlchemySyncRepositoryT], Generic[ModelT, SQLAlchemySyncRepositoryT], ): """Service object that operates on a repository object.""" def create( self, data: "ModelDictT[ModelT]", *, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, auto_refresh: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, bind_group: Optional[str] = None, ) -> "ModelT": """Wrap repository instance creation. Args: data: Representation to be created. auto_expunge: Remove object from session before returning. auto_refresh: Refresh object from session before returning. auto_commit: Commit objects before returning. error_messages: An optional dictionary of templates to use for friendlier error messages to clients bind_group: Optional routing group to use for the operation. Returns: Representation of created instance. """ data = self.to_model(data, "create") return cast( "ModelT", self.repository.add( data=data, auto_commit=auto_commit, auto_expunge=auto_expunge, auto_refresh=auto_refresh, error_messages=error_messages, bind_group=bind_group, ), ) def create_many( self, data: "BulkModelDictT[ModelT]", *, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, bind_group: Optional[str] = None, ) -> Sequence[ModelT]: """Wrap repository bulk instance creation. Args: data: Representations to be created. auto_expunge: Remove object from session before returning. auto_commit: Commit objects before returning. error_messages: An optional dictionary of templates to use for friendlier error messages to clients bind_group: Optional routing group to use for the operation. Returns: Representation of created instances. """ if is_dto_data(data): data = data.create_instance() data = [(self.to_model(datum, "create")) for datum in cast("ModelDictListT[ModelT]", data)] return cast( "Sequence[ModelT]", self.repository.add_many( data=cast("List[ModelT]", data), # pyright: ignore[reportUnnecessaryCast] auto_commit=auto_commit, auto_expunge=auto_expunge, error_messages=error_messages, bind_group=bind_group, ), ) def update( self, data: "ModelDictT[ModelT]", item_id: Optional[Any] = None, *, attribute_names: Optional[Iterable[str]] = None, with_for_update: ForUpdateParameter = None, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, auto_refresh: Optional[bool] = None, id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, ) -> "ModelT": """Wrap repository update operation. Args: data: Representation to be updated. item_id: Identifier of item to be updated. attribute_names: an iterable of attribute names to pass into the ``update`` method. with_for_update: indicating FOR UPDATE should be used, or may be a dictionary containing flags to indicate a more specific set of FOR UPDATE flags for the SELECT auto_expunge: Remove object from session before returning. auto_refresh: Refresh object from session before returning. auto_commit: Commit objects before returning. id_attribute: Allows customization of the unique identifier to use for model fetching. Defaults to `id`, but can reference any surrogate or candidate key for the table. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. bind_group: Optional routing group to use for the operation. Raises: RepositoryError: If no configuration or session is provided. Returns: Updated representation. """ # ALWAYS convert data through to_model first to ensure operation hooks are called # This ensures custom to_model() implementations receive the operation="update" parameter # and that to_model_on_update() is properly invoked via the operation_map data = self.to_model(data, "update") if item_id is not None: # When item_id is provided, update existing instance rather than replacing it # This preserves relationships and database-managed fields existing_instance: ModelT = self.repository.get( item_id, id_attribute=id_attribute, load=load, execution_options=execution_options, with_for_update=with_for_update, bind_group=bind_group, ) # Extract attributes from converted model to update existing instance # Only copy attributes that were explicitly set (present in instance state) instance_state = sa_inspect(data) # pyright: ignore[reportOptionalMemberAccess] for attr in instance_state.mapper.attrs: # type: ignore[union-attr] # pyright: ignore[reportOptionalMemberAccess] # Check if attribute was explicitly set in the instance if attr.key in instance_state.dict: # type: ignore[union-attr] # pyright: ignore[reportOptionalMemberAccess] value = getattr(data, attr.key, MISSING) if value is not MISSING and hasattr(existing_instance, attr.key): setattr(existing_instance, attr.key, value) data = existing_instance elif id_attribute is None and self.repository.has_composite_pk: # For composite PKs, check if all PK values are present if not self.repository.has_primary_key_values(data): pk_names = ", ".join(self.repository.pk_attr_names) msg = f"Could not identify composite PK values. Required attributes: {pk_names}" raise RepositoryError(msg) elif ( self.repository.get_id_attribute_value( # pyright: ignore[reportUnknownMemberType] item=data, id_attribute=id_attribute, ) is None ): # No item_id provided and no ID on model - error msg = ( "Could not identify ID attribute value. One of the following is required: " f"``item_id`` or ``data.{id_attribute or self.repository.id_attribute}``" ) raise RepositoryError(msg) return cast( "ModelT", self.repository.update( data=data, attribute_names=attribute_names, with_for_update=with_for_update, auto_commit=auto_commit, auto_expunge=auto_expunge, auto_refresh=auto_refresh, id_attribute=id_attribute, error_messages=error_messages, load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), bind_group=bind_group, ), ) def update_many( self, data: "BulkModelDictT[ModelT]", *, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, ) -> Sequence[ModelT]: """Wrap repository bulk instance update. Args: data: Representations to be updated. auto_expunge: Remove object from session before returning. auto_commit: Commit objects before returning. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. bind_group: Optional routing group to use for the operation. Returns: Representation of updated instances. """ if is_dto_data(data): data = data.create_instance() data = [(self.to_model(datum, "update")) for datum in cast("ModelDictListT[ModelT]", data)] return cast( "Sequence[ModelT]", self.repository.update_many( cast("List[ModelT]", data), # pyright: ignore[reportUnnecessaryCast] auto_commit=auto_commit, auto_expunge=auto_expunge, error_messages=error_messages, load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), bind_group=bind_group, ), ) def upsert( self, data: "ModelDictT[ModelT]", item_id: Optional[Any] = None, *, attribute_names: Optional[Iterable[str]] = None, with_for_update: ForUpdateParameter = None, auto_expunge: Optional[bool] = None, auto_commit: Optional[bool] = None, auto_refresh: Optional[bool] = None, match_fields: Optional[Union[List[str], str]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, ) -> ModelT: """Wrap repository upsert operation. Args: data: Instance to update existing, or be created. Identifier used to determine if an existing instance exists is the value of an attribute on `data` named as value of `self.id_attribute`. item_id: Identifier of the object for upsert. attribute_names: an iterable of attribute names to pass into the ``update`` method. with_for_update: indicating FOR UPDATE should be used, or may be a dictionary containing flags to indicate a more specific set of FOR UPDATE flags for the SELECT auto_expunge: Remove object from session before returning. auto_refresh: Refresh object from session before returning. auto_commit: Commit objects before returning. match_fields: a list of keys to use to match the existing model. When empty, all fields are matched. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. bind_group: Optional routing group to use for the operation. Returns: Updated or created representation. """ data = self.to_model(data, "upsert") # Handle ID extraction and setting for single-column PKs # For composite PKs, the repository's upsert() handles this directly if item_id is None: if self.repository.has_composite_pk: # For composite PKs, extract the full PK if present if self.repository.has_primary_key_values(data): item_id = self.repository.get_primary_key_value(data) else: item_id = self.repository.get_id_attribute_value(item=data) # pyright: ignore[reportUnknownMemberType] if item_id is not None and not self.repository.has_composite_pk: # Only set single-column ID (composite PK values are already on the instance) self.repository.set_id_attribute_value(item_id, data) # pyright: ignore[reportUnknownMemberType] return cast( "ModelT", self.repository.upsert( data=data, attribute_names=attribute_names, with_for_update=with_for_update, auto_expunge=auto_expunge, auto_commit=auto_commit, auto_refresh=auto_refresh, match_fields=match_fields, error_messages=error_messages, load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), bind_group=bind_group, ), ) def upsert_many( self, data: "BulkModelDictT[ModelT]", *, auto_expunge: Optional[bool] = None, auto_commit: Optional[bool] = None, no_merge: bool = False, match_fields: Optional[Union[List[str], str]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, ) -> Sequence[ModelT]: """Wrap repository upsert operation. Args: data: Instance to update existing, or be created. auto_expunge: Remove object from session before returning. auto_commit: Commit objects before returning. no_merge: Skip the usage of optimized Merge statements (**reserved for future use**) match_fields: a list of keys to use to match the existing model. When empty, all fields are matched. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. bind_group: Optional routing group to use for the operation. Returns: Updated or created representation. """ if is_dto_data(data): data = data.create_instance() data = [(self.to_model(datum, "upsert")) for datum in cast("ModelDictListT[ModelT]", data)] return cast( "Sequence[ModelT]", self.repository.upsert_many( data=cast("List[ModelT]", data), # pyright: ignore[reportUnnecessaryCast] auto_expunge=auto_expunge, auto_commit=auto_commit, no_merge=no_merge, match_fields=match_fields, error_messages=error_messages, load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), bind_group=bind_group, ), ) def get_or_upsert( self, *filters: Union[StatementFilter, ColumnElement[bool]], match_fields: Optional[Union[List[str], str]] = None, upsert: bool = True, attribute_names: Optional[Iterable[str]] = None, with_for_update: ForUpdateParameter = None, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, auto_refresh: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[ModelT, bool]: """Wrap repository instance creation. Args: *filters: Types for specific filtering operations. match_fields: a list of keys to use to match the existing model. When empty, all fields are matched. upsert: When using match_fields and actual model values differ from `kwargs`, perform an update operation on the model. create: Should a model be created. If no model is found, an exception is raised. attribute_names: an iterable of attribute names to pass into the ``update`` method. with_for_update: indicating FOR UPDATE should be used, or may be a dictionary containing flags to indicate a more specific set of FOR UPDATE flags for the SELECT auto_expunge: Remove object from session before returning. auto_refresh: Refresh object from session before returning. auto_commit: Commit objects before returning. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. bind_group: Optional routing group to use for the operation. **kwargs: Identifier of the instance to be retrieved. Returns: Representation of created instance. """ match_fields = match_fields or self.match_fields validated_model = self.to_model(kwargs, "create") return cast( "tuple[ModelT, bool]", self.repository.get_or_upsert( *filters, match_fields=match_fields, upsert=upsert, attribute_names=attribute_names, with_for_update=with_for_update, auto_commit=auto_commit, auto_expunge=auto_expunge, auto_refresh=auto_refresh, error_messages=error_messages, load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), bind_group=bind_group, **model_to_dict(validated_model), ), ) def get_and_update( self, *filters: Union[StatementFilter, ColumnElement[bool]], match_fields: Optional[Union[List[str], str]] = None, attribute_names: Optional[Iterable[str]] = None, with_for_update: ForUpdateParameter = None, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, auto_refresh: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[ModelT, bool]: """Wrap repository instance creation. Args: *filters: Types for specific filtering operations. match_fields: a list of keys to use to match the existing model. When empty, all fields are matched. attribute_names: an iterable of attribute names to pass into the ``update`` method. with_for_update: indicating FOR UPDATE should be used, or may be a dictionary containing flags to indicate a more specific set of FOR UPDATE flags for the SELECT auto_expunge: Remove object from session before returning. auto_refresh: Refresh object from session before returning. auto_commit: Commit objects before returning. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. bind_group: Optional routing group to use for the operation. **kwargs: Identifier of the instance to be retrieved. Returns: Representation of updated instance. """ match_fields = match_fields or self.match_fields validated_model = self.to_model(kwargs, "update") return cast( "tuple[ModelT, bool]", self.repository.get_and_update( *filters, match_fields=match_fields, attribute_names=attribute_names, with_for_update=with_for_update, auto_commit=auto_commit, auto_expunge=auto_expunge, auto_refresh=auto_refresh, error_messages=error_messages, load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), bind_group=bind_group, **model_to_dict(validated_model), ), ) def delete( self, item_id: PrimaryKeyType, *, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, ) -> ModelT: """Wrap repository delete operation. Args: item_id: Identifier of instance to be deleted. For single primary key models, pass a scalar value (int, str, UUID, etc.). For composite primary key models, pass a tuple of values in column order, or a dict mapping attribute names to values. auto_commit: Commit objects before returning. auto_expunge: Remove object from session before returning. id_attribute: Allows customization of the unique identifier to use for model fetching. Defaults to `id`, but can reference any surrogate or candidate key for the table. Only applicable for single primary key models. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. bind_group: Optional routing group to use for the operation. Returns: Representation of the deleted instance. Examples: # Single primary key >>> deleted_user = await service.delete(123) # Composite primary key (tuple format) >>> deleted = await service.delete((user_id, role_id)) # Composite primary key (dict format) >>> deleted = await service.delete({"user_id": 1, "role_id": 5}) """ return cast( "ModelT", self.repository.delete( item_id=item_id, auto_commit=auto_commit, auto_expunge=auto_expunge, id_attribute=id_attribute, error_messages=error_messages, load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), bind_group=bind_group, ), ) def delete_many( self, item_ids: List[PrimaryKeyType], *, auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]] = None, chunk_size: Optional[int] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, ) -> Sequence[ModelT]: """Wrap repository bulk instance deletion. Args: item_ids: List of identifiers of instances to be deleted. For single primary key models, pass a list of scalar values. For composite primary key models, pass a list of tuples or dicts. auto_expunge: Remove object from session before returning. auto_commit: Commit objects before returning. id_attribute: Allows customization of the unique identifier to use for model fetching. Defaults to `id`, but can reference any surrogate or candidate key for the table. Only applicable for single primary key models. chunk_size: Allows customization of the ``insertmanyvalues_max_parameters`` setting for the driver. Defaults to `950` if left unset. error_messages: An optional dictionary of templates to use for friendlier error messages to clients load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. bind_group: Optional routing group to use for the operation. Returns: Representation of removed instances. Examples: # Single primary key >>> deleted = await service.delete_many([1, 2, 3]) # Composite primary key (tuple format) >>> deleted = await service.delete_many( ... [ ... (user_id_1, role_id_1), ... (user_id_2, role_id_2), ... ] ... ) # Composite primary key (dict format) >>> deleted = await service.delete_many( ... [ ... {"user_id": 1, "role_id": 5}, ... {"user_id": 1, "role_id": 6}, ... ] ... ) """ return cast( "Sequence[ModelT]", self.repository.delete_many( item_ids=item_ids, auto_commit=auto_commit, auto_expunge=auto_expunge, id_attribute=id_attribute, chunk_size=chunk_size, error_messages=error_messages, load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), bind_group=bind_group, ), ) def delete_where( self, *filters: Union[StatementFilter, ColumnElement[bool]], auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, sanity_check: bool = True, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, bind_group: Optional[str] = None, **kwargs: Any, ) -> Sequence[ModelT]: """Wrap repository scalars operation. Args: *filters: Types for specific filtering operations. auto_expunge: Remove object from session before returning. auto_commit: Commit objects before returning. error_messages: An optional dictionary of templates to use for friendlier error messages to clients sanity_check: When true, the length of selected instances is compared to the deleted row count load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. bind_group: Optional routing group to use for the operation. **kwargs: Instance attribute value filters. Returns: The list of instances deleted from the repository. """ return cast( "Sequence[ModelT]", self.repository.delete_where( *filters, auto_commit=auto_commit, auto_expunge=auto_expunge, error_messages=error_messages, sanity_check=sanity_check, load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), bind_group=bind_group, **kwargs, ), ) python-advanced-alchemy-1.9.3/advanced_alchemy/service/_typing.py000066400000000000000000000262011516556515500252010ustar00rootroot00000000000000# ruff: noqa: RUF100, PLR0913, A002, DOC201, PLR6301, PLR0917, ARG004, ARG002, ARG001 """Wrapper around library classes for compatibility when libraries are installed.""" import enum from dataclasses import dataclass from typing import Any, ClassVar, Final, Optional, Protocol, Union, cast, runtime_checkable from typing_extensions import Literal, TypeVar, dataclass_transform @runtime_checkable class DataclassProtocol(Protocol): """Protocol for instance checking dataclasses.""" __dataclass_fields__: "ClassVar[dict[str, Any]]" @runtime_checkable class DictProtocol(Protocol): """Protocol for objects with a __dict__ attribute.""" __dict__: dict[str, Any] T = TypeVar("T") T_co = TypeVar("T_co", covariant=True) # Always define stub types for type checking class BaseModelLike: """Placeholder implementation.""" model_fields: ClassVar[dict[str, Any]] = {} __slots__ = ("__dict__", "__pydantic_extra__", "__pydantic_fields_set__", "__pydantic_private__") def __init__(self, **data: Any) -> None: for key, value in data.items(): setattr(self, key, value) def model_dump( # noqa: PLR0913 self, /, *, include: "Optional[Any]" = None, # noqa: ARG002 exclude: "Optional[Any]" = None, # noqa: ARG002 context: "Optional[Any]" = None, # noqa: ARG002 by_alias: bool = False, # noqa: ARG002 exclude_unset: bool = False, # noqa: ARG002 exclude_defaults: bool = False, # noqa: ARG002 exclude_none: bool = False, # noqa: ARG002 round_trip: bool = False, # noqa: ARG002 warnings: "Union[bool, Literal['none', 'warn', 'error']]" = True, # noqa: ARG002 serialize_as_any: bool = False, # noqa: ARG002 ) -> "dict[str, Any]": """Placeholder implementation.""" return {k: v for k, v in self.__dict__.items() if not k.startswith("_")} def model_dump_json( # noqa: PLR0913 self, /, *, include: "Optional[Any]" = None, # noqa: ARG002 exclude: "Optional[Any]" = None, # noqa: ARG002 context: "Optional[Any]" = None, # noqa: ARG002 by_alias: bool = False, # noqa: ARG002 exclude_unset: bool = False, # noqa: ARG002 exclude_defaults: bool = False, # noqa: ARG002 exclude_none: bool = False, # noqa: ARG002 round_trip: bool = False, # noqa: ARG002 warnings: "Union[bool, Literal['none', 'warn', 'error']]" = True, # noqa: ARG002 serialize_as_any: bool = False, # noqa: ARG002 ) -> str: """Placeholder implementation.""" return "{}" class TypeAdapterStub: """Placeholder implementation.""" def __init__( self, type: Any, # noqa: A002 *, config: "Optional[Any]" = None, # noqa: ARG002 _parent_depth: int = 2, # noqa: ARG002 module: "Optional[str]" = None, # noqa: ARG002 ) -> None: """Initialize.""" self._type = type def validate_python( # noqa: PLR0913 self, object: Any, /, *, strict: "Optional[bool]" = None, # noqa: ARG002 from_attributes: "Optional[bool]" = None, # noqa: ARG002 context: "Optional[dict[str, Any]]" = None, # noqa: ARG002 experimental_allow_partial: "Union[bool, Literal['off', 'on', 'trailing-strings']]" = False, # noqa: ARG002 ) -> Any: """Validate Python object.""" return object @dataclass class FailFastStub: """Placeholder implementation for FailFast.""" fail_fast: bool = True # Try to import real implementations at runtime try: from pydantic import BaseModel as _RealBaseModel from pydantic import FailFast as _RealFailFast from pydantic import TypeAdapter as _RealTypeAdapter BaseModel = _RealBaseModel TypeAdapter = _RealTypeAdapter FailFast = _RealFailFast PYDANTIC_INSTALLED = True # pyright: ignore[reportConstantRedefinition] except ImportError: BaseModel = BaseModelLike # type: ignore[assignment,misc] TypeAdapter = TypeAdapterStub # type: ignore[assignment,misc] FailFast = FailFastStub # type: ignore[assignment,misc] PYDANTIC_INSTALLED = False # pyright: ignore[reportConstantRedefinition] # Always define stub types for msgspec @dataclass_transform() class StructLike: """Placeholder implementation.""" __struct_fields__: ClassVar[tuple[str, ...]] = () __slots__ = () def __init__(self, **kwargs: Any) -> None: for key, value in kwargs.items(): setattr(self, key, value) def convert_stub( # noqa: PLR0913 obj: Any, # noqa: ARG001 type: Any, # noqa: A002,ARG001 *, strict: bool = True, # noqa: ARG001 from_attributes: bool = False, # noqa: ARG001 dec_hook: "Optional[Any]" = None, # noqa: ARG001 builtin_types: "Optional[Any]" = None, # noqa: ARG001 str_keys: bool = False, # noqa: ARG001 ) -> Any: """Placeholder implementation.""" return {} class UnsetTypeStub(enum.Enum): UNSET = "UNSET" UNSET_STUB = UnsetTypeStub.UNSET # Try to import real implementations at runtime try: from msgspec import UNSET as _REAL_UNSET from msgspec import Struct as _RealStruct from msgspec import UnsetType as _RealUnsetType from msgspec import convert as _real_convert Struct = _RealStruct UnsetType = _RealUnsetType UNSET = _REAL_UNSET convert = _real_convert MSGSPEC_INSTALLED = True # pyright: ignore[reportConstantRedefinition] except ImportError: Struct = StructLike # type: ignore[assignment,misc] UnsetType = UnsetTypeStub # type: ignore[assignment,misc] UNSET = UNSET_STUB # type: ignore[assignment] # pyright: ignore[reportConstantRedefinition] convert = convert_stub MSGSPEC_INSTALLED = False # pyright: ignore[reportConstantRedefinition] # Always define stub type for DTOData @runtime_checkable class DTODataLike(Protocol[T]): """Placeholder implementation.""" __slots__ = ("_backend", "_data_as_builtins") def __init__(self, backend: Any, data_as_builtins: Any) -> None: """Initialize.""" def create_instance(self, **kwargs: Any) -> T: return cast("T", kwargs) def update_instance(self, instance: T, **kwargs: Any) -> T: """Update instance.""" return cast("T", kwargs) def as_builtins(self) -> Any: """Convert to builtins.""" return {} # Try to import real implementation at runtime try: from litestar.dto.data_structures import DTOData as _RealDTOData # pyright: ignore[reportUnknownVariableType] DTOData = _RealDTOData LITESTAR_INSTALLED = True # pyright: ignore[reportConstantRedefinition] except ImportError: DTOData = DTODataLike # type: ignore[assignment,misc] LITESTAR_INSTALLED = False # pyright: ignore[reportConstantRedefinition] # Always define stub types for attrs @dataclass_transform() class AttrsLike: """Placeholder Implementation for attrs classes""" __attrs_attrs__: ClassVar[tuple[Any, ...]] = () __slots__ = () def __init__(self, **kwargs: Any) -> None: for key, value in kwargs.items(): setattr(self, key, value) def __repr__(self) -> str: return f"{self.__class__.__name__}()" def attrs_asdict_stub(*args: Any, **kwargs: Any) -> "dict[str, Any]": # noqa: ARG001 """Placeholder implementation""" return {} def attrs_define_stub(*args: Any, **kwargs: Any) -> Any: # noqa: ARG001 """Placeholder implementation""" return lambda cls: cls # pyright: ignore[reportUnknownVariableType,reportUnknownLambdaType] def attrs_field_stub(*args: Any, **kwargs: Any) -> Any: # noqa: ARG001 """Placeholder implementation""" return None def attrs_fields_stub(*args: Any, **kwargs: Any) -> "tuple[Any, ...]": # noqa: ARG001 """Placeholder implementation""" return () def attrs_has_stub(*args: Any, **kwargs: Any) -> bool: # noqa: ARG001 """Placeholder implementation""" return False class AttrsNothingStub: """Placeholder for attrs.NOTHING sentinel value""" def __repr__(self) -> str: return "NOTHING" ATTRS_NOTHING_STUB = AttrsNothingStub() # Try to import real implementations at runtime try: from attrs import NOTHING as _real_attrs_nothing # noqa: N811 from attrs import AttrsInstance as _RealAttrsInstance # pyright: ignore from attrs import asdict as _real_attrs_asdict from attrs import define as _real_attrs_define from attrs import field as _real_attrs_field from attrs import fields as _real_attrs_fields from attrs import has as _real_attrs_has AttrsInstance = _RealAttrsInstance attrs_asdict = _real_attrs_asdict attrs_define = _real_attrs_define attrs_field = _real_attrs_field attrs_fields = _real_attrs_fields attrs_has = _real_attrs_has attrs_nothing = _real_attrs_nothing ATTRS_INSTALLED = True # pyright: ignore[reportConstantRedefinition] except ImportError: AttrsInstance = AttrsLike # type: ignore[misc] attrs_asdict = attrs_asdict_stub attrs_define = attrs_define_stub attrs_field = attrs_field_stub attrs_fields = attrs_fields_stub attrs_has = attrs_has_stub # type: ignore[assignment] attrs_nothing = ATTRS_NOTHING_STUB # type: ignore[assignment] ATTRS_INSTALLED = False # pyright: ignore[reportConstantRedefinition] try: from cattrs import structure as cattrs_structure from cattrs import unstructure as cattrs_unstructure CATTRS_INSTALLED = True # pyright: ignore[reportConstantRedefinition] except ImportError: def cattrs_unstructure(*args: Any, **kwargs: Any) -> Any: # noqa: ARG001 """Placeholder implementation""" return {} def cattrs_structure(*args: Any, **kwargs: Any) -> Any: # noqa: ARG001 """Placeholder implementation""" return {} CATTRS_INSTALLED = False # pyright: ignore[reportConstantRedefinition] try: import sqlmodel as _sqlmodel # noqa: F401 # pyright: ignore[reportUnusedImport] SQLMODEL_INSTALLED = True # pyright: ignore[reportConstantRedefinition] except ImportError: SQLMODEL_INSTALLED = False # pyright: ignore[reportConstantRedefinition] class EmptyEnum(enum.Enum): """A sentinel enum used as placeholder.""" EMPTY = 0 EmptyType = Union[Literal[EmptyEnum.EMPTY], UnsetType] Empty: Final = EmptyEnum.EMPTY __all__ = ( "ATTRS_INSTALLED", "ATTRS_NOTHING_STUB", "CATTRS_INSTALLED", "LITESTAR_INSTALLED", "MSGSPEC_INSTALLED", "PYDANTIC_INSTALLED", "SQLMODEL_INSTALLED", "UNSET", "UNSET_STUB", "AttrsInstance", "AttrsLike", "AttrsNothingStub", "BaseModel", "BaseModelLike", "DTOData", "DTODataLike", "DataclassProtocol", "DictProtocol", "Empty", "EmptyEnum", "EmptyType", "FailFast", "FailFastStub", "Struct", "StructLike", "T", "T_co", "TypeAdapter", "TypeAdapterStub", "UnsetType", "UnsetTypeStub", "attrs_asdict", "attrs_asdict_stub", "attrs_define", "attrs_define_stub", "attrs_field", "attrs_field_stub", "attrs_fields", "attrs_fields_stub", "attrs_has", "attrs_has_stub", "attrs_nothing", "cattrs_structure", "cattrs_unstructure", "convert", "convert_stub", ) python-advanced-alchemy-1.9.3/advanced_alchemy/service/_util.py000066400000000000000000000322341516556515500246470ustar00rootroot00000000000000"""Service object implementation for SQLAlchemy. RepositoryService object is generic on the domain model type which should be a SQLAlchemy model. """ import datetime from collections.abc import Sequence from enum import Enum from functools import lru_cache, partial from pathlib import Path, PurePath from typing import TYPE_CHECKING, Any, Callable, Optional, Union, cast, overload from uuid import UUID from advanced_alchemy.exceptions import AdvancedAlchemyError from advanced_alchemy.filters import LimitOffset, StatementFilter from advanced_alchemy.service.pagination import OffsetPagination from advanced_alchemy.service.typing import ( ATTRS_INSTALLED, CATTRS_INSTALLED, MSGSPEC_INSTALLED, PYDANTIC_INSTALLED, BaseModel, FilterTypeT, ModelDTOT, Struct, convert, fields, get_type_adapter, is_attrs_schema, schema_dump, structure, ) if TYPE_CHECKING: from sqlalchemy import ColumnElement, RowMapping from sqlalchemy.engine.row import Row from advanced_alchemy.base import ModelProtocol from advanced_alchemy.repository.typing import ModelOrRowMappingT __all__ = ("ResultConverter", "find_filter") DEFAULT_TYPE_DECODERS = [ # pyright: ignore[reportUnknownVariableType] (lambda x: x is UUID, lambda t, v: t(v.hex)), # pyright: ignore[reportUnknownLambdaType,reportUnknownMemberType] (lambda x: x is datetime.datetime, lambda t, v: t(v.isoformat())), # pyright: ignore[reportUnknownLambdaType,reportUnknownMemberType] (lambda x: x is datetime.date, lambda t, v: t(v.isoformat())), # pyright: ignore[reportUnknownLambdaType,reportUnknownMemberType] (lambda x: x is datetime.time, lambda t, v: t(v.isoformat())), # pyright: ignore[reportUnknownLambdaType,reportUnknownMemberType] (lambda x: x is Enum, lambda t, v: t(v.value)), # pyright: ignore[reportUnknownLambdaType,reportUnknownMemberType] ] def find_filter( filter_type: type[FilterTypeT], filters: "Union[Sequence[Union[StatementFilter, ColumnElement[bool]]], Sequence[StatementFilter]]", ) -> "Union[FilterTypeT, None]": """Get the filter specified by filter type from the filters. Args: filter_type: The type of filter to find. filters: filter types to apply to the query Returns: The match filter instance or None """ return next( (cast("Optional[FilterTypeT]", filter_) for filter_ in filters if isinstance(filter_, filter_type)), None, ) class ResultConverter: """Simple mixin to help convert to a paginated response model. Single objects are transformed to the supplied schema type, and lists of objects are automatically transformed into an `OffsetPagination` response of the supplied schema type. Args: data: A database model instance or row mapping. Type: :class:`~advanced_alchemy.repository.typing.ModelOrRowMappingT` Returns: The converted schema object. """ @overload def to_schema( self, data: "ModelOrRowMappingT", *, schema_type: None = None, ) -> "ModelOrRowMappingT": ... @overload def to_schema( self, data: "Union[ModelProtocol, RowMapping, Row[Any], dict[str, Any]]", *, schema_type: "type[ModelDTOT]", ) -> "ModelDTOT": ... @overload def to_schema( self, data: "ModelOrRowMappingT", total: "Optional[int]" = None, *, schema_type: None = None, ) -> "ModelOrRowMappingT": ... @overload def to_schema( self, data: "Union[ModelProtocol, RowMapping, Row[Any], dict[str, Any]]", total: "Optional[int]" = None, *, schema_type: "type[ModelDTOT]", ) -> "ModelDTOT": ... @overload def to_schema( self, data: "ModelOrRowMappingT", total: "Optional[int]" = None, filters: "Union[Sequence[Union[StatementFilter, ColumnElement[bool]]], Sequence[StatementFilter], None]" = None, *, schema_type: None = None, ) -> "ModelOrRowMappingT": ... @overload def to_schema( self, data: "Union[ModelProtocol, RowMapping, Row[Any], dict[str, Any]]", total: "Optional[int]" = None, filters: "Union[Sequence[Union[StatementFilter, ColumnElement[bool]]], Sequence[StatementFilter], None]" = None, *, schema_type: "type[ModelDTOT]", ) -> "ModelDTOT": ... @overload def to_schema( self, data: "Sequence[Row[Any]]", total: "int", ) -> "OffsetPagination[Row[Any]]": ... @overload def to_schema( self, data: "Sequence[ModelOrRowMappingT]", *, schema_type: None = None, ) -> "OffsetPagination[ModelOrRowMappingT]": ... @overload def to_schema( self, data: "Union[Sequence[ModelProtocol], Sequence[RowMapping], Sequence[Row[Any]], Sequence[dict[str, Any]]]", *, schema_type: "type[ModelDTOT]", ) -> "OffsetPagination[ModelDTOT]": ... @overload def to_schema( self, data: "Sequence[ModelOrRowMappingT]", total: "Optional[int]" = None, filters: "Union[Sequence[Union[StatementFilter, ColumnElement[bool]]], Sequence[StatementFilter], None]" = None, *, schema_type: None = None, ) -> "OffsetPagination[ModelOrRowMappingT]": ... @overload def to_schema( self, data: "Union[Sequence[ModelProtocol], Sequence[RowMapping], Sequence[Row[Any]], Sequence[dict[str, Any]]]", total: "Optional[int]" = None, filters: "Union[Sequence[Union[StatementFilter, ColumnElement[bool]]], Sequence[StatementFilter], None]" = None, *, schema_type: "type[ModelDTOT]", ) -> "OffsetPagination[ModelDTOT]": ... def to_schema( self, data: "Union[ModelOrRowMappingT, Sequence[ModelOrRowMappingT], ModelProtocol, Sequence[ModelProtocol], RowMapping, Sequence[RowMapping], Row[Any], Sequence[Row[Any]], dict[str, Any], Sequence[dict[str, Any]]]", total: "Optional[int]" = None, filters: "Union[Sequence[Union[StatementFilter, ColumnElement[bool]]], Sequence[StatementFilter], None]" = None, *, schema_type: "Optional[type[ModelDTOT]]" = None, ) -> "Union[ModelOrRowMappingT, OffsetPagination[ModelOrRowMappingT], ModelDTOT, OffsetPagination[ModelDTOT]]": """Convert the object to a response schema. When `schema_type` is None, the model is returned with no conversion. Args: data: The return from one of the service calls. Type: :class:`~advanced_alchemy.repository.typing.ModelOrRowMappingT` total: The total number of rows in the data. filters: :class:`~advanced_alchemy.filters.StatementFilter`| :class:`sqlalchemy.sql.expression.ColumnElement` Collection of route filters. schema_type: :class:`~advanced_alchemy.service.typing.ModelDTOT` Optional schema type to convert the data to Raises: AdvancedAlchemyError: If `schema_type` is not a valid Pydantic, Msgspec, or attrs schema and all libraries are not installed. Returns: :class:`~advanced_alchemy.base.ModelProtocol` | :class:`sqlalchemy.orm.RowMapping` | :class:`~advanced_alchemy.service.pagination.OffsetPagination` | :class:`msgspec.Struct` | :class:`pydantic.BaseModel` | :class:`attrs class` """ if filters is None: filters = [] if schema_type is None: if not isinstance(data, Sequence): return cast("ModelOrRowMappingT", data) # type: ignore[unreachable,unused-ignore] return cast( "OffsetPagination[ModelOrRowMappingT]", _create_pagination(cast("Sequence[ModelOrRowMappingT]", data), filters, total), ) if MSGSPEC_INSTALLED and issubclass(schema_type, Struct): if not isinstance(data, Sequence): return cast( "ModelDTOT", convert( obj=data, type=schema_type, from_attributes=True, dec_hook=partial( _default_msgspec_deserializer, type_decoders=DEFAULT_TYPE_DECODERS, ), ), ) converted_items = convert( obj=data, type=list[schema_type], # type: ignore[valid-type] from_attributes=True, dec_hook=partial( _default_msgspec_deserializer, type_decoders=DEFAULT_TYPE_DECODERS, ), ) return cast("OffsetPagination[ModelDTOT]", _create_pagination(converted_items, filters, total)) if PYDANTIC_INSTALLED and issubclass(schema_type, BaseModel): if not isinstance(data, Sequence): return cast( "ModelDTOT", get_type_adapter(schema_type).validate_python(data, from_attributes=True), ) validated_items = get_type_adapter(list[schema_type]).validate_python(data, from_attributes=True) # type: ignore[valid-type] # pyright: ignore[reportUnknownArgumentType] return cast("OffsetPagination[ModelDTOT]", _create_pagination(validated_items, filters, total)) if CATTRS_INSTALLED and is_attrs_schema(schema_type): if not isinstance(data, Sequence): return cast("ModelDTOT", structure(schema_dump(data), schema_type)) structured_items = [cast("ModelDTOT", structure(schema_dump(item), schema_type)) for item in data] return cast("OffsetPagination[ModelDTOT]", _create_pagination(structured_items, filters, total)) if ATTRS_INSTALLED and is_attrs_schema(schema_type): # Cache field names for performance field_names = _get_attrs_field_names(schema_type) # type: ignore[arg-type] if not isinstance(data, Sequence): return cast("ModelDTOT", _convert_attrs_item(data, schema_type, field_names)) converted_items = [_convert_attrs_item(item, schema_type, field_names) for item in data] return cast("OffsetPagination[ModelDTOT]", _create_pagination(converted_items, filters, total)) if not MSGSPEC_INSTALLED and not PYDANTIC_INSTALLED and not ATTRS_INSTALLED: msg = "Either Msgspec, Pydantic, or attrs must be installed to use schema conversion" raise AdvancedAlchemyError(msg) msg = "`schema_type` should be a valid Pydantic, Msgspec, or attrs schema" raise AdvancedAlchemyError(msg) # Private helper functions def _default_msgspec_deserializer( target_type: Any, value: Any, type_decoders: "Union[Sequence[tuple[Callable[[Any], bool], Callable[[Any, Any], Any]]], None]" = None, ) -> Any: # pragma: no cover """Transform values non-natively supported by ``msgspec`` Args: target_type: Encountered type value: Value to coerce type_decoders: Optional sequence of type decoders Raises: TypeError: If the value cannot be coerced to the target type Returns: A ``msgspec``-supported type """ if isinstance(value, target_type): return value if type_decoders: for predicate, decoder in type_decoders: if predicate(target_type): return decoder(target_type, value) if issubclass(target_type, (Path, PurePath, UUID)): return target_type(value) try: return target_type(value) except Exception as e: msg = f"Unsupported type: {type(value)!r}" raise TypeError(msg) from e @lru_cache(maxsize=128) def _get_attrs_field_names(schema_type: "type[Any]") -> "set[str]": """Get and cache the field names for a given attrs class. Args: schema_type: attrs class to get field names for. Returns: Set of field names for the attrs class. """ if ATTRS_INSTALLED and is_attrs_schema(schema_type): return {field.name for field in fields(schema_type)} return set() def _convert_attrs_item(item: Any, schema_type: "type[ModelDTOT]", field_names: "set[str]") -> "ModelDTOT": """Convert a single item to attrs schema using cached field names. Args: item: Item to convert. schema_type: Target attrs schema type. field_names: Cached set of field names. Returns: Converted attrs instance. """ item_dict = schema_dump(item) filtered_dict = {k: v for k, v in item_dict.items() if k in field_names} return schema_type(**filtered_dict) # type: ignore[return-value] def _create_pagination(items: Any, filters: Any, total: "Optional[int]") -> "OffsetPagination[Any]": """Create OffsetPagination with consistent limit_offset logic. Args: items: Items to paginate. filters: Filters to extract LimitOffset from. total: Total count or None. Returns: OffsetPagination instance. """ limit_offset = find_filter(LimitOffset, filters=filters) or LimitOffset(limit=len(items), offset=0) return OffsetPagination( items=items, limit=limit_offset.limit, offset=limit_offset.offset, total=total or len(items), ) python-advanced-alchemy-1.9.3/advanced_alchemy/service/pagination.py000066400000000000000000000011531516556515500256600ustar00rootroot00000000000000from collections.abc import Sequence from dataclasses import dataclass from typing import Generic, TypeVar T = TypeVar("T") __all__ = ("OffsetPagination",) @dataclass class OffsetPagination(Generic[T]): """Container for data returned using limit/offset pagination.""" __slots__ = ("items", "limit", "offset", "total") items: Sequence[T] """List of data being sent as part of the response.""" limit: int """Maximal number of items to send.""" offset: int """Offset from the beginning of the query. Identical to an index. """ total: int """Total number of items.""" python-advanced-alchemy-1.9.3/advanced_alchemy/service/typing.py000066400000000000000000000411501516556515500250420ustar00rootroot00000000000000"""Service object implementation for SQLAlchemy. RepositoryService object is generic on the domain model type which should be a SQLAlchemy model. """ from functools import lru_cache from typing import ( TYPE_CHECKING, Annotated, Any, TypeVar, Union, cast, overload, ) from sqlalchemy import RowMapping from typing_extensions import TypeAlias, TypeGuard from advanced_alchemy.service._typing import ( ATTRS_INSTALLED, CATTRS_INSTALLED, LITESTAR_INSTALLED, MSGSPEC_INSTALLED, PYDANTIC_INSTALLED, UNSET, AttrsInstance, AttrsLike, BaseModel, BaseModelLike, DictProtocol, DTOData, DTODataLike, FailFast, Struct, StructLike, T, TypeAdapter, UnsetType, attrs_nothing, convert, ) from advanced_alchemy.service._typing import attrs_asdict as asdict from advanced_alchemy.service._typing import attrs_fields as fields from advanced_alchemy.service._typing import attrs_has as has from advanced_alchemy.service._typing import cattrs_structure as structure from advanced_alchemy.service._typing import cattrs_unstructure as unstructure from advanced_alchemy.typing import SQLMODEL_INSTALLED if TYPE_CHECKING: from collections.abc import Sequence from sqlalchemy.engine.row import Row from advanced_alchemy.filters import StatementFilter from advanced_alchemy.repository.typing import ModelT PYDANTIC_USE_FAILFAST = False # leave permanently disabled for now FilterTypeT = TypeVar("FilterTypeT", bound="StatementFilter") """Type variable for filter types. :class:`~advanced_alchemy.filters.StatementFilter` """ SupportedSchemaModel: TypeAlias = Union[StructLike, BaseModelLike, AttrsLike] """Type alias for objects that support schema conversion methods (model_dump, asdict, etc.).""" ModelDTOT = TypeVar("ModelDTOT", bound="Union[SupportedSchemaModel, Any]") """Type variable for model DTOs. :class:`msgspec.Struct`|:class:`pydantic.BaseModel`|:class:`attrs class` """ PydanticOrMsgspecT = SupportedSchemaModel """Type alias for supported schema models. :class:`msgspec.Struct` or :class:`pydantic.BaseModel` or :class:`attrs class` """ ModelDictT: TypeAlias = "Union[dict[str, Any], ModelT, SupportedSchemaModel, DTODataLike[ModelT], Any]" """Type alias for model dictionaries. Represents: - :type:`dict[str, Any]` | :class:`~advanced_alchemy.base.ModelProtocol` | :class:`msgspec.Struct` | :class:`pydantic.BaseModel` | :class:`attrs class` | :class:`litestar.dto.data_structures.DTOData` | :class:`~advanced_alchemy.base.ModelProtocol` """ ModelDictListT: TypeAlias = "Sequence[Union[dict[str, Any], ModelT, SupportedSchemaModel, Any]]" """Type alias for model dictionary lists. A list or sequence of any of the following: - :type:`Sequence`[:type:`dict[str, Any]` | :class:`~advanced_alchemy.base.ModelProtocol` | :class:`msgspec.Struct` | :class:`pydantic.BaseModel` | :class:`attrs class`] """ BulkModelDictT: TypeAlias = ( "Union[Sequence[Union[dict[str, Any], ModelT, SupportedSchemaModel, Any]], DTODataLike[list[ModelT]]]" ) """Type alias for bulk model dictionaries. :type:`Sequence`[ :type:`dict[str, Any]` | :class:`~advanced_alchemy.base.ModelProtocol` | :class:`msgspec.Struct` | :class:`pydantic.BaseModel` | :class:`attrs class`] | :class:`litestar.dto.data_structures.DTOData` """ @lru_cache(typed=True) def get_type_adapter(f: "type[T]") -> Any: """Caches and returns a pydantic type adapter. Args: f: Type to create a type adapter for. Returns: :class:`pydantic.TypeAdapter`[:class:`typing.TypeVar`[T]] """ if PYDANTIC_USE_FAILFAST: return TypeAdapter(Annotated[f, FailFast()]) # pyright: ignore return TypeAdapter(f) @lru_cache(maxsize=128, typed=True) def get_attrs_fields(cls: Any) -> "tuple[Any, ...]": """Caches and returns attrs fields for a given attrs class. Args: cls: attrs class to get fields for. Returns: Tuple of attrs fields. """ if ATTRS_INSTALLED: return fields(cls) # type: ignore[no-any-return] return () def is_dto_data(v: Any) -> TypeGuard[DTODataLike[Any]]: """Check if a value is a Litestar DTOData object. Args: v: Value to check. Returns: bool """ return LITESTAR_INSTALLED and isinstance(v, DTOData) def is_pydantic_model(v: Any) -> TypeGuard[BaseModelLike]: """Check if a value is a pydantic model. Args: v: Value to check. Returns: bool """ if not PYDANTIC_INSTALLED: return False if isinstance(v, type): try: return issubclass(v, BaseModel) except TypeError: return False return isinstance(v, BaseModel) def is_sqlmodel_table_model(v: Any) -> bool: """Check if a value is a SQLModel table model instance or class. Detects the dual nature of SQLModel ``table=True`` models: they are both Pydantic ``BaseModel`` subclasses AND SQLAlchemy-mapped models (have ``__mapper__``). This check fires BEFORE ``is_pydantic_model()`` to prevent SQLModel table instances from being misidentified as plain Pydantic schemas. Args: v: Value to check (instance or class). Returns: bool: ``True`` if *v* is a SQLModel table model, ``False`` otherwise. """ if not SQLMODEL_INSTALLED: return False if isinstance(v, type): try: return issubclass(v, BaseModel) and hasattr(v, "__mapper__") except TypeError: return False return isinstance(v, BaseModel) and hasattr(v, "__mapper__") def is_msgspec_struct(v: Any) -> TypeGuard[StructLike]: """Check if a value is a msgspec struct. Args: v: Value to check. Returns: bool """ return MSGSPEC_INSTALLED and isinstance(v, Struct) def is_attrs_instance(obj: Any) -> TypeGuard[AttrsLike]: """Check if a value is an attrs class instance. Args: obj: Value to check. Returns: bool """ return ATTRS_INSTALLED and has(obj.__class__) def is_attrs_schema(cls: Any) -> TypeGuard["type[AttrsLike]"]: """Check if a class type is an attrs schema. Args: cls: Class to check. Returns: bool """ return ATTRS_INSTALLED and has(cls) def is_dataclass(obj: Any) -> TypeGuard[Any]: """Check if an object is a dataclass.""" return hasattr(obj, "__dataclass_fields__") def is_dataclass_with_field(obj: Any, field_name: str) -> TypeGuard[object]: # Can't specify dataclass type directly """Check if an object is a dataclass and has a specific field.""" return is_dataclass(obj) and hasattr(obj, field_name) def is_dataclass_without_field(obj: Any, field_name: str) -> TypeGuard[object]: """Check if an object is a dataclass and does not have a specific field.""" return is_dataclass(obj) and not hasattr(obj, field_name) def is_attrs_instance_with_field(v: Any, field_name: str) -> TypeGuard[AttrsLike]: """Check if an attrs instance has a specific field. Args: v: Value to check. field_name: Field name to check for. Returns: bool """ return is_attrs_instance(v) and hasattr(v, field_name) def is_attrs_instance_without_field(v: Any, field_name: str) -> TypeGuard[AttrsLike]: """Check if an attrs instance does not have a specific field. Args: v: Value to check. field_name: Field name to check for. Returns: bool """ return is_attrs_instance(v) and not hasattr(v, field_name) def is_dict(v: Any) -> TypeGuard[dict[str, Any]]: """Check if a value is a dictionary. Args: v: Value to check. Returns: bool """ return isinstance(v, dict) def has_dict_attribute(obj: Any) -> "TypeGuard[DictProtocol]": """Check if an object has a __dict__ attribute. Args: obj: Value to check. Returns: bool """ # Protocol checking returns True for None, so add explicit check return obj is not None and hasattr(obj, "__dict__") def is_row_mapping(v: Any) -> TypeGuard["RowMapping"]: """Check if a value is a SQLAlchemy RowMapping. Args: v: Value to check. Returns: bool """ return isinstance(v, RowMapping) def is_dict_with_field(v: Any, field_name: str) -> TypeGuard[dict[str, Any]]: """Check if a dictionary has a specific field. Args: v: Value to check. field_name: Field name to check for. Returns: bool """ return is_dict(v) and field_name in v def is_dict_without_field(v: Any, field_name: str) -> TypeGuard[dict[str, Any]]: """Check if a dictionary does not have a specific field. Args: v: Value to check. field_name: Field name to check for. Returns: bool """ return is_dict(v) and field_name not in v def is_pydantic_model_with_field(v: Any, field_name: str) -> TypeGuard[BaseModelLike]: """Check if a pydantic model has a specific field. Args: v: Value to check. field_name: Field name to check for. Returns: bool """ return is_pydantic_model(v) and hasattr(v, field_name) def is_pydantic_model_without_field(v: Any, field_name: str) -> TypeGuard[BaseModelLike]: """Check if a pydantic model does not have a specific field. Args: v: Value to check. field_name: Field name to check for. Returns: bool """ return is_pydantic_model(v) and not hasattr(v, field_name) def is_msgspec_struct_with_field(v: Any, field_name: str) -> TypeGuard[StructLike]: """Check if a msgspec struct has a specific field. Args: v: Value to check. field_name: Field name to check for. Returns: bool """ return is_msgspec_struct(v) and hasattr(v, field_name) def is_msgspec_struct_without_field(v: Any, field_name: str) -> "TypeGuard[StructLike]": """Check if a msgspec struct does not have a specific field. Args: v: Value to check. field_name: Field name to check for. Returns: bool """ return is_msgspec_struct(v) and not hasattr(v, field_name) def is_schema(v: Any) -> "TypeGuard[SupportedSchemaModel]": """Check if a value is a msgspec Struct, Pydantic model, or attrs instance. SQLModel ``table=True`` models are excluded โ€” they are ORM-mapped models, not schemas that should be decomposed to dicts. Args: v: Value to check. Returns: bool """ if is_sqlmodel_table_model(v): return False return is_msgspec_struct(v) or is_pydantic_model(v) or is_attrs_instance(v) def is_schema_or_dict(v: Any) -> "TypeGuard[Union[SupportedSchemaModel, dict[str, Any]]]": """Check if a value is a msgspec Struct, Pydantic model, attrs class, or dict. Args: v: Value to check. Returns: bool """ return is_schema(v) or is_dict(v) def is_schema_with_field(v: Any, field_name: str) -> "TypeGuard[SupportedSchemaModel]": """Check if a value is a msgspec Struct, Pydantic model, or attrs instance with a specific field. SQLModel ``table=True`` models are excluded. Args: v: Value to check. field_name: Field name to check for. Returns: bool """ if is_sqlmodel_table_model(v): return False return ( is_msgspec_struct_with_field(v, field_name) or is_pydantic_model_with_field(v, field_name) or is_attrs_instance_with_field(v, field_name) ) def is_schema_without_field(v: Any, field_name: str) -> "TypeGuard[SupportedSchemaModel]": """Check if a value is a msgspec Struct, Pydantic model, or attrs instance without a specific field. SQLModel ``table=True`` models are excluded. Args: v: Value to check. field_name: Field name to check for. Returns: bool """ return is_schema(v) and not hasattr(v, field_name) def is_schema_or_dict_with_field(v: Any, field_name: str) -> "TypeGuard[Union[SupportedSchemaModel, dict[str, Any]]]": """Check if a value is a msgspec Struct, Pydantic model, attrs instance, or dict with a specific field. Args: v: Value to check. field_name: Field name to check for. Returns: bool """ return is_schema_with_field(v, field_name) or is_dict_with_field(v, field_name) def is_schema_or_dict_without_field( v: Any, field_name: str ) -> "TypeGuard[Union[SupportedSchemaModel, dict[str, Any]]]": """Check if a value is a msgspec Struct, Pydantic model, attrs instance, or dict without a specific field. Args: v: Value to check. field_name: Field name to check for. Returns: bool """ return is_schema_or_dict(v) and not is_schema_or_dict_with_field(v, field_name) @overload def schema_dump(data: "RowMapping", exclude_unset: bool = True) -> "dict[str, Any]": ... @overload def schema_dump(data: "Row[Any]", exclude_unset: bool = True) -> "dict[str, Any]": ... @overload def schema_dump(data: "DTODataLike[Any]", exclude_unset: bool = True) -> "dict[str, Any]": ... @overload def schema_dump(data: "ModelT", exclude_unset: bool = True) -> "ModelT": ... # pyright: ignore[reportOverlappingOverload] @overload def schema_dump( data: Any, exclude_unset: bool = True, ) -> "dict[str, Any]": ... def schema_dump( data: "Union[dict[str, Any], ModelT, SupportedSchemaModel, DTODataLike[ModelT], RowMapping, Row[Any]]", exclude_unset: bool = True, ) -> "Union[dict[str, Any], ModelT]": """Dump a data object to a dictionary. Args: data: :type:`dict[str, Any]` | :class:`advanced_alchemy.base.ModelProtocol` | :class:`msgspec.Struct` | :class:`pydantic.BaseModel` | :class:`attrs class` | :class:`litestar.dto.data_structures.DTOData[ModelT]` | :class:`sqlalchemy.RowMapping` | :class:`sqlalchemy.engine.row.Row` exclude_unset: :type:`bool` Whether to exclude unset values. Returns: Union[:type: dict[str, Any], :class:`~advanced_alchemy.base.ModelProtocol`] """ if is_dict(data): return data if is_row_mapping(data): return dict(data) if is_sqlmodel_table_model(data): return cast("ModelT", data) # type: ignore[no-return-any] if is_pydantic_model(data): return data.model_dump(exclude_unset=exclude_unset) if is_msgspec_struct(data): if exclude_unset: return { f: getattr(data, f) for f in data.__struct_fields__ if hasattr(data, f) and getattr(data, f) is not UNSET } return {f: getattr(data, f, None) for f in data.__struct_fields__} if is_attrs_instance(data): if exclude_unset: # Filter out attrs.NOTHING values for partial updates def filter_unset_attrs(attr: Any, value: Any) -> bool: # noqa: ARG001 return value is not attrs_nothing return asdict(data, filter=filter_unset_attrs) # Use cattrs for enhanced performance and type-aware serialization when available if CATTRS_INSTALLED: return unstructure(data) # type: ignore[no-any-return] # Fallback to basic attrs.asdict when cattrs is not available return asdict(data) if is_dto_data(data): return cast("dict[str, Any]", data.as_builtins()) if has_dict_attribute(data): return data.__dict__ return cast("ModelT", data) # type: ignore[no-return-any] __all__ = ( "ATTRS_INSTALLED", "CATTRS_INSTALLED", "LITESTAR_INSTALLED", "MSGSPEC_INSTALLED", "PYDANTIC_INSTALLED", "PYDANTIC_USE_FAILFAST", "UNSET", "AttrsInstance", "AttrsLike", "BaseModel", "BaseModelLike", "BulkModelDictT", "DTOData", "DTODataLike", "FailFast", "FilterTypeT", "ModelDTOT", "ModelDictListT", "ModelDictT", "PydanticOrMsgspecT", "Struct", "StructLike", "SupportedSchemaModel", "TypeAdapter", "UnsetType", "asdict", "attrs_nothing", "convert", "fields", "get_attrs_fields", "get_type_adapter", "has", "is_attrs_instance", "is_attrs_instance_with_field", "is_attrs_instance_without_field", "is_attrs_schema", "is_dataclass", "is_dataclass_with_field", "is_dataclass_without_field", "is_dict", "is_dict_with_field", "is_dict_without_field", "is_dto_data", "is_msgspec_struct", "is_msgspec_struct_with_field", "is_msgspec_struct_without_field", "is_pydantic_model", "is_pydantic_model_with_field", "is_pydantic_model_without_field", "is_row_mapping", "is_schema", "is_schema_or_dict", "is_schema_or_dict_with_field", "is_schema_or_dict_without_field", "is_schema_with_field", "is_schema_without_field", "is_sqlmodel_table_model", "schema_dump", "structure", "unstructure", ) python-advanced-alchemy-1.9.3/advanced_alchemy/types/000077500000000000000000000000001516556515500226615ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/advanced_alchemy/types/__init__.py000066400000000000000000000025151516556515500247750ustar00rootroot00000000000000"""SQLAlchemy custom types for use with the ORM.""" from advanced_alchemy.types import encrypted_string, file_object, password_hash from advanced_alchemy.types.datetime import DateTimeUTC from advanced_alchemy.types.encrypted_string import ( EncryptedString, EncryptedText, EncryptionBackend, FernetBackend, ) from advanced_alchemy.types.file_object import ( FileObject, FileObjectList, StorageBackend, StorageBackendT, StorageRegistry, StoredObject, storages, ) from advanced_alchemy.types.guid import GUID, NANOID_INSTALLED, UUID_UTILS_INSTALLED from advanced_alchemy.types.identity import BigIntIdentity from advanced_alchemy.types.json import ORA_JSONB, JsonB from advanced_alchemy.types.mutables import MutableList from advanced_alchemy.types.password_hash.base import HashedPassword, PasswordHash __all__ = ( "GUID", "NANOID_INSTALLED", "ORA_JSONB", "UUID_UTILS_INSTALLED", "BigIntIdentity", "DateTimeUTC", "EncryptedString", "EncryptedText", "EncryptionBackend", "FernetBackend", "FileObject", "FileObjectList", "HashedPassword", "JsonB", "MutableList", "PasswordHash", "StorageBackend", "StorageBackendT", "StorageRegistry", "StoredObject", "encrypted_string", "file_object", "password_hash", "storages", ) python-advanced-alchemy-1.9.3/advanced_alchemy/types/datetime.py000066400000000000000000000022011516556515500250220ustar00rootroot00000000000000import datetime from typing import Optional from sqlalchemy import DateTime from sqlalchemy.engine import Dialect from sqlalchemy.types import TypeDecorator __all__ = ("DateTimeUTC",) class DateTimeUTC(TypeDecorator[datetime.datetime]): """Timezone Aware DateTime. Ensure UTC is stored in the database and that TZ aware dates are returned for all dialects. """ impl = DateTime(timezone=True) cache_ok = True @property def python_type(self) -> type[datetime.datetime]: return datetime.datetime def process_bind_param(self, value: Optional[datetime.datetime], dialect: Dialect) -> Optional[datetime.datetime]: if value is None: return value if not value.tzinfo: msg = "tzinfo is required" raise TypeError(msg) return value.astimezone(datetime.timezone.utc) def process_result_value(self, value: Optional[datetime.datetime], dialect: Dialect) -> Optional[datetime.datetime]: if value is None: return value if value.tzinfo is None: return value.replace(tzinfo=datetime.timezone.utc) return value python-advanced-alchemy-1.9.3/advanced_alchemy/types/encrypted_string.py000066400000000000000000000312261516556515500266220ustar00rootroot00000000000000import abc import base64 import contextlib import os from typing import TYPE_CHECKING, Any, Callable, Optional, Union from sqlalchemy import String, Text, TypeDecorator from sqlalchemy import func as sql_func from advanced_alchemy.exceptions import IntegrityError if TYPE_CHECKING: from sqlalchemy.engine import Dialect cryptography = None # type: ignore[var-annotated,unused-ignore] with contextlib.suppress(ImportError): from cryptography.fernet import Fernet from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import hashes __all__ = ("EncryptedString", "EncryptedText", "EncryptionBackend", "FernetBackend", "PGCryptoBackend") class EncryptionBackend(abc.ABC): """Abstract base class for encryption backends. This class defines the interface that all encryption backends must implement. Concrete implementations should provide the actual encryption/decryption logic. Attributes: passphrase (bytes): The encryption passphrase used by the backend. """ def mount_vault(self, key: "Union[str, bytes]") -> None: """Mounts the vault with the provided encryption key. Args: key (str | bytes): The encryption key used to initialize the backend. """ if isinstance(key, str): key = key.encode() @abc.abstractmethod def init_engine(self, key: "Union[bytes, str]") -> None: # pragma: no cover """Initializes the encryption engine with the provided key. Args: key (bytes | str): The encryption key. Raises: NotImplementedError: If the method is not implemented by the subclass. """ @abc.abstractmethod def encrypt(self, value: Any) -> str: # pragma: no cover """Encrypts the given value. Args: value (Any): The value to encrypt. Returns: str: The encrypted value. Raises: NotImplementedError: If the method is not implemented by the subclass. """ @abc.abstractmethod def decrypt(self, value: Any) -> str: # pragma: no cover """Decrypts the given value. Args: value (Any): The value to decrypt. Returns: str: The decrypted value. Raises: NotImplementedError: If the method is not implemented by the subclass. """ class PGCryptoBackend(EncryptionBackend): """PostgreSQL pgcrypto-based encryption backend. This backend uses PostgreSQL's pgcrypto extension for encryption/decryption operations. Requires the pgcrypto extension to be installed in the database. Attributes: passphrase (bytes): The base64-encoded passphrase used for encryption and decryption. """ def init_engine(self, key: "Union[bytes, str]") -> None: """Initializes the pgcrypto engine with the provided key. Args: key (bytes | str): The encryption key. """ if isinstance(key, str): key = key.encode() self.passphrase = base64.urlsafe_b64encode(key) def encrypt(self, value: Any) -> str: """Encrypts the given value using pgcrypto. Args: value (Any): The value to encrypt. Returns: str: The encrypted value. """ if not isinstance(value, str): # pragma: no cover value = repr(value) value = value.encode() return sql_func.pgp_sym_encrypt(value, self.passphrase) # type: ignore[return-value] def decrypt(self, value: Any) -> str: """Decrypts the given value using pgcrypto. Args: value (Any): The value to decrypt. Returns: str: The decrypted value. """ if not isinstance(value, str): # pragma: no cover value = str(value) return sql_func.pgp_sym_decrypt(value, self.passphrase) # type: ignore[return-value] class FernetBackend(EncryptionBackend): """Fernet-based encryption backend. This backend uses the Python cryptography library's Fernet implementation for encryption/decryption operations. Provides symmetric encryption with built-in rotation support. Attributes: key (bytes): The base64-encoded key used for encryption and decryption. fernet (cryptography.fernet.Fernet): The Fernet instance used for encryption/decryption. """ def mount_vault(self, key: "Union[str, bytes]") -> None: """Mounts the vault with the provided encryption key. This method hashes the key using SHA256 before initializing the engine. Args: key (str | bytes): The encryption key. """ if isinstance(key, str): key = key.encode() digest = hashes.Hash(hashes.SHA256(), backend=default_backend()) # pyright: ignore[reportPossiblyUnboundVariable] digest.update(key) engine_key = digest.finalize() self.init_engine(engine_key) def init_engine(self, key: "Union[bytes, str]") -> None: """Initializes the Fernet engine with the provided key. Args: key (bytes | str): The encryption key. """ if isinstance(key, str): key = key.encode() self.key = base64.urlsafe_b64encode(key) self.fernet = Fernet(self.key) # pyright: ignore[reportPossiblyUnboundVariable] def encrypt(self, value: Any) -> str: """Encrypts the given value using Fernet. Args: value (Any): The value to encrypt. Returns: str: The encrypted value. """ if not isinstance(value, str): value = repr(value) value = value.encode() encrypted = self.fernet.encrypt(value) return encrypted.decode("utf-8") def decrypt(self, value: Any) -> str: """Decrypts the given value using Fernet. Args: value (Any): The value to decrypt. Returns: str: The decrypted value. """ if not isinstance(value, str): # pragma: no cover value = str(value) decrypted: Union[str, bytes] = self.fernet.decrypt(value.encode()) if not isinstance(decrypted, str): decrypted = decrypted.decode("utf-8") # pyright: ignore[reportAttributeAccessIssue] return decrypted DEFAULT_ENCRYPTION_KEY = os.urandom(32) class EncryptedString(TypeDecorator[str]): """SQLAlchemy TypeDecorator for storing encrypted string values in a database. This type provides transparent encryption/decryption of string values using the specified backend. It extends :class:`sqlalchemy.types.TypeDecorator` and implements String as its underlying type. Args: key (str | bytes | Callable[[], str | bytes] | None): The encryption key. Can be a string, bytes, or callable returning either. Defaults to os.urandom(32). backend (Type[EncryptionBackend] | None): The encryption backend class to use. Defaults to FernetBackend. length (int | None): The length of the unencrypted string. This is used for documentation and validation purposes only, as encrypted strings will be longer. **kwargs (Any | None): Additional arguments passed to the underlying String type. Attributes: key (str | bytes | Callable[[], str | bytes]): The encryption key. backend (EncryptionBackend): The encryption backend instance. length (int | None): The unencrypted string length. """ impl = String cache_ok = True def __init__( self, key: "Union[str, bytes, Callable[[], Union[str, bytes]]]" = DEFAULT_ENCRYPTION_KEY, backend: "type[EncryptionBackend]" = FernetBackend, length: "Optional[int]" = None, **kwargs: Any, ) -> None: """Initializes the EncryptedString TypeDecorator. Args: key (str | bytes | Callable[[], str | bytes] | None): The encryption key. Can be a string, bytes, or callable returning either. Defaults to os.urandom(32). backend (Type[EncryptionBackend] | None): The encryption backend class to use. Defaults to FernetBackend. length (int | None): The length of the unencrypted string. This is used for documentation and validation purposes only. **kwargs (Any | None): Additional arguments passed to the underlying String type. """ super().__init__() self.key = key self.backend = backend() self.length = length def __repr__(self) -> str: """Return a string representation of the EncryptedString.""" key_repr = self.key.__name__ if callable(self.key) else repr(self.key) return f"EncryptedString(key={key_repr}, backend={self.backend.__class__.__name__}, length={self.length})" @property def python_type(self) -> type[str]: """Returns the Python type for this type decorator. Returns: Type[str]: The Python string type. """ return str def load_dialect_impl(self, dialect: "Dialect") -> Any: """Loads the appropriate dialect implementation based on the database dialect. Note: The actual column length will be larger than the specified length due to encryption overhead. For most encryption methods, the encrypted string will be approximately 1.35x longer than the original. Args: dialect (Dialect): The SQLAlchemy dialect. Returns: Any: The dialect-specific type descriptor. """ if dialect.name in {"mysql", "mariadb"}: # For MySQL/MariaDB, always use Text to avoid length limitations return dialect.type_descriptor(Text()) if dialect.name == "oracle": # Oracle has a 4000-byte limit for VARCHAR2 (by default) return dialect.type_descriptor(String(length=4000)) return dialect.type_descriptor(String()) def process_bind_param(self, value: Any, dialect: "Dialect") -> "Union[str, None]": """Processes the value before binding it to the SQL statement. This method encrypts the value using the specified backend and validates length if specified. Args: value (Any): The value to process. dialect (Dialect): The SQLAlchemy dialect. Raises: IntegrityError: If the unencrypted value exceeds the maximum length. Returns: str | None: The encrypted value or None if the input is None. """ if value is None: return value # Validate length if specified if self.length is not None and len(str(value)) > self.length: msg = f"Unencrypted value exceeds maximum unencrypted length of {self.length}" raise IntegrityError(msg) self.mount_vault() return self.backend.encrypt(value) def process_result_value(self, value: Any, dialect: "Dialect") -> "Union[str, None]": """Processes the value after retrieving it from the database. This method decrypts the value using the specified backend. Args: value (Any): The value to process. dialect (Dialect): The SQLAlchemy dialect. Returns: str | None: The decrypted value or None if the input is None. """ if value is None: return value self.mount_vault() return self.backend.decrypt(value) def mount_vault(self) -> None: """Mounts the vault with the encryption key. If the key is callable, it is called to retrieve the key. Otherwise, the key is used directly. """ key = self.key() if callable(self.key) else self.key self.backend.mount_vault(key) class EncryptedText(EncryptedString): """SQLAlchemy TypeDecorator for storing encrypted text/CLOB values in a database. This type provides transparent encryption/decryption of text values using the specified backend. It extends :class:`EncryptedString` and implements Text as its underlying type. This is suitable for storing larger encrypted text content compared to EncryptedString. Args: key (str | bytes | Callable[[], str | bytes] | None): The encryption key. Can be a string, bytes, or callable returning either. Defaults to os.urandom(32). backend (Type[EncryptionBackend] | None): The encryption backend class to use. Defaults to FernetBackend. **kwargs (Any | None): Additional arguments passed to the underlying String type. """ impl = Text cache_ok = True def load_dialect_impl(self, dialect: "Dialect") -> Any: """Loads the appropriate dialect implementation for Text type. Args: dialect (Dialect): The SQLAlchemy dialect. Returns: Any: The dialect-specific Text type descriptor. """ return dialect.type_descriptor(Text()) python-advanced-alchemy-1.9.3/advanced_alchemy/types/file_object/000077500000000000000000000000001516556515500251265ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/advanced_alchemy/types/file_object/__init__.py000066400000000000000000000022571516556515500272450ustar00rootroot00000000000000"""File object types for handling file metadata and operations using storage backends. Provides `FileObject` for representing file metadata and `StoredObject` as the SQLAlchemy type for database persistence. Includes support for various storage backends (`fsspec`, `obstore`). The overall design, including concepts like storage backends and the separation of file representation from the stored type, draws inspiration from the `sqlalchemy-file` library [https://github.com/jowilf/sqlalchemy-file]. Special thanks to its contributors. """ from advanced_alchemy.types.file_object.base import AsyncDataLike, PathLike, StorageBackend, StorageBackendT from advanced_alchemy.types.file_object.data_type import StoredObject from advanced_alchemy.types.file_object.file import FileObject, FileObjectList from advanced_alchemy.types.file_object.registry import StorageRegistry, storages from advanced_alchemy.types.file_object.session_tracker import FileObjectSessionTracker __all__ = [ "AsyncDataLike", "FileObject", "FileObjectList", "FileObjectSessionTracker", "PathLike", "StorageBackend", "StorageBackendT", "StorageRegistry", "StoredObject", "storages", ] python-advanced-alchemy-1.9.3/advanced_alchemy/types/file_object/_typing.py000066400000000000000000000030621516556515500271520ustar00rootroot00000000000000"""Internal typing helpers for file_object, handling optional Pydantic integration.""" from typing import Any, Protocol, TypeVar, runtime_checkable # Define a generic type variable for CoreSchema placeholder if needed CoreSchemaT = TypeVar("CoreSchemaT") try: # Attempt to import real Pydantic components from pydantic import GetCoreSchemaHandler # pyright: ignore from pydantic_core import core_schema # pyright: ignore PYDANTIC_INSTALLED = True except ImportError: PYDANTIC_INSTALLED = False # pyright: ignore @runtime_checkable class GetCoreSchemaHandler(Protocol): # type: ignore[no-redef] """Placeholder for Pydantic's GetCoreSchemaHandler.""" def __call__(self, source_type: Any) -> Any: ... def __getattr__(self, item: str) -> Any: # Allow arbitrary attribute access return Any # Define a placeholder for core_schema module class CoreSchemaModulePlaceholder: """Placeholder for pydantic_core.core_schema module.""" # Define placeholder types/functions used in FileObject.__get_pydantic_core_schema__ CoreSchema = Any # Placeholder for the CoreSchema type itself def __getattr__(self, name: str) -> Any: """Return a dummy function/type for any requested attribute.""" def dummy_schema_func(*args: Any, **kwargs: Any) -> Any: # noqa: ARG001 return Any return dummy_schema_func core_schema = CoreSchemaModulePlaceholder() # type: ignore[assignment] __all__ = ("GetCoreSchemaHandler", "core_schema") python-advanced-alchemy-1.9.3/advanced_alchemy/types/file_object/_utils.py000066400000000000000000000046671516556515500270140ustar00rootroot00000000000000"""Utility functions for file object types.""" from datetime import datetime from typing import TYPE_CHECKING, Any, Optional from zlib import adler32 if TYPE_CHECKING: from advanced_alchemy.types.file_object.base import PathLike from advanced_alchemy.types.file_object.file import FileObject def get_mtime_equivalent(info: dict[str, Any]) -> Optional[float]: """Return standardized mtime from different implementations. Args: info: Dictionary containing file metadata Returns: Standardized timestamp or None if not available """ # Check these keys in order of preference mtime_keys = ( "mtime", "last_modified", "uploaded_at", "timestamp", "Last-Modified", "modified_at", "modification_time", ) mtime = next((info[key] for key in mtime_keys if key in info), None) if mtime is None or isinstance(mtime, float): return mtime if isinstance(mtime, datetime): return mtime.timestamp() if isinstance(mtime, str): try: return datetime.fromisoformat(mtime.replace("Z", "+00:00")).timestamp() except ValueError: pass return None def get_or_generate_etag(file_object: "FileObject", info: dict[str, Any], modified_time: Optional[float] = None) -> str: """Return standardized etag from different implementations. Args: file_object: Path to the file info: Dictionary containing file metadata modified_time: Optional modified time for the file Returns: Standardized etag or None if not available """ # Check these keys in order of preference etag_keys = ( "e_tag", "etag", "etag_key", ) etag = next((info[key] for key in etag_keys if key in info), None) if etag is not None: return str(etag) if file_object.etag is not None: return file_object.etag return create_etag_for_file(file_object.path, modified_time, info.get("size", file_object.size)) # type: ignore[arg-type] def create_etag_for_file(path: "PathLike", modified_time: Optional[float], file_size: int) -> str: """Create an etag. Notes: - Function is derived from flask. Returns: An etag. """ check = adler32(str(path).encode("utf-8")) & 0xFFFFFFFF parts = [str(file_size), str(check)] if modified_time: parts.insert(0, str(modified_time)) return f'"{"-".join(parts)}"' python-advanced-alchemy-1.9.3/advanced_alchemy/types/file_object/backends/000077500000000000000000000000001516556515500267005ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/advanced_alchemy/types/file_object/backends/__init__.py000066400000000000000000000000001516556515500307770ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/advanced_alchemy/types/file_object/backends/fsspec.py000066400000000000000000000321671516556515500305460ustar00rootroot00000000000000# advanced_alchemy/types/file_object/backends/fsspec.py # ruff: noqa: SLF001 """FSSpec-backed storage backend for file objects.""" import datetime import os from collections.abc import AsyncIterable, AsyncIterator, Iterable, Sequence from pathlib import Path from typing import IO, TYPE_CHECKING, Any, Optional, Union, cast from advanced_alchemy.exceptions import MissingDependencyError from advanced_alchemy.types.file_object._utils import get_mtime_equivalent, get_or_generate_etag from advanced_alchemy.types.file_object.base import ( PathLike, StorageBackend, ) from advanced_alchemy.types.file_object.file import FileObject from advanced_alchemy.utils.sync_tools import async_ try: # Correct import for AsyncFileSystem and try importing async file handle import fsspec # pyright: ignore[reportMissingTypeStubs] from fsspec.asyn import AsyncFileSystem # pyright: ignore[reportMissingTypeStubs] except ImportError as e: msg = "fsspec" raise MissingDependencyError(msg) from e if TYPE_CHECKING: from fsspec import AbstractFileSystem # pyright: ignore[reportMissingTypeStubs] def _join_path(prefix: str, path: str) -> str: if not prefix: return path prefix = prefix.rstrip("/") path = path.lstrip("/") return f"{prefix}/{path}" class FSSpecBackend(StorageBackend): """FSSpec-backed storage backend implementing both sync and async operations.""" driver = "fsspec" # Changed backend identifier to driver default_expires_in = 3600 prefix: Optional[str] def __init__( self, key: str, fs: "Union[AbstractFileSystem, AsyncFileSystem, str]", prefix: Optional[str] = None, **kwargs: Any, ) -> None: """Initialize FSSpecBackend. Args: key: The key of the backend instance. fs: The FSSpec filesystem instance (sync or async) or protocol string. prefix: Optional path prefix to prepend to all paths. **kwargs: Additional keyword arguments to pass to fsspec.filesystem. """ self.fs = fsspec.filesystem(fs, **kwargs) if isinstance(fs, str) else fs # pyright: ignore self.is_async = isinstance(self.fs, AsyncFileSystem) protocol = getattr(self.fs, "protocol", None) protocol = cast("Optional[str]", protocol[0] if isinstance(protocol, (list, tuple)) else protocol) self.protocol = protocol or "file" self.key = key self.prefix = prefix self.kwargs = kwargs def _prepare_path(self, path: PathLike) -> str: path_str = self._to_path(path) if self.prefix: return _join_path(self.prefix, path_str) return path_str def get_content(self, path: PathLike, *, options: Optional[dict[str, Any]] = None) -> bytes: """Return the bytes stored at the specified location. Args: path: Path to retrieve (relative to prefix if set). options: Optional backend-specific options passed to fsspec's open. """ content = self.fs.cat_file(self._prepare_path(path), **(options or {})) # pyright: ignore if isinstance(content, str): return content.encode("utf-8") return cast("bytes", content) async def get_content_async(self, path: PathLike, *, options: Optional[dict[str, Any]] = None) -> bytes: """Return the bytes stored at the specified location asynchronously. Args: path: Path to retrieve (relative to prefix if set). options: Optional backend-specific options passed to fsspec's open. """ if not self.is_async: # Fallback for sync filesystems - Note: get_content is sync, wrapping with async_ # Pass the original relative path to the sync method wrapper return await async_(self.get_content)(path=path, options=options) content = await self.fs._cat_file(self._prepare_path(path), **(options or {})) # pyright: ignore if isinstance(content, str): return content.encode("utf-8") return cast("bytes", content) def save_object( self, file_object: FileObject, data: Union[bytes, IO[bytes], Path, Iterable[bytes]], *, use_multipart: Optional[bool] = None, chunk_size: int = 5 * 1024 * 1024, max_concurrency: int = 12, ) -> FileObject: """Save data to the specified path using info from FileObject. Args: file_object: FileObject instance with metadata (path, content_type, etc.) Path should be relative if prefix is used. data: The data to save (bytes, byte iterator, file-like object, Path) use_multipart: Ignored. chunk_size: Size of chunks when reading from IO/Path. max_concurrency: Ignored. Returns: FileObject object representing the saved file, potentially updated. """ full_path = self._prepare_path(file_object.path) if isinstance(data, Path): self.fs.put(full_path, data) # pyright: ignore else: self.fs.pipe(full_path, data) # pyright: ignore info = file_object.to_dict() fs_info = self.fs.info(full_path) # pyright: ignore if isinstance(fs_info, dict): info.update(fs_info) # pyright: ignore file_object.size = cast("int", info.get("size", file_object.size)) # pyright: ignore file_object.last_modified = ( get_mtime_equivalent(info) or datetime.datetime.now(tz=datetime.timezone.utc).timestamp() # pyright: ignore ) file_object.etag = get_or_generate_etag(file_object, info, file_object.last_modified) # pyright: ignore # Merge backend metadata if available and different backend_meta: dict[str, Any] = info.get("metadata", {}) # pyright: ignore if backend_meta and backend_meta != file_object.metadata: file_object.update_metadata(backend_meta) # pyright: ignore return file_object async def save_object_async( self, file_object: FileObject, data: Union[bytes, IO[bytes], Path, Iterable[bytes], AsyncIterable[bytes]], *, use_multipart: Optional[bool] = None, chunk_size: int = 5 * 1024 * 1024, max_concurrency: int = 12, ) -> FileObject: """Save data to the specified path asynchronously using info from FileObject. Args: file_object: FileObject instance with metadata (path, content_type, etc.) Path should be relative if prefix is used. data: The data to save (bytes, async byte iterator, file-like object, Path) use_multipart: Ignored. chunk_size: Size of chunks when reading from IO/Path/AsyncIterator. max_concurrency: Ignored. Returns: FileObject object representing the saved file, potentially updated. """ full_path = self._prepare_path(file_object.path) if not self.is_async: # Fallback for sync filesystems. Handle async data carefully. # Pass the original relative path to the sync method wrapper if isinstance(data, (AsyncIterator, AsyncIterable)) and not isinstance(data, (bytes, str)): # Read async stream into memory for sync backend (potential memory issue) all_data = b"".join([chunk async for chunk in data]) return await async_(self.save_object)(file_object=file_object, data=all_data, chunk_size=chunk_size) return await async_(self.save_object)(file_object=file_object, data=data, chunk_size=chunk_size) # type: ignore if isinstance(data, Path): await self.fs._put(full_path, data) # pyright: ignore else: await self.fs._pipe(full_path, data) # pyright: ignore info = file_object.to_dict() fs_info = await self.fs._info(full_path) # pyright: ignore if isinstance(fs_info, dict): info.update(fs_info) # pyright: ignore file_object.size = cast("int", info.get("size", file_object.size)) # pyright: ignore file_object.last_modified = ( get_mtime_equivalent(info) or datetime.datetime.now(tz=datetime.timezone.utc).timestamp() # pyright: ignore ) file_object.etag = get_or_generate_etag(file_object, info, file_object.last_modified) # pyright: ignore # Merge backend metadata if available and different backend_meta: dict[str, Any] = info.get("metadata", {}) # pyright: ignore if backend_meta and backend_meta != file_object.metadata: file_object.update_metadata(backend_meta) # pyright: ignore return file_object def delete_object(self, paths: Union[PathLike, Sequence[PathLike]]) -> None: """Delete the object(s) at the specified location(s). Args: paths: Path or sequence of paths to delete (relative to prefix if set). """ if isinstance(paths, (str, Path, os.PathLike)): path_list = [self._prepare_path(paths)] else: path_list = [self._prepare_path(p) for p in paths] self.fs.rm(path_list, recursive=False) # pyright: ignore async def delete_object_async(self, paths: Union[PathLike, Sequence[PathLike]]) -> None: """Delete the object(s) at the specified location(s) asynchronously. Args: paths: Path or sequence of paths to delete (relative to prefix if set). """ if not self.is_async: # Pass the original relative path(s) to the sync method wrapper return await async_(self.delete_object)(paths=paths) path_list = ( [self._prepare_path(paths)] if isinstance(paths, (str, Path, os.PathLike)) else [self._prepare_path(p) for p in paths] ) await self.fs._rm(path_list, recursive=False) # pyright: ignore return None def sign( self, paths: Union[PathLike, Sequence[PathLike]], *, expires_in: Optional[int] = None, for_upload: bool = False, # Often not directly supported by generic fsspec sign ) -> Union[str, list[str]]: """Create signed URLs for accessing files. Note: Upload URL generation (`for_upload=True`) is generally not supported by fsspec's generic `sign` method. This typically requires backend-specific methods (e.g., S3 presigned POST URLs). Args: paths: The path or paths of the file(s) (relative to prefix if set). expires_in: The expiration time of the URL in seconds (backend-dependent default). for_upload: If True, attempt to generate an upload URL (likely unsupported). Returns: A signed URL string if a single path is given, or a list of strings if multiple paths are provided. Raises: NotImplementedError: If the backend doesn't support signing or if `for_upload=True`. """ if for_upload: msg = "Generating signed URLs for upload is generally not supported by fsspec's generic sign method." raise NotImplementedError(msg) expires_in = expires_in or self.default_expires_in is_single = isinstance(paths, (str, Path, os.PathLike)) path_list = [self._prepare_path(paths)] if is_single else [self._prepare_path(p) for p in paths] # type: ignore if not hasattr(self.fs, "sign"): msg = f"Filesystem object {type(self.fs).__name__} does not have a 'sign' method." raise NotImplementedError(msg) signed_urls: list[str] = [] try: # fsspec sign method might take expiration in seconds # Ensure this is a list comprehension, not a generator expression signed_urls.extend([self.fs.sign(path_str, expiration=expires_in) for path_str in path_list]) # pyright: ignore except NotImplementedError as e: # This might be raised by the sign method itself if not implemented for the protocol msg = f"Signing URLs not supported by {self.protocol} backend via fsspec." raise NotImplementedError(msg) from e return signed_urls[0] if is_single else signed_urls async def sign_async( self, paths: Union[PathLike, Sequence[PathLike]], *, expires_in: Optional[int] = None, for_upload: bool = False, ) -> Union[str, list[str]]: """Create signed URLs for accessing files asynchronously. Note: Upload URL generation (`for_upload=True`) is generally not supported by fsspec's generic `sign` method. This typically requires backend-specific methods (e.g., S3 presigned POST URLs). Args: paths: The path or paths of the file(s) (relative to prefix if set). expires_in: The expiration time of the URL in seconds (backend-dependent default). for_upload: If True, attempt to generate an upload URL (likely unsupported). Returns: A signed URL string if a single path is given, or a list of strings if multiple paths are provided. """ return await async_(self.sign)(paths=paths, expires_in=expires_in, for_upload=for_upload) python-advanced-alchemy-1.9.3/advanced_alchemy/types/file_object/backends/obstore.py000066400000000000000000000320151516556515500307300ustar00rootroot00000000000000"""Obstore-backed storage backend for file objects.""" import datetime import os from pathlib import Path from typing import TYPE_CHECKING, Any, Optional, Union, cast from advanced_alchemy._serialization import encode_json from advanced_alchemy.exceptions import MissingDependencyError from advanced_alchemy.types.file_object._utils import get_mtime_equivalent, get_or_generate_etag from advanced_alchemy.types.file_object.base import ( AsyncDataLike, DataLike, PathLike, StorageBackend, ) if TYPE_CHECKING: from collections.abc import Sequence from advanced_alchemy.types.file_object.file import FileObject try: from obstore import sign as obstore_sign from obstore import sign_async as obstore_sign_async from obstore.store import ObjectStore, from_url except ImportError as e: raise MissingDependencyError(package="obstore") from e def schema_from_type(obj: Any) -> str: # noqa: PLR0911 """Extract the schema from an object. Args: obj: Object to parse Returns: The schema extracted from the object """ from obstore.store import AzureStore, GCSStore, HTTPStore, LocalStore, MemoryStore, S3Store if isinstance(obj, S3Store): return "s3" if isinstance(obj, AzureStore): return "azure" if isinstance(obj, GCSStore): return "gcs" if isinstance(obj, LocalStore): return "file" if isinstance(obj, HTTPStore): return "http" if isinstance(obj, MemoryStore): return "memory" return "file" def _serialize_metadata_for_attributes(metadata: "dict[str, Any]") -> "dict[str, str]": """Serialize metadata values to strings for obstore attributes. Obstore attributes must be strings. Non-string values (lists, dicts, etc.) are serialized using the project's ``encode_json`` to avoid TypeError when passed to obstore's ``put()``. Args: metadata: The metadata dict from a FileObject. Returns: A dict with all values as strings. """ result: dict[str, str] = {} for key, value in metadata.items(): if isinstance(value, str): result[key] = value else: result[key] = encode_json(value) return result class ObstoreBackend(StorageBackend): """Obstore-backed storage backend implementing both sync and async operations.""" driver = "obstore" def __init__(self, key: str, fs: "Union[ObjectStore, str]", **kwargs: "Any") -> None: """Initialize ObstoreBackend. Args: fs: The ObjectStore instance from the obstore package key: The key for the storage backend kwargs: Additional keyword arguments to pass to the ObjectStore constructor """ self.fs = from_url(fs, **kwargs) if isinstance(fs, str) else fs # pyright: ignore self.protocol = schema_from_type(self.fs) # pyright: ignore self.key = key self.options = kwargs def get_content(self, path: "PathLike", *, options: "Optional[dict[str, Any]]" = None) -> bytes: """Return the bytes stored at the specified location. Args: path: Path to retrieve options: Optional backend-specific options """ options = options or {} # Filter out unsupported options supported_options = { k: v for k, v in options.items() if k in {"use_multipart", "chunk_size", "max_concurrency"} } obj = self.fs.get(self._to_path(path), **supported_options) return obj.bytes().to_bytes() # type: ignore[no-any-return] async def get_content_async(self, path: "PathLike", *, options: "Optional[dict[str, Any]]" = None) -> bytes: """Return the bytes stored at the specified location asynchronously. Args: path: Path to retrieve options: Optional backend-specific options """ options = options or {} # Filter out unsupported options supported_options = { k: v for k, v in options.items() if k in {"use_multipart", "chunk_size", "max_concurrency"} } obj = await self.fs.get_async(self._to_path(path), **supported_options) return (await obj.bytes_async()).to_bytes() # type: ignore[no-any-return] def save_object( self, file_object: "FileObject", data: "DataLike", *, use_multipart: "Optional[bool]" = None, chunk_size: int = 5 * 1024 * 1024, max_concurrency: int = 12, ) -> "FileObject": """Save data to the specified path using info from FileObject. Args: file_object: FileObject instance with metadata (path, content_type, etc.). data: The data to save. use_multipart: Whether to use multipart upload. chunk_size: Size of each chunk in bytes. max_concurrency: Maximum number of concurrent uploads. Returns: A FileObject object representing the saved file, potentially updated. """ from obstore.store import LocalStore # Prepare attributes with content_type and custom metadata attributes: dict[str, Any] = {} if file_object.content_type: attributes["Content-Type"] = file_object.content_type # Add any custom metadata from file_object.metadata # Obstore attributes must be strings, so serialize non-string values to JSON if file_object.metadata: attributes.update(_serialize_metadata_for_attributes(file_object.metadata)) # LocalStore doesn't support attributes parameter - skip it for local filesystem put_params: dict[str, Any] = { "use_multipart": use_multipart, "chunk_size": chunk_size, "max_concurrency": max_concurrency, } if not isinstance(self.fs, LocalStore): put_params["attributes"] = attributes or None _ = self.fs.put(file_object.path, data, **put_params) info = self.fs.head(file_object.path) file_object.size = cast("int", info.get("size", file_object.size)) # pyright: ignore file_object.last_modified = ( get_mtime_equivalent(info) or datetime.datetime.now(tz=datetime.timezone.utc).timestamp() # pyright: ignore ) file_object.etag = get_or_generate_etag(file_object, info, file_object.last_modified) # pyright: ignore # Merge backend metadata if available and different backend_meta: dict[str, Any] = info.get("metadata", {}) # pyright: ignore if backend_meta and backend_meta != file_object.metadata: file_object.update_metadata(backend_meta) # pyright: ignore return file_object async def save_object_async( self, file_object: "FileObject", data: "AsyncDataLike", *, use_multipart: "Optional[bool]" = None, chunk_size: int = 5 * 1024 * 1024, max_concurrency: int = 12, ) -> "FileObject": """Save data to the specified path asynchronously using info from FileObject. Args: file_object: FileObject instance with metadata (path, content_type, etc.). data: The data to save. use_multipart: Whether to use multipart upload. chunk_size: Size of each chunk in bytes. max_concurrency: Maximum number of concurrent uploads. Returns: A FileObject object representing the saved file, potentially updated. """ from obstore.store import LocalStore # Prepare attributes with content_type and custom metadata attributes: dict[str, Any] = {} if file_object.content_type: attributes["Content-Type"] = file_object.content_type # Add any custom metadata from file_object.metadata # Obstore attributes must be strings, so serialize non-string values to JSON if file_object.metadata: attributes.update(_serialize_metadata_for_attributes(file_object.metadata)) # LocalStore doesn't support attributes parameter - skip it for local filesystem put_params: dict[str, Any] = { "use_multipart": use_multipart, "chunk_size": chunk_size, "max_concurrency": max_concurrency, } if not isinstance(self.fs, LocalStore): put_params["attributes"] = attributes or None _ = await self.fs.put_async(file_object.path, data, **put_params) info = await self.fs.head_async(file_object.path) file_object.size = cast("int", info.get("size", file_object.size)) # pyright: ignore file_object.last_modified = ( get_mtime_equivalent(info) or datetime.datetime.now(tz=datetime.timezone.utc).timestamp() # pyright: ignore ) file_object.etag = get_or_generate_etag(file_object, info, file_object.last_modified) # pyright: ignore # Merge backend metadata if available and different backend_meta: dict[str, Any] = info.get("metadata", {}) # pyright: ignore if backend_meta and backend_meta != file_object.metadata: file_object.update_metadata(backend_meta) # pyright: ignore return file_object def delete_object(self, paths: "Union[PathLike, Sequence[PathLike]]") -> None: """Delete the specified paths. Args: paths: Path or paths to delete """ if isinstance(paths, (str, Path, os.PathLike)): path_list = [self._to_path(paths)] else: path_list = [self._to_path(p) for p in paths] self.fs.delete(path_list) async def delete_object_async(self, paths: "Union[PathLike, Sequence[PathLike]]") -> None: """Delete the specified paths asynchronously. Args: paths: Path or paths to delete """ if isinstance(paths, (str, Path, os.PathLike)): path_list = [self._to_path(paths)] else: path_list = [self._to_path(p) for p in paths] await self.fs.delete_async(path_list) def sign( self, paths: "Union[PathLike, Sequence[PathLike]]", *, expires_in: "Optional[int]" = None, for_upload: bool = False, ) -> "Union[str, list[str]]": """Create a signed URL for accessing or uploading the file. Args: paths: The path or list of paths of the file expires_in: The expiration time of the URL in seconds for_upload: If True, generates a URL suitable for uploads (e.g., presigned POST) Returns: A URL or list of URLs for accessing the file """ http_method = "PUT" if for_upload else "GET" expires_delta = ( datetime.timedelta(seconds=expires_in) if expires_in is not None else datetime.timedelta(hours=1) ) if isinstance(paths, (str, Path, os.PathLike)): single_path = self._to_path(paths) try: return obstore_sign(store=self.fs, method=http_method, paths=single_path, expires_in=expires_delta) # type: ignore except ValueError as e: msg = f"Error signing path {single_path}: {e}" raise NotImplementedError(msg) from e path_list = [self._to_path(p) for p in paths] try: return obstore_sign(store=self.fs, method=http_method, paths=path_list, expires_in=expires_delta) # type: ignore except ValueError as e: msg = f"Error signing paths {path_list}: {e}" raise NotImplementedError(msg) from e async def sign_async( self, paths: "Union[PathLike, Sequence[PathLike]]", *, expires_in: "Optional[int]" = None, for_upload: bool = False, ) -> "Union[str, list[str]]": """Sign a URL for a given path asynchronously. Args: paths: Path to sign expires_in: Expiration time in seconds for_upload: Whether the URL is for uploading a file Returns: A URL or list of URLs for accessing the file """ http_method = "PUT" if for_upload else "GET" expires_delta = ( datetime.timedelta(seconds=expires_in) if expires_in is not None else datetime.timedelta(hours=1) ) if isinstance(paths, (str, Path, os.PathLike)): single_path = self._to_path(paths) try: return await obstore_sign_async( # type: ignore store=self.fs, # pyright: ignore method=http_method, paths=single_path, expires_in=expires_delta, ) except ValueError as e: msg = f"Error signing path {single_path}: {e}" raise NotImplementedError(msg) from e path_list = [self._to_path(p) for p in paths] try: return await obstore_sign_async( # type: ignore store=self.fs, # pyright: ignore method=http_method, paths=path_list, expires_in=expires_delta, ) except ValueError as e: msg = f"Error signing paths {path_list}: {e}" raise NotImplementedError(msg) from e python-advanced-alchemy-1.9.3/advanced_alchemy/types/file_object/base.py000066400000000000000000000130051516556515500264110ustar00rootroot00000000000000"""Generic unified storage protocol compatible with multiple backend implementations.""" import os from abc import ABC, abstractmethod from collections.abc import AsyncIterable, AsyncIterator, Iterable, Iterator, Sequence from pathlib import Path from typing import IO, TYPE_CHECKING, Any, Optional, TypeVar, Union from typing_extensions import TypeAlias if TYPE_CHECKING: from advanced_alchemy.types.file_object.file import FileObject # Type variables T = TypeVar("T") StorageBackendT = TypeVar("StorageBackendT", bound="StorageBackend") PathLike: TypeAlias = Union[str, Path, os.PathLike[Any]] DataLike: TypeAlias = Union[IO[bytes], Path, bytes, Iterator[bytes], Iterable[bytes]] AsyncDataLike: TypeAlias = Union[ IO[bytes], Path, bytes, AsyncIterator[bytes], AsyncIterable[bytes], Iterator[bytes], Iterable[bytes] ] class StorageBackend(ABC): """Unified protocol for storage backend implementations supporting both sync and async operations.""" driver: str """The name of the storage backend.""" protocol: str """The protocol used by the storage backend.""" key: str """The key of the backend instance.""" def __init__(self, key: str, fs: Any, **kwargs: Any) -> None: """Initialize the storage backend. Args: key: The key of the backend instance fs: The filesystem or storage client **kwargs: Additional keyword arguments """ self.fs = fs self.key = key self.options = kwargs @staticmethod def _to_path(path: "PathLike") -> str: """Convert a path-like object to a string. Args: path: The path to convert Returns: str: The string representation of the path """ return str(path) @abstractmethod def get_content(self, path: "PathLike", *, options: Optional[dict[str, Any]] = None) -> bytes: """Get the content of a file. Args: path: Path to the file options: Optional backend-specific options Returns: bytes: The file content """ @abstractmethod async def get_content_async(self, path: "PathLike", *, options: Optional[dict[str, Any]] = None) -> bytes: """Get the content of a file asynchronously. Args: path: Path to the file options: Optional backend-specific options Returns: bytes: The file content """ @abstractmethod def save_object( self, file_object: "FileObject", data: "DataLike", *, use_multipart: "Optional[bool]" = None, chunk_size: int = 5 * 1024 * 1024, max_concurrency: int = 12, ) -> "FileObject": """Store a file using information from a FileObject. Args: file_object: A FileObject instance containing metadata like path, content_type. data: The file data to store. use_multipart: Whether to use multipart upload. chunk_size: Size of chunks for multipart upload. max_concurrency: Maximum number of concurrent uploads. Returns: FileObject: The stored file object, potentially updated with backend info (size, etag, etc.). """ @abstractmethod async def save_object_async( self, file_object: "FileObject", data: AsyncDataLike, *, use_multipart: Optional[bool] = None, chunk_size: int = 5 * 1024 * 1024, max_concurrency: int = 12, ) -> "FileObject": """Store a file asynchronously using information from a FileObject. Args: file_object: A FileObject instance containing metadata like path, content_type. data: The file data to store. use_multipart: Whether to use multipart upload. chunk_size: Size of chunks for multipart upload. max_concurrency: Maximum number of concurrent uploads. Returns: FileObject: The stored file object, potentially updated with backend info (size, etag, etc.). """ @abstractmethod def delete_object(self, paths: Union[PathLike, Sequence[PathLike]]) -> None: """Delete one or more files. Args: paths: Path or paths to delete """ @abstractmethod async def delete_object_async(self, paths: Union[PathLike, Sequence[PathLike]]) -> None: """Delete one or more files asynchronously. Args: paths: Path or paths to delete """ @abstractmethod def sign( self, paths: Union[PathLike, Sequence[PathLike]], *, expires_in: Optional[int] = None, for_upload: bool = False, ) -> Union[str, list[str]]: """Generate a signed URL for one or more files. Args: paths: Path or paths to generate URLs for expires_in: Optional expiration time in seconds for_upload: Whether the URL is for upload Returns: str: The signed URL """ @abstractmethod async def sign_async( self, paths: Union[PathLike, Sequence[PathLike]], *, expires_in: Optional[int] = None, for_upload: bool = False, ) -> Union[str, list[str]]: """Generate a signed URL for one or more files asynchronously. Args: paths: Path or paths to generate URLs for expires_in: Optional expiration time in seconds for_upload: Whether the URL is for upload Returns: str: The signed URL """ python-advanced-alchemy-1.9.3/advanced_alchemy/types/file_object/data_type.py000066400000000000000000000127271516556515500274630ustar00rootroot00000000000000from typing import Any, Optional, Union, cast from sqlalchemy import TypeDecorator from advanced_alchemy._serialization import decode_json from advanced_alchemy.types.file_object.base import StorageBackend from advanced_alchemy.types.file_object.file import FileObject from advanced_alchemy.types.file_object.registry import storages from advanced_alchemy.types.json import JsonB from advanced_alchemy.types.mutables import MutableList # Define the type hint for the value this TypeDecorator handles FileObjectOrList = Union[FileObject, list[FileObject], set[FileObject], MutableList[FileObject]] OptionalFileObjectOrList = Optional[FileObjectOrList] class StoredObject(TypeDecorator[OptionalFileObjectOrList]): """Custom SQLAlchemy type for storing single or multiple file metadata. Stores file metadata in JSONB and handles file validation, processing, and storage operations through a configured storage backend. """ impl = JsonB cache_ok = True # Default settings multiple: bool _raw_backend: Union[str, StorageBackend] _resolved_backend: "Optional[StorageBackend]" = None @property def python_type(self) -> "type[OptionalFileObjectOrList]": """Specifies the Python type used, accounting for the `multiple` flag.""" # This provides a hint to SQLAlchemy and type checkers return MutableList[FileObject] if self.multiple else Optional[FileObject] # type: ignore @property def backend(self) -> "StorageBackend": """Resolves and returns the storage backend instance.""" # Return cached version if available if self._resolved_backend is None: self._resolved_backend = ( storages.get_backend(self._raw_backend) if isinstance(self._raw_backend, str) else self._raw_backend ) return self._resolved_backend @property def storage_key(self) -> str: """Returns the storage key from the resolved backend.""" return self.backend.key def __init__( self, backend: Union[str, StorageBackend], multiple: bool = False, *args: "Any", **kwargs: "Any", ) -> None: """Initialize StoredObject type. Args: backend: Key to retrieve the backend or from the storage registry or storage backend to use. multiple: If True, stores a list of files; otherwise, a single file. *args: Additional positional arguments for TypeDecorator. **kwargs: Additional keyword arguments for TypeDecorator. """ super().__init__(*args, **kwargs) self.multiple = multiple self._raw_backend = backend def __repr__(self) -> str: """Return a string representation of the StoredObject.""" if self.multiple: return f"StoredObject(backend='{self.backend.key}', multiple=True)" return f"StoredObject(backend='{self.backend.key}')" def process_bind_param( self, value: "Optional[FileObjectOrList]", dialect: "Any", ) -> "Optional[Union[dict[str, Any], list[dict[str, Any]]]]": """Convert FileObject(s) to JSON representation for the database. Injects the configured backend into the FileObject before conversion. Note: This method expects an already processed FileInfo or its dict representation. Use handle_upload() or handle_upload_async() for processing raw uploads. Args: value: The value to process dialect: The SQLAlchemy dialect Raises: TypeError: If the input value is not a FileObject or a list of FileObjects. Returns: A dictionary representing the file metadata, or None if the input value is None. """ if value is None: return None if self.multiple: if not isinstance(value, (list, MutableList, set)): return [value.to_dict()] if value else [] return [item.to_dict() for item in value if item] if isinstance(value, (list, MutableList, set)): msg = f"Expected a single FileObject for multiple=False, got {type(value)}" raise TypeError(msg) return value.to_dict() if value else None def process_result_value( self, value: "Optional[Union[bytes, str, dict[str, Any], list[dict[str, Any]]]]", dialect: "Any" ) -> "Optional[FileObjectOrList]": """Convert database JSON back to FileObject or MutableList[FileObject]. Args: value: The value to process dialect: The SQLAlchemy dialect Raises: TypeError: If the input value is not a list of dicts. Returns: FileObject or MutableList[FileObject] or None. """ if value is None: return None if self.multiple: if isinstance(value, dict): # If the DB returns a single dict, wrap it in a list value = [value] elif isinstance(value, (str, bytes)): # Decode JSON string or bytes to dict value = [cast("dict[str, Any]", decode_json(value))] return MutableList[FileObject]([FileObject(**v) for v in value if v]) # pyright: ignore if isinstance(value, list): msg = f"Expected dict from DB for multiple=False, got {type(value)}" raise TypeError(msg) if isinstance(value, (bytes, str)): value = cast("dict[str,Any]", decode_json(value)) return FileObject(**value) python-advanced-alchemy-1.9.3/advanced_alchemy/types/file_object/file.py000066400000000000000000000433101516556515500264200ustar00rootroot00000000000000"""Generic unified storage protocol compatible with multiple backend implementations.""" import mimetypes from pathlib import Path from typing import TYPE_CHECKING, Any, Optional, Union from sqlalchemy.ext.mutable import MutableList from typing_extensions import TypeAlias from advanced_alchemy.exceptions import MissingDependencyError from advanced_alchemy.types.file_object._typing import PYDANTIC_INSTALLED, GetCoreSchemaHandler, core_schema from advanced_alchemy.types.file_object.base import AsyncDataLike, DataLike, StorageBackend from advanced_alchemy.types.file_object.registry import storages if TYPE_CHECKING: from advanced_alchemy.types.file_object.base import PathLike class FileObject: """Represents file metadata during processing using a dataclass structure. This class provides a unified interface for handling file metadata and operations across different storage backends. Content or a source path can optionally be provided during initialization via kwargs, store it internally, and add save/save_async methods to persist this pending data using the configured backend. """ __slots__ = ( "_checksum", "_content_type", "_etag", "_filename", "_last_modified", "_metadata", "_pending_source_content", "_pending_source_path", "_raw_backend", "_resolved_backend", "_size", "_to_filename", "_version_id", ) def __init__( self, backend: "Union[str, StorageBackend]", filename: str, to_filename: Optional[str] = None, content_type: Optional[str] = None, size: Optional[int] = None, last_modified: Optional[float] = None, checksum: Optional[str] = None, etag: Optional[str] = None, version_id: Optional[str] = None, metadata: Optional[dict[str, Any]] = None, source_path: "Optional[PathLike]" = None, content: "Optional[Union[DataLike, AsyncDataLike]]" = None, ) -> None: """Perform post-initialization validation and setup. Handles default path, content type guessing, backend protocol inference, and processing of 'content' or 'source_path' from extra kwargs. Raises: ValueError: If filename is not provided, size is negative, backend/protocol mismatch, or both 'content' and 'source_path' are provided. """ self._size = size self._last_modified = last_modified self._checksum = checksum self._etag = etag self._version_id = version_id self._metadata = metadata or {} self._filename = filename self._content_type = content_type self._to_filename = to_filename self._resolved_backend: Optional[StorageBackend] = backend if isinstance(backend, StorageBackend) else None self._raw_backend = backend self._pending_source_path = Path(source_path) if source_path is not None else None self._pending_source_content = content if self._pending_source_content is not None and self._pending_source_path is not None: msg = "Cannot provide both 'source_content' and 'source_path' during initialization." raise ValueError(msg) def __repr__(self) -> str: """Return a string representation of the FileObject.""" return f"FileObject(filename={self.path}, backend={self.backend.key}, size={self.size}, content_type={self.content_type}, etag={self.etag}, last_modified={self.last_modified}, version_id={self.version_id})" def __eq__(self, other: object) -> bool: """Check equality based on filename and backend key. Args: other: The object to compare with. Returns: bool: True if the objects are equal, False otherwise. """ if not isinstance(other, FileObject): return False return self.path == other.path and self.backend.key == other.backend.key def __hash__(self) -> int: """Return a hash based on filename and backend key.""" return hash((self.path, self.backend.key)) @property def backend(self) -> "StorageBackend": if self._resolved_backend is None: self._resolved_backend = ( storages.get_backend(self._raw_backend) if isinstance(self._raw_backend, str) else self._raw_backend ) return self._resolved_backend @property def filename(self) -> str: return self.path @property def content_type(self) -> str: if self._content_type is None: guessed_type, _ = mimetypes.guess_type(self._filename) self._content_type = guessed_type or "application/octet-stream" return self._content_type @property def protocol(self) -> str: return self.backend.protocol if self.backend else "file" @property def path(self) -> str: return self._to_filename or self._filename @property def has_pending_data(self) -> bool: return bool(self._pending_source_content or self._pending_source_path) @property def metadata(self) -> dict[str, Any]: return self._metadata @metadata.setter def metadata(self, value: dict[str, Any]) -> None: self._metadata = value @property def size(self) -> "Optional[int]": return self._size @size.setter def size(self, value: int) -> None: self._size = value @property def last_modified(self) -> "Optional[float]": return self._last_modified @last_modified.setter def last_modified(self, value: float) -> None: self._last_modified = value @property def checksum(self) -> "Optional[str]": return self._checksum @checksum.setter def checksum(self, value: str) -> None: self._checksum = value @property def etag(self) -> "Optional[str]": return self._etag @etag.setter def etag(self, value: str) -> None: self._etag = value @property def version_id(self) -> "Optional[str]": return self._version_id @version_id.setter def version_id(self, value: str) -> None: self._version_id = value def update_metadata(self, metadata: "dict[str, Any]") -> None: """Update the file metadata. Args: metadata: New metadata to merge with existing metadata. """ self.metadata.update(metadata) def to_dict(self) -> "dict[str, Any]": """Convert FileObject to a dictionary for storage or serialization. Note: The 'backend' attribute is intentionally excluded as it's often not serializable or relevant for storage representations. The 'extra' dict is included. Returns: dict[str, Any]: A dictionary representation of the file information. """ # Use dataclasses.asdict and filter out the backend return { "filename": self.path, "content_type": self.content_type, "size": self.size, "last_modified": self.last_modified, "checksum": self.checksum, "etag": self.etag, "version_id": self.version_id, "metadata": self.metadata, "backend": self.backend.key, } def get_content(self, *, options: "Optional[dict[str, Any]]" = None) -> bytes: """Get the file content from the storage backend. Args: options: Optional backend-specific options. Returns: bytes: The file content. """ return self.backend.get_content(self.path, options=options) async def get_content_async(self, *, options: "Optional[dict[str, Any]]" = None) -> bytes: """Get the file content from the storage backend asynchronously. Args: options: Optional backend-specific options. Returns: bytes: The file content. """ return await self.backend.get_content_async(self.path, options=options) def sign( self, *, expires_in: "Optional[int]" = None, for_upload: bool = False, ) -> str: """Generate a signed URL for the file. Args: expires_in: Optional expiration time in seconds. for_upload: Whether the URL is for upload. Raises: RuntimeError: If no signed URL is generated. Returns: str: The signed URL. """ result = self.backend.sign(self.path, expires_in=expires_in, for_upload=for_upload) if isinstance(result, list): if not result: msg = "No signed URL generated" raise RuntimeError(msg) return result[0] return result async def sign_async( self, *, expires_in: "Optional[int]" = None, for_upload: bool = False, ) -> str: """Generate a signed URL for the file asynchronously. Args: expires_in: Optional expiration time in seconds. for_upload: Whether the URL is for upload. Returns: str: The signed URL. Raises: RuntimeError: If no signed URL is generated. """ result = await self.backend.sign_async(self.path, expires_in=expires_in, for_upload=for_upload) if isinstance(result, list): if not result: msg = "No signed URL generated" raise RuntimeError(msg) return result[0] return result def delete(self) -> None: """Delete the file from storage. Raises: RuntimeError: If no backend is configured or path is missing. """ if not self.backend: msg = "No storage backend configured" raise RuntimeError(msg) self.backend.delete_object(self.path) async def delete_async(self) -> None: """Delete the file from storage asynchronously.""" await self.backend.delete_object_async(self.path) def save( self, data: Optional[DataLike] = None, *, use_multipart: Optional[bool] = None, chunk_size: int = 5 * 1024 * 1024, max_concurrency: int = 12, ) -> "FileObject": """Save data to the storage backend using this FileObject's metadata. If `data` is provided, it is used directly. If `data` is None, checks internal source_content or source_path. Clears pending attributes after successful save. Args: data: Optional data to save (bytes, iterator, file-like, Path). If None, internal pending data is used. use_multipart: Passed to the backend's save method. chunk_size: Passed to the backend's save method. max_concurrency: Passed to the backend's save method. Returns: The updated FileObject instance returned by the backend. Raises: TypeError: If trying to save async data synchronously. """ if data is None and self._pending_source_content is not None: data = self._pending_source_content # type: ignore[assignment] elif data is None and self._pending_source_path is not None: data = self._pending_source_path if data is None: msg = "No data provided and no pending content/path found to save." raise TypeError(msg) # The backend's save method is expected to update the FileObject instance in-place # and return the updated instance. updated_self = self.backend.save_object( file_object=self, data=data, use_multipart=use_multipart, chunk_size=chunk_size, max_concurrency=max_concurrency, ) # Clear pending attributes after successful save self._pending_source_content = None self._pending_source_path = None return updated_self async def save_async( self, data: Optional[AsyncDataLike] = None, *, use_multipart: Optional[bool] = None, chunk_size: int = 5 * 1024 * 1024, max_concurrency: int = 12, ) -> "FileObject": """Save data to the storage backend asynchronously. If `data` is provided, it is used directly. If `data` is None, checks internal source_content or source_path. Clears pending attributes after successful save. Uses asyncio.to_thread for reading source_path if backend doesn't handle Path directly. Args: data: Optional data to save (bytes, async iterator, file-like, Path, etc.). If None, internal pending data is used. use_multipart: Passed to the backend's async save method. chunk_size: Passed to the backend's async save method. max_concurrency: Passed to the backend's async save method. Returns: The updated FileObject instance returned by the backend. Raises: TypeError: If trying to save sync data asynchronously. """ if data is None and self._pending_source_content is not None: data = self._pending_source_content elif data is None and self._pending_source_path is not None: data = self._pending_source_path if data is None: msg = "No data provided and no pending content/path found to save." raise TypeError(msg) # Backend's async save method updates the FileObject instance updated_self = await self.backend.save_object_async( file_object=self, data=data, # Pass the determined data source use_multipart=use_multipart, chunk_size=chunk_size, max_concurrency=max_concurrency, ) # Clear pending attributes after successful save self._pending_source_content = None self._pending_source_path = None return updated_self @classmethod def __get_pydantic_core_schema__( cls, source_type: Any, handler: "GetCoreSchemaHandler", # Use imported GetCoreSchemaHandler ) -> "core_schema.CoreSchema": # Use imported core_schema """Get the Pydantic core schema for FileObject. This method defines how Pydantic should validate and serialize FileObject instances. It creates a schema that validates dictionaries with the required fields and converts them to FileObject instances. Raises: MissingDependencyError: If Pydantic is not installed when this method is called. Args: source_type: The source type (FileObject) handler: The Pydantic schema handler Returns: A Pydantic core schema for FileObject """ if not PYDANTIC_INSTALLED: raise MissingDependencyError(package="pydantic") def validate_from_dict(data: dict[str, Any]) -> "FileObject": # We expect a dictionary derived from to_dict() # We need to resolve the backend string back to an instance if needed backend_input = data.get("backend") if backend_input is None: msg = "backend is required" raise TypeError(msg) key = backend_input if isinstance(backend_input, str) else backend_input.key return cls( backend=key, filename=data["filename"], to_filename=data.get("to_filename"), content_type=data.get("content_type"), size=data.get("size"), last_modified=data.get("last_modified"), checksum=data.get("checksum"), etag=data.get("etag"), version_id=data.get("version_id"), metadata=data.get("metadata"), ) typed_dict_schema = core_schema.typed_dict_schema( { "filename": core_schema.typed_dict_field(core_schema.str_schema()), "backend": core_schema.typed_dict_field(core_schema.str_schema()), "to_filename": core_schema.typed_dict_field(core_schema.str_schema(), required=False), "content_type": core_schema.typed_dict_field(core_schema.str_schema(), required=False), "size": core_schema.typed_dict_field(core_schema.int_schema(), required=False), "last_modified": core_schema.typed_dict_field(core_schema.float_schema(), required=False), "checksum": core_schema.typed_dict_field(core_schema.str_schema(), required=False), "etag": core_schema.typed_dict_field(core_schema.str_schema(), required=False), "version_id": core_schema.typed_dict_field(core_schema.str_schema(), required=False), "metadata": core_schema.typed_dict_field( core_schema.nullable_schema( core_schema.dict_schema(core_schema.str_schema(), core_schema.any_schema()) ), required=False, ), } ) validation_schema = core_schema.union_schema( [ core_schema.is_instance_schema(cls), core_schema.chain_schema( [ typed_dict_schema, core_schema.no_info_plain_validator_function(validate_from_dict), ] ), ] ) return core_schema.json_or_python_schema( json_schema=validation_schema, python_schema=validation_schema, serialization=core_schema.plain_serializer_function_ser_schema( lambda instance: instance.to_dict(), # pyright: ignore info_arg=False, return_schema=typed_dict_schema, ), # pyright: ignore ) FileObjectList: TypeAlias = MutableList[FileObject] python-advanced-alchemy-1.9.3/advanced_alchemy/types/file_object/registry.py000066400000000000000000000105061516556515500273520ustar00rootroot00000000000000from importlib.util import find_spec from typing import TYPE_CHECKING, Any, Callable, Optional, Union, overload from advanced_alchemy._serialization import decode_json, encode_json from advanced_alchemy.exceptions import ImproperConfigurationError from advanced_alchemy.utils.module_loader import import_string from advanced_alchemy.utils.singleton import SingletonMeta if TYPE_CHECKING: from advanced_alchemy.types.file_object.base import StorageBackend DEFAULT_BACKEND = ( "advanced_alchemy.types.file_object.backends.obstore.ObstoreBackend" if find_spec("obstore") else "advanced_alchemy.types.file_object.backends.fsspec.FSSpecBackend" ) class StorageRegistry(metaclass=SingletonMeta): """A provider for creating and managing threaded portals.""" def __init__( self, json_serializer: "Callable[[Any], str]" = encode_json, json_deserializer: Callable[[Union[str, bytes]], Any] = decode_json, default_backend: "Union[str, type[StorageBackend]]" = DEFAULT_BACKEND, ) -> None: """Initialize the PortalProvider.""" self._registry: dict[str, StorageBackend] = {} self.json_serializer = json_serializer self.json_deserializer = json_deserializer self.default_backend: str = ( DEFAULT_BACKEND if isinstance(default_backend, str) else default_backend.__qualname__ ) def set_default_backend(self, default_backend: "Union[str, type[StorageBackend]]") -> None: """Set the default storage backend. Args: default_backend: The default storage backend """ self.default_backend = default_backend if isinstance(default_backend, str) else default_backend.__qualname__ def is_registered(self, key: str) -> bool: """Check if a storage backend is registered in the registry. Args: key: The key of the storage backend Returns: bool: True if the storage backend is registered, False otherwise. """ return key in self._registry def get_backend(self, key: str) -> "StorageBackend": """Retrieve a configured storage backend from the registry. Returns: StorageBackend: The storage backend associaStorageBackendiven key. Raises: ImproperConfigurationError: If no storage backend is registered with the given key. """ try: return self._registry[key] except KeyError as e: msg = f'No storage backend registered with key "{key}"' raise ImproperConfigurationError(msg) from e @overload def register_backend(self, value: "str") -> None: ... @overload def register_backend(self, value: "str", key: None = None) -> None: ... @overload def register_backend(self, value: "str", key: str) -> None: ... @overload def register_backend(self, value: "StorageBackend", key: None = None) -> None: ... @overload def register_backend(self, value: "StorageBackend", key: str) -> None: ... def register_backend(self, value: "Union[StorageBackend, str]", key: "Optional[str]" = None) -> None: """Register a new storage backend in the registry. Args: value: The storage backend to register. key: The key to register the storage backend with. Raises: ImproperConfigurationError: If a string value is provided without a key. """ if isinstance(value, str): if key is None: msg = "key is required when registering a string value" raise ImproperConfigurationError(msg) self._registry[key] = import_string(self.default_backend)(fs=value, key=key) else: if key is not None: msg = "key is not allowed when registering a StorageBackend" raise ImproperConfigurationError(msg) self._registry[value.key] = value def unregister_backend(self, key: str) -> None: """Unregister a storage backend from the registry.""" if key in self._registry: del self._registry[key] def clear_backends(self) -> None: """Clear the registry.""" self._registry.clear() def registered_backends(self) -> list[str]: """Return a list of all registered keys.""" return list(self._registry.keys()) storages = StorageRegistry() python-advanced-alchemy-1.9.3/advanced_alchemy/types/file_object/session_tracker.py000066400000000000000000000201611516556515500306760ustar00rootroot00000000000000# ruff: noqa: UP037 """Application ORM configuration.""" import asyncio import logging import sys from typing import TYPE_CHECKING, Any, Union if sys.version_info >= (3, 11): from builtins import ExceptionGroup else: from exceptiongroup import ExceptionGroup # type: ignore[import-not-found,unused-ignore] if TYPE_CHECKING: from pathlib import Path from advanced_alchemy.types.file_object import FileObject logger = logging.getLogger("advanced_alchemy") class FileObjectSessionTracker: """Tracks FileObject changes within a single session transaction.""" def __init__(self, raise_on_error: bool = False) -> None: """Initialize empty tracking state. Args: raise_on_error: If True, raise exceptions on file operation failures. If False, log warnings and continue. Internal structures: - ``pending_saves``: ``FileObject -> data`` to be saved on commit - ``pending_deletes``: ``FileObject`` instances to delete on commit - ``_saved_in_transaction``: successfully saved objects used for selective cleanup on rollback """ self.raise_on_error = raise_on_error # Stores objects that have pending data to be saved on commit. # Maps FileObject -> data source (bytes or Path) self.pending_saves: "dict[FileObject, Union[bytes, Path]]" = {} # Stores objects that should be deleted from storage on commit. self.pending_deletes: "set[FileObject]" = set() # Stores objects that were successfully saved within this transaction, # needed for rollback cleanup. self._saved_in_transaction: "set[FileObject]" = set() def add_pending_save(self, obj: "FileObject", data: "Union[bytes, Path]") -> None: """Mark a FileObject for saving.""" self.pending_saves[obj] = data # If this object was previously marked for deletion, unmark it. self.pending_deletes.discard(obj) def add_pending_delete(self, obj: "FileObject") -> None: """Mark a FileObject for deletion.""" # If this object was pending save, unmark it. self.pending_saves.pop(obj, None) # Only add to pending deletes if it actually exists in storage (has a path) if obj.path: self.pending_deletes.add(obj) def commit(self) -> None: """Process pending saves and deletes after a successful commit.""" for obj, data in self.pending_saves.items(): try: obj.save(data) self._saved_in_transaction.add(obj) except Exception: if self.raise_on_error: logger.exception("error saving file for object %s", obj) raise logger.warning("error saving file for object %s", obj, exc_info=True) for obj in self.pending_deletes: try: obj.delete() except FileNotFoundError: pass except Exception: if self.raise_on_error: logger.exception("error deleting file for object %s", obj) raise logger.warning("error deleting file for object %s", obj, exc_info=True) self.clear() async def commit_async(self) -> None: """Process pending saves and deletes after a successful commit.""" save_items: "list[tuple[FileObject, Union[bytes, Path]]]" = list(self.pending_saves.items()) delete_items: "list[FileObject]" = list(self.pending_deletes) save_results: "list[Any]" = await asyncio.gather( *(obj.save_async(data) for obj, data in save_items), return_exceptions=True, ) delete_results: "list[Any]" = await asyncio.gather( *(obj.delete_async() for obj in delete_items), return_exceptions=True, ) errors: list[Exception] = [] for (obj, _data), result in zip(save_items, save_results): if isinstance(result, BaseException): if isinstance(result, Exception): if self.raise_on_error: logger.error( "error saving file for object %s", obj, exc_info=(type(result), result, result.__traceback__), ) else: # Legacy behavior: warning level logger.warning( "error saving file for object %s", obj, exc_info=(type(result), result, result.__traceback__), ) errors.append(result) else: # BaseException (e.g., CancelledError) - always raise raise result else: self._saved_in_transaction.add(obj) for obj_to_delete, result in zip(delete_items, delete_results): if isinstance(result, FileNotFoundError): continue if isinstance(result, BaseException): if isinstance(result, Exception): if self.raise_on_error: logger.error( "error deleting file %s", obj_to_delete.path or obj_to_delete, exc_info=(type(result), result, result.__traceback__), ) else: logger.warning( "error deleting file %s", obj_to_delete.path or obj_to_delete, exc_info=(type(result), result, result.__traceback__), ) errors.append(result) else: raise result if errors and self.raise_on_error: if len(errors) == 1: raise errors[0] msg = "multiple FileObject operation failures" raise ExceptionGroup(msg, errors) if not errors: self.clear() def rollback(self) -> None: """Clean up files saved during a transaction that is being rolled back.""" for obj in self._saved_in_transaction: if obj.path: try: obj.delete() except FileNotFoundError: # Ignore if the file is already gone (shouldn't happen often here) pass except Exception: logger.exception("error deleting file during rollback %s", obj.path or obj) raise self.clear() async def rollback_async(self) -> None: """Clean up files saved during a transaction that is being rolled back.""" objects_to_delete = [obj for obj in self._saved_in_transaction if obj.path] if not objects_to_delete: self.clear() return delete_results = await asyncio.gather( *(obj.delete_async() for obj in objects_to_delete), return_exceptions=True, ) errors: list[Exception] = [] for obj, result in zip(objects_to_delete, delete_results): if isinstance(result, FileNotFoundError): continue if isinstance(result, BaseException): if isinstance(result, Exception): logger.error( "error deleting file during rollback %s", obj.path or obj, exc_info=(type(result), result, result.__traceback__), ) errors.append(result) else: # Propagate BaseExceptions like CancelledError raise result self.clear() if errors: if len(errors) == 1: raise errors[0] msg = "multiple FileObject rollback failures" raise ExceptionGroup(msg, errors) def clear(self) -> None: """Clear the tracker's state.""" self.pending_saves.clear() self.pending_deletes.clear() self._saved_in_transaction.clear() python-advanced-alchemy-1.9.3/advanced_alchemy/types/guid.py000066400000000000000000000064351516556515500241730ustar00rootroot00000000000000from base64 import b64decode from importlib.util import find_spec from typing import Any, Optional, Union, cast from uuid import UUID from sqlalchemy.dialects.mssql import UNIQUEIDENTIFIER as MSSQL_UNIQUEIDENTIFIER from sqlalchemy.dialects.oracle import RAW as ORA_RAW from sqlalchemy.dialects.postgresql import UUID as PG_UUID from sqlalchemy.engine import Dialect from sqlalchemy.types import BINARY, CHAR, TypeDecorator from typing_extensions import Buffer __all__ = ("GUID",) UUID_UTILS_INSTALLED = find_spec("uuid_utils") NANOID_INSTALLED = find_spec("fastnanoid") class GUID(TypeDecorator[UUID]): """Platform-independent GUID type. Uses PostgreSQL's UUID type (Postgres, DuckDB, Cockroach), MSSQL's UNIQUEIDENTIFIER type, Oracle's RAW(16) type, otherwise uses BINARY(16) or CHAR(32), storing as stringified hex values. Will accept stringified UUIDs as a hexstring or an actual UUID """ impl = BINARY(16) cache_ok = True @property def python_type(self) -> type[UUID]: return UUID def __init__(self, *args: Any, binary: bool = True, **kwargs: Any) -> None: self.binary = binary def load_dialect_impl(self, dialect: Dialect) -> Any: if dialect.name in {"postgresql", "duckdb", "cockroachdb"}: return dialect.type_descriptor(PG_UUID()) if dialect.name == "oracle": return dialect.type_descriptor(ORA_RAW(16)) if dialect.name == "mssql": return dialect.type_descriptor(MSSQL_UNIQUEIDENTIFIER()) if self.binary: return dialect.type_descriptor(BINARY(16)) return dialect.type_descriptor(CHAR(32)) def process_bind_param( self, value: Optional[Union[bytes, str, UUID]], dialect: Dialect, ) -> Optional[Union[bytes, str]]: if value is None: return value if dialect.name in {"postgresql", "duckdb", "cockroachdb", "mssql"}: return str(value) value = self.to_uuid(value) if value is None: return value if dialect.name in {"oracle", "spanner+spanner"}: return value.bytes return value.bytes if self.binary else value.hex def process_result_value( self, value: Optional[Union[bytes, str, UUID]], dialect: Dialect, ) -> Optional[UUID]: if value is None: return value if value.__class__.__name__ == "UUID": return cast("UUID", value) if dialect.name == "spanner+spanner": return UUID(bytes=b64decode(cast("Union[str, Buffer]", value))) if self.binary: return UUID(bytes=cast("bytes", value)) return UUID(hex=cast("str", value)) @staticmethod def to_uuid(value: Any) -> Optional[UUID]: if value.__class__.__name__ == "UUID" or value is None: return cast("Optional[UUID]", value) try: value = UUID(hex=value) except (TypeError, ValueError): value = UUID(bytes=value) return cast("Optional[UUID]", value) def compare_values(self, x: Any, y: Any) -> bool: """Compare two values for equality.""" if x.__class__.__name__ == "UUID" and y.__class__.__name__ == "UUID": return cast("bool", x.bytes == y.bytes) return cast("bool", x == y) python-advanced-alchemy-1.9.3/advanced_alchemy/types/identity.py000066400000000000000000000003101516556515500250560ustar00rootroot00000000000000from sqlalchemy.types import BigInteger, Integer BigIntIdentity = BigInteger().with_variant(Integer, "sqlite") """A ``BigInteger`` variant that reverts to an ``Integer`` for unsupported variants.""" python-advanced-alchemy-1.9.3/advanced_alchemy/types/json.py000066400000000000000000000061341516556515500242100ustar00rootroot00000000000000from typing import Any, Optional, Union, cast from sqlalchemy import text, util from sqlalchemy.dialects.oracle import BLOB as ORA_BLOB from sqlalchemy.dialects.postgresql import JSONB as PG_JSONB from sqlalchemy.engine import Dialect from sqlalchemy.types import JSON as _JSON from sqlalchemy.types import SchemaType, TypeDecorator, TypeEngine from advanced_alchemy._serialization import decode_json, encode_json __all__ = ("ORA_JSONB",) class ORA_JSONB(TypeDecorator[dict[str, Any]], SchemaType): # noqa: N801 """Oracle Binary JSON type. JsonB = _JSON().with_variant(PG_JSONB, "postgresql").with_variant(ORA_JSONB, "oracle") """ impl = ORA_BLOB cache_ok = True @property def python_type(self) -> type[dict[str, Any]]: return dict def __init__(self, *args: Any, **kwargs: Any) -> None: """Initialize JSON type""" self.name = kwargs.pop("name", None) self.oracle_strict = kwargs.pop("oracle_strict", True) def coerce_compared_value(self, op: Any, value: Any) -> Any: return self.impl.coerce_compared_value(op=op, value=value) # type: ignore[no-untyped-call, call-arg] def load_dialect_impl(self, dialect: Dialect) -> TypeEngine[Any]: return dialect.type_descriptor(ORA_BLOB()) def process_bind_param(self, value: Any, dialect: Dialect) -> Optional[Any]: return value if value is None else encode_json(value) def process_result_value(self, value: Union[bytes, None], dialect: Dialect) -> Optional[Any]: if dialect.oracledb_ver < (2,): # type: ignore[attr-defined] return value if value is None else decode_json(value) return value def _should_create_constraint(self, compiler: Any, **kw: Any) -> bool: return cast("bool", compiler.dialect.name == "oracle") def _variant_mapping_for_set_table(self, column: Any) -> Optional[dict[str, Any]]: if column.type._variant_mapping: # noqa: SLF001 variant_mapping = dict(column.type._variant_mapping) # noqa: SLF001 variant_mapping["_default"] = column.type else: variant_mapping = None return variant_mapping @util.preload_module("sqlalchemy.sql.schema") def _set_table(self, column: Any, table: Any) -> None: schema = util.preloaded.sql_schema variant_mapping = self._variant_mapping_for_set_table(column) constraint_options = "(strict)" if self.oracle_strict else "" sqltext = text(f"{column.name} is json {constraint_options}") e = schema.CheckConstraint( sqltext, name=f"{column.name}_is_json", _create_rule=util.portable_instancemethod( # type: ignore[no-untyped-call] self._should_create_constraint, {"variant_mapping": variant_mapping}, ), _type_bound=True, ) table.append_constraint(e) JsonB = ( _JSON().with_variant(PG_JSONB, "postgresql").with_variant(ORA_JSONB, "oracle").with_variant(PG_JSONB, "cockroachdb") ) """A JSON type that uses native ``JSONB`` where possible and ``Binary`` or ``Blob`` as an alternative. """ python-advanced-alchemy-1.9.3/advanced_alchemy/types/mutables.py000066400000000000000000000105721516556515500250540ustar00rootroot00000000000000from typing import Any, TypeVar, cast, no_type_check from sqlalchemy.ext.mutable import Mutable from sqlalchemy.ext.mutable import MutableList as SQLMutableList from typing_extensions import Self T = TypeVar("T", bound="Any") class MutableList(SQLMutableList[T]): # pragma: no cover """A list type that implements :class:`Mutable`. The :class:`MutableList` object implements a list that will emit change events to the underlying mapping when the contents of the list are altered, including when values are added or removed. This is a replication of default Mutablelist provide by SQLAlchemy. The difference here is the properties _removed which keep every element removed from the list in order to be able to delete them after commit and keep them when session rolled back. """ def __init__(self, *args: "Any", **kwargs: "Any") -> None: super().__init__(*args, **kwargs) self._pending_removed: set[T] = set() self._pending_append: list[T] = [] @classmethod def coerce(cls, key: "Any", value: "Any") -> "Any": # pragma: no cover if not isinstance(value, MutableList): if isinstance(value, list): return MutableList[T](value) # this call will raise ValueError return Mutable.coerce(key, value) return cast("MutableList[T]", value) @no_type_check def __reduce_ex__(self, proto: int) -> "tuple[type[MutableList[T]], tuple[list[T]]]": # pragma: no cover return self.__class__, (list(self),) # needed for backwards compatibility with # older pickles def __getstate__(self) -> "tuple[list[T], set[T]]": # pragma: no cover return list(self), self._pending_removed def __setstate__(self, state: "Any") -> None: # pragma: no cover self[:] = state[0] self._pending_removed = state[1] def __setitem__(self, index: "Any", value: "Any") -> None: """Detect list set events and emit change events.""" old_value = self[index] if isinstance(index, slice) else [self[index]] list.__setitem__(self, index, value) # pyright: ignore[reportUnknownMemberType,reportUnknownArgumentType] self.changed() self._pending_removed.update(old_value) # pyright: ignore[reportArgumentType] def __delitem__(self, index: "Any") -> None: """Detect list del events and emit change events.""" old_value = self[index] if isinstance(index, slice) else [self[index]] list.__delitem__(self, index) # pyright: ignore[reportUnknownMemberType,reportUnknownArgumentType] self.changed() self._pending_removed.update(old_value) # pyright: ignore[reportArgumentType] def pop(self, *arg: "Any") -> "T": result = list.pop(self, *arg) # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType] self.changed() self._pending_removed.add(result) # pyright: ignore[reportArgumentType,reportUnknownArgumentType] return result # pyright: ignore[reportUnknownVariableType] def append(self, x: "Any") -> None: list.append(self, x) # pyright: ignore[reportUnknownMemberType] self._pending_append.append(x) self.changed() def extend(self, x: "Any") -> None: list.extend(self, x) # pyright: ignore[reportUnknownMemberType] self._pending_append.extend(x) self.changed() @no_type_check def __iadd__(self, x: "Any") -> "Self": self.extend(x) return self def insert(self, i: "Any", x: "Any") -> None: list.insert(self, i, x) # pyright: ignore[reportUnknownMemberType] self._pending_append.append(x) self.changed() def remove(self, i: "T") -> None: list.remove(self, i) # pyright: ignore[reportUnknownMemberType] self._pending_removed.add(i) self.changed() def clear(self) -> None: self._pending_removed.update(self) list.clear(self) # pyright: ignore[reportUnknownMemberType] self.changed() def sort(self, **kw: "Any") -> None: list.sort(self, **kw) # pyright: ignore[reportUnknownMemberType] self.changed() def reverse(self) -> None: list.reverse(self) # pyright: ignore[reportUnknownMemberType] self.changed() def _finalize_pending(self) -> None: """Finalize pending changes by clearing the pending append list.""" self._pending_append.clear() python-advanced-alchemy-1.9.3/advanced_alchemy/types/password_hash/000077500000000000000000000000001516556515500255265ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/advanced_alchemy/types/password_hash/__init__.py000066400000000000000000000000001516556515500276250ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/advanced_alchemy/types/password_hash/argon2.py000066400000000000000000000044111516556515500272700ustar00rootroot00000000000000"""Argon2 Hashing Backend using argon2-cffi.""" from typing import TYPE_CHECKING, Any, Union from advanced_alchemy.types.password_hash.base import HashingBackend if TYPE_CHECKING: from sqlalchemy import BinaryExpression, ColumnElement from argon2 import PasswordHasher as Argon2PasswordHasher # pyright: ignore from argon2.exceptions import InvalidHash, VerifyMismatchError # pyright: ignore __all__ = ("Argon2Hasher",) class Argon2Hasher(HashingBackend): """Hashing backend using Argon2 via the argon2-cffi library.""" def __init__(self, **kwargs: Any) -> None: """Initialize Argon2Backend. Args: **kwargs: Optional keyword arguments to pass to the argon2.PasswordHasher constructor. See argon2-cffi documentation for available parameters (e.g., time_cost, memory_cost, parallelism, hash_len, salt_len, type). """ self.hasher = Argon2PasswordHasher(**kwargs) # pyright: ignore def hash(self, value: "Union[str, bytes]") -> str: """Hash the password using Argon2. Args: value: The plain text password (will be encoded to UTF-8 if string). Returns: The Argon2 hash string. """ return self.hasher.hash(self._ensure_bytes(value)) def verify(self, plain: "Union[str, bytes]", hashed: str) -> bool: """Verify a plain text password against an Argon2 hash. Args: plain: The plain text password (will be encoded to UTF-8 if string). hashed: The Argon2 hash string to verify against. Returns: True if the password matches the hash, False otherwise. """ try: self.hasher.verify(hashed, self._ensure_bytes(plain)) except (VerifyMismatchError, InvalidHash): return False except Exception: # noqa: BLE001 return False return True def compare_expression(self, column: "ColumnElement[str]", plain: "Union[str, bytes]") -> "BinaryExpression[bool]": """Direct SQL comparison is not supported for Argon2. Raises: NotImplementedError: Always raised. """ msg = "Argon2Hasher does not support direct SQL comparison." raise NotImplementedError(msg) python-advanced-alchemy-1.9.3/advanced_alchemy/types/password_hash/base.py000066400000000000000000000136751516556515500270260ustar00rootroot00000000000000"""Base classes for password hashing backends.""" import abc from typing import Any, Union, cast from sqlalchemy import BinaryExpression, ColumnElement, FunctionElement, String, TypeDecorator class HashingBackend(abc.ABC): """Abstract base class for password hashing backends. This class defines the interface that all password hashing backends must implement. Concrete implementations should provide the actual hashing and verification logic. """ @staticmethod def _ensure_bytes(value: Union[str, bytes]) -> bytes: if isinstance(value, str): return value.encode("utf-8") return value @abc.abstractmethod def hash(self, value: "Union[str, bytes]") -> "Union[str, Any]": """Hash the given value. Args: value: The plain text value to hash. Returns: Either a string (the hash) or a SQLAlchemy SQL expression for DB-side hashing. """ @abc.abstractmethod def verify(self, plain: "Union[str, bytes]", hashed: str) -> bool: """Verify a plain text value against a hash. Args: plain: The plain text value to verify. hashed: The hash to verify against. Returns: True if the plain text matches the hash, False otherwise. """ @abc.abstractmethod def compare_expression(self, column: "ColumnElement[str]", plain: "Union[str, bytes]") -> "BinaryExpression[bool]": """Generate a SQLAlchemy expression for comparing a column with a plain text value. Args: column: The SQLAlchemy column to compare. plain: The plain text value to compare against. Returns: A SQLAlchemy binary expression for the comparison. """ class HashedPassword: """Wrapper class for a hashed password. This class holds the hash string and provides a method to verify a plain text password against it. """ def __hash__(self) -> int: return hash(self.hash_string) def __init__(self, hash_string: str, backend: "HashingBackend") -> None: """Initialize a HashedPassword object. Args: hash_string: The hash string. backend: The hashing backend to use for verification. """ self.hash_string = hash_string self.backend = backend def verify(self, plain_password: "Union[str, bytes]") -> bool: """Verify a plain text password against this hash. Args: plain_password: The plain text password to verify. Returns: True if the password matches the hash, False otherwise. """ return self.backend.verify(plain_password, self.hash_string) class PasswordHash(TypeDecorator[str]): """SQLAlchemy TypeDecorator for storing hashed passwords in a database. This type provides transparent hashing of password values using the specified backend. It extends :class:`sqlalchemy.types.TypeDecorator` and implements String as its underlying type. """ impl = String cache_ok = True def __init__(self, backend: "HashingBackend", length: int = 128) -> None: """Initialize the PasswordHash TypeDecorator. Args: backend: The hashing backend class to use length: The maximum length of the hash string. Defaults to 128. """ self.length = length super().__init__(length=length) self.backend = backend def __repr__(self) -> str: """Return a string representation of the PasswordHash.""" return f"PasswordHash(backend=sa.{self.backend.__class__.__name__}(), length={self.length})" @property def python_type(self) -> "type[str]": """Returns the Python type for this type decorator. Returns: The Python string type. """ return str def process_bind_param(self, value: Any, dialect: Any) -> "Union[str, FunctionElement[str], None]": """Process the value before binding it to the SQL statement. This method hashes the value using the specified backend. If the backend returns a SQLAlchemy FunctionElement (for DB-side hashing), it is returned directly. Otherwise, the hashed string is returned. Args: value: The value to process. dialect: The SQLAlchemy dialect. Returns: The hashed string, a SQLAlchemy FunctionElement, or None. """ if value is None: return value hashed_value = self.backend.hash(value) # Check if the backend returned a SQL function for DB-side hashing if isinstance(hashed_value, FunctionElement): return cast("FunctionElement[str]", hashed_value) # Otherwise, assume it's a string or HashedPassword object (convert to string) return str(hashed_value) def process_result_value(self, value: Any, dialect: Any) -> "Union[HashedPassword, None]": # type: ignore[override] """Process the value after retrieving it from the database. This method wraps the hash string in a HashedPassword object. Args: value: The value to process. dialect: The SQLAlchemy dialect. Returns: A HashedPassword object or None if the input is None. """ if value is None: return value # Ensure the retrieved value is a string before passing to HashedPassword return HashedPassword(str(value), self.backend) def compare_value( self, column: "ColumnElement[str]", plain_password: "Union[str, bytes]" ) -> "BinaryExpression[bool]": """Generate a SQLAlchemy expression for comparing a column with a plain text password. Args: column: The SQLAlchemy column to compare. plain_password: The plain text password to compare against. Returns: A SQLAlchemy binary expression for the comparison. """ return self.backend.compare_expression(column, plain_password) python-advanced-alchemy-1.9.3/advanced_alchemy/types/password_hash/passlib.py000066400000000000000000000037711516556515500275450ustar00rootroot00000000000000"""Passlib Hashing Backend.""" from typing import TYPE_CHECKING, Any, Union from advanced_alchemy.types.password_hash.base import HashingBackend if TYPE_CHECKING: from passlib.context import CryptContext from sqlalchemy import BinaryExpression, ColumnElement __all__ = ("PasslibHasher",) class PasslibHasher(HashingBackend): """Hashing backend using Passlib. Relies on the `passlib` package being installed. Install with `pip install passlib` or `uv pip install passlib`. """ def __init__(self, context: "CryptContext") -> None: """Initialize PasslibBackend. Args: context: The Passlib CryptContext to use for hashing and verification. """ self.context = context def hash(self, value: "Union[str, bytes]") -> str: """Hash the given value using the Passlib context. Args: value: The plain text value to hash. Will be converted to string. Returns: The hashed string. """ return self.context.hash(self._ensure_bytes(value)) def verify(self, plain: "Union[str, bytes]", hashed: str) -> bool: """Verify a plain text value against a hash using the Passlib context. Args: plain: The plain text value to verify. Will be converted to string. hashed: The hash to verify against. Returns: True if the plain text matches the hash, False otherwise. """ try: return self.context.verify(self._ensure_bytes(plain), hashed) except Exception: # noqa: BLE001 # Passlib can raise various errors for invalid hashes return False def compare_expression(self, column: "ColumnElement[str]", plain: Any) -> "BinaryExpression[bool]": """Direct SQL comparison is not supported for Passlib. Raises: NotImplementedError: Always raised. """ msg = "PasslibHasher does not support direct SQL comparison." raise NotImplementedError(msg) python-advanced-alchemy-1.9.3/advanced_alchemy/types/password_hash/pwdlib.py000066400000000000000000000034521516556515500273650ustar00rootroot00000000000000"""Pwdlib Hashing Backend.""" from typing import TYPE_CHECKING, Any, Union from advanced_alchemy.types.password_hash.base import HashingBackend if TYPE_CHECKING: from pwdlib.hashers.base import HasherProtocol from sqlalchemy import BinaryExpression, ColumnElement __all__ = ("PwdlibHasher",) class PwdlibHasher(HashingBackend): """Hashing backend using Pwdlib.""" def __init__(self, hasher: "HasherProtocol") -> None: """Initialize PwdlibBackend. Args: hasher: The Pwdlib hasher to use for hashing and verification. """ self.hasher = hasher def hash(self, value: "Union[str, bytes]") -> str: """Hash the given value using the Pwdlib context. Args: value: The plain text value to hash. Will be converted to string. Returns: The hashed string. """ return self.hasher.hash(self._ensure_bytes(value)) def verify(self, plain: "Union[str, bytes]", hashed: str) -> bool: """Verify a plain text value against a hash using the Pwdlib context. Args: plain: The plain text value to verify. Will be converted to string. hashed: The hash to verify against. Returns: True if the plain text matches the hash, False otherwise. """ try: return self.hasher.verify(self._ensure_bytes(plain), hashed) except Exception: # noqa: BLE001 return False def compare_expression(self, column: "ColumnElement[str]", plain: Any) -> "BinaryExpression[bool]": """Direct SQL comparison is not supported for Pwdlib. Raises: NotImplementedError: Always raised. """ msg = "PwdlibHasher does not support direct SQL comparison." raise NotImplementedError(msg) python-advanced-alchemy-1.9.3/advanced_alchemy/typing.py000066400000000000000000000006111516556515500233770ustar00rootroot00000000000000"""Public type shims for optional dependencies. Re-exports foundational stub types so that internal and external code can ``from advanced_alchemy.typing import SQLModelBase`` without reaching into private modules. """ from advanced_alchemy._typing import SQLMODEL_INSTALLED, SQLModelBase, SQLModelBaseLike __all__ = ( "SQLMODEL_INSTALLED", "SQLModelBase", "SQLModelBaseLike", ) python-advanced-alchemy-1.9.3/advanced_alchemy/utils/000077500000000000000000000000001516556515500226555ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/advanced_alchemy/utils/__init__.py000066400000000000000000000000001516556515500247540ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/advanced_alchemy/utils/cli_tools.py000066400000000000000000000172171516556515500252260ustar00rootroot00000000000000"""Compatibility utilities for Click and Rich-Click. This module provides a small compatibility layer so CLI code can opt into alias support without depending on Rich-Click 1.9+ being installed. When Rich-Click with alias support is available it is used; otherwise a local ``AliasedGroup`` implementation mimics the behaviour for plain Click (or older Rich-Click versions). Usage: from advanced_alchemy.utils.cli_tools import click, group, command @group(name="database", aliases=["db"]) def database_group(): ... """ import inspect from collections.abc import Iterable from typing import Any, Callable, Final, Optional from typing_extensions import ParamSpec _rich_click_available = False _rich_click_aliases_supported = False _rich_group_cls: "Optional[type[click.Group]]" = None try: import rich_click as click from rich_click import RichGroup _rich_group_init_params = inspect.signature(RichGroup.__init__).parameters _rich_click_available = True _rich_click_aliases_supported = "aliases" in _rich_group_init_params _rich_group_cls = RichGroup except ImportError: import click # type: ignore[no-redef] _RICH_CLICK_AVAILABLE: Final[bool] = _rich_click_available _RICH_CLICK_ALIASES_SUPPORTED: Final[bool] = _rich_click_aliases_supported __all__ = [ "AliasedGroup", "click", "command", "group", ] P = ParamSpec("P") _base_click_group: "type[click.Group]" = click.Group def _supports_aliases_param(cls: type[Any]) -> bool: """Return True when the provided class ``__init__`` accepts ``aliases``.""" try: return "aliases" in inspect.signature(cls.__init__).parameters except (TypeError, ValueError): return False class AliasedGroup(click.Group): """Click group that understands command aliases. The implementation mirrors Rich-Click's alias handling so that plain Click environments can keep working when ``aliases`` are supplied. """ def __init__( self, *args: Any, aliases: Optional[Iterable[str]] = None, **kwargs: Any, ) -> None: aliases_iterable = aliases or () super().__init__(*args, **kwargs) self.aliases = tuple(aliases_iterable) self._alias_mapping: dict[str, str] = {} def add_command( self, cmd: click.Command, name: Optional[str] = None, aliases: Optional[Iterable[str]] = None, **kwargs: Any, ) -> None: super().add_command(cmd, name, **kwargs) command_name = name or cmd.name if command_name is None: return all_aliases: tuple[str, ...] = tuple(aliases or ()) + tuple(getattr(cmd, "aliases", ()) or ()) for alias in all_aliases: self._alias_mapping[alias] = command_name self._alias_mapping.pop(command_name, None) def get_command(self, ctx: click.Context, cmd_name: str) -> Optional[click.Command]: resolved_name = self._alias_mapping.get(cmd_name, cmd_name) return super().get_command(ctx, resolved_name) def resolve_command( self, ctx: click.Context, args: list[str] ) -> tuple[Optional[str], Optional[click.Command], list[str]]: cmd_name, cmd, remaining_args = super().resolve_command(ctx, args) if cmd is None: return cmd_name, cmd, remaining_args canonical_name = cmd.name or cmd_name return canonical_name, cmd, remaining_args def _patch_group_class_for_aliases(group_cls: type[click.Group]) -> None: """Patch a click.Group subclass to support aliases if not already supported. This ensures parent groups (which we don't control) can resolve aliases of child groups that were created with aliases. Args: group_cls: The group class to patch (e.g., click.Group, RichGroup). """ if _supports_aliases_param(group_cls): return if getattr(group_cls, "_aa_alias_patched", False): return _original_add_command = group_cls.add_command _original_get_command = group_cls.get_command def _patched_add_command( self: click.Group, cmd: click.Command, name: Optional[str] = None, aliases: Optional[Iterable[str]] = None, **kwargs: Any, ) -> None: _original_add_command(self, cmd, name, **kwargs) if not hasattr(self, "_alias_mapping"): self._alias_mapping = {} # type: ignore[attr-defined] command_name = name or cmd.name if command_name: all_aliases = tuple(aliases or ()) + tuple(getattr(cmd, "aliases", ()) or ()) for alias in all_aliases: self._alias_mapping[alias] = command_name # type: ignore[attr-defined] self._alias_mapping.pop(command_name, None) # type: ignore[attr-defined] def _patched_get_command( self: click.Group, ctx: click.Context, cmd_name: str, ) -> Optional[click.Command]: alias_mapping: dict[str, str] = getattr(self, "_alias_mapping", {}) resolved_name: str = alias_mapping.get(cmd_name, cmd_name) return _original_get_command(self, ctx, resolved_name) group_cls.add_command = _patched_add_command # type: ignore[method-assign] group_cls.get_command = _patched_get_command # type: ignore[method-assign] group_cls._aa_alias_patched = True # type: ignore[attr-defined] # noqa: SLF001 def _patch_click_group_for_aliases() -> None: """Patch click.Group (and RichGroup if present) to support aliases. This function patches the base click.Group class so that ANY group (including parent groups we don't control like Litestar's main CLI) can properly resolve aliases of child commands/groups. """ _patch_group_class_for_aliases(_base_click_group) if _RICH_CLICK_AVAILABLE and not _RICH_CLICK_ALIASES_SUPPORTED and _rich_group_cls is not None: _patch_group_class_for_aliases(_rich_group_cls) _patch_click_group_for_aliases() def _alias_enabled_group_class(cls: Optional[type[click.Group]], aliases: Optional[Iterable[str]]) -> type[click.Group]: """Choose a group class that can accept ``aliases`` safely.""" base_cls: type[click.Group] if cls is not None: base_cls = cls elif _RICH_CLICK_ALIASES_SUPPORTED and _rich_group_cls is not None: base_cls = _rich_group_cls else: base_cls = AliasedGroup if aliases is None or _supports_aliases_param(base_cls): return base_cls class AliasedCustomGroup(AliasedGroup, base_cls): # type: ignore[valid-type,misc] """Hybrid group that adds alias handling to a custom group class.""" AliasedCustomGroup.__name__ = f"Aliased{base_cls.__name__}" return AliasedCustomGroup def group( name: Optional[str] = None, cls: Optional[type[click.Group]] = None, **attrs: Any, ) -> Callable[[Callable[P, Any]], click.Group]: """Wrapper around ``click.group`` with alias support.""" aliases = attrs.get("aliases") group_cls = _alias_enabled_group_class(cls, aliases) return click.group(name=name, cls=group_cls, **attrs) def command( name: Optional[str] = None, cls: Optional[type[click.Command]] = None, **attrs: Any, ) -> Callable[[Callable[P, Any]], click.Command]: """Wrapper around ``click.command`` that preserves aliases on plain Click.""" target_cls = cls or click.Command aliases = attrs.pop("aliases", None) if aliases and not _supports_aliases_param(target_cls): def decorator(func: Callable[P, Any]) -> click.Command: cmd = click.command(name=name, cls=target_cls, **attrs)(func) cmd.aliases = tuple(aliases) # type: ignore[attr-defined] return cmd return decorator return click.command(name=name, cls=target_cls, **attrs) python-advanced-alchemy-1.9.3/advanced_alchemy/utils/dataclass.py000066400000000000000000000121241516556515500251660ustar00rootroot00000000000000from dataclasses import Field, fields, is_dataclass from inspect import isclass from typing import TYPE_CHECKING, Any, ClassVar, Optional, Protocol, final, runtime_checkable if TYPE_CHECKING: from collections.abc import Iterable from collections.abc import Set as AbstractSet from typing_extensions import TypeAlias, TypeGuard __all__ = ( "DataclassProtocol", "Empty", "EmptyType", "extract_dataclass_fields", "extract_dataclass_items", "is_dataclass_class", "is_dataclass_instance", "simple_asdict", ) @final class Empty: """A sentinel class used as placeholder.""" EmptyType: "TypeAlias" = type[Empty] """Type alias for the :class:`~advanced_alchemy.utils.dataclass.Empty` sentinel class.""" @runtime_checkable class DataclassProtocol(Protocol): """Protocol for instance checking dataclasses""" __dataclass_fields__: "ClassVar[dict[str, Any]]" def extract_dataclass_fields( dt: "DataclassProtocol", exclude_none: bool = False, exclude_empty: bool = False, include: "Optional[AbstractSet[str]]" = None, exclude: "Optional[AbstractSet[str]]" = None, ) -> "tuple[Field[Any], ...]": """Extract dataclass fields. Args: dt: :class:`DataclassProtocol` instance. exclude_none: Whether to exclude None values. exclude_empty: Whether to exclude Empty values. include: An iterable of fields to include. exclude: An iterable of fields to exclude. Returns: A tuple of dataclass fields. """ include = include or set() exclude = exclude or set() if common := (include & exclude): msg = f"Fields {common} are both included and excluded." raise ValueError(msg) dataclass_fields: Iterable[Field[Any]] = fields(dt) if exclude_none: dataclass_fields = (field for field in dataclass_fields if getattr(dt, field.name) is not None) if exclude_empty: dataclass_fields = (field for field in dataclass_fields if getattr(dt, field.name) is not Empty) if include: dataclass_fields = (field for field in dataclass_fields if field.name in include) if exclude: dataclass_fields = (field for field in dataclass_fields if field.name not in exclude) return tuple(dataclass_fields) def extract_dataclass_items( dt: "DataclassProtocol", exclude_none: bool = False, exclude_empty: bool = False, include: "Optional[AbstractSet[str]]" = None, exclude: "Optional[AbstractSet[str]]" = None, ) -> tuple[tuple[str, Any], ...]: """Extract dataclass name, value pairs. Unlike the 'asdict' method exports by the stdlib, this function does not pickle values. Args: dt: :class:`DataclassProtocol` instance. exclude_none: Whether to exclude None values. exclude_empty: Whether to exclude Empty values. include: An iterable of fields to include. exclude: An iterable of fields to exclude. Returns: A tuple of key/value pairs. """ dataclass_fields = extract_dataclass_fields(dt, exclude_none, exclude_empty, include, exclude) return tuple((field.name, getattr(dt, field.name)) for field in dataclass_fields) def simple_asdict( obj: "DataclassProtocol", exclude_none: bool = False, exclude_empty: bool = False, convert_nested: bool = True, exclude: "Optional[AbstractSet[str]]" = None, ) -> "dict[str, Any]": """Convert a dataclass to a dictionary. This method has important differences to the standard library version: - it does not deepcopy values - it does not recurse into collections Args: obj: :class:`DataclassProtocol` instance. exclude_none: Whether to exclude None values. exclude_empty: Whether to exclude Empty values. convert_nested: Whether to recursively convert nested dataclasses. exclude: An iterable of fields to exclude. Returns: A dictionary of key/value pairs. """ ret: dict[str, Any] = {} for field in extract_dataclass_fields(obj, exclude_none, exclude_empty, exclude=exclude): value = getattr(obj, field.name) if is_dataclass_instance(value) and convert_nested: ret[field.name] = simple_asdict(value, exclude_none, exclude_empty) else: ret[field.name] = getattr(obj, field.name) return ret def is_dataclass_instance(obj: Any) -> "TypeGuard[DataclassProtocol]": """Check if an object is a dataclass instance. Args: obj: An object to check. Returns: True if the object is a dataclass instance. """ return hasattr(type(obj), "__dataclass_fields__") # pyright: ignore[reportUnknownArgumentType] def is_dataclass_class(annotation: Any) -> "TypeGuard[type[DataclassProtocol]]": """Wrap :func:`is_dataclass ` in a :data:`typing.TypeGuard`. Args: annotation: tested to determine if instance or type of :class:`dataclasses.dataclass`. Returns: ``True`` if instance or type of ``dataclass``. """ try: return isclass(annotation) and is_dataclass(annotation) except TypeError: # pragma: no cover return False python-advanced-alchemy-1.9.3/advanced_alchemy/utils/deprecation.py000066400000000000000000000075071516556515500255350ustar00rootroot00000000000000import inspect from functools import wraps from typing import Callable, Literal, Optional from warnings import warn from typing_extensions import ParamSpec, TypeVar __all__ = ("deprecated", "warn_deprecation") T = TypeVar("T") P = ParamSpec("P") DeprecatedKind = Literal["function", "method", "classmethod", "attribute", "property", "class", "parameter", "import"] def warn_deprecation( version: str, deprecated_name: str, kind: DeprecatedKind, *, removal_in: Optional[str] = None, alternative: Optional[str] = None, info: Optional[str] = None, pending: bool = False, ) -> None: """Warn about a call to a (soon to be) deprecated function. Args: version: Advanced Alchemy version where the deprecation will occur deprecated_name: Name of the deprecated function removal_in: Advanced Alchemy version where the deprecated function will be removed alternative: Name of a function that should be used instead info: Additional information pending: Use :class:`warnings.PendingDeprecationWarning` instead of :class:`warnings.DeprecationWarning` kind: Type of the deprecated thing """ parts = [] if kind == "import": access_type = "Import of" elif kind in {"function", "method"}: access_type = "Call to" else: access_type = "Use of" if pending: parts.append(f"{access_type} {kind} awaiting deprecation {deprecated_name!r}") # pyright: ignore[reportUnknownMemberType] else: parts.append(f"{access_type} deprecated {kind} {deprecated_name!r}") # pyright: ignore[reportUnknownMemberType] parts.extend( # pyright: ignore[reportUnknownMemberType] ( f"Deprecated in advanced-alchemy {version}", f"This {kind} will be removed in {removal_in or 'the next major version'}", ), ) if alternative: parts.append(f"Use {alternative!r} instead") # pyright: ignore[reportUnknownMemberType] if info: parts.append(info) # pyright: ignore[reportUnknownMemberType] text = ". ".join(parts) # pyright: ignore[reportUnknownArgumentType] warning_class = PendingDeprecationWarning if pending else DeprecationWarning warn(text, warning_class, stacklevel=2) def deprecated( version: str, *, removal_in: Optional[str] = None, alternative: Optional[str] = None, info: Optional[str] = None, pending: bool = False, kind: Optional[Literal["function", "method", "classmethod", "property"]] = None, ) -> Callable[[Callable[P, T]], Callable[P, T]]: """Create a decorator wrapping a function, method or property with a warning call about a (pending) deprecation. Args: version: Advanced Alchemy version where the deprecation will occur removal_in: Advanced Alchemy version where the deprecated function will be removed alternative: Name of a function that should be used instead info: Additional information pending: Use :class:`warnings.PendingDeprecationWarning` instead of :class:`warnings.DeprecationWarning` kind: Type of the deprecated callable. If ``None``, will use ``inspect`` to figure out if it's a function or method Returns: A decorator wrapping the function call with a warning """ def decorator(func: Callable[P, T]) -> Callable[P, T]: @wraps(func) def wrapped(*args: P.args, **kwargs: P.kwargs) -> T: warn_deprecation( version=version, deprecated_name=func.__name__, info=info, alternative=alternative, pending=pending, removal_in=removal_in, kind=kind or ("method" if inspect.ismethod(func) else "function"), ) return func(*args, **kwargs) return wrapped return decorator python-advanced-alchemy-1.9.3/advanced_alchemy/utils/fixtures.py000066400000000000000000000355551516556515500251150ustar00rootroot00000000000000import csv import gzip import io import zipfile from functools import partial from typing import TYPE_CHECKING, Any, Union from advanced_alchemy._serialization import decode_json from advanced_alchemy.exceptions import MissingDependencyError if TYPE_CHECKING: from pathlib import Path from anyio import Path as AsyncPath __all__ = ("open_fixture", "open_fixture_async") def open_fixture(fixtures_path: "Union[Path, AsyncPath]", fixture_name: str) -> Any: """Loads JSON or CSV file with the specified fixture name. Supports plain files, gzipped files (.json.gz, .csv.gz), and zipped files (.json.zip, .csv.zip). The function automatically detects the file format based on file extension and handles decompression transparently. JSON files take priority over CSV files. Supports both lowercase and uppercase variations for better compatibility with database exports. For CSV files, returns a list of dictionaries using csv.DictReader where each row becomes a dictionary with column headers as keys. Note that CSV values are always strings, unlike JSON which preserves data types. Args: fixtures_path: The path to look for fixtures. Can be a :class:`pathlib.Path` or :class:`anyio.Path` instance. fixture_name: The fixture name to load (without file extension). Raises: FileNotFoundError: If no fixture file is found with any supported extension. OSError: If there's an error reading or decompressing the file. ValueError: If the JSON content is invalid, or if a zip file doesn't contain the expected JSON/CSV files. zipfile.BadZipFile: If the zip file is corrupted. gzip.BadGzipFile: If the gzip file is corrupted. csv.Error: If the CSV content is malformed. Returns: Any: The parsed data from the fixture file (JSON data or list of dictionaries from CSV). Examples: >>> from pathlib import Path >>> fixtures_path = Path("./fixtures") # Load JSON fixture >>> data = open_fixture(fixtures_path, "users") # loads users.json, users.json.gz, or users.json.zip >>> print(data) [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}] # Load CSV fixture >>> data = open_fixture(fixtures_path, "states") # loads states.csv, states.csv.gz, or states.csv.zip >>> print(data) [{"abbreviation": "AL", "name": "Alabama"}, {"abbreviation": "AK", "name": "Alaska"}] """ from pathlib import Path base_path = Path(fixtures_path) # Try different file extensions in order of preference # Include both case variations for better compatibility with database exports # JSON formats take priority over CSV for backward compatibility file_variants = [ (base_path / f"{fixture_name}.json", "json_plain"), (base_path / f"{fixture_name.upper()}.json.gz", "json_gzip"), # Uppercase first (common for exports) (base_path / f"{fixture_name}.json.gz", "json_gzip"), (base_path / f"{fixture_name.upper()}.json.zip", "json_zip"), (base_path / f"{fixture_name}.json.zip", "json_zip"), (base_path / f"{fixture_name}.csv", "csv_plain"), (base_path / f"{fixture_name.upper()}.csv", "csv_plain"), # Uppercase plain CSV (base_path / f"{fixture_name.upper()}.csv.gz", "csv_gzip"), (base_path / f"{fixture_name}.csv.gz", "csv_gzip"), (base_path / f"{fixture_name.upper()}.csv.zip", "csv_zip"), (base_path / f"{fixture_name}.csv.zip", "csv_zip"), ] for fixture_path, file_type in file_variants: if fixture_path.exists(): try: # JSON handling if file_type == "json_plain": with fixture_path.open(mode="r", encoding="utf-8") as f: f_data = f.read() return decode_json(f_data) if file_type == "json_gzip": with fixture_path.open(mode="rb") as f: compressed_data = f.read() f_data = gzip.decompress(compressed_data).decode("utf-8") return decode_json(f_data) if file_type == "json_zip": with zipfile.ZipFile(fixture_path, mode="r") as zf: # Look for JSON file inside zip json_files = [name for name in zf.namelist() if name.endswith(".json")] if not json_files: msg = f"No JSON files found in zip archive: {fixture_path}" raise ValueError(msg) # Use the first JSON file found, or prefer one matching the fixture name json_file = next((name for name in json_files if name == f"{fixture_name}.json"), json_files[0]) with zf.open(json_file, mode="r") as f: f_data = f.read().decode("utf-8") return decode_json(f_data) # CSV handling if file_type == "csv_plain": with fixture_path.open(mode="r", encoding="utf-8-sig", newline="") as f: reader = csv.DictReader(f) return list(reader) if file_type == "csv_gzip": with fixture_path.open(mode="rb") as f: compressed_data = f.read() f_data = gzip.decompress(compressed_data).decode("utf-8-sig") reader = csv.DictReader(io.StringIO(f_data)) return list(reader) if file_type == "csv_zip": with zipfile.ZipFile(fixture_path, mode="r") as zf: # Look for CSV file inside zip csv_files = [name for name in zf.namelist() if name.endswith(".csv")] if not csv_files: msg = f"No CSV files found in zip archive: {fixture_path}" raise ValueError(msg) # Use the first CSV file found, or prefer one matching the fixture name csv_file = next((name for name in csv_files if name == f"{fixture_name}.csv"), csv_files[0]) with zf.open(csv_file, mode="r") as f: f_data = f.read().decode("utf-8-sig") reader = csv.DictReader(io.StringIO(f_data)) return list(reader) continue # Skip unknown file types except (OSError, zipfile.BadZipFile, gzip.BadGzipFile) as exc: msg = f"Error reading fixture file {fixture_path}: {exc}" raise OSError(msg) from exc # No valid fixture file found msg = f"Could not find the {fixture_name} fixture (tried .json, .json.gz, .json.zip, .csv, .csv.gz, .csv.zip with case variations)" raise FileNotFoundError(msg) def _read_zip_file(path: "AsyncPath", name: str) -> str: """Helper function to read JSON zip files.""" with zipfile.ZipFile(str(path), mode="r") as zf: # Look for JSON file inside zip json_files = [file for file in zf.namelist() if file.endswith(".json")] if not json_files: error_msg = f"No JSON files found in zip archive: {path}" raise ValueError(error_msg) # Use the first JSON file found, or prefer one matching the fixture name json_file = next((file for file in json_files if file == f"{name}.json"), json_files[0]) with zf.open(json_file, mode="r") as f: return f.read().decode("utf-8") def _read_csv_zip_file(path: "AsyncPath", name: str) -> "list[dict[str, Any]]": """Helper function to read CSV zip files.""" with zipfile.ZipFile(str(path), mode="r") as zf: # Look for CSV file inside zip csv_files = [file for file in zf.namelist() if file.endswith(".csv")] if not csv_files: error_msg = f"No CSV files found in zip archive: {path}" raise ValueError(error_msg) # Use the first CSV file found, or prefer one matching the fixture name csv_file = next((file for file in csv_files if file == f"{name}.csv"), csv_files[0]) with zf.open(csv_file, mode="r") as f: f_data = f.read().decode("utf-8-sig") reader = csv.DictReader(io.StringIO(f_data)) return list(reader) async def open_fixture_async(fixtures_path: "Union[Path, AsyncPath]", fixture_name: str) -> Any: """Loads JSON or CSV file with the specified fixture name asynchronously. Supports plain files, gzipped files (.json.gz, .csv.gz), and zipped files (.json.zip, .csv.zip). The function automatically detects the file format based on file extension and handles decompression transparently. JSON files take priority over CSV files. Supports both lowercase and uppercase variations for better compatibility with database exports. For compressed files and CSV parsing, operations are performed in a thread pool to avoid blocking the event loop. For CSV files, returns a list of dictionaries using csv.DictReader where each row becomes a dictionary with column headers as keys. Note that CSV values are always strings, unlike JSON which preserves data types. Args: fixtures_path: The path to look for fixtures. Can be a :class:`pathlib.Path` or :class:`anyio.Path` instance. fixture_name: The fixture name to load (without file extension). Raises: MissingDependencyError: If the `anyio` library is not installed. FileNotFoundError: If no fixture file is found with any supported extension. OSError: If there's an error reading or decompressing the file. ValueError: If the JSON content is invalid, or if a zip file doesn't contain the expected JSON/CSV files. zipfile.BadZipFile: If the zip file is corrupted. gzip.BadGzipFile: If the gzip file is corrupted. csv.Error: If the CSV content is malformed. Returns: Any: The parsed data from the fixture file (JSON data or list of dictionaries from CSV). Examples: >>> from anyio import Path as AsyncPath >>> fixtures_path = AsyncPath("./fixtures") # Load JSON fixture >>> data = await open_fixture_async(fixtures_path, "users") # loads users.json, users.json.gz, or users.json.zip >>> print(data) [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}] # Load CSV fixture >>> data = await open_fixture_async(fixtures_path, "states") # loads states.csv, states.csv.gz, or states.csv.zip >>> print(data) [{"abbreviation": "AL", "name": "Alabama"}, {"abbreviation": "AK", "name": "Alaska"}] """ try: from anyio import Path as AsyncPath except ImportError as exc: msg = "The `anyio` library is required to use this function. Please install it with `pip install anyio`." raise MissingDependencyError(msg) from exc from advanced_alchemy.utils.sync_tools import async_ base_path = AsyncPath(fixtures_path) # Try different file extensions in order of preference # Include both case variations for better compatibility with database exports # JSON formats take priority over CSV for backward compatibility file_variants = [ (base_path / f"{fixture_name}.json", "json_plain"), (base_path / f"{fixture_name.upper()}.json.gz", "json_gzip"), # Uppercase first (common for exports) (base_path / f"{fixture_name}.json.gz", "json_gzip"), (base_path / f"{fixture_name.upper()}.json.zip", "json_zip"), (base_path / f"{fixture_name}.json.zip", "json_zip"), (base_path / f"{fixture_name}.csv", "csv_plain"), (base_path / f"{fixture_name.upper()}.csv", "csv_plain"), # Uppercase plain CSV (base_path / f"{fixture_name.upper()}.csv.gz", "csv_gzip"), (base_path / f"{fixture_name}.csv.gz", "csv_gzip"), (base_path / f"{fixture_name.upper()}.csv.zip", "csv_zip"), (base_path / f"{fixture_name}.csv.zip", "csv_zip"), ] for fixture_path, file_type in file_variants: if await fixture_path.exists(): try: # JSON handling if file_type == "json_plain": async with await fixture_path.open(mode="r", encoding="utf-8") as f: f_data = await f.read() return decode_json(f_data) if file_type == "json_gzip": # Read gzipped files using binary pattern async with await fixture_path.open(mode="rb") as f: # type: ignore[assignment] compressed_json: bytes = await f.read() # type: ignore[assignment] # Decompress in thread pool to avoid blocking def _decompress_gzip(data: bytes) -> str: return gzip.decompress(data).decode("utf-8") f_data = await async_(partial(_decompress_gzip, compressed_json))() return decode_json(f_data) if file_type == "json_zip": # Read zipped files in thread pool to avoid blocking f_data = await async_(partial(_read_zip_file, fixture_path, fixture_name))() return decode_json(f_data) # CSV handling if file_type == "csv_plain": async with await fixture_path.open(mode="r", encoding="utf-8-sig") as f: f_data = await f.read() # Parse CSV in thread pool to avoid blocking def _parse_csv(data: str) -> "list[dict[str, Any]]": reader = csv.DictReader(io.StringIO(data)) return list(reader) return await async_(partial(_parse_csv, f_data))() if file_type == "csv_gzip": async with await fixture_path.open(mode="rb") as f: # type: ignore[assignment] compressed_csv: bytes = await f.read() # type: ignore[assignment] def _decompress_and_parse_csv(data: bytes) -> "list[dict[str, Any]]": decompressed = gzip.decompress(data).decode("utf-8-sig") reader = csv.DictReader(io.StringIO(decompressed)) return list(reader) return await async_(partial(_decompress_and_parse_csv, compressed_csv))() if file_type == "csv_zip": return await async_(partial(_read_csv_zip_file, fixture_path, fixture_name))() except (OSError, zipfile.BadZipFile, gzip.BadGzipFile) as exc: msg = f"Error reading fixture file {fixture_path}: {exc}" raise OSError(msg) from exc # No valid fixture file found msg = f"Could not find the {fixture_name} fixture (tried .json, .json.gz, .json.zip, .csv, .csv.gz, .csv.zip with case variations)" raise FileNotFoundError(msg) python-advanced-alchemy-1.9.3/advanced_alchemy/utils/module_loader.py000066400000000000000000000052131516556515500260430ustar00rootroot00000000000000"""General utility functions.""" import sys from importlib import import_module from importlib.util import find_spec from pathlib import Path from typing import TYPE_CHECKING, Any, Optional if TYPE_CHECKING: from types import ModuleType __all__ = ( "import_string", "module_to_os_path", ) def module_to_os_path(dotted_path: str = "app") -> Path: """Find Module to OS Path. Return a path to the base directory of the project or the module specified by `dotted_path`. Args: dotted_path: The path to the module. Defaults to "app". Raises: TypeError: The module could not be found. Returns: Path: The path to the module. """ try: if (src := find_spec(dotted_path)) is None: # pragma: no cover msg = f"Couldn't find the path for {dotted_path}" raise TypeError(msg) except ModuleNotFoundError as e: msg = f"Couldn't find the path for {dotted_path}" raise TypeError(msg) from e path = Path(str(src.origin)) return path.parent if path.is_file() else path def import_string(dotted_path: str) -> Any: """Dotted Path Import. Import a dotted module path and return the attribute/class designated by the last name in the path. Raise ImportError if the import failed. Args: dotted_path: The path of the module to import. Raises: ImportError: Could not import the module. Returns: object: The imported object. """ def _is_loaded(module: "Optional[ModuleType]") -> bool: spec = getattr(module, "__spec__", None) initializing = getattr(spec, "_initializing", False) return bool(module and spec and not initializing) def _cached_import(module_path: str, class_name: str) -> Any: """Import and cache a class from a module. Args: module_path: dotted path to module. class_name: Class or function name. Returns: object: The imported class or function """ # Check whether module is loaded and fully initialized. module = sys.modules.get(module_path) if not _is_loaded(module): module = import_module(module_path) return getattr(module, class_name) try: module_path, class_name = dotted_path.rsplit(".", 1) except ValueError as e: msg = "%s doesn't look like a module path" raise ImportError(msg, dotted_path) from e try: return _cached_import(module_path, class_name) except AttributeError as e: msg = "Module '%s' does not define a '%s' attribute/class" raise ImportError(msg, module_path, class_name) from e python-advanced-alchemy-1.9.3/advanced_alchemy/utils/portals.py000066400000000000000000000163731516556515500247250ustar00rootroot00000000000000"""This module provides a portal provider and portal for calling async functions from synchronous code.""" import asyncio import functools import queue import threading from typing import TYPE_CHECKING, Any, Callable, ClassVar, Optional, TypeVar, cast from warnings import warn from advanced_alchemy.exceptions import ImproperConfigurationError if TYPE_CHECKING: from collections.abc import Coroutine __all__ = ("Portal", "PortalProvider", "PortalProviderSingleton") _R = TypeVar("_R") class PortalProviderSingleton(type): """A singleton metaclass for PortalProvider that creates unique instances per event loop.""" _instances: "ClassVar[dict[tuple[type, Optional[asyncio.AbstractEventLoop]], PortalProvider]]" = {} def __call__(cls, *args: Any, **kwargs: Any) -> "PortalProvider": # Use a tuple of the class and loop as the key key = (cls, kwargs.get("loop")) if key not in cls._instances: cls._instances[key] = super().__call__(*args, **kwargs) return cls._instances[key] class PortalProvider(metaclass=PortalProviderSingleton): """A provider for creating and managing threaded portals.""" def __init__(self, /, loop: Optional[asyncio.AbstractEventLoop] = None) -> None: """Initialize the PortalProvider.""" self._request_queue: queue.Queue[ tuple[ Callable[..., Coroutine[Any, Any, Any]], tuple[Any, ...], dict[str, Any], queue.Queue[tuple[Optional[Any], Optional[Exception]]], ] ] = queue.Queue() self._result_queue: queue.Queue[tuple[Optional[Any], Optional[Exception]]] = queue.Queue() self._loop: Optional[asyncio.AbstractEventLoop] = loop self._thread: Optional[threading.Thread] = None self._ready_event: threading.Event = threading.Event() @property def portal(self) -> "Portal": """The portal instance.""" return Portal(self) @property def is_running(self) -> bool: """Whether the portal provider is running.""" return self._thread is not None and self._thread.is_alive() @property def is_ready(self) -> bool: """Whether the portal provider is ready.""" return self._ready_event.is_set() @property def loop(self) -> "asyncio.AbstractEventLoop": # pragma: no cover """The event loop. Raises: ImproperConfigurationError: If the portal provider is not started. """ if self._loop is None: msg = "The PortalProvider is not started. Did you forget to call .start()?" raise ImproperConfigurationError(msg) return self._loop def start(self) -> None: """Starts the background thread and event loop.""" if self._thread is not None: # pragma: no cover warn("PortalProvider already started", stacklevel=2) return self._thread = threading.Thread(target=self._run_event_loop, daemon=True) self._thread.start() self._ready_event.wait() # Wait for the loop to be ready def stop(self) -> None: """Stops the background thread and event loop.""" if self._loop is None or self._thread is None: return self._loop.call_soon_threadsafe(self._loop.stop) self._thread.join() self._loop.close() self._loop = None self._thread = None self._ready_event.clear() def _run_event_loop(self) -> None: # pragma: no cover """The main function of the background thread.""" if self._loop is None: self._loop = asyncio.new_event_loop() asyncio.set_event_loop(self._loop) self._ready_event.set() # Signal that the loop is ready self._loop.run_forever() @staticmethod async def _async_caller( func: "Callable[..., Coroutine[Any, Any, _R]]", args: tuple[Any, ...], kwargs: dict[str, Any], ) -> _R: """Wrapper to run the async function and send the result to the result queue. Args: func: The async function to call. args: Positional arguments to the function. kwargs: Keyword arguments to the function. Returns: The result of the async function. """ result: _R = await func(*args, **kwargs) return result def call(self, func: "Callable[..., Coroutine[Any, Any, _R]]", *args: Any, **kwargs: Any) -> _R: """Calls an async function from a synchronous context. Args: func: The async function to call. *args: Positional arguments to the function. **kwargs: Keyword arguments to the function. Raises: ImproperConfigurationError: If the portal provider is not started. Returns: The result of the async function. """ if self._loop is None: msg = "The PortalProvider is not started. Did you forget to call .start()?" raise ImproperConfigurationError(msg) # Create a new result queue local_result_queue: queue.Queue[tuple[Optional[_R], Optional[Exception]]] = queue.Queue() # Send the request to the background thread self._request_queue.put((func, args, kwargs, local_result_queue)) # Trigger the execution in the event loop _handle = self._loop.call_soon_threadsafe(self._process_request) # Wait for the result from the background thread result, exception = local_result_queue.get() if exception: raise exception return cast("_R", result) def _process_request(self) -> None: # pragma: no cover """Processes a request from the request queue in the event loop.""" assert self._loop is not None # noqa: S101 if not self._request_queue.empty(): func, args, kwargs, local_result_queue = self._request_queue.get() future = asyncio.run_coroutine_threadsafe(self._async_caller(func, args, kwargs), self._loop) # Attach a callback to handle the result/exception future.add_done_callback( functools.partial(self._handle_future_result, local_result_queue=local_result_queue), # pyright: ignore[reportArgumentType] ) @staticmethod def _handle_future_result( future: "asyncio.Future[Any]", local_result_queue: "queue.Queue[tuple[Optional[Any], Optional[Exception]]]", ) -> None: # pragma: no cover """Handles the result or exception from the completed future.""" try: result = future.result() local_result_queue.put((result, None)) except Exception as e: # noqa: BLE001 local_result_queue.put((None, e)) class Portal: def __init__(self, provider: "PortalProvider") -> None: self._provider = provider def call(self, func: "Callable[..., Coroutine[Any, Any, _R]]", *args: Any, **kwargs: Any) -> _R: """Calls an async function using the associated PortalProvider. Args: func: The async function to call. *args: Positional arguments to the function. **kwargs: Keyword arguments to the function. Returns: The result of the async function. """ return self._provider.call(func, *args, **kwargs) python-advanced-alchemy-1.9.3/advanced_alchemy/utils/singleton.py000066400000000000000000000024561516556515500252400ustar00rootroot00000000000000from typing import Any, TypeVar _T = TypeVar("_T") class SingletonMeta(type): """Metaclass for singleton pattern.""" # We store instances keyed by the class type _instances: dict[type, object] = {} def __call__(cls: type[_T], *args: Any, **kwargs: Any) -> _T: """Call method for the singleton metaclass. Args: cls: The class being instantiated. *args: Positional arguments for the class constructor. **kwargs: Keyword arguments for the class constructor. Returns: The singleton instance of the class. """ # Use SingletonMeta._instances to access the class attribute if cls not in SingletonMeta._instances: # pyright: ignore[reportUnnecessaryContains] # Create the instance using super().__call__ which calls the class's __new__ and __init__ instance = super().__call__(*args, **kwargs) # type: ignore SingletonMeta._instances[cls] = instance # Return the cached instance. We cast here because the dictionary stores `object`, # but we know it's of type _T for the given cls key. # Mypy might need an ignore here depending on configuration, but pyright should handle it. return SingletonMeta._instances[cls] # type: ignore[return-value] python-advanced-alchemy-1.9.3/advanced_alchemy/utils/sync_tools.py000066400000000000000000000301521516556515500254240ustar00rootroot00000000000000# ruff: noqa: PYI036, SLF001, ARG001 """Utilities for async/sync interoperability in Advanced Alchemy. This module provides utilities for converting between async and sync functions, managing concurrency limits, and handling context managers. Used primarily for adapter implementations that need to support both sync and async patterns. """ import asyncio import concurrent.futures import functools import inspect import sys import threading from contextlib import AbstractAsyncContextManager, AbstractContextManager from typing import TYPE_CHECKING, Any, Generic, Optional, TypeVar, Union, cast from typing_extensions import ParamSpec if TYPE_CHECKING: from collections.abc import Awaitable, Callable, Coroutine from types import TracebackType try: import uvloop # pyright: ignore[reportMissingImports] except ImportError: uvloop = None # type: ignore[assignment,unused-ignore] class _ThreadLocalState: """Thread-local state for tracking context manager state. Uses typed attributes instead of dynamic attribute access for MyPyC compatibility. """ __slots__ = ("in_thread_consistent_context",) def __init__(self) -> None: self.in_thread_consistent_context: bool = False # Thread-local storage to track when we're in a thread-consistent context _thread_local = threading.local() def _get_thread_state() -> _ThreadLocalState: """Get or create thread-local state. Returns: Thread-local state object with typed attributes. """ try: return _thread_local.state # type: ignore[no-any-return] except AttributeError: state = _ThreadLocalState() _thread_local.state = state return state ReturnT = TypeVar("ReturnT") ParamSpecT = ParamSpec("ParamSpecT") T = TypeVar("T") class NoValue: """Sentinel class for missing values.""" NO_VALUE = NoValue() def is_async_context() -> bool: """Check if we are currently in an async context (event loop is running). Returns: True if an event loop is running, False otherwise. """ try: asyncio.get_running_loop() except RuntimeError: return False return True class CapacityLimiter: """Limits the number of concurrent operations using a semaphore.""" def __init__(self, total_tokens: int) -> None: """Initialize the capacity limiter. Args: total_tokens: Maximum number of concurrent operations allowed """ self._total_tokens = total_tokens self._semaphore_instance: Optional[asyncio.Semaphore] = None @property def _semaphore(self) -> asyncio.Semaphore: """Lazy initialization of asyncio.Semaphore for Python 3.9 compatibility.""" if self._semaphore_instance is None: self._semaphore_instance = asyncio.Semaphore(self._total_tokens) return self._semaphore_instance async def acquire(self) -> None: """Acquire a token from the semaphore.""" await self._semaphore.acquire() def release(self) -> None: """Release a token back to the semaphore.""" self._semaphore.release() @property def total_tokens(self) -> int: """Get the number of tokens currently available.""" if self._semaphore_instance is None: return self._total_tokens return self._semaphore_instance._value @total_tokens.setter def total_tokens(self, value: int) -> None: self._total_tokens = value self._semaphore_instance = None async def __aenter__(self) -> None: """Async context manager entry.""" await self.acquire() async def __aexit__( self, exc_type: "Optional[type[BaseException]]", exc_val: "Optional[BaseException]", exc_tb: "Optional[TracebackType]", ) -> None: """Async context manager exit.""" self.release() _default_limiter = CapacityLimiter(15) def run_(async_function: "Callable[ParamSpecT, Coroutine[Any, Any, ReturnT]]") -> "Callable[ParamSpecT, ReturnT]": """Convert an async function to a blocking function using asyncio.run(). Args: async_function: The async function to convert. Returns: A blocking function that runs the async function. """ @functools.wraps(async_function) def wrapper(*args: "ParamSpecT.args", **kwargs: "ParamSpecT.kwargs") -> "ReturnT": partial_f = functools.partial(async_function, *args, **kwargs) try: loop = asyncio.get_running_loop() except RuntimeError: loop = None if loop is not None: if loop.is_running(): import concurrent.futures with concurrent.futures.ThreadPoolExecutor() as executor: future = executor.submit(asyncio.run, partial_f()) return future.result() else: return asyncio.run(partial_f()) if uvloop and sys.platform != "win32": uvloop.install() # pyright: ignore[reportUnknownMemberType] return asyncio.run(partial_f()) return wrapper def await_( async_function: "Callable[ParamSpecT, Coroutine[Any, Any, ReturnT]]", raise_sync_error: bool = True ) -> "Callable[ParamSpecT, ReturnT]": """Convert an async function to a blocking one, running in the main async loop. Args: async_function: The async function to convert. raise_sync_error: If False, runs in a new event loop if no loop is present. If True (default), raises RuntimeError if no loop is running. Returns: A blocking function that runs the async function. """ @functools.wraps(async_function) def wrapper(*args: "ParamSpecT.args", **kwargs: "ParamSpecT.kwargs") -> "ReturnT": partial_f = functools.partial(async_function, *args, **kwargs) try: loop = asyncio.get_running_loop() except RuntimeError: if raise_sync_error: msg = "await_ called without a running event loop and raise_sync_error=True" raise RuntimeError(msg) from None return asyncio.run(partial_f()) else: if loop.is_running(): try: current_task = asyncio.current_task(loop=loop) except RuntimeError: current_task = None if current_task is not None: # This is a workaround for sync-over-async calls from within a running loop. # It creates a future and then manually drives the event loop # until that future is resolved. This is not ideal and uses a # private API (`_run_once`), but it avoids deadlocking the loop. task = asyncio.ensure_future(partial_f(), loop=loop) while not task.done() and loop.is_running(): loop._run_once() # type: ignore[attr-defined] return task.result() future = asyncio.run_coroutine_threadsafe(partial_f(), loop) return future.result() if raise_sync_error: msg = "await_ found a non-running loop via get_running_loop()" raise RuntimeError(msg) return asyncio.run(partial_f()) return wrapper def async_( function: "Callable[ParamSpecT, ReturnT]", *, limiter: "Optional[CapacityLimiter]" = None ) -> "Callable[ParamSpecT, Awaitable[ReturnT]]": """Convert a blocking function to an async one using asyncio.to_thread(). Args: function: The blocking function to convert. limiter: Limit the total number of threads. Returns: An async function that runs the original function in a thread. """ @functools.wraps(function) async def wrapper(*args: "ParamSpecT.args", **kwargs: "ParamSpecT.kwargs") -> "ReturnT": partial_f = functools.partial(function, *args, **kwargs) used_limiter = limiter or _default_limiter async with used_limiter: return await asyncio.to_thread(partial_f) return wrapper def ensure_async_( function: "Callable[ParamSpecT, Union[Awaitable[ReturnT], ReturnT]]", ) -> "Callable[ParamSpecT, Awaitable[ReturnT]]": """Convert a function to an async one if it is not already. Args: function: The function to convert. Returns: An async function that runs the original function. """ if inspect.iscoroutinefunction(function): return function @functools.wraps(function) async def wrapper(*args: "ParamSpecT.args", **kwargs: "ParamSpecT.kwargs") -> "ReturnT": result = function(*args, **kwargs) if inspect.isawaitable(result): return await result # Check if we're in an async context already try: # If we can get the current event loop, we're in async context _ = asyncio.get_running_loop() state = _get_thread_state() if state.in_thread_consistent_context: return result except RuntimeError: # No event loop, need to run in thread return await async_(lambda: result)() return result return wrapper class _ContextManagerWrapper(Generic[T]): def __init__(self, cm: AbstractContextManager[T]) -> None: self._cm = cm self._executor: Optional[concurrent.futures.ThreadPoolExecutor] = None async def __aenter__(self) -> T: # Use a single thread executor to ensure same thread for enter/exit loop = asyncio.get_running_loop() self._executor = concurrent.futures.ThreadPoolExecutor(max_workers=1) def _enter_with_flag() -> T: # Set thread-local flag to indicate we're in a thread-consistent context state = _get_thread_state() state.in_thread_consistent_context = True return self._cm.__enter__() future = loop.run_in_executor(self._executor, _enter_with_flag) return await future async def __aexit__( self, exc_type: "Optional[type[BaseException]]", exc_val: "Optional[BaseException]", exc_tb: "Optional[TracebackType]", ) -> "Optional[bool]": # Use the same executor to ensure same thread if self._executor is None: # Fallback to any thread if executor wasn't created return await asyncio.to_thread(self._cm.__exit__, exc_type, exc_val, exc_tb) loop = asyncio.get_running_loop() try: def _exit_with_flag_clear() -> "Optional[bool]": try: return self._cm.__exit__(exc_type, exc_val, exc_tb) finally: # Clear thread-local flag when exiting state = _get_thread_state() state.in_thread_consistent_context = False future = loop.run_in_executor(self._executor, _exit_with_flag_clear) return await future finally: # Clean up the executor self._executor.shutdown(wait=False) self._executor = None def with_ensure_async_( obj: "Union[AbstractContextManager[T], AbstractAsyncContextManager[T]]", ) -> "AbstractAsyncContextManager[T]": """Convert a context manager to an async one if it is not already. Args: obj: The context manager to convert. Returns: An async context manager that runs the original context manager. """ if isinstance(obj, AbstractContextManager): return cast("AbstractAsyncContextManager[T]", _ContextManagerWrapper(obj)) return obj async def get_next(iterable: Any, default: Any = NO_VALUE, *args: Any) -> Any: # pragma: no cover """Return the next item from an async iterator. Args: iterable: An async iterable. default: An optional default value to return if the iterable is empty. *args: The remaining args Returns: The next value of the iterable. Raises: StopAsyncIteration: The iterable given is not async. """ has_default = bool(not isinstance(default, NoValue)) try: return await iterable.__anext__() except StopAsyncIteration as exc: if has_default: return default raise StopAsyncIteration from exc python-advanced-alchemy-1.9.3/advanced_alchemy/utils/text.py000066400000000000000000000037551516556515500242250ustar00rootroot00000000000000"""General utility functions.""" import re import unicodedata from functools import lru_cache from typing import Optional __all__ = ( "check_email", "slugify", ) def check_email(email: str) -> str: """Validate an email. Very simple email validation. Args: email (str): The email to validate. Raises: ValueError: If the email is invalid. Returns: str: The validated email. """ if "@" not in email: msg = "Invalid email!" raise ValueError(msg) return email.lower() def slugify(value: str, allow_unicode: bool = False, separator: Optional[str] = None) -> str: """Slugify. Convert to ASCII if ``allow_unicode`` is ``False``. Convert spaces or repeated dashes to single dashes. Remove characters that aren't alphanumerics, underscores, or hyphens. Convert to lowercase. Also strip leading and trailing whitespace, dashes, and underscores. Args: value (str): the string to slugify allow_unicode (bool, optional): allow unicode characters in slug. Defaults to False. separator (str, optional): by default a `-` is used to delimit word boundaries. Set this to configure something different. Returns: str: a slugified string of the value parameter """ if allow_unicode: value = unicodedata.normalize("NFKC", value) else: value = unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode("ascii") value = re.sub(r"[^\w\s-]", "", value.lower()) if separator is not None: return re.sub(r"[-\s]+", "-", value).strip("-_").replace("-", separator) return re.sub(r"[-\s]+", "-", value).strip("-_") @lru_cache(maxsize=100) def camelize(string: str) -> str: """Convert a string to camel case. Args: string (str): The string to convert. Returns: str: The converted string. """ return "".join(word if index == 0 else word.capitalize() for index, word in enumerate(string.split("_"))) python-advanced-alchemy-1.9.3/codecov.yml000066400000000000000000000002531516556515500204130ustar00rootroot00000000000000coverage: status: project: default: target: auto threshold: 2% patch: default: target: auto comment: require_changes: true python-advanced-alchemy-1.9.3/docs/000077500000000000000000000000001516556515500171765ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/docs/PYPI_README.md000066400000000000000000000464661516556515500213360ustar00rootroot00000000000000

Litestar Logo - Light

| Project | Status | |-----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | CI/CD | [![Latest Release](https://github.com/litestar-org/advanced-alchemy/actions/workflows/publish.yml/badge.svg)](https://github.com/litestar-org/advanced-alchemy/actions/workflows/publish.yml) [![ci](https://github.com/litestar-org/advanced-alchemy/actions/workflows/ci.yml/badge.svg)](https://github.com/litestar-org/advanced-alchemy/actions/workflows/ci.yml) [![Documentation Building](https://github.com/litestar-org/advanced-alchemy/actions/workflows/docs.yml/badge.svg?branch=main)](https://github.com/litestar-org/advanced-alchemy/actions/workflows/docs.yml) | | Quality | [![Coverage](https://codecov.io/github/litestar-org/advanced-alchemy/graph/badge.svg?token=vKez4Pycrc)](https://codecov.io/github/litestar-org/advanced-alchemy) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=litestar-org_advanced-alchemy&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=litestar-org_advanced-alchemy) [![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=litestar-org_advanced-alchemy&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=litestar-org_advanced-alchemy) [![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=litestar-org_advanced-alchemy&metric=reliability_rating)](https://sonarcloud.io/summary/new_code?id=litestar-org_advanced-alchemy) [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=litestar-org_advanced-alchemy&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=litestar-org_advanced-alchemy) | | Package | [![PyPI - Version](https://img.shields.io/pypi/v/advanced-alchemy?labelColor=202235&color=edb641&logo=python&logoColor=edb641)](https://badge.fury.io/py/advanced-alchemy) ![PyPI - Support Python Versions](https://img.shields.io/pypi/pyversions/advanced-alchemy?labelColor=202235&color=edb641&logo=python&logoColor=edb641) ![Advanced Alchemy PyPI - Downloads](https://img.shields.io/pypi/dm/advanced-alchemy?logo=python&label=package%20downloads&labelColor=202235&color=edb641&logoColor=edb641) | | Community | [![Discord](https://img.shields.io/discord/919193495116337154?labelColor=202235&color=edb641&label=chat%20on%20discord&logo=discord&logoColor=edb641)](https://discord.gg/litestar) [![Matrix](https://img.shields.io/badge/chat%20on%20Matrix-bridged-202235?labelColor=202235&color=edb641&logo=matrix&logoColor=edb641)](https://matrix.to/#/#litestar:matrix.org) | | Meta | [![Litestar Project](https://img.shields.io/badge/Litestar%20Org-%E2%AD%90%20Advanced%20Alchemy-202235.svg?logo=python&labelColor=202235&color=edb641&logoColor=edb641)](https://github.com/litestar-org/advanced-alchemy) [![types - Mypy](https://img.shields.io/badge/types-Mypy-202235.svg?logo=python&labelColor=202235&color=edb641&logoColor=edb641)](https://github.com/python/mypy) [![License - MIT](https://img.shields.io/badge/license-MIT-202235.svg?logo=python&labelColor=202235&color=edb641&logoColor=edb641)](https://spdx.org/licenses/) [![linting - Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/charliermarsh/ruff/main/assets/badge/v2.json&labelColor=202235)](https://github.com/astral-sh/ruff) |
# Advanced Alchemy Check out the [project documentation][project-docs] ๐Ÿ“š for more information. ## About A carefully crafted, thoroughly tested, optimized companion library for SQLAlchemy, offering: - Sync and async repositories, featuring common CRUD and highly optimized bulk operations - Integration with major web frameworks including Litestar, Starlette, FastAPI, Sanic - Custom-built alembic configuration and CLI with optional framework integration - Utility base classes with audit columns, primary keys and utility functions - [SQLModel](https://sqlmodel.tiangolo.com/) compatibility โ€” use `SQLModel` `table=True` models directly with repositories and services - Composite primary key support โ€” work with multi-column primary keys across repositories, services, and bulk operations - Read/write replica routing with automatic query routing, round-robin/random replica selection, and sticky-primary mode - Dogpile caching integration for query result caching - Built in `File Object` data type for storing objects: - Unified interface for various storage backends ([`fsspec`](https://filesystem-spec.readthedocs.io/en/latest/) and [`obstore`](https://developmentseed.org/obstore/latest/)) - Optional lifecycle event hooks integrated with SQLAlchemy's event system to automatically save and delete files as records are inserted, updated, or deleted. - Optimized JSON types including a custom JSON type for Oracle - Integrated support for UUID6 and UUID7 using [`uuid-utils`](https://github.com/aminalaee/uuid-utils) (install with the `uuid` extra) - Integrated support for Nano ID using [`fastnanoid`](https://github.com/oliverlambson/fastnanoid) (install with the `nanoid` extra) - Custom encrypted text type with multiple backend support including [`pgcrypto`](https://www.postgresql.org/docs/current/pgcrypto.html) for PostgreSQL and the Fernet implementation from [`cryptography`](https://cryptography.io/en/latest/) for other databases - Custom password hashing type with multiple backend support including [`Argon2`](https://github.com/P-H-C/phc-winner-argon2), [`Passlib`](https://passlib.readthedocs.io/en/stable/), and [`Pwdlib`](https://pwdlib.readthedocs.io/en/stable/) with automatic salt generation - Pre-configured base classes with audit columns UUID or Big Integer primary keys and a [sentinel column](https://docs.sqlalchemy.org/en/20/core/connections.html#configuring-sentinel-columns). - Synchronous and asynchronous repositories featuring: - Common CRUD operations for SQLAlchemy models - Bulk inserts, updates, upserts, and deletes with dialect-specific enhancements - Integrated counts, pagination, sorting, filtering with `LIKE`, `IN`, `IS NULL`/`IS NOT NULL`, and dates before and/or after. - Tested support for multiple database backends including: - SQLite via [aiosqlite](https://aiosqlite.omnilib.dev/en/stable/) or [sqlite](https://docs.python.org/3/library/sqlite3.html) - Postgres via [asyncpg](https://magicstack.github.io/asyncpg/current/) or [psycopg3 (async or sync)](https://www.psycopg.org/psycopg3/) - MySQL via [asyncmy](https://github.com/long2ice/asyncmy) - Oracle via [oracledb (async or sync)](https://oracle.github.io/python-oracledb/) (tested on 18c and 23c) - Google Spanner via [spanner-sqlalchemy](https://github.com/googleapis/python-spanner-sqlalchemy/) - DuckDB via [duckdb_engine](https://github.com/Mause/duckdb_engine) - Microsoft SQL Server via [pyodbc](https://github.com/mkleehammer/pyodbc) or [aioodbc](https://github.com/aio-libs/aioodbc) - CockroachDB via [sqlalchemy-cockroachdb (async or sync)](https://github.com/cockroachdb/sqlalchemy-cockroachdb) - ...and much more ## Usage ### Installation ```shell pip install advanced-alchemy ``` > [!IMPORTANT]\ > Check out [the installation guide][install-guide] in our official documentation! ### Repositories Advanced Alchemy includes a set of asynchronous and synchronous repository classes for easy CRUD operations on your SQLAlchemy models.
Click to expand the example ```python from advanced_alchemy import base, repository, config from sqlalchemy import create_engine from sqlalchemy.orm import Mapped, sessionmaker class User(base.UUIDBase): # you can optionally override the generated table name by manually setting it. __tablename__ = "user_account" # type: ignore[assignment] email: Mapped[str] name: Mapped[str] class UserRepository(repository.SQLAlchemySyncRepository[User]): """User repository.""" model_type = User db = config.SQLAlchemySyncConfig(connection_string="duckdb:///:memory:", session_config=config.SyncSessionConfig(expire_on_commit=False)) # Initializes the database. with db.get_engine().begin() as conn: User.metadata.create_all(conn) with db.get_session() as db_session: repo = UserRepository(session=db_session) # 1) Create multiple users with `add_many` bulk_users = [ {"email": 'cody@litestar.dev', 'name': 'Cody'}, {"email": 'janek@litestar.dev', 'name': 'Janek'}, {"email": 'peter@litestar.dev', 'name': 'Peter'}, {"email": 'jacob@litestar.dev', 'name': 'Jacob'} ] objs = repo.add_many([User(**raw_user) for raw_user in bulk_users]) db_session.commit() print(f"Created {len(objs)} new objects.") # 2) Select paginated data and total row count. Pass additional filters as kwargs created_objs, total_objs = repo.list_and_count(LimitOffset(limit=10, offset=0), name="Cody") print(f"Selected {len(created_objs)} records out of a total of {total_objs}.") # 3) Let's remove the batch of records selected. deleted_objs = repo.delete_many([new_obj.id for new_obj in created_objs]) print(f"Removed {len(deleted_objs)} records out of a total of {total_objs}.") # 4) Let's count the remaining rows remaining_count = repo.count() print(f"Found {remaining_count} remaining records after delete.") ```
For a full standalone example, see the sample [here][standalone-example] ### Services Advanced Alchemy includes an additional service class to make working with a repository easier. This class is designed to accept data as a dictionary or SQLAlchemy model, and it will handle the type conversions for you.
Here's the same example from above but using a service to create the data: ```python from advanced_alchemy import base, repository, filters, service, config from sqlalchemy import create_engine from sqlalchemy.orm import Mapped, sessionmaker class User(base.UUIDBase): # you can optionally override the generated table name by manually setting it. __tablename__ = "user_account" # type: ignore[assignment] email: Mapped[str] name: Mapped[str] class UserService(service.SQLAlchemySyncRepositoryService[User]): """User repository.""" class Repo(repository.SQLAlchemySyncRepository[User]): """User repository.""" model_type = User repository_type = Repo db = config.SQLAlchemySyncConfig(connection_string="duckdb:///:memory:", session_config=config.SyncSessionConfig(expire_on_commit=False)) # Initializes the database. with db.get_engine().begin() as conn: User.metadata.create_all(conn) with db.get_session() as db_session: service = UserService(session=db_session) # 1) Create multiple users with `add_many` objs = service.create_many([ {"email": 'cody@litestar.dev', 'name': 'Cody'}, {"email": 'janek@litestar.dev', 'name': 'Janek'}, {"email": 'peter@litestar.dev', 'name': 'Peter'}, {"email": 'jacob@litestar.dev', 'name': 'Jacob'} ]) print(objs) print(f"Created {len(objs)} new objects.") # 2) Select paginated data and total row count. Pass additional filters as kwargs created_objs, total_objs = service.list_and_count(LimitOffset(limit=10, offset=0), name="Cody") print(f"Selected {len(created_objs)} records out of a total of {total_objs}.") # 3) Let's remove the batch of records selected. deleted_objs = service.delete_many([new_obj.id for new_obj in created_objs]) print(f"Removed {len(deleted_objs)} records out of a total of {total_objs}.") # 4) Let's count the remaining rows remaining_count = service.count() print(f"Found {remaining_count} remaining records after delete.") ```
### Web Frameworks Advanced Alchemy works with nearly all Python web frameworks. Several helpers for popular libraries are included, and additional PRs to support others are welcomed. #### Litestar Advanced Alchemy is the official SQLAlchemy integration for Litestar. In addition to installing with `pip install advanced-alchemy`, it can also be installed as a Litestar extra with `pip install litestar[sqlalchemy]`.
Litestar Example ```python from litestar import Litestar from litestar.plugins.sqlalchemy import SQLAlchemyPlugin, SQLAlchemyAsyncConfig # alternately... # from advanced_alchemy.extensions.litestar import SQLAlchemyAsyncConfig, SQLAlchemyPlugin alchemy = SQLAlchemyPlugin( config=SQLAlchemyAsyncConfig(connection_string="sqlite+aiosqlite:///test.sqlite"), ) app = Litestar(plugins=[alchemy]) ```
For a full Litestar example, check [here][litestar-example] #### Flask
Flask Example ```python from flask import Flask from advanced_alchemy.extensions.flask import AdvancedAlchemy, SQLAlchemySyncConfig app = Flask(__name__) alchemy = AdvancedAlchemy( config=SQLAlchemySyncConfig(connection_string="duckdb:///:memory:"), app=app, ) ```
For a full Flask example, see [here][flask-example] #### FastAPI
FastAPI Example ```python from advanced_alchemy.extensions.fastapi import AdvancedAlchemy, SQLAlchemyAsyncConfig from fastapi import FastAPI app = FastAPI() alchemy = AdvancedAlchemy( config=SQLAlchemyAsyncConfig(connection_string="sqlite+aiosqlite:///test.sqlite"), app=app, ) ```
For a full FastAPI example with optional CLI integration, see [here][fastapi-example] #### Starlette
Pre-built Example Apps ```python from advanced_alchemy.extensions.starlette import AdvancedAlchemy, SQLAlchemyAsyncConfig from starlette.applications import Starlette app = Starlette() alchemy = AdvancedAlchemy( config=SQLAlchemyAsyncConfig(connection_string="sqlite+aiosqlite:///test.sqlite"), app=app, ) ```
#### Sanic
Pre-built Example Apps ```python from sanic import Sanic from sanic_ext import Extend from advanced_alchemy.extensions.sanic import AdvancedAlchemy, SQLAlchemyAsyncConfig app = Sanic("AlchemySanicApp") alchemy = AdvancedAlchemy( sqlalchemy_config=SQLAlchemyAsyncConfig(connection_string="sqlite+aiosqlite:///test.sqlite"), ) Extend.register(alchemy) ```
## Contributing All [Litestar Organization][litestar-org] projects will always be a community-centered, available for contributions of any size. Before contributing, please review the [contribution guide][contributing]. If you have any questions, reach out to us on [Discord][discord], our org-wide [GitHub discussions][litestar-discussions] page, or the [project-specific GitHub discussions page][project-discussions].

[litestar-org]: https://github.com/litestar-org [contributing]: https://advanced-alchemy.litestar.dev/latest/contribution-guide.html [discord]: https://discord.gg/litestar [litestar-discussions]: https://github.com/orgs/litestar-org/discussions [project-discussions]: https://github.com/litestar-org/advanced-alchemy/discussions [project-docs]: https://advanced-alchemy.litestar.dev [install-guide]: https://advanced-alchemy.litestar.dev/latest/#installation [fastapi-example]: https://github.com/litestar-org/advanced-alchemy/blob/main/examples/fastapi/fastapi_service.py [flask-example]: https://github.com/litestar-org/advanced-alchemy/blob/main/examples/flask/flask_services.py [litestar-example]: https://github.com/litestar-org/advanced-alchemy/blob/main/examples/litestar/litestar_service.py [standalone-example]: https://github.com/litestar-org/advanced-alchemy/blob/main/examples/standalone.py python-advanced-alchemy-1.9.3/docs/__init__.py000066400000000000000000000000001516556515500212750ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/docs/_static/000077500000000000000000000000001516556515500206245ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/docs/_static/aa-banner-dark.svg000066400000000000000000000414701516556515500241160ustar00rootroot00000000000000 python-advanced-alchemy-1.9.3/docs/_static/aa-banner-light.svg000066400000000000000000000411271516556515500243030ustar00rootroot00000000000000 python-advanced-alchemy-1.9.3/docs/_static/custom.css000066400000000000000000000130631516556515500226530ustar00rootroot00000000000000/* Advanced Alchemy Custom Theme Configuration * Uses Shibuya theme's CSS variables for proper color integration * Shibuya provides --accent-1 through --accent-12 based on accent_color in conf.py * Current accent_color: amber (configured in docs/conf.py) */ :root { /* Typography scaling for responsive brand text */ --brand-font-size-xl: 6rem; --brand-font-size-lg: 5rem; --brand-font-size-md: 4rem; --brand-font-size-sm: 2.5rem; --brand-font-size-xs: 1.8rem; --brand-font-size-xxs: 1.6rem; --brand-letter-spacing-xl: 0.25em; --brand-letter-spacing-lg: 0.2em; --brand-letter-spacing-md: 0.1em; --brand-letter-spacing-sm: 0.05em; --brand-letter-spacing-xs: 0.03em; } /* Light mode: Use Shibuya's accent color variables * --accent-9 is the primary accent color * --accent-10 is slightly darker * --accent-11 is for high-contrast text * --accent-a1 through --accent-a12 are transparent variants */ html.light { --brand-text-glow: 0 0 10px var(--accent-a3), 0 0 20px var(--accent-a2), 0 0 30px var(--accent-a1); } /* Dark mode: Use Shibuya's accent color variables for dark theme */ html.dark { --brand-text-glow: 0 0 10px var(--accent-a6), 0 0 20px var(--accent-a5), 0 0 30px var(--accent-a4); } .title-with-logo { display: flex; align-items: center; justify-content: center; margin: 5rem auto 4rem; width: 100%; padding: 0 2rem; user-select: none; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; } html[class] .title-with-logo .brand-text { font-family: var(--sy-f-text); font-weight: 300; font-size: var(--brand-font-size-lg); letter-spacing: var(--brand-letter-spacing-xl); text-transform: uppercase; text-align: center; line-height: 1.4; max-width: 100%; word-break: break-word; word-wrap: break-word; overflow-wrap: break-word; hyphens: auto; -webkit-hyphens: auto; -ms-hyphens: auto; transition: color 0.3s ease, text-shadow 0.3s ease; } html.light .title-with-logo .brand-text { color: var(--sy-c-heading); text-shadow: var(--brand-text-glow); } html.dark .title-with-logo .brand-text { color: var(--sy-c-text); text-shadow: var(--brand-text-glow); } /* Button container wrapping */ .buttons.wrap { display: flex; flex-wrap: wrap; gap: 0.5rem; } .buttons.wrap .btn-no-wrap { flex: 0 0 auto; } /* Large screens */ @media (min-width: 1200px) { html[class] .title-with-logo .brand-text { font-size: var(--brand-font-size-xl); } } /* Medium-small screens */ @media (max-width: 991px) { html[class] .title-with-logo .brand-text { font-size: var(--brand-font-size-md); letter-spacing: var(--brand-letter-spacing-lg); } } /* Small screens */ @media (max-width: 767px) { html[class] .title-with-logo .brand-text { font-size: var(--brand-font-size-sm); letter-spacing: var(--brand-letter-spacing-md); } html[class] .title-with-logo { margin: 2rem auto 1.5rem; } } /* Extra small screens */ @media (max-width: 480px) { html[class] .title-with-logo .brand-text { font-size: var(--brand-font-size-xs); letter-spacing: var(--brand-letter-spacing-sm); line-height: 1.2; } html[class] .title-with-logo { margin: 1.5rem auto 1rem; padding: 0 1rem; } } /* Smallest screens */ @media (max-width: 360px) { html[class] .title-with-logo .brand-text { font-size: var(--brand-font-size-xxs); letter-spacing: var(--brand-letter-spacing-xs); } } /* Badge container styling - clean layout without box */ #badges { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 3em; } #badges img { margin-top: 0; margin-bottom: 0; } /* ===================================================== * Brand-Aligned UI Element Enhancements * Uses Shibuya's accent colors for consistency * ===================================================== */ /* Inline code styling - uses accent colors */ html.light code.literal { background-color: var(--accent-a2); border: 1px solid var(--accent-a4); color: var(--accent-11); padding: 0.1em 0.4em; border-radius: 0.3em; font-size: 90%; } html.dark code.literal { background-color: var(--accent-a3); border: 1px solid var(--accent-a5); color: var(--accent-11); padding: 0.1em 0.4em; border-radius: 0.3em; font-size: 90%; } /* Navigation links - accent color on hover */ html.light .toctree-wrapper a:hover, html.light .doc-toc a:hover { color: var(--accent-10); } html.dark .toctree-wrapper a:hover, html.dark .doc-toc a:hover { color: var(--accent-11); } /* Blockquote styling - accent-colored left border */ html.light blockquote { border-left: 4px solid var(--accent-9); background-color: var(--accent-a2); padding: 1em 1em 1em 1.5em; margin: 1em 0; } html.dark blockquote { border-left: 4px solid var(--accent-10); background-color: var(--accent-a3); padding: 1em 1em 1em 1.5em; margin: 1em 0; } /* Table header styling - accent colors */ html.light table thead th { background-color: var(--accent-a2); border-bottom: 2px solid var(--accent-9); } html.dark table thead th { background-color: var(--accent-a3); border-bottom: 2px solid var(--accent-10); } /* Admonition enhancements - accent-aligned colors */ html.light .admonition.note { border-left-color: var(--sy-c-heading); } html.light .admonition.tip { border-left-color: var(--accent-9); } html.dark .admonition.note { border-left-color: var(--accent-9); } html.dark .admonition.tip { border-left-color: var(--accent-10); } /* Ensure smooth transitions for theme switching */ code.literal, .toctree-wrapper a, .doc-toc a, blockquote, table thead th, .admonition { transition: all 0.3s ease; } python-advanced-alchemy-1.9.3/docs/_static/favicon.png000066400000000000000000000764041516556515500227720ustar00rootroot00000000000000‰PNG  IHDR๔๔หึ฿ŠขiTXtXML:com.adobe.xmp kQีjsRGBฎฮ้ IDATx^์ ˜Uี๗งz&!„คซ'dwA‘ลQBืLBาี BwE„ไU5ข lโ†โŠ"ฒ!้๊ษึี„EQQ@ลๅ•}ฯtuBXฒฬtฯšไU–ษLw฿[U]งŸ'๒ฬ=sฮ๏™ำต{ ๒B@! bO€bŸ$ „€B@@บL! „€@@zQRB@! ]ๆ€B@ ฝŠ()! „€†.s@! „@†E”„€B@HC—98k๗ํ^Ka;ต๑‰K>‚ ง(U๓ฉ6ะ58๔๔”นทญŽ`ˆ’ˆi่ฑ(“ู ีึพตlรเYฬฦŽo`b3ZbำR˜n๓ vzrๅrKฝ‹3!SาะcZ8 {dีพƒแืๆ3่0; งŽ ฐ ‡ 3นา‘‘$!B =จ"ูz…์~Dt ‡ดปxl!๛๔™ด]ZิBŸโJฤ‚€4๔X”I‚ฯษ๎ศ  84Sn‡อ9๎_”ณค*F% ]&Hl x๋ .mธ:ฦef=C]H„@ HC —AuูฬmAjฯM\๒’๐+ ๎ขมิ‘้ร–?"x„@’ HCOr๕c˜๛SKึ=4x ฏa๘rxV†eฺ+๎ ฯ…( h†ํ๚Ht/"IQ๑# =~5“ˆƒ}N,0ผท฿r 1‚eI“J" ๘ฮะœ=อ*?—H’ดxi่2bM ฒจ๗UH๑Eผ7ึ‰H๐ เ฿๘†qJฯœา4”แB c HC๏ุา&+ฑชcศ@pไ๊›’•yโฒ}”™ฮษไK?K\ๆ’ฐƒ€4t™"Eภ+๔อ๙ŸะืQ‰I2๗2ำ%ๆึตd•‡‡ฏ$ ]fEGX:kšI€อภ~๒V|์ส๎๒V˜sฟฦ. Xด˜€4๔wํ!๐ ›jโ ุ‹;Œ{|ฆr4Œ๔˜ฒN\ˆฟ :~ โeฬผ ”z฿ธ/=‡฿ฌชฎุ ค†žดŠKพร6-}ฃe>hฮ-กฌSฯฑ^0A%|9ฏ\…žุ †.ณ!‘‚+๖ษ๋hr๒dผำฬญ๘ฒNL<วzภึ*แ๛ฤVOฎ\Vั[! i่2 Kภs,VMž€ƒาถ{ปชN\ํ=ว พmฅ?๔vู{]… ุ MคกหLH, r…ฎ|…{ษKo‰5”ฤ5†ฎฆHล‹€Ž็ฟr…ฎ~…๎ณฑGO~ล?ใ5{$Z!=าะฃW‰จE<วzภd%wr…ฎ|…n0ํ:5_zXฉb,„€r—9\žcญ0E…€\ก~…ผค0๖U๏dฺๅไ.S™€b+^D`์฿5ม%:”@ลฑVฌIo#W่สW่ƒฉ๎ํฆฯ^บช๙"ˆฅi่2Kภsฌงlง@บrC๏gd&ฯ\แ)ีAŒ…€†.s นt4tนๅฎRœฑงN}WyMrgขd.๔+t=E%†<วzภJกหบ๒zmO’๓ฬ•fก aาะe"$–€Ž†.W่๊W่jทูaข` Y๙! @@บ<17ฯฑ‚7ซ_ฅ’…4t๕†พvOุอ*ฏWฉƒุ ! W่2Lภsฌ`ํ๓ฮJไ–ป๒-wำvๅยBiŠฑุD@~‘d&$–€็XุE €4ti่JHŒ…€>าะ๕ฑฅ˜ะัะๅ–ป๚-wนBู/Ž„Yาะ#[ ,l^มz„”ศy่ส็กKCWšb,C@บL†ฤ๐๋~ป+[๎rห]i‰ฑะG@บ>–ข3žc=`W•ฐๅ–ป๒-๗!ำvปUj ถB@l" ]fBb HCW/ฝ็(7๔AำvวฉG" B@HC—9XาะีKฏกกo4mwผz$ข „€4t™‰% /ลฉ—s,y)Nฃ(-คกkม("q$ W่๊Uำp…พด ๊‘ˆ‚าะe$–€\กซ—^ร๚ ฆํnฃ‰(! ]ๆ@b ศบz้5\ก?oฺ๎D๕HDAi่2K@ึกซ—s,ีu่ฯ™ถ;I=QB@บฬฤ+t๕าkธBึดษ๊‘ˆ‚าะe$–€\กซ—^ร๚3ฆํNQD„€†.s ฑไp๕าkธB—†ฎ^QรคกหDH, r8‹๊:๔5ฆํNM์$”ฤ…€Fาะ5ยฉx๐๋;ฉD-{น+oบฺดดJ ฤVMคกหLH,ฯฑฐณ9mM๕-๗ชiปฆR ฤXi่2’MภsฌวผJ…‚\กหบส[! “€\ก๋ค)Zฑ" ฃกCฎะๅ =Vณ^‚ํdาะ;นบ’จ<วzภ๖*˜ไ ]ฎะUๆุ คก๋ค)Zฑ"เ9ึใvP Zฎะๅ ]i‰ฑะG@บ>–ข3žc=`;•ฐๅ ]ฎะUๆุ คก๋ค)Zฑ"เ9ึำฆ4ืตDบ4๔XMz ถฃ HC๏่๒Jrฃจ8ึ*zT( mgปทซhฤูVรNqฒ=ฮ@bi่‘*‡ำJ:บ์gฝS\๐W&ธแ1๒Gz+'ฝ๘๊hาะ;บผ’h<วชPฺิDnนห-w๙-Q! =*•8ZNภs,€าถฃาะฅกท|โŠC!ฐาะej$–€Ž†.หXฒl-ฑฟA’xิHCZE$ž–๐ซ @ํค/9mm์g่ฃWTžกทlฦ‹ฃN' ฝำ+,๙m‘€็XซLQA$ทๅ–ปส[! “€4t4E+V<วZ`[• ฅกKCW™?b+t†ฎ“ฆhลЀކ.ฯะๅzฌ&ฝัคกwty%นัxŽ๕ €ษJ”d/wy)Ni‰ฑะG@บ>–ข3žc= `ขJุrห]nนซฬฑ: HCืISดbEภsฌ็lฃด4ti่*๓Gl…€NาะuาญX๐ๅ็ฟ†. =V“^‚ํhาะ;บผ’ฯะUŸB^ŠSRT5mWi๛]™ๅB@l" ]fBb h8)LฎะนBO์/$9าะ#W จU*Žตž€๑*ไ–ป4t•๙#ถB@'i่:iŠVฌxŽตภ8• ฅกKCW™?b+t†ฎ“ฆhลŠ€็X[>ฅปLdบ๊{๒ ฝน&ใ„ภคกหI,ฯฑtฉ+tนBW™?b+t†ฎ“ฆhลŠ€็XCR*AKC—†ฎ2ฤV่$ ]'MัŠฯฑj • ฅกKCW™?b+t†ฎ“ฆhลŠ€$–€–†.หศฦ2‰ ’ฤฃF@zิ*"๑ด„แญีU“7ช:“[๎rห]u‰ฝะE@บ.’ข+ผ๐่qี‰•`/wฅ4ti่JHŒ…€Fาะ5ยฉx[๎๊๕’u่๊ EA่" ]Iั‰์V“ืั:ๅ ๅบrห]nน+M 1 HCืSคโCเ‰E๓ถŸz.ธบT๛ศฒ5Yถฆ6ƒฤZh# ]JЁUnvbj=ซณ\กหบ๊{! ‹€4t]$E'Vt5tศ3ty†ซ™/มv2i่\]ษm‹n:p’ั=nญ2"i่าะ•'‘=คก๋แ(*1#ฐ๚ฦ์<ญV [nนห-wี9$๖B@i่บHŠNฌ่j่rห’+๔Xอ| ถ“ HC๏ไ๊Jn[$ฐf๑ASkFwU‘r—†ฎ<‰D@่! ]GQ‰ต‹ฒ™ม จ†-ทๅ–ป๊{! ‹€4t]$E'Vžu๎ูˆฎUชAKC—†ฎ:‡ฤ^่" ]Iั‰] ]žกห3๔XM| ถฃ HC๏่๒Jr["ฐชž"zR•\กหบ๊{! ‹€4t]$E'V๚๐ปžPZ^Š“—โ”'‘=คก๋แ(*1#0ฐtึฦะะใสaห^๎ฒ—ป๒$! ‡€4t=E%fไ ]OมไB@Œ@@บL‹Ž!P้ฯฮ$฿8เฃZ–”์งบS\#ฅ š๙ี์ืพž™ป๒oสX!าะ“Pๅฮ๑ฉ%‡N๋<ž'ุฝีฉส-wท๋ฌแ f|ห!‹hมฟN&:š€4๔Ž.o็&็9ฝ‡๑'˜qx[ณ”+๔V^กT๊Gˆ่ป]๔ƒษ3Wxm โ\ด™€4๔6@ืO€๘*6|เำ์Yฟex# ๆ7Oอ—๏ฯCด•=ว šh:Qห฿ฎ%ฎ]–ฮฏsโ‘„@ห HCo9rqุ(อg—Ÿสภว ่iิ>ฬ๑]ฦ8s9Kชa๚ˆฒถ็XA๓+b1ฦŒK2y๗ฆˆล%แP HCฏˆซ๐๚ญ7ขFŸ๑‡UtBดฝด7‡จy้ชc}“Sฃ(ƒd_HฅEQŒObบ HCืMT๔” ฒ๛D_`+‹…)@8ัฬน฿ำEิตซ‹ณ{ฑAQฟล}/็›ywaิyJ|B@…€4tzbซ•ภๆF~>€Cต ‡!ฦx0ฝ5ฟŽฌ๒P๒qา๔+h”Gว ๆ๛ˆ่หS๏œqฝผƒjIˆ †021ะMภsฒ๏๘๗ม ฬึญ’:&ใLnล๏Cา•lฐtฐป6๘;ปD.๐เ/ฟ4*ลL˜๙า•‘‹W คก+ภS5รœ้K ฬTSjฉ๕c cพ™[๑›–zธณีล์ฎพOืƒฐฤC}qx๔ g๕ไ\ ‡๔ฤ(k ตc HC๏ุาF71ฯ้จ]PN=สเ๒ซ%ำ๘y}{cm›๓d๛ั-Wญโ๔žBเslง^ึ(ฑK>ฮH๒๒รึ/ahษ_ยฐ“xx๚ฆพํบฦ๑—ม์๊‡OL‹.n๐'.–F^ษ*No/มŸะ‘vฎ฿ฒญ#ฏ๔กszๆ๚d[ฃ็B Iาะ›'f๕xtแถfยงA)ลณณ๋wฺศ` ัเYpัgv2v๙.ข—?mN8ษVEkƒ)8~.่Š0`็ป 7ิ&^*_เ"\% mDาะeb„Jภ+fำบฮ)ุ_๘ฦ”1พ˜ไMbBb๛ู๊ฒ™bศท}6๖Nj…฿&|<ลภง3ถ{Uถb"ฺB@z[ฐwพำJฑ๏mฤ~ฐF๛Mceฒงเ/„๙ึๆr๋บ!=k๙3cล(?‡@ต฿สณwNศ‹bsqํ8ูN6œ๚‹ช^าะ๕๒Lผš็ไ&3ฏ >ภˆ๛˜q5uีฎ6gฏ|4bฑ%:ร[ปW?=i6 šฟ๙ภษ2ฤฬ—˜4aูล ŠKB/! ]&„6•B๏ˆ๘โˆฝแ<ฏฌฦ•=sJิ–ฌ…J` hf๘๘มKu‘๘0p 8~ชํ‰€$!๐2าะeJ(Xใ๔พบ1€สb๚ๆ๏m๐']#/7้ƒฺjฅี…]|๐i  WํมขRw๊,yTำู๊ ฦ" },B๒๓Q T‹ฝŸaๆฏDำ๓ฬ๘i*…๏Oใ‘˜$ Vนู‰ฦz:Ž|œยn$U%žใ4ู^ฃุ๋$ ]'อi ฏ)๏ฎ  YmO›๐ 1]ผพถอOๅjผํี=€Š“=ยฦ@6tgc:เSใ>>}๖าUc•B dาะC‰๒žำ{(ภ?is~+™๑5ำvo–๕โmฎDฏvfผู‡๑9€Žhีv[Hณส“™S^ โR‡€4t™ u`'7พส๋/แ“u้8ฦ ี.žjฏ“~yQŒแญ„ู?„๙RmŠŸ ๘สิ฿๒99ษญMทญู[8วŸภฆพ“นOฒ!|อOu]ฺs่ฒ'ฺƒ8Ž,5Kf์V๓ป>ฮญ… (wch$๛ึศ‚’ภ:–€\กwli๕%Vqฌ๐=[้Sญ_‰€› ะS์า๕[ษศคจmjฎPซ}‹ฟไxbัผญวงž๛ €ฯต8๋็4?m—Š-๖+๎D@z‚Š=Zชk4ต–๊^ ฦ;[Š„๑wƒ๐qyNR๊‰wๆ-™ฑjฉ˜B>@7ํRเW>B@;iฺ่‘ฦOpU๖5)Ÿ–ุต…ั?ะ‚๔sๆทh ต๚WBเ?ผ‚๕nพเUญรB—ฆsฅณไึOŠ'i่Iฉ๔๒ฌ๔gg’Oฟj๑Yิ?๎ฎ๑ง'ฯ+WŽ_า`Ÿ๘ิ:ใหiู‡o\;๏อ*ฏo™Kqิ๑คกw|‰ทœ W่ˆƒ-ู]‹๔!yŽ˜เIแิซลพ}˜Ÿตl๓$ย]C์พ$O7๚ฆ]๚zธ>Dฝ“HC๏คjพ(—j฿ม๐~€• Gส-๖H’ึˆ6ํฎh8ฟ6TDL_4๓ฅก๚๑Ž! ฝcJ๙฿D*๋ํDX`bธ้๑oบŒ๑semyธ”E=š<'7X๗ €f…!ใ23๏žช๏าะ;ขŒMb๓nW+ljjŒ_ฅŸฯK๓oุช&ภ ีท฿r1a7/˜ถ{^„QHh =EะBe๑Œ7‘บ @Z—ๆH:D๘J:็~6Lข-โD ๊d฿ว ‡๙พ  +_'.kk HCo-๏ะผญY2cทกZ๊ฮฐ—ฆ1๑‡3น๒OCKD„…@L l~Ynq˜บˆO็\'ฆˆ$์ HCp+ไƒณฬ}ฃ๛w ผ&Dฯ๘พ๑ฎžน+}ˆดˆ5jaฦLฉเ•i!%ฒž :$3ง๔ป๔E6ฦคกวธxA่ผ๐่qี‰•[ผ#ฤTV†eฺ+๎ ั‡H Ž ฐบ˜•™–…๘{M ด฿ปtG“$ด†ฎ e{„<'๛+€ข๗'RฉฺASfฏ|0D"-:Šภฺๅ}ๆฦผŒภo)ฑG†ๅœ„่ฦTVzL „]-Z0ใ์ะR`{้๓แศ‹jHC[ล6ว๋ณวƒ้‡แ…O‡มพI๖ญแ๙e!ะ๙*Žu5๏ 'S^fฺๅฐพ0„ฒจ†F@zhhรฎ:ึ ฯอS›ผ'Eh,%แ.c=ฯœ๚ฎ๒š๐ฒe! มZuo[ฎ ซฉ3ใ;™ผ{r2hJ–ฃะุt+หำjติL ล๓ญƒ]ใrr/บ"š`Uว๚) ำqfพte(ฺ"าะcS*`•›˜ZGwุ3”ฐ wmš8c‡y‹^E_D…@‚ (_ฉ~l๋ ภ3Lป#N|๊าะc2˜AีขU0;ค๏ฅ๎ิŒ๔ฌๅฯ„ค/ฒB ๑”›๚่W๙]]o๎9tู‰PาะcRxฯฑ‚}œ?Nธ๔ท.ฃ๛ 9d%บข*^L`x๗o๙€ใt“!เSงญ}ํwื nmั‹>i่ัฏ*E๋Hb2ŒP ๘ืฦT๗ำg/]†พh !02ฯฑ~FS๐Sำv?,“G@zฤkพ้eบแœkจo ฝฝgฮญOFƒ„':Žภฆ๏+o"๐\ษ๛ '3v้[บuE/ฺคกGป>๐๋/B˜^*U{›์Y‘u6Ÿ้ช ฦŽrjปม๒V๙$„€4๔บR่ฟ?„ื๙ิ3ง,“m$ฐชž"๚=€u†มภ@—?ธว”นทญึฉ+Zั% =ขต๑œย๓‰0OŽ` ฌH & Tฯxฉ`ษูไ&%F4#ยM้œ{„NMัŠ.i่ฌMตุทฯ๏ฏ=<ย‰fฮ ใ‹‚๖PEP$‰ภๆ๓ิ—่ึš7๑ fฎผ€'Ÿ' =b~tแถžธU๐|wํกพfๆ3ต๋Š ZT ึ{™๐s-by!ๅ{O™ปโอบ"1าะ#Vชc}“SC๋—ฆํ‚ฎH ! ‘€W่]โ/h” ค๎4m๗š5E.bคกGจ โwยเC้๎ต๘€ฌ๒๚ดERอ<'๋”ำ,๛yำvฟคYSไ"D@zDŠม ทzbๅฏ ผFsHฯL{Mอ—ึฌ+rB@„D บlๆถIงฆhEƒ€4๔ิม+XwƒฐฏๆPไ%อ@ENดƒ€็๔~เำด๚๖้@sn้ญš"ึvาะ\ฯ้ภkc ืhฏฬผาใšuENเ…Gงช+ทะ๗–:ใ๏้็3{ัj-NG…H@zˆpว’่?x{ร๏๚—ฮgdO207=ว-Œๅ_~.„@<l[๑wญ;ษ1Ÿnๆห฿ˆ‰ฒาะ๋กาฏh]ฦ|๒vฺ.‡ฑŽ]g˜ข%„@ƒ*…๑U š6Y฿ฺCN[ิHดอRาะT€ล}–a๘%อ๎นv๏+๋อ5S9!ฺืง3ฎ5๓๎{#’ž„กH@บ"ภfฬ๙oํฎฎš>ำนฝ๋F๒๙ญ้นๅ`X๙!ะžZr่ด๎ฺเ฿คuฅGภAi cC+]!ŠNคกื J็ฐŠc}–€๓ujt–i—.ัซ)jB@D@ลษA ตลผ ทฺ}hฟปตiŠP[HCo1๖gƒ{6ข๋!อ/ยญL็,ธล้ˆ;! ฺ@ โXW๐>]ฎ ๘tฺv/าฅ':ํ! ฝล=ว Ž1a๏žœ๛š"%„@Œฌ*dงงˆภDaแฆtฮ=B‡–hดž€4๔0gU‹VฐœlO]๎ธ(cปฺOgำŸ่!ะUง๗$งaoม_^ฃeƒž™S๚]รzbะvาะ[P‚vxzlCmโ;ฬ[๔B ยB@Dœ€็Xฟัตื;ๅดํZOYย€4๔L ฯฑ‚gR{่rลDGfr%}๋Pu&:B Mn:p๏.5dL๓Sธ-…‘"ฺ>๏ลlL€แ31ึ3c=X๏๋77 7j๔, ำ.?ึฆด๊vซ…[65๓+–ี€ Œi่!—มsฒวt67„~3็ๆด้‰ˆ8ฏ฿z#1vaฆW<ํ ะ๖ ๎cถำผฏร+ˆ0p?3๛ภCLฉจ‹๎KฯZLV๋|>ซ%ย]fฮO‹–ˆดŒ€4๔Q{ฮ๐ณ๓7๊rS3๘ตำๆ”ƒฺไ#:†@uูฬm1X‹A{‚๙ต0ฐ7ฏำผ=rXผž&โ๛|ฆฟ๔7 —jk๕•ƒnvซษ๋(xIvg-‰2Ž2๓๎/ตh‰HKHCsฅhI mฟDธ<sO 1d‘กnใฦฝŒM{˜บใึ;จpAX^#.ตโ‹ธๆ๗uiฺฎถG…ญวŸ<าะCฌน็X๐&M.^‡ก]'ูทhา!ะ2โwRŠ็๚Lsๆ–9Ž–ฃวมดŒ‰]๎๊Zs่ฒ'ยฯsz๏xoฺLแLฎSZข>i่!1ฎ๖[y๖ฑXฃL=OฃžH P ญร ะ<0ฟ €ชณxŠ฿อภ ] ๋งุฅ๛uฅ ๙oฯ}ฆํj{dจ+Gั™€4๔f†็Xห๔i’ฏlจME–ฉiข)2กxbัผญวw=Ÿ๘(0ๆ†ขZ(IดO๔nฟ๒ูธพ'ฟโŸชaxŽu-ปHศNฅขjLb>i่!0๖œพ=ฏบค ๔‰ด]๚ฎ.=ับ ฟˆต˜วฬG๐^]บ ื๙+ƒฏๅฎ๎+›ฝ-๏๛๛w่เ(๋าuPl†4๔8Wœ์w ๔qMาฅŸหผ†ๆ฿Pำค'2B@™ภšล}ปืศ? „˜ฌ,(#x`\‘ฮ•–5zมึqฏl Dฦพ้ ญK5– ฎ‹€4๔บ0ี?h๕ู)xz\ใํฦฟ๓gา๙ฒฮ็๑๕'$#…ภ‹ ,๎ณ รถž-`ZH€๑ ๔ฎ!‡“็•ƒท็วhฝSศธึฬปrfL๊ํ ]3ฏh?0พฆY6ป—™.ษไK? A[$…ภจ ู ขฏj|/Dˆ7M€ฎ#บ _๙็ฑ$*Ž๕s]Bh(ตK๚ฐๅŒๅS~>าะ5ณฏ8ึฟxตfูห=B„ Ÿv•;ออบˆด@ล้ํ%ppE~จเˆB?๛Xษปwn)ฒ`—=๘6ทRพfๆ3ี…D!,าะ5’ญ-›’[”b`€@mจm๓y๛ฝฤ“ๅร+X๓A8[ใ> ษฺุl—LLอ—VŽไถ๊X71p˜†ึSwjzTถบีOวIHCืXRฟ8uG%ฝnT2ฐUว:oK#ฏV๔†6ฮ{๙ก*›—^GธLฺ._จCK4๔†ฎ‰้@ม~W(;?ีb…A_ฬุฅเฑ|„@C }ฏ#โK ฌ—Oผ ฌHN|๑f5ีbถฤL:ŽD}ฬดโงsฃ—†ฎฉถ•b๏นฤ%Mr*2%เฤดํฎ""ถษ ฐvyŸนqƒ> +Y'"ห.\;/ุอ*ฏ๗ }ณ@R-™หัชZ0†!" ]Uฯฑ‚ท?ฃ๔อ๕€ฯh๕‰OšpŠL T‹ึYฬ8ภถ-p'.ฺCเa"|"sฯฑ‚๎สGข2๐๓Œํพฟ=้ˆืัHCื0?<ง๗P€—hา.A ๓7ฆบพ2}๖า็ต‹‹`, Ty>๘ฒWcดƒMฐ๊#๘ท~๓ฟu ฌc/ู˜‰†wOแ €ฑ€ญ6เ‡MlG๐แ๚ไF‰ภ฿าเgฝ?ธqZฯแท?ซAK$4†ฎฆ็Xฟ๐n RกH/ฮ /ศ๖ฑกเ่ำ7๕mื=ฮ3Mะ/ ๔adเ?`0=ศ†๑เะF<ฐแ+žึ™ำ*7;ฑ{ฝบVรฎม"Aปดภปmn:]ถB+๘ข|aQ0gๆKWช ‰‚NาะiV—อ–kkeZdN เำ.้y–ึขจล:J1๛!b๚F\nฏ๐G๎"โป|ค˜ษญะ๒–ถ:ษM ซ‹ู]ด'3ฟl์๐ž ์ู™W๗#Pcพีฬ—g่โ):zHCWไX-f฿ฯLฑฺฝม‹บิ้Sๆฎx@1}18ส’์๋ฉF—่pจOƒฑDw๚์ฉ'_C„c5ด๊อ3w๖Sต7ม|ืฎcMฅjปO™ฝ๒มธึชใ–†ฎXีชำ{#ƒP”i‹9 ฅบฯ“็๋mมชำg๚gง ็k<$Hgผ/€แ‚h9 ^fฮqตLจ3HZมฉg3อxoš‘ั`๚ข™/-ˆL<ศว๊”บ ฯูR๋(ึ/†ฯืA|V&Wi‰หภHจ8ฝง๘(‚ฦภp=Kงฺ๎-‘Rp’;a(u4ˆ่€ดR๖ำvwiฅC๑5:นBW˜!ž“= ๋$ขdz; œุษWKQ‚F,›ืo1๏†~šOt ]ตgเMไขี$h๎T3ห ๗ฤyW>ƒ้-m9ซ˜ˆีE@z]˜Fไ9ึBG+HDฮ”—ฃ+uŽ์ืนาl1 U‹fผึH—6~๖u(9zDธŽ|Z(่๋ใ์าg€๐k๋ณŠศ(ฦฬผ{BDขI|าะ›œ.<`ยึทZี่[ญŒแฐ/๙๑ว™IŸ`Z“!้4๓ˆ้tพt…NQัาK`อโƒฆ๚F๗8UฏrSjทƒ๑M3๏_rๅำ$M{้ำ)W๎q๘xฆํfโhb”†d•ซ…์\&Zิค๙‹อึื&pฯ4ซ;น๑ซฑแ8€]5hซI0๎๑ว๗ฬu๏RkxแัใVOชœ]‚uอืื.ซ็ln :]kU!;=œ ข`K6}ษ้๒ใ•ไๅถ{tfฃ4๔&kแฌ+@8ฑI๓šš9๗˜—๋x…0๑9‘ุอ‹ฐฐF|ฮด9ๅ)็+J*…๑vTR3~’ˆพ™ข๎๏o;gIUMJฌว"๐๏mฅใ“ ์;ึุถœq™™wฯh‹oq๚าะ›œžc=ชใชO8ผ'็ผฅ0<'{`|>K^˜~‚ฎกฯ›ณWนหง…ชNoŽม_ฐO พU…ˆฯM็ส฿kc ‰u]-Z63.†7ฐ‰า็!ำvƒ๓ไำfาะ›(@ตุทณOฆ/7Ycฺ๎ิzt*๋p"k>฿Tฯ๘Pว0พ—bใ"ู˜&Tสรโ^1{<˜NฐW๘ถ์!8 6ธแBูฟปUุไ{๓œ๎าด้V+๛ฆs+๎m?dG ฝ‰๚W ึูD~กT??0m7xFV๗งZฬพ‡™พ`๛บBศ7แk้\๙ถะ\$Px๓ฆ0'่ไถื™้'์ใฬผาใ ,EdS^ส0q๚,€ญ#่L=/q$:i่M”฿sฌ ุ„้KL ฬ&O,šท๕ธิ๓Ÿ'๐งUcะcฯม>—šv๙z=zษTYoํหŒ3™๑v bH.W]ํฎฤ่ƒwบบเโโธvFส ?e์า[ฺƒ๘~ๅ *a2ี7fง๘ใiตP›ถซ๔bำฆ๕ซตo4KC<:$ž๐qบb’}๋€ม$hT์๛˜่`ผ3๙ฎaโำe็ภTขVฒo๒‰~ ใผ๓พd( ฅvIถ‘fํลN€\ก7ศฐR์}1ชAณ†ำฅฆ]๚”บPqฒG(ุ!L้ ‚ŽXซAืpUฺ.๕๊v†ฺ๊B๏Œ๘"ฬ•uผN๙๘i๙๒SA9yYT‹ู™้+๊z7G'!&œ–ษน฿ิฉ)Z†/xN๖2`๘%%ฅOl๕ไสe%‘฿†7ž}F—ฆ&ี9๛ฦตๆาš4c)ใญูฬcFฐiH”NแZCฤงฆsๅซc V‚~ ตห๛ฬมEญพ <ฆI็สQ>ีฏใgŠ4๔K์ญ?€๑ึอ^>|iปกผศ2Pด๖HฟหL–bŒa˜?ฆ~€ำธิ๒N฿^6xนmจถั‚๙`ไžมค0 *jะ]ใOLžWฎ(๊ˆyฤlฺu฿oๅ2ทฺžl’1‰ GzฅึuบZpyฦ.ึ€๋†‡nZฟN—E๋6+าธภR6๘<~๗?kœW๛ฬog`ู’2าy€งฆsฎำ๐ไƒX๐œำb+N฿#-ฺู7=คก7ภ+d็€H๙™03Ÿ’ษ—/oภuSCtณ[mปฮ8—ม็4%ะr#๚3ภแทLtท_๓‰๊๓ง–:-ๅo3ใฟƒyxีCTž…Vนu >?c—ƒใUๅ“ร[ษ’qYุ{ฤ3pQฦv#ฒ๚&!ล}Qšาะจyตh]ภŒณ0qhอเืถrีเสฑ^VฑUco‡=bเa˜e‚g0{lภcีฎ<ฌUงพซผFW|มsศก๕ตํู0v`๒ท7`lฯฬฏแ๕`ผก/ฉๆฦภ5l }ชgฮญมj๙$ภjว:ฤ฿tuอฅ?ึ๎{ำ.๏฿œถXฉ†AฯฑnpP&# }ภดW+j4e>|  ่› t๒6kX ๐jญ†ีd ๊๛La"ƒถูtJk˜ฤรŸ“›Y#๚›๏ำษ=sWธ‘ Qk)อทแฟ`Šnว๒]7ั๚๕คกืฯ žcญฐU&ฏสภw3ถ๛  U[ฯฑ> ฿i˜ ช%๖‘&ฐš d)Qคkิถเึ.สfปŒฏ€Y๋yๆฤฏชฃห~ เํษO}ž@ืฅ):m#ฐ หปวัW&ฯ\แต- qK›€ žฏซ๚Bธหฬน๛ลBฬƒ–†^gซŽ๕MNญs๘ˆร๘cฺvUืฐซ„ฐE๊อ3wๆTํ๓ |@W(ND4 l ฦ๗ปบฯŸ>{้ชPœˆh"่๘;ภฏMเmใพ 5Ž—†^gีtศB„หำ9๗”:]ถeุช์kRพ๑ฅฐ—ทด%นNtJ๔Cย9 ญ‹๚œชลc™๙Uฯd`nzŽ[Pี๛ฦHCฏƒ/X`T๗ฟ%ุH้%2>”ฑซ๊pู๖!ซ ฝป๘เ“Aรง8Ei›าถณ‰@O€้5๘WDu~IMุผฤ๕_M˜พิ„้‹fพด@YG" ฝ\žำท'เตŽกฃฉีjฏ›6oๅช๊ดฺ~๓๛มหsooตo๑๗_ม^ู>Œoerฅ…‹‹@ลฑ‚ว6=jอo4ํ๒‘aล(บ#PซYBจzk>ชg}ฏ5mw8#>ขt วe/๚8๓ )๖gภ๘iอฏ};Ž_Cb"ฒ!๐ ึb๒Š.ฺถ฿†bฑ6—†^G๙<ว๚<€`/d…Mปหฺ^ž๔๐™๐[ัGผOรA5 L;ุ”๙V๑ณž[w๕N๓์ !ะ•b๏นฤ%UgQ[ัฃšO์ฅกืQ%ฯ้ฝV๙%ฑ}ฆด้%::qฐ%ช|š%@ธ ฬื๚]ื๖บ์‰feฤNจ๐ }ณ@Ra[ŸL๚‘ษส †^0ฏ` ยพu ๒ๆœ™/๗+iDxuฟตoองc œ๗ฝKฤรHx๔7€ฏญ|m+๗๗H๒F T—อ–kส็"ำI้|้Šฆุฑ!ICฏฃดžcฝ ๚†ปฑง๊<<คŽฐ:ฤ[๛N8’^ฟนญมDฯ๙oมผHั/ฆฮq๏‰^xQา xŽ๕wีใฃฐอuา๊( }ŒŠฏY2cทZ-๕€โฤxาด5bkL์๔ oฬ@/3,{ฦ6™ฦ๗ ธ›ม%"r‡ถโ•ฒแFใลขต*N๖fอS๒Jธรฬนมฑย๒ii่c€๖Šึl0”n•หานro‹jy7มYโใ†g2! ฦส3ข•๑zใ6จœZ๏ปIบ3ญRl9oษŒม๎ืคศ฿ž‰ฆ1mฦ*†?ภŒวz๒ๅ?ฤ%—0โ๔ ึE œฅจฌiปvrก"‘อฅกธ๊๔žฤเ๏จิมWd์๒I*nฌ๕g๒_O>ํยเ_ด_ฒcะŸ1ˆb๗MฑK๗wzญโ˜_ีฑdฆYLœ%เ-&ี‘ว?๘ฝAผธซ;ต,I๛ใ{ล์๑`๚aŒF’JีvŸ2{ๅƒช:b_i่cpชฒ_!ขฯิ‡s ฃgšy๗kJ 5๔ข๏;ผ#1vแUvฐkˆXึ๔ภ๐8€วภ๔(ื˜๎๏ษฏ๘gˆพEZ‘ภภMN2บฦ๐\fPฟR$EŒ%5ƒ~ู3ง๔Gล#m>ธM5H6xVfNyนชŽุืG@๚X ฑฎฆ`ฝตยG๖5V€7†้ชBvบaคv"Sต๑ Oภ‹ืODใHฐม'ฌ7ภ|`ƒcƒ`6ิ˜Ÿ๏๒ฑ&5>๕h’ฎฦยซN๋•7ฟก}&6ค4%ฤ–ผภดหฟ ัGค‡WNั€rฬ5๓eๅ+}ๅ8" }ŒB{Žu+€ƒTๆC\ท|UษYl…@ซ TŠูำ%ญ<{€kฦี๘ดษ๓ส•V็ถ?ฯฑžQพณมe3_\ุฑŠ&าะวn่ฉฎฉ6mW8หoœ‰@p—ฆหภ5ฬฌ hวg ˆ>fๆJ7ดรyX>='{@จ่แg้œ๛A ฑญŸ€4šQX1ƒชEซฆ๘ล็ฆํพพ’ศH! ๊%์wภš€žzmยG—ฆŸ3?M๓ofฤใzฏ๑‡•aพีฬ—g(iˆqคก‚ชฒจ๗U”โว๊ฆ9ยภฟQ{sฺvWั[! ^I ZฬžศL‘ฺ‰Œ€2cซรMปธ6๎5ำs†6m7ฬ—WใŽYkาะGม9ะ฿๛ร็ปTˆ๘iปผ #! 4พsๆX—‚๐4ศ…!๑OJอJถ‘0ฤ[ฅ้9ฝ๘๛Š8sSD`E1ฏƒ€4๔ัฎะ๛ณ3ษงeupโŸ›ฑห็ซhˆญ%เ9ูห:=โLํ2ฦฝi9Kชs‹แU ูนLดH5~ƒiืฉ๙ารช:b?6i่ฃ0๒œ์1]76ฦQFศฒ %|b,^L ZฬพŸ™~*ทฅŸหdใ๚L}`ฑ๕Vร€๒Žy#ฯ• IDATำ!S๓ฅ•1ฉYฌร”†>J๙t์'kะc๛!มGˆ@ฅฟw๒๙ฮ…4v(Œหฬผ{ฦุฃ7"X="zR52bผ/wฏQี๛ฑ HC๕ ึOž76ฦ-`2๖ฯไV^ECl…@า ๏ึ=๎w s\ฟิkZๅƒฟ๒๐ษŒ]๚V(pE๔%คกฺะu<ซใLปฌ๔ฆผฬY!t^มบ„ccสaอะ ๑๚ํ_๑tโ๗๋ ซฤอDŸหไJ_Vั๚HC…Sตh]ลŒิ‡rไQ้ikวั~w ชhˆญH2ฏะ7 ไ/7บฮดKฑ๛BRuฌปxำa6 พฤดหช'ท)๘OŽฉ4๔ั๚ฏ™กฒ†ผjฺฎ™œ้$™ ฝžZr่6ตมร๒ฤ๛C˜cๆ%qJยsฌ[1ๆ˜ถ๛1E 1ฏƒ€4๔Q yŽ๒Zว- กฟ™viฯๆํลR$›@ีษ~šA_ํ 1mw๏8ๅโญ๋ม˜ฏ3aก™sQาใบHCญกฒ+Atp]$Gฤฟ1ํ๒;›ทK!\.<`ย„‰[=โถฎแn฿วใ†เ ’มุ‰lhิ™sfพšพfaฯฑ~เ8%YBฟ™ssJb\i่ฃ_กoง๏Wษฑ›ฮ•{›ต;!d^ั๚฿ำอ ุฝัgบ:“wG\ท๙ึw๔)€฿ ื?/3ํฒย]?ฝัŒฅVuฒ฿bะ)c็raฃฦฏ~ki่ฃ7๔ฟxc8_>’‹ฆ]V}ิผ{ฑ1&เ)฿!{i๒มQงตAใŒF6ฏณ๏aฆเKลd](SMŸฝt•.ฝ0u*Žu!ฃๆC=ช๑ซ฿Z๚h ฝ`=ยn๕ใ|้H:m—ีฌฝุ ค๐œ์Ž{ก๋๘ล œ›ฑ šแ9P่{A~ฐ๔ฮอุฟโk~Œึe{ล์ภด@1๏'MAQCฬ๋  ใ—ฅ7๑ขพ3žKUโY-‰บ“T‹ึ'˜qนŽœ™ด]พPEห[2c'ิR๗˜ชขณูvฉiปณ5่„.แ9ฝมc‡‹ญ1mW7ล0:฿\๚hW่Žl1ญ้iภ๔3_๚Hำ๖b(Jภ+XฟแH๕๔๕}ฉฎ๖๗ฬพฏcO๒uฆํnญž[๘ :ถฟ๐ŒiปSยVSo1dœ@ี้อ1ุQeแC;๔ฬนU๙ดฐ—วแญ"sTโ#เ[iคŠF+l+…๑UŠพคก+ฌื\๚่ =8Tฅ้-' t~ฺ.[o1dœ@ล้=…ภjงs๎2snำ{HŒV=/์ลcIซW่ˆฌ8/e lE€๕šKCฝกหfvชๆ+วัฅฆ]๚T๓๖b)’G ZดฮbฦEŠ™ุด]ๅg#ล ๅ\vฦ=f}“bŽก›{N๏Gพข#yห]`ฝๆาะGo่ซฌ= vคJๅS๋-†ŒBจฌณ‰ะิš๑๐c\lๆ]ล QFฎ†ฆ็่O™ถซt,i+ๆŠžปXmฺnบ๑&‡4๔Qf@ลฑEภซ&‰œ2คOL“I โXŸ%เ|•์|nฦ.+iŒๆ฿s,V‰/ฐ5m7๒+N๏ฉฆbฎrห]`ฝๆ‘ŸP๕&ฦ8ฯฑ‚$๖iV›?K็6k/vB ‰ผB๖4}]-๗๐V˜ฌ]gn๔+//่๐[ƒเฅ_ž5mWvฒjฌถlํฌ3@ธ๔ล#Fอsd)i่a่eบาะGํญมhด496ฐEำXtฏ˜=L?TหI฿†2/ฃZ˜ฑ7S๊^ต๘๐„iปMฟpซ่ปn๓ชcJป์๑ศตn(( }ด†๎Xม1‡Moั({นGxๆKh‘%เฌwƒ๐ ฅšyww%-{…์ ๚ขv,ฮFื๑๘@,rUฌg$ฬฅกึะีทŸ\bฺฎาzีHฬ Bดภ๊~k_฿วช.}ๆท๕ไหPีyนฝ็X+(‹—/๛^มบ„ณ”๎0s๎Jb\i่ฃ`ช:ึOh๚8ๅดํZuUB !0L€๘*ึฏWฦม๘‘™wOPึy‘@uqv/6่ฯชšD๘J:็~VU'l{ฯฑ~เ8E?ŽiปyE 1ฏƒ€4๔ัzั๚63Nฎƒใ–†iฺ๎;์ลT$’€็Xฐ‡j๒\ฃ3๓Jซ๊ŸฝŽซ๓a-ข๙fฎtƒฎธยาฉ:ฝ72๘}fบ:“/}@ECl๋# }ด†๎X็3ะ๔ทh๎ฯุ๎k๊+…Œ‰ภ*7;1๕ผฑRผปฒOƒxภD Ÿ๙ ƒŒG@Ÿk๏้9๖g…d x๋ NTฮDใ‹ฉีb๖ฬ๔3ๅ˜t32:ัำHžc`†Šพ์วกBฏ1[i่ฃ๐าฐ|ๆำvทiฌ$2บRด๚ศวl"๔1๐–†ˆ0๎ัr0-1๓+–5d+ƒ#Cภ+X๓Aธ^G@Dtv:W๚ชŠึš%3vซี†฿lŸจข3l“]โ‚P=ว๚ €7*ๅฬe3_œ’†ืE@๚hW่ลc™๙šบHnaPmOšf•ŸSัH‚ํ๐!เc@9ะuV๔:.3]—ษ—ด\Y%กQศq๕ู)xZญ-ฦef=ฃฝb6k0]งr”๒ห~ษดฯ7Kซm<ว ท™ฎๆ—ฮ2ํา%jb]i่ฃPช8ฝฝhm๚“๒WO™ปโฆ:ฐZ์=ฬ็1๒ฃ ๚_"ผtฎ|u‡#ํ˜๔ชE๋ืฬ8\cB๗๚ฬวื๛ๆ๛‹ๆm=>๕์๙ฎ1๘„ื๗ไ่ิ KKวŽx >มฬ•ƒ—๋ไ2i่ฃึ๒F+๏4s+~rc'?P่{ฺAtp‹ƒ_YซีN˜6oๅถุฏธk€ฮ๎/vอเ+@ฉgr+~?RHk4ต–๊>๚฿;ป- wฟ๕Ok๏˜รn:p’ั=nํ˜วภ„wgr๎ฏTuฤ~lาะGa๔ฌspฯFtญใ–G0๘]ปkNณi;ษV็ศ S3v้ญ๖+#เ9–าIcx{€@๗‚8x ~ญ๏ำN—0ฟdั{ำนาตQhฯ่Uูืค|Rโหd์ฟฅ/OํษฌsฝJCฅถฬ jัชถE๓XSƒ˜NJ็KWŒ5. ?tแถž8แ*€ŠBพม^๛Syซ’]…x$†Wจฒ'QG|๑"เ_Swศด`‡Z{…พY ฉjฌCƒฦ๔ํ_๑ดชŽุM@๚Œ4ผ๒ำvฯป=โ้›๚ถ๋๎๖†฿Zห5ž;y^๙‡m„๏Z<ิCภsฌG์Xฯุ(‰ำีyภQำY่ฑ8U.ส๓ฆ‘ุคกะ เํ@}๑ุเy]ฦ.Ÿิฌ}'ุm~a €"šฯฃ>3{๒+ั๘–็d†฿2๑‡oฺๅใ”@UqŽอนรดืว)๏8ว* }์†!9Fกศ+Lฉ`kำU…์๔QฐŸvิO–z(ๅพeส๔-•Šuๅขผ๒ษ‡mN'Žฯ‘=ง๗Z€฿ฃˆNฮณPุˆน4๔1hU๋Bงจ/๛ธiปฑฟ]ุL›๖ไ^w+@okฦพ 6ทฅŸหdi ม{๒‰ƒท'ฟ๋z"V}กฮ0s๎e๕ Žฮ(ฏ` ‚๊]…˜ถ๛ฑ่dีู‘HCฃพUง๗$Ge ฆบ'NŸฝ๔y8ฺVœ์w ๔๑8ล—C3โฤTWฌ^ฑ๏ฐ‡.ฝ้\iฺฎ๊แ&- ๕ฅn<ว ^d›ฆโœs2ถ{Š†ุึO@๚X ฝ฿สณล๕#}ๅHตทLตWIE#nถ•‚u8^ฒ\U– ดLูฉ๙าสึนO๕จณ"ฆŸิ;พอใnOO[kั~w ถ9ކ๋:๑Žˆ? ›95Œฟii่c ซ,ž๑2R๗5Mxุ฿cฺe-๛Rซลั๋อฯอƒฐ&ทฦฃv/O๚ƒ๗ƒ^ดsี"่ฌฏ๐ดˆ…'๒hw฿ืีžำท'เU ƒงsๅิtฤบ^าะว ตi๛ว็To—'j้šŽ#๋ภa“ี a‘ีฃ๋ฌo€๐I=jฺU๎ฃกT.}ุ๒Gด+ทHPื.}q9UฎEXCw# ฝฤžc=`—:†Ž8„Ÿgl๗อฺวษฎZศฮeขEqŠyKฑฦ๑อไNเ^oีข๕ f\^๏๘–Œc,งqฉฃาณ–?ำ!9ฉญ ˜qถขSฆํ๊:W1 N7—†^G…='๋”ซc่ศCbดsำ9n6๔+ธMทงชND์ำv๓‰Eย@ตฟ๏`๖˜ํฤ—ค—t\v‚—๒฿ผ@œฑฬปณฺ_—ไD ฝŽZ{๋"ฮชc่–†”ฑซฺ’DBJCฏฃ๐…์~ัˆG-ึa><คำ—oฌ]”อ ฆh ^uŒ๓8b,Wegxำิ9๎=ช:b_?i่uฐzjษกtืŸซc่‡แ๒tฮ=EE#สถ:6เyQ~คกิฌf฿พหด -{HศNฅb”๙Kl/%0ุ7ึN`ฆ๋šžaเš”๏urฃ๒œ์™]ข:งL•ข ฑA{^'0ฏ`=ยnuiุฆํพCม>าฆ'{3ๆirม๔ฆฉ๙RpvำŸี…]|โ`3ŸฉM‹Ÿ!ใ›f=MYGฺB า฿ป?๙œx@4๗@๐R์ฒด]๊ˆีcๅ_)๔ŒˆUWๅีดฝฦ๒%?ืK@z<=วZเ่:‡8ฌ“ฟฑzŽตภ$>-ƒ๚2vฉคชุWœ^ฏะ ๕ำv๗ึ # PYิ๛ชTjhZอ0Lƒ1A=ฬ˜@ฬซ|ƒV‘งปบjซฆฬ^๙`ยmy^ม๚›๚] บฮดKวถ<๘„;”†^็จ:V๐ยี…uqXงฎk|tง ›a[ฦฏฬผ๛ne ฺ่ไ&ๅฆๅ$6•ญ(xึ9ธg#บVฉฦFเฯคํฒา฿Kี’h/ ฝฮช,๎ณ รWบr$ยษ้œซtะKแถt˜็Xมแ?RuฦJ=[Xฟิ([XชX์#O@ืqิ่ ญ‘‡ƒฅกืYค๊ฒ™๒`mMรGFภUiŠFm='{1@ŸRŒ-ดฺžc๛สฟN)>ๆš๙๒•4ฤXDœ@ลฑพCภIŠaฎOOเId•‡uฤผAาะฆl‰๑w3๏พก—ฑ๊9ึuŽฉ7ุ‘N]#ะ๙iปtnฝŒ๓œK>ณ›—•cUU่‰m\xŽ๕oT‰—ˆtฎซข!ถอ†ทŠc]Mภ๛0yลPJํา์r,ฟaฺjyNM|‚™++฿ถ)Oฏ˜=LชWืฑ=ื:ฬฺ‹v็xฆvzศ฿่iศ่Kฆํ~^ƒŽH4H@zภ*N๏ฉf&ฏฺทn+N๏"ฯUแยŒ#2y๗&-ูV๚ณ3ษงeJฺŒ‚™w•rT๒/ฦB dž“=  Tฐมณ2sสสำจฦ‘D{i่ TฝZ˜ฑ7S๊LFJฟ0ํ’า๒75๚ญซE๋ืฬ8\E9ฬอ[*ลพท๛ฟS‰ภJำvQิs!Yีข๕mfœฌเ†ฺฤmv˜ท่Uฑoœ€4๔™yŽ์qฌฒ่Zำvทmะmค‡W๋็ผW%H&H&W‰Šฦ–lซN๏<ม*|ธhฺeญ๛ิ+#ฆB@;M'%v๔Zฺกk”† Pฯฑ~@mญดOšsKw4่:ฒรฝ‚uJ˜0ใณ™ผ๛•0’ฌz{g&GUํ๑ฉž™,d›ฎž@ล ~Šฌฒ#aาีูฆซ"K* ่ำ‡ ่c…'‘EADA|\@@ฒ™้๊$0]M FQAT“™๎ž“mfบฮฃ&อ Iฆง๋Vwu๗ฉ๏ห๘ๆ๙Ÿ฿ํฬ้ฎพuฯyL|ปGํtำvปบษ%jŽ@w*๚n‡ษ๓A: 1ํ/ื *IH ๚0Jั™ๅ5ตi$oลฎa๐ๅรDนใ๐ดnฺำ9ใ้ ๑•NlY๚ดgQš d“ั๋ˆ่2ฏd็ฎW‚2?จ฿๔บC=.ัMปอฃ†L๗@@ z ๐Tlซฅ่ฏฺั‘ใ6‘’wๅ๑ไๆxๆo%,ห;ฆธM8(ฤ/้Uฯ้๋ื2๗ฉทผ๊ศ|!4๙T์ฃฬ|ŸW_ฬ…H"s›W™_:)่%ฐห&cg๑%LืตGฬดŠvฃ^l(››ณŒ'ด*|:ผj๊ ด`ใE‹”OEž~ฃ›๖ั^ศ\!Tร=้qWy8Z-mหิ<๋ม—๔Vyํ’™ }žฟ๋ญฅO}นT๔J0-(็;ฆจ86›Š]AฬWซ๐รฬ฿Œ$2_Qก%B hr–แyใั—oฝ<๚ชซ้RะK\๎œeภQ%N˜ๆ็ณื^|•27฿=˜5zพ”นป˜๓0๒3บ™r๛ฌ} ~๘ฟร9[~(qวมQ-ณํg†'?ีF@ษ)Š๎๏2yำˆฅ—‚^โ2ไ’ัซAไญ™aฑทใ%Zดผe…๗*4๖:i8/f'‹ัฬฆŒSˆq €}Š_ไ˜Wtำ~O‘ce˜จ*นd์ว >วซii/์• š๙RะKไ˜k}?Uโ๔Mkะš๔๑mKฆUๅ-ใRฎ๗!‘4˜ตpวO์mm{๛็่,E฿—?๛ \1ํo๘“H Šฬฺ `ฌG#yดฝœž้1ผL฿F@ บ‡ืBฮ2วฮผใZCอZ{ฦฟฎเ๛ธญŠ๛ h ภ}ํ`ข‡%j๊m ๏S+•ฌผพไSั3™้ง^ณfฆ{"‰ด๛†Zฎ ‚๎aT<พฦzยžแมF ฆ*:5.9๐ฐi{=/นˆ !ฐ#œeธั<ŸฮH “ยfz‘ฎ<)่ึ kE?B Gู‹FะๆๆO;‘gYะ|ํฮ“vL$แ$—จIyหธ‹ณฝ'Gฅ›iฯ-Wฝ๛…mค +x-d“ฑŸ๑™ฅœ๓ป&&2k=๊jz61$Pฆve†hพO?X^ลค(ภ‹g…๛^๗ุ๊†ฆo?ลiB^cอๅ]udบBRะภฬ%งอ9KH]ฅ›๖• t%‘ณb฿๘‹2ตƒyD-ศซ#TPีใ€…ใ๖GT๙5ค ซแˆœeธ-?ƒNJพG@"ฆํ็A)%{๓:1—Œ-q ฿ฌแาp–ืeพ:œeฌ0ษซOŸ13zี‘๙j HAWฤS]ท1žง›™Ÿ+ฒ(U'S)Lj#3>Iุ)ิ)!H9+z:@๗+0ทZ7m•X —€tEฏƒœ ืถสฑดหtำžชศVเdบฺ#5 n}W…อ ะบู๑b…}Hx!Pน”๑๖Œ_$์๋ผ๊ศ|๕ค +dšK)0ฺ;Kฯ:xsQ4าข๏(x~ฟด ‰๎ธhธญYK &ณ„@ๅ t[ญr0๐ฉ็ซฑI‹Œ›‘๓,$ส HAWˆ4—4Nม๛ํ๒kซบ+ฤy+g๐ํ^๗ น„๎ซฝiผ@ฤ็…ใ™Cฮ‘B †ไ,ร}าไ R๚‘nฺŸR #>‚ฎjึ2: h๑*๋88ชeถŒWj˜ŸOE“™&(๛๚ไ๗ฯฤ๘z8a฿็“พศ ภ่NE฿ํ0ฝชย ฦกๆDๆYZขกž€tลL๓)ใฬ๘ŠWYตGฬ๔ฏ:ี2Ÿฮ uหถฑƒsธy7y๔พ€‡ ฤ?Žดež$Œ๎QTฆ j#Kw‚เS5๓r=‘iญถ๋ษฏtลซล๔ธก๐wฒ๕๚nธำŽŽกอ#Fh๏มพ็ปj‚ำรภ:b^าViษ›ฃ ห'™อ*ึA4„@ต่Nฦ๖wˆGj\ต๛Ž8‚๎ร2ไSฦฃฬ˜ซ@๚!ดOS Snกo๊uZ๚ฦˆ)TภบๆDZษ›งš$I(๛tศฃjU๐๊’‚๎ร"ๅRฦ,0ซ‘ึ’Gซิ!POT~wNเหยfๆ๚zโWนJA๗iีฒ–๑2๏๑.O๗๋f๚ฃuDAz" ๐ำywa๏7ัศ๔ิฟjฬU บOซ–ณขtฃ yr๘๐์ฬTh‰†ตO`p/ฯ_4aƒฏˆ˜™kkŸZ๕g(ง5\฿>ฅน 5บ'วํแ5แx&ๆUGๆืuํำhะpจp3@เI€6pF‘รฬš{@ศ๋็e@{^ๅฌj62๋๋‹Tํe›ณŒOŒxฝบพ[ๆ>๕–W!™๏?)่>2ฮ&ฃืัe*B!Ž– -ัจ]๙”aฒƒ“ˆ0ƒJส”ฑ „ลh.๛]I2ฉbบ“ัร"๗T8ฟ฿ฟฆ›๖ีKF‹€ŠVภz์qฺขุีใVรม๑็๐ชฉา‚ฮp&ษฺุ'เ๖`h็๙"ๅgไ? ~จทฅ๏ฌ}’ต‘aฮ2žpฌ‚lึF๑พ๒น’e’‚๎3่œปเ‹”„aดžศศ/V%0ซ_ค'k‹F_ใ‚ฯb[ท๕็bƒˆพ6ำท๚@TUPุQ ^ ว3_WแK4สC@ บฯœ?ฅฏVp๒™๋ดณ/ิxภ^ณ–n๐ูถศœภเฆK๗VจŠป?Egห ฿iภๅa3*z’ , ถโ#๒ุ์๑๊น฿9€žย(ž$Ÿฮหฒtส‚HAW†rืBนคq ( %๏š•`ฌV‘l{๋ค…พ`Zes ๏่f๚ฟ*๋AขoO g_{๛{sUŸจฏาM๛J!\]ค —aฝบŸ8Isึ( ตฑฟO;`ฯน๋้‰L•่JEฯะ˜~`t ,~้8ฺน-‰Ž?ยO›ศ.Šฝ‹Bบ  tq_๏{dgป šๅี‚^&JฟKะM๛Œ2Y—0 O—0ใ†XูัBงใ8gดฬ~า ทบฑ”K๗ ไ*b๚l8‘AภซกDฅ —i1๓Oฯ}M[ฦซษ„S#q๛aZขl9หpoฃบทSƒzˆp’U‰–ˆŽ@ฮŠ}เ œฑ]" โวอสผT-žซg>eสŒฯ+สราM;กHKd*@@ z™ก็Sฦ็˜q›ชฐDัpทฟฏDLD*J@ z…๐็ฃณFฯ+ VศัŸ0ปใ…š"Uf9หX`f™ร๚Ž.ัอ๔>‰ืฅ์เฦฺgผ_€งuำ>^‘–ศT˜€๔ .@.i„ThแูpOไXš`ฏBM‘*ึงŒd™ย๙†Nฝ'ฒ7อฐเ{ฐ: Kw‚๐)E้๖Cรแz›‚"=‘ฉ0)่\€๕ํSš ZฃปAnœ:t‹nฆฟคNO”สE gE-€โ~ฤ#b›™V†—ธภyhi“ฮกŠ๘ q™๑ลHยฎฺ๕ฆ™MลN&fe‡Iฺ่ฐ™พข8ึrพRะ+ผบนคq!7ฉดA„„œฺฅ’จZ๙d๋!Lก็TGbเ†>โ'ล3๎ว]^]–1Us7โ1NQ์แ9ดSฌYwrnฯ{€^0VM๒๔—p~ฐอSC3(*Rะ+ผlG๒›ศNLๅ#J๋>D73Jš5TQ]„ฯงŒo0ใ+ช’e Mะฮือท}ๅ๓ษก.zา‰๘ฤp<ฃ๊yiUถชFgเwฤFz „cT™fฦq‘„+Uzข RะฐV๋‡„~ คฬcU๘ืSง eš"ไฌeผDŠ6:1pWฤดฯ)ีlถ}๚*< ฦJีุ~žบฉูL_ฌBซ5Tฟูัz<้zdY๋9KAศ +๎ฦถ5+ๆk๔Dๆฟ’ขุุbใ 8๘ƒ@ŠŽฮ.Š~€4ZBDฏuำVyJฅ๊|Œั=1Pี๏๊uิzxฦoTq9ช^$ร‰)cwB`เไง1#ฃ๏V ˆะฆวm๗Q(นJ@ีqภฬXภ}‡จ: $›2N!E m้ํฟ๗IO# KH[ษ่^!ขgT5^q“ts[ไภŸ@ฎท SRะUPTคัŒต:ฤO*’&๓†C8ถ%nIฑฎศ)"ทŒป8ซŸ‰g~โUg๛๙๊๚l๓<ฬ\ฅทZึb+>"อซช0ฯ;tำŒB=‘ )่[œeภนŠmฝBก#ไ6›bชŠไ๒–๑;๗$วxIOุzาุษไ์ขุ1b›งฎาM๛JีjU/g๎›uฏพ9š?8ูศlฎUf’—บ๏e„ฅ"9+>ุ์ถŸœคHr›LGxีิ™ฒIN1UrYหุ@ภhRW๊ฆ}•GNฯYัUํM›๏ือฬGฝiิว์l*v1_ญ0~ …cšอeฟSจ)R$ Ÿะธ(]ฉhTcฒ•[cฌ'lU ”ซGม5‹ข‘!Rะ)ือŒฒ๓ฝท_‹lส๘yคn…nฺ'ึใ'็|2:›‰ฆ6ส~7๘ฒฐ™น~8>dluP๖ขฉฮ๔ƒ๋:Ÿ2neฦ็U;dโs"๑ฬ]ชuEฏ4])ใรฝ#ใ้ cไH2S[<‰์bฒ๛l:<เIงฏ)่_#^8/”“uฯม>NนUฦซNจ„–ถๅTฎ-‚Eศ-i…็W4โษอC4`)สะNe“ฦDธฅิ๙๎<~6mฅEห‹Ÿ ฬๅ…๓š๒cฒhU์็้pOdŠดญULต ไค Wม"ญ]2sbcกฯ}ไฤ‡ช๔—&๔0ึ\ฎ`งuภ ˜Eอ‘๙ฮqž๛ืig…ใ๗๘‘^.e<ฦ|ฺ–nฺ 53}ฐ)ำcLลIu๖…ูkึาNลบ"Wค Wม"นปวŽะvwภ6ชทLlะงŒo[’Wฏ-ŠCศYฦ฿x0ฆ่ ๗ฝ๒/•cdŒ‘CๅฑปŸ่ึฐ™>฿‹Fญฬๅ ด๎c–=ฤเ(ฮฉ๐ฟ_T์Uไ| จ~Iๆ’ฑsA์ž$็ว๕5†Zๅ49?ะ๎^3—4R ดyฌ4vฉCลฬ% AธiจqCœ@Ÿ ›้‡W๋?gu/6๎bฦYชseะ๙3}ซj]ัซRะซgญœfญ่ํ:ฯ„gจ!4MŠบ/tw)ชnใฃฺำุX<+๏๔บ=™xV7mฅผU3็z๖^ไฟศzFผก๘ต๒VซReพัแ้ใๆdฒมหพ๖ nŒ j„F˜์ฑEf>ปŽ™/SC›ัอฬ)jดชS%g ฬ๓ม}^#>าฏ'|๐+’>‚๎#\?ฅณํญ’r‹๚>ล๙SŸ6uฯน๋|าูํ(๏ฎๅ๐่ณ3?,r6iF„ฯ•2wงs˜>ฉ'า?VฆWEB[฿ฌ}  ธถ7h(œ(MW| [ฅ’Rะซtแ\นได g1อง4^แตFๆคW๛ค/ฒƒrIc>ศใy้;ะ$เn tี3ืb@็ญXœ‰ฏV|ศIa7O42=ลxจฅ1ฏ-<~ิ่1#˜ๆC^ š1ำiดEฒJ HAฏา…f;kลพ@เ๏๙˜ฦ฿฿๎6ต9‘vญ’หGูค๑:ฅ:ใoDห๖9ฅ#ทฝ~็โ่{5G›Aเ3|8เ บSง?ญ:ง ๋ญ]2sฦB฿พ0ˆNืใ้ƒฮA•—€๔๒๒๖%š๒[ค๏t๙z-๖“ž/IึhŠ^ฮ k|Nีmิ‘g ถ6็g<‡๙่–Dฦ๋Y๕~ZTฎ=ฐวฅ+พำ๑oŸฬ_าO'๗)OZA@ z –มป‰ฌ[Dเู•vฉะI\˜N,s;BษๅN;:&ด‰ณ๕'๘ _~IยB=nŸ^ภ•‹8ุื=ฮu__\0พฅ'์K}ััช' ฝ๊—pk๎๗u{Œน‚#|Li#3>Iุ๎‘•r๙@ Ÿ2.aฦ >H—]ฒ€ยอe๎‘ลuq vฅ[`ผO ?ŽงKosŸ่ึ€ฌ๔Xฤm)ธท๚๚Cด’๗๚™ฎ ›้+ŒQฯฺy+๖[VJณ<๙Fฬ\Rธ•‰˜Kง‚๐s฿ข๋q๒พYแ๒‚^~ๆพF์Nฦ๖wˆ๏,=Ÿ๎ต;ฃDxls˜ํ=gัF_ชC๑๕)cZแnจชึ๋ล๐*๛ZงZŽ๏ผe\สภ7‹?ig8๊cำแžHœๆ?่น‰ฯฐ#ห„ช" ฝช–ซ8ณƒ\–๙๘Œ๚6#ะˆ็ศกลญหpFๅ,ใ๋พ6œ9Aห i๕๐8•d%ฬ“?เ็.๔›ฃ81ูศlส๚Šเ‚ต๑ไl๐๕ฅžDŠ›ผ!>น%žษ7\FK gE่ไbว—2nธ'OธP77ธzGu->qRศiธจYtผ9ŠgK1๗‘pIKAฏฑ>lส˜FๅบuหธHOุ฿ฎaœeOmอข9ฃG4๔ุ`S๖เฅ$ฌวํ K™ZMs๒)รdฦt}ง7๖lžฝ๏•›|Œ!า5F@ z-่Ž้tฅขQษm1สT9ี 8S๚ชซ#ณข๛€ษmฏzฐ:U”˜๏ิ?o=๛`zx’วธฎw# oๆฐGฏุR3K๖ง ›[O‚^/\{์ระุ}œfLา]อiKฏ*Cฌบ1Pิก= ๐ัAL˜฿'์ฯั›*O๋ญุ{ ฬp˜*อ]่H1๗p-หKAฏๅี.ทฎvใHMƒ  ,m, ๘rุดkโy๊ ผD:Fว4Œฅ{˜17~ถ๓pฅnฺWฬ“R;นd์\ะภ๑ส~5Bฺๆ7jldี;ก;=!๊.Or ^5็ผe\หภWU๋– —ใณzยv‚ึไีHt‚3Bปเำ|O๑ฐžฐO๕=ŽจiRะkzy฿™œŒ#ไกษŸฃ)฿r5ŸŽgVิj฿าอ-Žถมกไ[ ‚๙"ฬผ^‘๘ešตb1ป฿”7หู‰;tำLา’5N@ z/๐ฮา|ไf™฿'สm}:๊Nm _ฺ|rf}"๗%ๅl*v˜/'`ไ๐”๖ฐฯ1ั5ตๅ‹ฮkส้บ /๚xPฬฟ–‹AืGฬ๔eร[?-vN@ zพ2|bšหSyํ$โ‹ย๑ฬ=uŠ]yฺ[๛ีพฤเฯ๚๕U ฟeเ6ดค< ๆ“ญ‡05<๐eฐล บ bฆo-C, Q'ค ืษB๏,Mทgs“ำปˆ™Œ2cX}Rฺฑชฃ๎žZ–;vููไ๐้ ด)P๎๐;ณศ์L5C;$ ทฒๆSฑ‹| #†œเ}€Cฤว3๗{—!๐oRะๅี€œeธฟXส฿ๆ’xฯธGœสฅภ๚๖)อ…Pำt€ง8 <๐]๛๎;€1Vƒ๘9ฐถJ ั“อm๎5ๅฌ่qบอ็.…sฬŒ3คcaอฟด*’ ๔Š`^ะผ2ƒๅพ^่ณบ™.ว1ตๅฮ-0๑ฒNl้ฃฆIOF0˜ mtBœใทx๕ฤ๙™žภ˜-ƒ‘ม}$฿dเ์2„โoDฺpผใน2ฦ”PuD@ z-๖Pฉ๎์}ภ„กฦช9ง8„ #ณ2/ฉืE!ฐ•[๑9ฺr 1ปั~ฎ|;์œr๚๚No™๛ิ[ฒBภ/Rะ"[ฅบํWม•แDฌ๊'๐Bฺˆ+ๅ๘ุ*}ุvฮŠžะทผปŒ6-bfฎ-cL Uงค ื้ย•v6๛)Ÿ9ิ8Ÿ~พ„ซ๊กk—ODv;]ษ่QัMo๗o-3˜nVญdหฬUยํ‚€tyi์’@6<นิš*ƒ‰โ_ทQ™๘ตš ไฌid๐๕ž]๎<๔;0'2'ฝบฑ%^‚^ฟk_TๆƒŸn.ใษr๏๔Ex†\-;ƒ‹Zฒบด~I๋dงZPๆ oโNเ[รfๆบ_PvRะหŽผ๚ๆŸ>ž๛๚Pผ’๎N๐ี3๓h%}H์`Xปdๆฤ†B๏ื ๔)pูรเณไ๕Y๒r€€ty!M o—2p}ั|Hฯ๓7ร ๛>฿Bˆpีp7r2๑E ธุ‡y ฎข4 ฯhเำšใ™ฟ)R!0lRะ‡ฌพ'ไO;‘ว=ˆf๏J“`เฏฎŠ˜ถDCฎ:#0๘uะฅๆU4uฦอzยพฐข$ธO่๒(…@ฮŠ#l^ฅพฃ‰็0ใM฿1nN&[JN2ง:ธGๆ~๒d_ะ๑vi๘xธอNVุ‡„ไบผJ&MลN&ๆ;่%‹(žศภ}๐ฐi?ฅXZไ*H ำŽŽiุLgณƒ‹A˜\A+ƒก๙ืิ฿pZ๘ค'Qy/โ@l% ]^ žฌ{lฺž ฮAHxR=™๑{"๚Ÿ =›~ฒ๏•›Tห‹^yไฌุL‚€•'โะQ˜่ฟ#๑๔5C”B ผค ——wอFห'c็1ฑปan\ภ’|‹€Ÿ8Œ{# ๛W๓&vvB sQ๋๛B Ÿ`๐ุ3(ˆุึX๛ดt สŠˆ HA—ื„2[?ญ๓7A|Ž2Q…B๎&: tฟใ๔฿™ฝ์ ฅEส#G#{S@์>rv‚G9ีำณDtA8ž™jaั* HAWISดtตGj!Gษภ-yฑvs"๗ภ๚ฌac๎ๆJฆMงc>@ำ+๔์๘๎3?าz๙โๆ“3๋kx)$ต! ฝF2ˆiไRัO1ำuดั฿ฟ=๑J=ศงค›ฟ+ๅnn mข๙`œฒป}\๙ >&โO…ใ™u! Ž€tu,Ei'Oaภฆ่โ*๔7"$ ภา‚ึุฑืฌฅชฤw`mv-ฑทV่Ÿอฬณ 4'ฐFท๋xnff.r ช" ฝช–ซzอฎoŸv@Asnpjeั `9ภ) ”าอŽซศ{ลฌฎ]2sฆB เฬ˜ย*fฆ๘ภฮ ~ุXเฏสYลC“‘ม" =X๋Q๓n๒–แnx๚.GTaฒY"<ๆ•ฤฺส๕ฃU“ฬๆ*ฬCฉๅWํ่ศ๑›q3ลภ<D'* เณ˜ป{Ž๓ลpbู๓>‡y!เ+)่พโ๑`ๅฌุ™D|€wU1ฅ~~OŒ_‚h%มYY๋gy๓ยyก๕cึ๊P่h€Ž†รGƒp€†j[G^vภ—H3•j[9๑ป+RะๅตQ1kอ=ฒaรฬ์žว\1#jฟเ ๚#/€๘`~A73ฏซ ใฏฺ›‹ข‘Bศูทภก@ด1ฟ„c็)ฒ๛ว๑VูซŒ+๕„ํสฺ่B@-)่jyŠZ w>ปอ-?ใKจ†)opฟƒ‘—‰eP—งซ๊jขฆฎ๑mK๒~'าHtBShbH+Ld‡๖$ข‰ ny๛๑ฌ@ผ@๛ฝ]๒พF๛ํฅ๚=oฟพnึถ๐ทๅ1ด ะ—พ‚๎;b P,ญ}ื ๘€ฑลฮซฑq๎ฃ d ุย ^oa`‹๛`ธ๕ถlC฿>กo๑@ะ ๗oAภH†3ยo5xb•ฝแe™฿|๛ะ-Rศฝ ”นี@@ z5ฌRy\฿>ฅน_k๚2?Wว…ฝฮV—tป‰่†B๏–Zๆ>ๅ!‘Kิ4)่5ฝผีk 5z์ศณภ๔€ฉ๎lฤ} ไ|3๗๕}W yฉKจА‚^๑%ล่NฦZยg>ฃ˜๑2ฆ 0^๘–พ†ฆ;ๅ@ :\IYฺงสk บ œ:ึ฿ท‰ว•s€รI+—|ะ"[`žžศ,š1๑#สI@>ก—“ถฤRF€,ะ๒ว>9L็``S˜\พ˜๕`X"อu|[tฎ2Rะซlมฤ๎; t=vยXญqฤm[FตL€ž'rn๐ึ–ป๗ฟrS-g*น แ‚>\b2>ะึ/i\่ื>IDg0๐@›sลศq7‡่H[zUฑ“dœจ7Rะ๋mล๋(฿bใ 0Ÿฆyภภ๑คrUfz”เะ๒8อฐP=ึลฉจ )่•แ.QหL sQ๋๛ด††ำษแำ@8ฌฬแ%\q๚,%ฦฝ6l~DnฉMF mค หkก๎tงข๏.ฐ6›˜@ˆUw‚“p'ฌ๑าF๑‹rœิล‰PK@ บZžขVexแผฆ๙ฉ ‚ Pภ๛ซ)…ภํ=ฦท฿D-๓Dกวร๑Ž็†ž"#„€(†€๔b(ษ˜บ!ฐพ}ฺโฉ >Ž€ฃชดoปโ๕๒๚ถWศ.8ฺ-ณ;lลๆDNARะๅฅ vCภ=~vฬฃŽfโใ˜q,h }hด rนgฆฏฐย!^3OO62›๏Z  ฝQR(/uMณก‡‚๘0">”๗ฟq €ฆ๒: Dดบล›ษC+"Oท>K 8p&&„@‚^g .้๚G Ÿl=šv;x?@€[|—A™ฟ๐'0^€†—5ฦŸ๚˜41‘Y[†๐B"HA/’ ^ ไฌ่>pด(ไ์ฆ<‰A‰แ๖)o =ษk๓_๘ŸDดฦaฌใ5Œ^า์<่สT! สD@ z™@K!P 7ฯ ๗ั–‰šƒLh4๋อŒ1ถฟ๛7ow†={มิ ` ศ›ท่%๐&๊%`‹ฺHŽณ–H[M่_ืธfฯน๋Š๑&c„€6)่ม^q'„€B (Rะ‹ย$ƒ„€B@›€๔`ฏธB@!P)่Ea’AB@! ‚M@ zฐืG ! „€(Š€๔ข0ษ ! „€ม& =ุ๋#๎„€B@E@ zQ˜dB@`‚์๕wB@! Š" ฝ(L2H! „@ฐ HA๖๚ˆ;! „€E‚^&$„€B ุFฤ Yช@IENDฎB`‚python-advanced-alchemy-1.9.3/docs/_static/logo-default.png000066400000000000000000000764041516556515500237270ustar00rootroot00000000000000‰PNG  IHDR๔๔หึ฿ŠขiTXtXML:com.adobe.xmp kQีjsRGBฎฮ้ IDATx^์ ˜Uี๗งz&!„คซ'dwA‘ลQBืLBาี BwE„ไU5ข lโ†โŠ"ฒ!้๊ษึี„EQQ@ลๅ•}ฯtuBXฒฬtฯšไU–ษLw฿[U]งŸ'๒ฬ=sฮ๏™ำต{ ๒B@! bO€bŸ$ „€B@@บL! „€@@zQRB@! ]ๆ€B@ ฝŠ()! „€†.s@! „@†E”„€B@HC—98k๗ํ^Ka;ต๑‰K>‚ ง(U๓ฉ6ะ58๔๔”นทญŽ`ˆ’ˆi่ฑ(“ู ีึพตlรเYฬฦŽo`b3ZbำR˜n๓ vzrๅrKฝ‹3!SาะcZ8 {dีพƒแืๆ3่0; งŽ ฐ ‡ 3นา‘‘$!B =จ"ูz…์~Dt ‡ดปxl!๛๔™ด]ZิBŸโJฤ‚€4๔X”I‚ฯษ๎ศ  84Sn‡อ9๎_”ณค*F% ]&Hl x๋ .mธ:ฦef=C]H„@ HC —AuูฬmAjฯM\๒’๐+ ๎ขมิ‘้ร–?"x„@’ HCOr๕c˜๛SKึ=4x ฏa๘rxV†eฺ+๎ ฯ…( h†ํ๚Ht/"IQ๑# =~5“ˆƒ}N,0ผท฿r 1‚eI“J" ๘ฮะœ=อ*?—H’ดxi่2bM ฒจ๗UH๑Eผ7ึ‰H๐ เ฿๘†qJฯœา4”แB c HC๏ุา&+ฑชcศ@pไ๊›’•yโฒ}”™ฮษไK?K\ๆ’ฐƒ€4t™"Eภ+๔อ๙ŸะืQ‰I2๗2ำ%ๆึตd•‡‡ฏ$ ]fEGX:kšI€อภ~๒V|์ส๎๒V˜sฟฦ. Xด˜€4๔wํ!๐ ›jโ ุ‹;Œ{|ฆr4Œ๔˜ฒN\ˆฟ :~ โeฬผ ”z฿ธ/=‡฿ฌชฎุ ค†žดŠKพร6-}ฃe>hฮ-กฌSฯฑ^0A%|9ฏ\…žุ †.ณ!‘‚+๖ษ๋hr๒dผำฬญ๘ฒNL<วzภึ*แ๛ฤVOฎ\Vั[! i่2 Kภs,VMž€ƒาถ{ปชN\ํ=ว พmฅ?๔vู{]… ุ MคกหLH, r…ฎ|…{ษKo‰5”ฤ5†ฎฆHล‹€Ž็ฟr…ฎ~…๎ณฑGO~ล?ใ5{$Z!=าะฃW‰จE<วzภd%wr…ฎ|…n0ํ:5_zXฉb,„€r—9\žcญ0E…€\ก~…ผค0๖U๏dฺๅไ.S™€b+^D`์฿5ม%:”@ลฑVฌIo#W่สW่ƒฉ๎ํฆฯ^บช๙"ˆฅi่2Kภsฌงlง@บrC๏gd&ฯ\แ)ีAŒ…€†.s นt4tนๅฎRœฑงN}WyMrgขd.๔+t=E%†<วzภJกหบ๒zmO’๓ฬ•fก aาะe"$–€Ž†.W่๊W่jทูaข` Y๙! @@บ<17ฯฑ‚7ซ_ฅ’…4t๕†พvOุอ*ฏWฉƒุ ! W่2Lภsฌ`ํ๓ฮJไ–ป๒-wำvๅยBiŠฑุD@~‘d&$–€็XุE €4ti่JHŒ…€>าะ๕ฑฅ˜ะัะๅ–ป๚-wนBู/Ž„Yาะ#[ ,l^มz„”ศy่ส็กKCWšb,C@บL†ฤ๐๋~ป+[๎rห]i‰ฑะG@บ>–ข3žc=`W•ฐๅ–ป๒-๗!ำvปUj ถB@l" ]fBb HCW/ฝ็(7๔AำvวฉG" B@HC—9XาะีKฏกกo4mwผz$ข „€4t™‰% /ลฉ—s,y)Nฃ(-คกkม("q$ W่๊Uำp…พด ๊‘ˆ‚าะe$–€\กซ—^ร๚ ฆํnฃ‰(! ]ๆ@b ศบz้5\ก?oฺ๎D๕HDAi่2K@ึกซ—s,ีu่ฯ™ถ;I=QB@บฬฤ+t๕าkธBึดษ๊‘ˆ‚าะe$–€\กซ—^ร๚3ฆํNQD„€†.s ฑไp๕าkธB—†ฎ^QรคกหDH, r8‹๊:๔5ฆํNM์$”ฤ…€Fาะ5ยฉx๐๋;ฉD-{น+oบฺดดJ ฤVMคกหLH,ฯฑฐณ9mM๕-๗ชiปฆR ฤXi่2’MภsฌวผJ…‚\กหบส[! “€\ก๋ค)Zฑ" ฃกCฎะๅ =Vณ^‚ํdาะ;นบ’จ<วzภ๖*˜ไ ]ฎะUๆุ คก๋ค)Zฑ"เ9ึใvP Zฎะๅ ]i‰ฑะG@บ>–ข3žc=`;•ฐๅ ]ฎะUๆุ คก๋ค)Zฑ"เ9ึำฆ4ืตDบ4๔XMz ถฃ HC๏่๒Jrฃจ8ึ*zT( mgปทซhฤูVรNqฒ=ฮ@bi่‘*‡ำJ:บ์gฝS\๐W&ธแ1๒Gz+'ฝ๘๊hาะ;บผ’h<วชPฺิDnนห-w๙-Q! =*•8ZNภs,€าถฃาะฅกท|โŠC!ฐาะej$–€Ž†.หXฒl-ฑฟA’xิHCZE$ž–๐ซ @ํค/9mm์g่ฃWTžกทlฦ‹ฃN' ฝำ+,๙m‘€็XซLQA$ทๅ–ปส[! “€4t4E+V<วZ`[• ฅกKCW™?b+t†ฎ“ฆhลЀކ.ฯะๅzฌ&ฝัคกwty%นัxŽ๕ €ษJ”d/wy)Ni‰ฑะG@บ>–ข3žc= `ขJุrห]nนซฬฑ: HCืISดbEภsฌ็lฃด4ti่*๓Gl…€NาะuาญX๐ๅ็ฟ†. =V“^‚ํhาะ;บผ’ฯะUŸB^ŠSRT5mWi๛]™ๅB@l" ]fBb h8)LฎะนBO์/$9าะ#W จU*Žตž€๑*ไ–ป4t•๙#ถB@'i่:iŠVฌxŽตภ8• ฅกKCW™?b+t†ฎ“ฆhลŠ€็X[>ฅปLdบ๊{๒ ฝน&ใ„ภคกหI,ฯฑtฉ+tนBW™?b+t†ฎ“ฆhลŠ€็XCR*AKC—†ฎ2ฤV่$ ]'MัŠฯฑj • ฅกKCW™?b+t†ฎ“ฆhลŠ€$–€–†.หศฦ2‰ ’ฤฃF@zิ*"๑ด„แญีU“7ช:“[๎rห]u‰ฝะE@บ.’ข+ผ๐่qี‰•`/wฅ4ti่JHŒ…€Fาะ5ยฉx[๎๊๕’u่๊ EA่" ]Iั‰์V“ืั:ๅ ๅบrห]nน+M 1 HCืSคโCเ‰E๓ถŸz.ธบT๛ศฒ5Yถฆ6ƒฤZh# ]JЁUnvbj=ซณ\กหบ๊{! ‹€4t]$E'Vt5tศ3ty†ซ™/มv2i่\]ษm‹n:p’ั=nญ2"i่าะ•'‘=คก๋แ(*1#ฐ๚ฦ์<ญV [nนห-wี9$๖B@i่บHŠNฌ่j่rห’+๔Xอ| ถ“ HC๏ไ๊Jn[$ฐf๑ASkFwU‘r—†ฎ<‰D@่! ]GQ‰ต‹ฒ™ม จ†-ทๅ–ป๊{! ‹€4t]$E'Vžu๎ูˆฎUชAKC—†ฎ:‡ฤ^่" ]Iั‰] ]žกห3๔XM| ถฃ HC๏่๒Jr["ฐชž"zR•\กหบ๊{! ‹€4t]$E'V๚๐ปžPZ^Š“—โ”'‘=คก๋แ(*1#0ฐtึฦะะใสaห^๎ฒ—ป๒$! ‡€4t=E%fไ ]OมไB@Œ@@บL‹Ž!P้ฯฮ$฿8เฃZ–”์งบS\#ฅ š๙ี์ืพž™ป๒oสX!าะ“Pๅฮ๑ฉ%‡N๋<ž'ุฝีฉส-wท๋ฌแ f|ห!‹hมฟN&:š€4๔Ž.o็&็9ฝ‡๑'˜qx[ณ”+๔V^กT๊Gˆ่ป]๔ƒษ3Wxm โ\ด™€4๔6@ืO€๘*6|เำ์Yฟex# ๆ7Oอ—๏ฯCด•=ว šh:Qห฿ฎ%ฎ]–ฮฏsโ‘„@ห HCo9rqุ(อg—Ÿสภว ่iิ>ฬ๑]ฦ8s9Kชa๚ˆฒถ็XA๓+b1ฦŒK2y๗ฆˆล%แP HCฏˆซ๐๚ญ7ขFŸ๑‡UtBดฝด7‡จy้ชc}“Sฃ(ƒd_HฅEQŒObบ HCืMT๔” ฒ๛D_`+‹…)@8ัฬน฿ำEิตซ‹ณ{ฑAQฟล}/็›ywaิyJ|B@…€4tzbซ•ภๆF~>€Cต ‡!ฦx0ฝ5ฟŽฌ๒P๒qา๔+h”Gว ๆ๛ˆ่หS๏œqฝผƒjIˆ †021ะMภsฒ๏๘๗ม ฬึญ’:&ใLnล๏Cา•lฐtฐป6๘;ปD.๐เ/ฟ4*ลL˜๙า•‘‹W คก+ภS5รœ้K ฬTSjฉ๕c cพ™[๑›–zธณีล์ฎพOืƒฐฤC}qx๔ g๕ไ\ ‡๔ฤ(k ตc HC๏ุาF71ฯ้จ]PN=สเ๒ซ%ำ๘y}{cm›๓d๛ั-Wญโ๔žBเslง^ึ(ฑK>ฮH๒๒รึ/ahษ_ยฐ“xx๚ฆพํบฦ๑—ม์๊‡OL‹.n๐'.–F^ษ*No/มŸะ‘vฎ฿ฒญ#ฏ๔กszๆ๚d[ฃ็B Iาะ›'f๕xtแถfยงA)ลณณ๋wฺศ` ัเYpัgv2v๙.ข—?mN8ษVEkƒ)8~.่Š0`็ป 7ิ&^*_เ"\% mDาะeb„Jภ+fำบฮ)ุ_๘ฦ”1พ˜ไMbBb๛ู๊ฒ™bศท}6๖Nj…฿&|<ลภง3ถ{Uถb"ฺB@z[ฐwพำJฑ๏mฤ~ฐF๛Mceฒงเ/„๙ึๆr๋บ!=k๙3cล(?‡@ต฿สณwNศ‹bsqํ8ูN6œ๚‹ช^าะ๕๒Lผš็ไ&3ฏ >ภˆ๛˜q5uีฎ6gฏ|4bฑ%:ร[ปW?=i6 šฟ๙ภษ2ฤฬ—˜4aูล ŠKB/! ]&„6•B๏ˆ๘โˆฝแ<ฏฌฦ•=sJิ–ฌ…J` hf๘๘มKu‘๘0p 8~ชํ‰€$!๐2าะeJ(Xใ๔พบ1€สb๚ๆ๏m๐']#/7้ƒฺjฅี…]|๐i  WํมขRw๊,yTำู๊ ฦ" },B๒๓Q T‹ฝŸaๆฏDำ๓ฬ๘i*…๏Oใ‘˜$ Vนู‰ฦz:Ž|œยn$U%žใ4ู^ฃุ๋$ ]'อi ฏ)๏ฎ  YmO›๐ 1]ผพถอOๅjผํี=€Š“=ยฦ@6tgc:เSใ>>}๖าUc•B dาะC‰๒žำ{(ภ?is~+™๑5ำvo–๕โmฎDฏvfผู‡๑9€Žhีv[Hณส“™S^ โR‡€4t™ u`'7พส๋/แ“u้8ฦ ี.žjฏ“~yQŒแญ„ู?„๙RmŠŸ ๘สิ฿๒99ษญMทญู[8วŸภฆพ“นOฒ!|อOu]ฺs่ฒ'ฺƒ8Ž,5Kf์V๓ป>ฮญ… (wch$๛ึศ‚’ภ:–€\กwli๕%Vqฌ๐=[้Sญ_‰€› ะS์า๕[ษศคจmjฎPซ}‹ฟไxbัผญวงž๛ €ฯต8๋็4?m—Š-๖+๎D@z‚Š=Zชk4ต–๊^ ฦ;[Š„๑wƒ๐qyNR๊‰wๆ-™ฑjฉ˜B>@7ํRเW>B@;iฺ่‘ฦOpU๖5)Ÿ–ุต…ั?ะ‚๔sๆทh ต๚WBเ?ผ‚๕nพเUญรB—ฆsฅณไึOŠ'i่Iฉ๔๒ฌ๔gg’Oฟj๑Yิ?๎ฎ๑ง'ฯ+WŽ_า`Ÿ๘ิ:ใหiู‡o\;๏อ*ฏo™Kqิ๑คกw|‰ทœ W่ˆƒ-ู]‹๔!yŽ˜เIแิซลพ}˜Ÿตl๓$ย]C์พ$O7๚ฆ]๚zธ>Dฝ“HC๏คjพ(—j฿ม๐~€• Gส-๖H’ึˆ6ํฎh8ฟ6TDL_4๓ฅก๚๑Ž! ฝcJ๙฿D*๋ํDX`bธ้๑oบŒ๑semyธ”E=š<'7X๗ €f…!ใ23๏žช๏าะ;ขŒMb๓nW+ljjŒ_ฅŸฯK๓oุช&ภ ีท฿r1a7/˜ถ{^„QHh =EะBe๑Œ7‘บ @Z—ๆH:D๘J:็~6Lข-โD ๊d฿ว ‡๙พ  +_'.kk HCo-๏ะผญY2cทกZ๊ฮฐ—ฆ1๑‡3น๒OCKD„…@L l~Ynq˜บˆO็\'ฆˆ$์ HCp+ไƒณฬ}ฃ๛w ผ&Dฯ๘พ๑ฎžน+}ˆดˆ5jaฦLฉเ•i!%ฒž :$3ง๔ป๔E6ฦคกวธxA่ผ๐่qี‰•[ผ#ฤTV†eฺ+๎ ั‡H Ž ฐบ˜•™–…๘{M ด฿ปtG“$ด†ฎ e{„<'๛+€ข๗'RฉฺASfฏ|0D"-:Šภฺๅ}ๆฦผŒภo)ฑG†ๅœ„่ฦTVzL „]-Z0ใ์ะR`{้๓แศ‹jHC[ล6ว๋ณวƒ้‡แ…O‡มพI๖ญแ๙e!ะ๙*Žu5๏ 'S^fฺๅฐพ0„ฒจ†F@zhhรฎ:ึ ฯอS›ผ'Eh,%แ.c=ฯœ๚ฎ๒š๐ฒe! มZuo[ฎ ซฉ3ใ;™ผ{r2hJ–ฃะุt+หำjติL ล๓ญƒ]ใrr/บ"š`Uว๚) ำqfพte(ฺ"าะcS*`•›˜ZGwุ3”ฐ wmš8c‡y‹^E_D…@‚ (_ฉ~l๋ ภ3Lป#N|๊าะc2˜AีขU0;ค๏ฅ๎ิŒ๔ฌๅฯ„ค/ฒB ๑”›๚่W๙]]o๎9tู‰PาะcRxฯฑ‚}œ?Nธ๔ท.ฃ๛ 9d%บข*^L`x๗o๙€ใt“!เSงญ}ํwื nmั‹>i่ัฏ*E๋Hb2ŒP ๘ืฦT๗ำg/]†พh !02ฯฑ~FS๐Sำv?,“G@zฤkพ้eบแœkจo ฝฝgฮญOFƒ„':Žภฆ๏+o"๐\ษ๛ '3v้[บuE/ฺคกGป>๐๋/B˜^*U{›์Y‘u6Ÿ้ช ฦŽrjปม๒V๙$„€4๔บR่ฟ?„ื๙ิ3ง,“m$ฐชž"๚=€u†มภ@—?ธว”นทญึฉ+Zั% =ขต๑œย๓‰0OŽ` ฌH & Tฯxฉ`ษูไ&%F4#ยM้œ{„NMัŠ.i่ฌMตุทฯ๏ฏ=<ย‰fฮ ใ‹‚๖PEP$‰ภๆ๓ิ—่ึš7๑ fฎผ€'Ÿ' =b~tแถžธU๐|wํกพfๆ3ต๋Š ZT ึ{™๐s-by!ๅ{O™ปโอบ"1าะ#Vชc}“SC๋—ฆํ‚ฎH ! ‘€W่]โ/h” ค๎4m๗š5E.bคกGจ โwยเC้๎ต๘€ฌ๒๚ดERอ<'๋”ำ,๛yำvฟคYSไ"D@zDŠม ทzbๅฏ ผFsHฯL{Mอ—ึฌ+rB@„D บlๆถIงฆhEƒ€4๔ิม+XwƒฐฏๆPไ%อ@ENดƒ€็๔~เำด๚๖้@sn้ญš"ึvาะ\ฯ้ภkc ืhฏฬผาใšuENเ…Gงช+ทะ๗–:ใ๏้็3{ัj-NG…H@zˆpว’่?x{ร๏๚—ฮgdO207=ว-Œๅ_~.„@<l[๑wญ;ษ1Ÿnๆห฿ˆ‰ฒาะ๋กาฏh]ฦ|๒vฺ.‡ฑŽ]g˜ข%„@ƒ*…๑U š6Y฿ฺCN[ิHดอRาะT€ล}–a๘%อ๎นv๏+๋อ5S9!ฺืง3ฎ5๓๎{#’ž„กH@บ"ภfฬ๙oํฎฎš>ำนฝ๋F๒๙ญ้นๅ`X๙!ะžZr่ด๎ฺเ฿คuฅGภAi cC+]!ŠNคกื J็ฐŠc}–€๓ujt–i—.ัซ)jB@D@ลษA ตลผ ทฺ}hฟปตiŠP[HCo1๖gƒ{6ข๋!อ/ยญL็,ธล้ˆ;! ฺ@ โXW๐>]ฎ ๘tฺv/าฅ':ํ! ฝล=ว Ž1a๏žœ๛š"%„@Œฌ*dงงˆภDaแฆtฮ=B‡–hดž€4๔0gU‹VฐœlO]๎ธ(cปฺOgำŸ่!ะUง๗$งaoม_^ฃeƒž™S๚]รzbะvาะ[P‚vxzlCmโ;ฬ[๔B ยB@Dœ€็Xฟัตื;ๅดํZOYย€4๔L ฯฑ‚gR{่rลDGfr%}๋Pu&:B Mn:p๏.5dL๓Sธ-…‘"ฺ>๏ลlL€แ31ึ3c=X๏๋77 7j๔, ำ.?ึฆด๊vซ…[65๓+–ี€ Œi่!—มsฒวt67„~3็ๆด้‰ˆ8ฏ฿z#1vaฆW<ํ ะ๖ ๎cถำผฏร+ˆ0p?3๛ภCLฉจ‹๎KฯZLV๋|>ซ%ย]fฮO‹–ˆดŒ€4๔Q{ฮ๐ณ๓7๊rS3๘ตำๆ”ƒฺไ#:†@uูฬm1X‹A{‚๙ต0ฐ7ฏำผ=rXผž&โ๛|ฆฟ๔7 —jk๕•ƒnvซษ๋(xIvg-‰2Ž2๓๎/ตh‰HKHCsฅhI mฟDธ<sO 1d‘กnใฦฝŒM{˜บใึ;จpAX^#.ตโ‹ธๆ๗uiฺฎถG…ญวŸ<าะCฌน็X๐&M.^‡ก]'ูทhา!ะ2โwRŠ็๚Lsๆ–9Ž–ฃวมดŒ‰]๎๊Zs่ฒ'ยฯsz๏xoฺLแLฎSZข>i่!1ฎ๖[y๖ฑXฃL=OฃžH P ญร ะ<0ฟ €ชณxŠ฿อภ ] ๋งุฅ๛uฅ ๙oฯ}ฆํj{dจ+Gั™€4๔f†็Xห๔i’ฏlจME–ฉiข)2กxbัผญวw=Ÿ๘(0ๆ†ขZ(IดO๔nฟ๒ูธพ'ฟโŸชaxŽu-ปHศNฅขjLb>i่!0๖œพ=ฏบค ๔‰ด]๚ฎ.=ับ ฟˆต˜วฬG๐^]บ ื๙+ƒฏๅฎ๎+›ฝ-๏๛๛w่เ(๋าuPl†4๔8Wœ์w ๔qMาฅŸหผ†ๆ฿Pำค'2B@™ภšล}ปืศ? „˜ฌ,(#x`\‘ฮ•–5zมึqฏl Dฦพ้ ญK5– ฎ‹€4๔บ0ี?h๕ู)xz\ใํฦฟ๓gา๙ฒฮ็๑๕'$#…ภ‹ ,๎ณ รถž-`ZH€๑ ๔ฎ!‡“็•ƒท็วhฝSศธึฬปrfL๊ํ ]3ฏh?0พฆY6ป—™.ษไK? A[$…ภจ ู ขฏj|/Dˆ7M€ฎ#บ _๙็ฑ$*Ž๕s]Bh(ตK๚ฐๅŒๅS~>าะ5ณฏ8ึฟxตfูห=B„ Ÿv•;ออบˆด@ล้ํ%ppE~จเˆB?๛Xษปwn)ฒ`—=๘6ทRพfๆ3ี…D!,าะ5’ญ-›’[”b`€@mจm๓y๛ฝฤ“ๅร+X๓A8[ใ> ษฺุl—LLอ—VŽไถ๊X71p˜†ึSwjzTถบีOวIHCืXRฟ8uG%ฝnT2ฐUว:oK#ฏV๔†6ฮ{๙ก*›—^GธLฺ._จCK4๔†ฎ‰้@ม~W(;?ีb…A_ฬุฅเฑ|„@C }ฏ#โK ฌ—Oผ ฌHN|๑f5ีbถฤL:ŽD}ฬดโงsฃ—†ฎฉถ•b๏นฤ%Mr*2%เฤดํฎ""ถษ ฐvyŸนqƒ> +Y'"ห.\;/ุอ*ฏ๗ }ณ@R-™หัชZ0†!" ]Uฯฑ‚ท?ฃ๔อ๕€ฯh๕‰OšpŠL T‹ึYฬ8ภถ-p'.ฺCเa"|"sฯฑ‚๎สGข2๐๓Œํพฟ=้ˆืัHCื0?<ง๗P€—hา.A ๓7ฆบพ2}๖า็ต‹‹`, Ty>๘ฒWcดƒMฐ๊#๘ท~๓ฟu ฌc/ู˜‰†wOแ €ฑ€ญ6เ‡MlG๐แ๚ไF‰ภ฿าเgฝ?ธqZฯแท?ซAK$4†ฎฆ็Xฟ๐n RกH/ฮ /ศ๖ฑกเ่ำ7๕mื=ฮ3Mะ/ ๔adเ?`0=ศ†๑เะF<ฐแ+žึ™ำ*7;ฑ{ฝบVรฎม"Aปดภปmn:]ถB+๘ข|aQ0gๆKWช ‰‚NาะiV—อ–kkeZdN เำ.้y–ึขจล:J1๛!b๚F\nฏ๐G๎"โป|ค˜ษญะ๒–ถ:ษM ซ‹ู]ด'3ฟl์๐ž ์ู™W๗#Pcพีฬ—g่โ):zHCWไX-f฿ฯLฑฺฝม‹บิ้Sๆฎx@1}18ส’์๋ฉF—่pจOƒฑDw๚์ฉ'_C„c5ด๊อ3w๖Sต7ม|ืฎcMฅjปO™ฝ๒มธึชใ–†ฎXีชำ{#ƒP”i‹9 ฅบฯ“็๋mมชำg๚gง ็k<$Hgผ/€แ‚h9 ^fฮqตLจ3HZมฉg3อxoš‘ั`๚ข™/-ˆL<ศว๊”บ ฯูR๋(ึ/†ฯืA|V&Wi‰หภHจ8ฝง๘(‚ฦภp=Kงฺ๎-‘Rp’;a(u4ˆ่€ดR๖ำvwiฅC๑5:นBW˜!ž“= ๋$ขdz; œุษWKQ‚F,›ืo1๏†~šOt ]ตgเMไขี$h๎T3ห ๗ฤyW>ƒ้-m9ซ˜ˆีE@z]˜Fไ9ึBG+HDฮ”—ฃ+uŽ์ืนาl1 U‹fผึH—6~๖u(9zDธŽ|Z(่๋ใ์าg€๐k๋ณŠศ(ฦฬผ{BDขI|าะ›œ.<`ยึทZี่[ญŒแฐ/๙๑ว™IŸ`Z“!้4๓ˆ้tพt…NQัาK`อโƒฆ๚F๗8UฏrSjทƒ๑M3๏_rๅำ$M{้ำ)W๎q๘xฆํfโhb”†d•ซ…์\&Zิค๙‹อึื&pฯ4ซ;น๑ซฑแ8€]5hซI0๎๑ว๗ฬu๏RkxแัใVOชœ]‚uอืื.ซ็ln :]kU!;=œ ข`K6}ษ้๒ใ•ไๅถ{tfฃ4๔&kแฌ+@8ฑI๓šš9๗˜—๋x…0๑9‘ุอ‹ฐฐF|ฮด9ๅ)็+J*…๑vTR3~’ˆพ™ข๎๏o;gIUMJฌว"๐๏mฅใ“ ์;ึุถœq™™wฯh‹oq๚าะ›œžc=ชใชO8ผ'็ผฅ0<'{`|>K^˜~‚ฎกฯ›ณWนหง…ชNoŽม_ฐO พU…ˆฯM็ส฿kc ‰u]-Z63.†7ฐ‰า็!ำvƒ๓ไำfาะ›(@ตุทณOฆ/7Ycฺ๎ิzt*๋p"k>฿Tฯ๘Pว0พ—bใ"ู˜&Tสรโ^1{<˜NฐW๘ถ์!8 6ธแBูฟปUุไ{๓œ๎าด้V+๛ฆs+๎m?dG ฝ‰๚W ึูD~กT??0m7xFV๗งZฬพ‡™พ`๛บBศ7แk้\๙ถะ\$Px๓ฆ0'่ไถื™้'์ใฬผาใ ,EdS^ส0q๚,€ญ#่L=/q$:i่M”฿sฌ ุ„้KL ฬ&O,šท๕ธิ๓Ÿ'๐งUcะcฯม>—šv๙z=zษTYoํหŒ3™๑v bH.W]ํฎฤ่ƒwบบเโโธvFส ?e์า[ฺƒ๘~ๅ *a2ี7fง๘ใiตP›ถซ๔bำฆ๕ซตo4KC<:$ž๐qบb’}๋€ม$hT์๛˜่`ผ3๙ฎaโำe็ภTขVฒo๒‰~ ใผ๓พd( ฅvIถ‘fํลN€\ก7ศฐR์}1ชAณ†ำฅฆ]๚”บPqฒG(ุ!L้ ‚ŽXซAืpUฺ.๕๊v†ฺ๊B๏Œ๘"ฬ•uผN๙๘i๙๒SA9yYT‹ู™้+๊z7G'!&œ–ษน฿ิฉ)Z†/xN๖2`๘%%ฅOl๕ไสe%‘฿†7ž}F—ฆ&ี9๛ฦตๆาš4c)ใญูฬcFฐiH”NแZCฤงฆsๅซc V‚~ ตห๛ฬมEญพ <ฆI็สQ>ีฏใgŠ4๔K์ญ?€๑ึอ^>|iปกผศ2Pด๖HฟหL–bŒa˜?ฆ~€ำธิ๒N฿^6xนmจถั‚๙`ไžมค0 *jะ]ใOLžWฎ(๊ˆyฤlฺu฿oๅ2ทฺžl’1‰ GzฅึuบZpyฦ.ึ€๋†‡nZฟN—E๋6+าธภR6๘<~๗?kœW๛ฬog`ู’2าy€งฆsฎำ๐ไƒX๐œำb+N฿#-ฺู7=คก7ภ+d็€H๙™03Ÿ’ษ—/oภuSCtณ[mปฮ8—ม็4%ะr#๚3ภแทLtท_๓‰๊๓ง–:-ๅo3ใฟƒyxีCTž…Vนu >?c—ƒใUๅ“ร[ษ’qYุ{ฤ3pQฦv#ฒ๚&!ล}Qšาะจyตh]ภŒณ0qhอเืถrีเสฑ^VฑUco‡=bเa˜e‚g0{lภcีฎ<ฌUงพซผFW|มsศก๕ตํู0v`๒ท7`lฯฬฏแ๕`ผก/ฉๆฦภ5l }ชgฮญมj๙$ภjว:ฤ฿tuอฅ?ึ๎{ำ.๏฿œถXฉ†AฯฑnpP&# }ภดW+j4e>|  ่› t๒6kX ๐jญ†ีd ๊๛La"ƒถูtJk˜ฤรŸ“›Y#๚›๏ำษ=sWธ‘ Qk)อทแฟ`Šnว๒]7ั๚๕คกืฯ žcญฐU&ฏสภw3ถ๛  U[ฯฑ> ฿i˜ ช%๖‘&ฐš d)Qคkิถเึ.สfปŒฏ€Y๋yๆฤฏชฃห~ เํษO}ž@ืฅ):m#ฐ หปวัW&ฯ\แต- qK›€ žฏซ๚Bธหฬน๛ลBฬƒ–†^gซŽ๕MNญs๘ˆร๘cฺvUืฐซ„ฐE๊อ3wๆTํ๓ |@W(ND4 l ฦ๗ปบฯŸ>{้ชPœˆh"่๘;ภฏMเmใพ 5Ž—†^gีtศB„หำ9๗”:]ถeุช์kRพ๑ฅฐ—ทด%นNtJ๔Cย9 ญ‹๚œชลc™๙Uฯd`nzŽ[Pี๛ฦHCฏƒ/X`T๗ฟ%ุH้%2>”ฑซ๊pู๖!ซ ฝป๘เ“Aรง8Ei›าถณ‰@O€้5๘WDu~IMุผฤ๕_M˜พิ„้‹fพด@YG" ฝ\žำท'เตŽกฃฉีjฏ›6oๅช๊ดฺ~๓๛มหsooตo๑๗_ม^ู>Œoerฅ…‹‹@ลฑ‚ว6=jอo4ํ๒‘aล(บ#PซYBจzk>ชg}ฏ5mw8#>ขt วe/๚8๓ )๖gภ๘iอฏ};Ž_Cb"ฒ!๐ ึb๒Š.ฺถ฿†bฑ6—†^G๙<ว๚<€`/d…Mปหฺ^ž๔๐™๐[ัGผOรA5 L;ุ”๙V๑ณž[w๕N๓์ !ะ•b๏นฤ%UgQ[ัฃšO์ฅกืQ%ฯ้ฝV๙%ฑ}ฆด้%::qฐ%ช|š%@ธ ฬื๚]ื๖บ์‰feฤNจ๐ }ณ@Ra[ŸL๚‘ษส †^0ฏ` ยพu ๒ๆœ™/๗+iDxuฟตoองc œ๗ฝKฤรHx๔7€ฏญ|m+๗๗H๒F T—อ–kส็"ำI้|้Šฆุฑ!ICฏฃดžcฝ ๚†ปฑง๊<<คŽฐ:ฤ[๛N8’^ฟนญมDฯ๙oมผHั/ฆฮq๏‰^xQา xŽ๕wีใฃฐอuา๊( }ŒŠฏY2cทZ-๕€โฤxาด5bkL์๔ oฬ@/3,{ฦ6™ฦ๗ ธ›ม%"r‡ถโ•ฒแFใลขต*N๖fอS๒Jธรฬนมฑย๒ii่c€๖Šึl0”n•หานro‹jy7มYโใ†g2! ฦส3ข•๑zใ6จœZ๏ปIบ3ญRl9oษŒม๎ืคศ฿ž‰ฆ1mฦ*†?ภŒวz๒ๅ?ฤ%—0โ๔ ึE œฅจฌiปvrก"‘อฅกธ๊๔žฤเ๏จิมWd์๒I*nฌ๕g๒_O>ํยเ_ด_ฒcะŸ1ˆb๗MฑK๗wzญโ˜_ีฑdฆYLœ%เ-&ี‘ว?๘ฝAผธซ;ต,I๛ใ{ล์๑`๚aŒF’JีvŸ2{ๅƒช:b_i่cpชฒ_!ขฯิ‡s ฃgšy๗kJ 5๔ข๏;ผ#1vแUvฐkˆXึ๔ภ๐8€วภ๔(ื˜๎๏ษฏ๘gˆพEZ‘ภภMN2บฦ๐\fPฟR$EŒ%5ƒ~ู3ง๔Gล#m>ธM5H6xVfNyนชŽุืG@๚X ฑฎฆ`ฝตยG๖5V€7†้ชBvบaคv"Sต๑ Oภ‹ืODใHฐม'ฌ7ภ|`ƒcƒ`6ิ˜Ÿ๏๒ฑ&5>๕h’ฎฦยซN๋•7ฟก}&6ค4%ฤ–ผภดหฟ ัGค‡WNั€rฬ5๓eๅ+}ๅ8" }ŒB{Žu+€ƒTๆC\ท|UษYl…@ซ TŠูำ%ญ<{€kฦี๘ดษ๓ส•V็ถ?ฯฑžQพณมe3_\ุฑŠ&าะวn่ฉฎฉ6mW8หoœ‰@p—ฆหภ5ฬฌ hวg ˆ>fๆJ7ดรyX>='{@จ่แg้œ๛A ฑญŸ€4šQX1ƒชEซฆ๘ล็ฆํพพ’ศH! ๊%์wภš€žzmยG—ฆŸ3?M๓ofฤใzฏ๑‡•aพีฬ—g(iˆqคก‚ชฒจ๗U”โว๊ฆ9ยภฟQ{sฺvWั[! ^I ZฬžศL‘ฺ‰Œ€2cซรMปธ6๎5ำs†6m7ฬ—WใŽYkาะGม9ะ฿๛ร็ปTˆ๘iปผ #! 4พsๆX—‚๐4ศ…!๑OJอJถ‘0ฤ[ฅ้9ฝ๘๛Š8sSD`E1ฏƒ€4๔ัฎะ๛ณ3ษงeupโŸ›ฑห็ซhˆญ%เ9ูห:=โLํ2ฦฝi9Kชs‹แU ูนLดH5~ƒiืฉ๙ารช:b?6i่ฃ0๒œ์1]76ฦQFศฒ %|b,^L ZฬพŸ™~*ทฅŸหdใ๚L}`ฑ๕Vร€๒Žy#ฯ• IDATำ!S๓ฅ•1ฉYฌร”†>J๙t์'kะc๛!มGˆ@ฅฟw๒๙ฮ…4v(Œหฬผ{ฦุฃ7"X="zR52bผ/wฏQี๛ฑ HC๕ ึOž76ฦ-`2๖ฯไV^ECl…@า ๏ึ=๎w s\ฟิkZๅƒฟ๒๐ษŒ]๚V(pE๔%คกฺะu<ซใLปฌ๔ฆผฬY!t^มบ„ccสaอะ ๑๚ํ_๑tโ๗๋ ซฤอDŸหไJ_Vั๚HC…Sตh]ลŒิ‡rไQ้ikวั~w ชhˆญH2ฏะ7 ไ/7บฮดKฑ๛BRuฌปxำa6 พฤดหช'ท)๘OŽฉ4๔ั๚ฏ™กฒ†ผjฺฎ™œ้$™ ฝžZr่6ตมร๒ฤ๛C˜cๆ%qJยsฌ[1ๆ˜ถ๛1E 1ฏƒ€4๔Q yŽ๒Zว- กฟ™viฯๆํลR$›@ีษ~šA_ํ 1mw๏8ๅโญ๋ม˜ฏ3aก™sQาใบHCญกฒ+Atp]$Gฤฟ1ํ๒;›ทK!\.<`ย„‰[=โถฎแn฿วใ†เ ’มุ‰lhิ™sfพšพfaฯฑ~เ8%YBฟ™ssJb\i่ฃ_กoง๏Wษฑ›ฮ•{›ต;!d^ั๚฿ำอ ุฝัgบ:“wG\ท๙ึw๔)€฿ ื?/3ํฒย]?ฝัŒฅVuฒ฿bะ)c็raฃฦฏ~ki่ฃ7๔ฟxc8_>’‹ฆ]V}ิผ{ฑ1&เ)฿!{i๒มQงตAใŒF6ฏณ๏aฆเKลd](SMŸฝt•.ฝ0u*Žu!ฃๆC=ช๑ซ฿Z๚h ฝ`=ยn๕ใ|้H:m—ีฌฝุ ค๐œ์Ž{ก๋๘ล œ›ฑ šแ9P่{A~ฐ๔ฮอุฟโk~Œึe{ล์ภด@1๏'MAQCฬ๋  ใ—ฅ7๑ขพ3žKUโY-‰บ“T‹ึ'˜qนŽœ™ด]พPEห[2c'ิR๗˜ชขณูvฉiปณ5่„.แ9ฝมc‡‹ญ1mW7ล0:฿\๚hW่Žl1ญ้iภ๔3_๚Hำ๖b(Jภ+XฟแH๕๔๕}ฉฎ๖๗ฬพฏcO๒uฆํnญž[๘ :ถฟ๐ŒiปSยVSo1dœ@ี้อ1ุQeแC;๔ฬนU๙ดฐ—วแญ"sTโ#เ[iคŠF+l+…๑UŠพคก+ฌื\๚่ =8Tฅ้-' t~ฺ.[o1dœ@ล้=…ภjงs๎2snำ{HŒV=/์ลcIซW่ˆฌ8/e lE€๕šKCฝกหfvชๆ+วัฅฆ]๚T๓๖b)’G ZดฮbฦEŠ™ุด]ๅg#ล ๅ\vฦ=f}“bŽก›{N๏Gพข#yห]`ฝๆาะGo่ซฌ= vคJๅS๋-†ŒBจฌณ‰ะิš๑๐c\lๆ]ล QFฎ†ฆ็่O™ถซt,i+ๆŠžปXmฺnบ๑&‡4๔Qf@ลฑEภซ&‰œ2คOL“I โXŸ%เ|•์|nฦ.+iŒๆ฿s,V‰/ฐ5m7๒+N๏ฉฆbฎrห]`ฝๆ‘ŸP๕&ฦ8ฯฑ‚$๖iV›?K็6k/vB ‰ผB๖4}]-๗๐V˜ฌ]gn๔+//่๐[ƒเฅ_ž5mWvฒjฌถlํฌ3@ธ๔ล#Fอsd)i่a่eบาะGํญมhด496ฐEำXtฏ˜=L?TหI฿†2/ฃZ˜ฑ7S๊^ต๘๐„iปMฟpซ่ปn๓ชcJป์๑ศตn(( }ด†๎Xม1‡Moั({นGxๆKh‘%เฌwƒ๐ ฅšyww%-{…์ ๚ขv,ฮFื๑๘@,rUฌg$ฬฅกึะีทŸ\bฺฎาzีHฬ Bดภ๊~k_฿วช.}ๆท๕ไหPีyนฝ็X+(‹—/๛^มบ„ณ”๎0s๎Jb\i่ฃ`ช:ึOh๚8ๅดํZuUB !0L€๘*ึฏWฦม๘‘™wOPึy‘@uqv/6่ฯชšD๘J:็~VU'l{ฯฑ~เ8E?ŽiปyE 1ฏƒ€4๔ัzั๚63Nฎƒใ–†iฺ๎;์ลT$’€็Xฐ‡j๒\ฃ3๓Jซ๊ŸฝŽซ๓a-ข๙fฎtƒฎธยาฉ:ฝ72๘}fบ:“/}@ECl๋# }ด†๎X็3ะ๔ทh๎ฯุ๎k๊+…Œ‰ภ*7;1๕ผฑRผปฒOƒxภD Ÿ๙ ƒŒG@Ÿk๏้9๖g…d x๋ NTฮDใ‹ฉีb๖ฬ๔3ๅ˜t32:ัำHžc`†Šพ์วกBฏ1[i่ฃ๐าฐ|ๆำvทiฌ$2บRด๚ศวl"๔1๐–†ˆ0๎ัr0-1๓+–5d+ƒ#Cภ+X๓Aธ^G@Dtv:W๚ชŠึš%3vซี†฿lŸจข3l“]โ‚P=ว๚ €7*ๅฬe3_œ’†ืE@๚hW่ลc™๙šบHnaPmOšf•ŸSัH‚ํ๐!เc@9ะuV๔:.3]—ษ—ด\Y%กQศq๕ู)xZญ-ฦef=ฃฝb6k0]งr”๒ห~ษดฯ7Kซm<ว ท™ฎๆ—ฮ2ํา%jb]i่ฃPช8ฝฝhm๚“๒WO™ปโฆ:ฐZ์=ฬ็1๒ฃ ๚_"ผtฎ|u‡#ํ˜๔ชE๋ืฬ8\cB๗๚ฬวื๛ๆ๛‹ๆm=>๕์๙ฎ1๘„ื๗ไ่ิ KKวŽx >มฬ•ƒ—๋ไ2i่ฃึ๒F+๏4s+~rc'?P่{ฺAtp‹ƒ_YซีN˜6oๅถุฏธk€ฮ๎/vอเ+@ฉgr+~?RHk4ต–๊>๚฿;ป- wฟ๕Ok๏˜รn:p’ั=nํ˜วภ„wgr๎ฏTuฤ~lาะGa๔ฌspฯFtญใ–G0๘]ปkNณi;ษV็ศ S3v้ญ๖+#เ9–าIcx{€@๗‚8x ~ญ๏ำN—0ฟdั{ำนาตQhฯ่Uูืค|Rโหd์ฟฅ/OํษฌsฝJCฅถฬ jัชถE๓XSƒ˜NJ็KWŒ5. ?tแถž8แ*€ŠBพม^๛Syซ’]…x$†Wจฒ'QG|๑"เ_Swศด`‡Z{…พY ฉjฌCƒฦ๔ํ_๑ดชŽุM@๚Œ4ผ๒ำvฯป=โ้›๚ถ๋๎๖†฿Zห5ž;y^๙‡m„๏Z<ิCภsฌG์Xฯุ(‰ำีyภQำY่ฑ8U.ส๓ฆ‘ุคกะ เํ@}๑ุเy]ฦ.Ÿิฌ}'ุm~a €"šฯฃ>3{๒+ั๘–็d†฿2๑‡oฺๅใ”@UqŽอนรดืว)๏8ว* }์†!9Fกศ+Lฉ`kำU…์๔QฐŸvิO–z(ๅพeส๔-•Šuๅขผ๒ษ‡mN'Žฯ‘=ง๗Z€฿ฃˆNฮณPุˆน4๔1hU๋Bงจ/๛ธiปฑฟ]ุL›๖ไ^w+@okฦพ 6ทฅŸหdi ม{๒‰ƒท'ฟ๋z"V}กฮ0s๎e๕ Žฮ(ฏ` ‚๊]…˜ถ๛ฑ่dีู‘HCฃพUง๗$Ge ฆบ'NŸฝ๔y8ฺVœ์w ๔๑8ล—C3โฤTWฌ^ฑ๏ฐ‡.ฝ้\iฺฎ๊แ&- ๕ฅn<ว ^d›ฆโœs2ถ{Š†ุึO@๚X ฝ฿สณล๕#}ๅHตทLตWIE#nถ•‚u8^ฒ\U– ดLูฉ๙าสึนO๕จณ"ฆŸิ;พอใnOO[kั~w ถ9ކ๋:๑Žˆ? ›95Œฟii่c ซ,ž๑2R๗5Mxุ฿cฺe-๛Rซลั๋อฯอƒฐ&ทฦฃv/O๚ƒ๗ƒ^ดsี"่ฌฏ๐ดˆ…'๒hw฿ืีžำท'เU ƒงsๅิtฤบ^าะว ตi๛ว็To—'j้šŽ#๋ภa“ี a‘ีฃ๋ฌo€๐I=jฺU๎ฃกT.}ุ๒Gด+ทHPื.}q9UฎEXCw# ฝฤžc=`—:†Ž8„Ÿgl๗อฺวษฎZศฮeขEqŠyKฑฦ๑อไNเ^oีข๕ f\^๏๘–Œc,งqฉฃาณ–?ำ!9ฉญ ˜qถขSฆํ๊:W1 N7—†^G…='๋”ซc่ศCbดsำ9n6๔+ธMทงชND์ำv๓‰Eย@ตฟ๏`๖˜ํฤ—ค—t\v‚—๒฿ผ@œฑฬปณฺ_—ไD ฝŽZ{๋"ฮชc่–†”ฑซฺ’DBJCฏฃ๐…์~ัˆG-ึa><คำ—oฌ]”อ ฆh ^uŒ๓8b,Wegxำิ9๎=ช:b_?i่uฐzjษกtืŸซc่‡แ๒tฮ=EE#สถ:6เyQ~คกิฌf฿พหด -{HศNฅb”๙Kl/%0ุ7ึN`ฆ๋šžaเš”๏urฃ๒œ์™]ข:งL•ข ฑA{^'0ฏ`=ยnuiุฆํพCม>าฆ'{3ๆirม๔ฆฉ๙RpvำŸี…]|โ`3ŸฉM‹Ÿ!ใ›f=MYGฺB า฿ป?๙œx@4๗@๐R์ฒด]๊ˆีcๅ_)๔ŒˆUWๅีดฝฦ๒%?ืK@z<=วZเ่:‡8ฌ“ฟฑzŽตภ$>-ƒ๚2vฉคชุWœ^ฏะ ๕ำv๗ึ # PYิ๛ชTjhZอ0Lƒ1A=ฬ˜@ฬซ|ƒV‘งปบjซฆฬ^๙`ยmy^ม๚›๚] บฮดKวถ<๘„;”†^็จ:V๐ยี…uqXงฎk|tง ›a[ฦฏฬผ๛ne ฺ่ไ&ๅฆๅ$6•ญ(xึ9ธg#บVฉฦFเฯคํฒา฿Kี’h/ ฝฮช,๎ณ รWบr$ยษ้œซtะKแถt˜็Xมแ?RuฦJ=[Xฟิ([XชX์#O@ืqิ่ ญ‘‡ƒฅกืYค๊ฒ™๒`mMรGFภUiŠFm='{1@ŸRŒ-ดฺžc๛สฟN)>ๆš๙๒•4ฤXDœ@ลฑพCภIŠaฎOOเId•‡uฤผAาะฆl‰๑w3๏พก—ฑ๊9ึuŽฉ7ุ‘N]#ะ๙iปtnฝŒ๓œK>ณ›—•cUU่‰m\xŽ๕oT‰—ˆtฎซข!ถอ†ทŠc]Mภ๛0yลPJํา์r,ฟaฺjyNM|‚™++฿ถ)Oฏ˜=LชWืฑ=ื:ฬฺ‹v็xฆvzศ฿่iศ่Kฆํ~^ƒŽH4H@zภ*N๏ฉf&ฏฺทn+N๏"ฯUแยŒ#2y๗&-ูV๚ณ3ษงeJฺŒ‚™w•rT๒/ฦB dž“=  Tฐมณ2sสสำจฦ‘D{i่ TฝZ˜ฑ7S๊LFJฟ0ํ’า๒75๚ญซE๋ืฬ8\E9ฬอ[*ลพท๛ฟS‰ภJำvQิs!Yีข๕mfœฌเ†ฺฤmv˜ท่Uฑoœ€4๔™yŽ์qฌฒ่Zำvทmะmค‡W๋็ผW%H&H&W‰Šฦ–lซN๏<ม*|ธhฺeญ๛ิ+#ฆB@;M'%v๔Zฺกk”† Pฯฑ~@mญดOšsKw4่:ฒรฝ‚uJ˜0ใณ™ผ๛•0’ฌz{g&GUํ๑ฉž™,d›ฎž@ล ~Šฌฒ#aาีูฆซ"K* ่ำ‡ ่c…'‘EADA|\@@ฒ™้๊$0]M FQAT“™๎ž“mfบฮฃ&อ Iฆง๋Vwu๗ฉ๏ห๘ๆ๙Ÿ฿ํฬ้ฎพuฯyL|ปGํtำvปบษ%jŽ@w*๚n‡ษ๓A: 1ํ/ื *IH ๚0Jั™ๅ5ตi$oลฎa๐ๅรDนใ๐ดnฺำ9ใ้ ๑•NlY๚ดgQš d“ั๋ˆ่2ฏd็ฎW‚2?จ฿๔บC=.ัMปอฃ†L๗@@ z ๐Tlซฅ่ฏฺั‘ใ6‘’wๅ๑ไๆxๆo%,ห;ฆธM8(ฤ/้Uฯ้๋ื2๗ฉทผ๊ศ|!4๙T์ฃฬ|ŸW_ฬ…H"s›W™_:)่%ฐห&cg๑%LืตGฬดŠvฃ^l(››ณŒ'ด*|:ผj๊ ด`ใE‹”OEž~ฃ›๖ั^ศ\!Tร=้qWy8Z-mหิ<๋ม—๔Vyํ’™ }žฟ๋ญฅO}นT๔J0-(็;ฆจ86›Š]AฬWซ๐รฬ฿Œ$2_Qก%B hr–แyใั—oฝ<๚ชซ้RะK\๎œeภQ%N˜ๆ็ณื^|•27฿=˜5zพ”นป˜๓0๒3บ™r๛ฌ} ~๘ฟร9[~(qวมQ-ณํg†'?ีF@ษ)Š๎๏2yำˆฅ—‚^โ2ไ’ัซAไญ™aฑทใ%Zดผe…๗*4๖:i8/f'‹ัฬฆŒSˆq €}Š_ไ˜Wtำ~O‘ce˜จ*นd์ว >วซii/์• š๙RะKไ˜k}?Uโ๔Mkะš๔๑mKฆUๅ-ใRฎ๗!‘4˜ตpวO์mm{๛็่,E฿—?๛ \1ํo๘“H Šฬฺ `ฌG#yดฝœž้1ผL฿F@ บ‡ืBฮ2วฮผใZCอZ{ฦฟฎเ๛ธญŠ๛ h ภ}ํ`ข‡%j๊m ๏S+•ฌผพไSั3™้ง^ณfฆ{"‰ด๛†Zฎ ‚๎aT<พฦzยžแมF ฆ*:5.9๐ฐi{=/นˆ !ฐ#œeธั<ŸฮH “ยfz‘ฎ<)่ึ kE?B Gู‹FะๆๆO;‘gYะ|ํฮ“vL$แ$—จIyหธ‹ณฝ'Gฅ›iฯ-Wฝ๛…mค +x-d“ฑŸ๑™ฅœ๓ป&&2k=๊jz61$Pฆve†hพO?X^ลค(ภ‹g…๛^๗ุ๊†ฆo?ลiB^cอๅ]udบBRะภฬ%งอ9KH]ฅ›๖• t%‘ณb฿๘‹2ตƒyD-ศซ#TPีใ€…ใ๖GT๙5ค ซแˆœeธ-?ƒNJพG@"ฆํ็A)%{๓:1—Œ-q ฿ฌแาp–ืeพ:œeฌ0ษซOŸ13zี‘๙j HAWฤS]ท1žง›™Ÿ+ฒ(U'S)Lj#3>Iุ)ิ)!H9+z:@๗+0ทZ7m•X —€tEฏƒœ ืถสฑดหtำžชศVเdบฺ#5 n}W…อ ะบู๑b…}Hx!Pน”๑๖Œ_$์๋ผ๊ศ|๕ค +dšK)0ฺ;Kฯ:xsQ4าข๏(x~ฟด ‰๎ธhธญYK &ณ„@ๅ t[ญr0๐ฉ็ซฑI‹Œ›‘๓,$ส HAWˆ4—4Nม๛ํ๒kซบ+ฤy+g๐ํ^๗ น„๎ซฝiผ@ฤ็…ใ™Cฮ‘B †ไ,ร}าไ R๚‘nฺŸR #>‚ฎjึ2: h๑*๋88ชeถŒWj˜ŸOE“™&(๛๚ไ๗ฯฤ๘z8a฿็“พศ ภ่NE฿ํ0ฝชย ฦกๆDๆYZขกž€tลL๓)ใฬ๘ŠWYตGฬ๔ฏ:ี2Ÿฮ uหถฑƒsธy7y๔พ€‡ ฤ?Žดež$Œ๎QTฆ j#Kw‚เS5๓r=‘iญถ๋ษฏtลซล๔ธก๐wฒ๕๚nธำŽŽกอ#Fh๏มพ็ปj‚ำรภ:b^าViษ›ฃ ห'™อ*ึA4„@ต่Nฦ๖wˆGj\ต๛Ž8‚๎ร2ไSฦฃฬ˜ซ@๚!ดOS Snกo๊uZ๚ฦˆ)TภบๆDZษ›งš$I(๛tศฃjU๐๊’‚๎ร"ๅRฦ,0ซ‘ึ’Gซิ!POT~wNเหยfๆ๚zโWนJA๗iีฒ–๑2๏๑.O๗๋f๚ฃuDAz" ๐ำywa๏7ัศ๔ิฟjฬU บOซ–ณขtฃ yr๘๐์ฬTh‰†ตO`p/ฯ_4aƒฏˆ˜™kkŸZ๕g(ง5\฿>ฅน 5บ'วํแ5แx&ๆUGๆืuํำhะpจp3@เI€6pF‘รฬš{@ศ๋็e@{^ๅฌj62๋๋‹Tํe›ณŒOŒxฝบพ[ๆ>๕–W!™๏?)่>2ฮ&ฃืัe*B!Ž– -ัจ]๙”aฒƒ“ˆ0ƒJส”ฑ „ลh.๛]I2ฉbบ“ัร"๗T8ฟ฿ฟฆ›๖ีKF‹€ŠVภz์qฺขุีใVรม๑็๐ชฉา‚ฮp&ษฺุ'เ๖`h็๙"ๅgไ? ~จทฅ๏ฌ}’ต‘aฮ2žpฌ‚lึF๑พ๒น’e’‚๎3่œปเ‹”„aดžศศ/V%0ซ_ค'k‹F_ใ‚ฯb[ท๕็bƒˆพ6ำท๚@TUPุQ ^ ว3_WแK4สC@ บฯœ?ฅฏVp๒™๋ดณ/ิxภ^ณ–n๐ูถศœภเฆK๗VจŠป?Egห ฿iภๅa3*z’ , ถโ#๒ุ์๑๊น฿9€žย(ž$Ÿฮหฒtส‚HAW†rืBนคq ( %๏š•`ฌV‘l{๋ค…พ`Zes ๏่f๚ฟ*๋AขoO g_{๛{sUŸจฏาM๛J!\]ค —aฝบŸ8Isึ( ตฑฟO;`ฯน๋้‰L•่JEฯะ˜~`t ,~้8ฺน-‰Ž?ยO›ศ.Šฝ‹Bบ  tq_๏{dgป šๅี‚^&JฟKะM๛Œ2Y—0 O—0ใ†XูัBงใ8gดฬ~า ทบฑ”K๗ ไ*b๚l8‘AภซกDฅ —i1๓Oฯ}M[ฦซษ„S#q๛aZขl9หpoฃบทSƒzˆp’U‰–ˆŽ@ฮŠ}เ œฑ]" โวอสผT-žซg>eสŒฯ+สราM;กHKd*@@ z™ก็Sฦ็˜q›ชฐDัpทฟฏDLD*J@ z…๐็ฃณFฯ+ VศัŸ0ปใ…š"Uf9หX`f™ร๚Ž.ัอ๔>‰ืฅ์เฦฺgผ_€งuำ>^‘–ศT˜€๔ .@.i„ThแูpOไXš`ฏBM‘*ึงŒd™ย๙†Nฝ'ฒ7อฐเ{ฐ: Kw‚๐)E้๖Cรแz›‚"=‘ฉ0)่\€๕ํSš ZฃปAnœ:t‹nฆฟคNO”สE gE-€โ~ฤ#b›™V†—ธภyhi“ฮกŠ๘ q™๑ลHยฎฺ๕ฆ™MลN&fe‡Iฺ่ฐ™พข8ึrพRะ+ผบนคq!7ฉดA„„œฺฅ’จZ๙d๋!Lก็TGbเ†>โ'ล3๎ว]^]–1Us7โ1NQ์แ9ดSฌYwrnฯ{€^0VM๒๔—p~ฐอSC3(*Rะ+ผlG๒›ศNLๅ#J๋>D73Jš5TQ]„ฯงŒo0ใ+ช’e Mะฮือท}ๅ๓ษก.zา‰๘ฤp<ฃ๊yiUถชFgเwฤFz „cT™fฦq‘„+Uzข RะฐV๋‡„~ คฬcU๘ืSง eš"ไฌeผDŠ6:1pWฤดฯ)ีlถ}๚*< ฦJีุ~žบฉูL_ฌBซ5Tฟูัz<้zdY๋9KAศ +๎ฦถ5+ๆk๔Dๆฟ’ขุุbใ 8๘ƒ@ŠŽฮ.Š~€4ZBDฏuำVyJฅ๊|Œั=1Pี๏๊uิzxฦoTq9ช^$ร‰)cwB`เไง1#ฃ๏V ˆะฆวm๗Q(นJ@ีqภฬXภ}‡จ: $›2N!E m้ํฟ๗IO# KH[ษ่^!ขgT5^q“ts[ไภŸ@ฎท SRะUPTคัŒต:ฤO*’&๓†C8ถ%nIฑฎศ)"ทŒป8ซŸ‰g~โUg๛๙๊๚l๓<ฬ\ฅทZึb+>"อซช0ฯ;tำŒB=‘ )่[œeภนŠmฝBก#ไ6›bชŠไ๒–๑;๗$วxIOุzาุษไ์ขุ1b›งฎาM๛JีjU/g๎›uฏพ9š?8ูศlฎUf’—บ๏e„ฅ"9+>ุ์ถŸœคHr›LGxีิ™ฒIN1UrYหุ@ภhRW๊ฆ}•GNฯYัUํM›๏ือฬGฝiิว์l*v1_ญ0~ …cšอeฟSจ)R$ Ÿะธ(]ฉhTcฒ•[cฌ'lU ”ซGม5‹ข‘!Rะ)ือŒฒ๓ฝท_‹lส๘yคn…nฺ'ึใ'็|2:›‰ฆ6ส~7๘ฒฐ™น~8>dluP๖ขฉฮ๔ƒ๋:Ÿ2neฦ็U;dโs"๑ฬ]ชuEฏ4])ใรฝ#ใ้ cไH2S[<‰์bฒ๛l:<เIงฏ)่_#^8/”“uฯม>NนUฦซNจ„–ถๅTฎ-‚Eศ-i…็W4โษอC4`)สะNe“ฦDธฅิ๙๎<~6mฅEห‹Ÿ ฬๅ…๓š๒cฒhU์็้pOdŠดญULต ไค Wม"ญ]2sbcกฯ}ไฤ‡ช๔—&๔0ึ\ฎ`งuภ ˜Eอ‘๙ฮqž๛ืig…ใ๗๘‘^.e<ฦ|ฺ–nฺ 53}ฐ)ำcLลIu๖…ูkึาNลบ"Wค Wม"นปวŽะvwภ6ชทLlะงŒo[’Wฏ-ŠCศYฦ฿x0ฆ่ ๗ฝ๒/•cdŒ‘CๅฑปŸ่ึฐ™>฿‹Fญฬๅ ด๎c–=ฤเ(ฮฉ๐ฟ_T์Uไ| จ~Iๆ’ฑsA์ž$็ว๕5†Zๅ49?ะ๎^3—4R ดyฌ4vฉCลฬ% AธiจqCœ@Ÿ ›้‡W๋?gu/6๎bฦYชseะ๙3}ซj]ัซRะซgญœfญ่ํ:ฯ„gจ!4MŠบ/tw)ชnใฃฺำุX<+๏๔บ=™xV7mฅผU3็z๖^ไฟศzFผก๘ต๒VซReพัแ้ใๆdฒมหพ๖ nŒ j„F˜์ฑEf>ปŽ™/SC›ัอฬ)jดชS%g ฬ๓ม}^#>าฏ'|๐+’>‚๎#\?ฅณํญ’r‹๚>ล๙SŸ6uฯน๋|าูํ(๏ฎๅ๐่ณ3?,r6iF„ฯ•2wงs˜>ฉ'า?VฆWEB[฿ฌ}  ธถ7h(œ(MW| [ฅ’Rะซtแ\นได g1อง4^แตFๆคW๛ค/ฒƒrIc>ศใy้;ะ$เn tี3ืb@็ญXœ‰ฏV|ศIa7O42=ลxจฅ1ฏ-<~ิ่1#˜ๆC^ š1ำiดEฒJ HAฏา…f;kลพ@เ๏๙˜ฦ฿฿๎6ต9‘vญ’หGูค๑:ฅ:ใoDห๖9ฅ#ทฝ~็โ่{5G›Aเ3|8เ บSง?ญ:ง ๋ญ]2sฦB฿พ0ˆNืใ้ƒฮA•—€๔๒๒๖%š๒[ค๏t๙z-๖“ž/IึhŠ^ฮ k|Nีmิ‘g ถ6็g<‡๙่–Dฦ๋Y๕~ZTฎ=ฐวฅ+พำ๑oŸฬ_าO'๗)OZA@ z –มป‰ฌ[Dเู•vฉะI\˜N,s;BษๅN;:&ด‰ณ๕'๘ _~IยB=nŸ^ภ•‹8ุื=ฮu__\0พฅ'์K}ััช' ฝ๊—pk๎๗u{Œน‚#|Li#3>Iุ๎‘•r๙@ Ÿ2.aฦ >H—]ฒ€ยอe๎‘ลuq vฅ[`ผO ?ŽงKosŸ่ึ€ฌ๔Xฤm)ธท๚๚Cด’๗๚™ฎ ›้+ŒQฯฺy+๖[VJณ<๙Fฬ\Rธ•‰˜Kง‚๐s฿ข๋q๒พYแ๒‚^~ๆพF์Nฦ๖wˆ๏,=Ÿ๎ต;ฃDxls˜ํ=gัF_ชC๑๕)cZแnจชึ๋ล๐*๛ZงZŽ๏ผe\สภ7‹?ig8๊cำแžHœๆ?่น‰ฯฐ#ห„ช" ฝช–ซ8ณƒ\–๙๘Œ๚6#ะˆ็ศกลญหpFๅ,ใ๋พ6œ9Aห i๕๐8•d%ฬ“?เ็.๔›ฃ81ูศlส๚Šเ‚ต๑ไl๐๕ฅžDŠ›ผ!>น%žษ7\FK gE่ไbว—2nธ'OธP77ธzGu->qRศiธจYtผ9ŠgK1๗‘pIKAฏฑ>lส˜FๅบuหธHOุ฿ฎaœeOmอข9ฃG4๔ุ`S๖เฅ$ฌวํ K™ZMs๒)รdฦt}ง7๖lžฝ๏•›|Œ!า5F@ z-่Ž้tฅขQษm1สT9ี 8S๚ชซ#ณข๛€ษmฏzฐ:U”˜๏ิ?o=๛`zx’วธฎw# oๆฐGฏุR3K๖ง ›[O‚^/\{์ระุ}œfLา]อiKฏ*Cฌบ1Pิก= ๐ัAL˜฿'์ฯั›*O๋ญุ{ ฬp˜*อ]่H1๗p-หKAฏๅี.ทฎvใHMƒ  ,m, ๘rุดkโy๊ ผD:Fว4Œฅ{˜17~ถ๓pฅnฺWฬ“R;นd์\ะภ๑ส~5Bฺๆ7jldี;ก;=!๊.Or ^5็ผe\หภWU๋– —ใณzยv‚ึไีHt‚3Bปเำ|O๑ฐžฐO๕=ŽจiRะkzy฿™œŒ#ไกษŸฃ)฿r5ŸŽgVิj฿าอ-Žถมกไ[ ‚๙"ฬผ^‘๘ešตb1ป฿”7หู‰;tำLา’5N@ z/๐ฮา|ไf™฿'สm}:๊Nm _ฺ|rf}"๗%ๅl*v˜/'`ไ๐”๖ฐฯ1ั5ตๅ‹ฮkส้บ /๚xPฬฟ–‹AืGฬ๔eร[?-vN@ zพ2|bšหSyํ$โ‹ย๑ฬ=uŠ]yฺ[๛ีพฤเฯ๚๕U ฟeเ6ดค< ๆ“ญ‡05<๐eฐล บ bฆo-C, Q'ค ืษB๏,Mทgs“ำปˆ™Œ2cX}Rฺฑชฃ๎žZ–;vููไ๐้ ด)P๎๐;ณศ์L5C;$ ทฒๆSฑ‹| #†œเ}€Cฤว3๗{—!๐oRะๅี€œeธฟXส฿ๆ’xฯธGœสฅภ๚๖)อ…Pำt€ง8 <๐]๛๎;€1Vƒ๘9ฐถJ ั“อm๎5ๅฌ่qบอ็.…sฬŒ3คcaอฟด*’ ๔Š`^ะผ2ƒๅพ^่ณบ™.ว1ตๅฮ-0๑ฒNl้ฃฆIOF0˜ mtBœใทx๕ฤ๙™žภ˜-ƒ‘ม}$฿dเ์2„โoDฺpผใน2ฦ”PuD@ z-๖Pฉ๎์}ภ„กฦช9ง8„ #ณ2/ฉืE!ฐ•[๑9ฺr 1ปั~ฎ|;์œr๚๚No™๛ิ[ฒBภ/Rะ"[ฅบํWม•แDฌ๊'๐Bฺˆ+ๅ๘ุ*}ุvฮŠžะทผปŒ6-bfฎ-cL Uงค ื้ย•v6๛)Ÿ9ิ8Ÿ~พ„ซ๊กk—ODv;]ษ่QัMo๗o-3˜nVญdหฬUยํ‚€tyi์’@6<นิš*ƒ‰โ_ทQ™๘ตš ไฌid๐๕ž]๎<๔;0'2'ฝบฑ%^‚^ฟk_TๆƒŸn.ใษr๏๔Ex†\-;ƒ‹Zฒบด~I๋dงZPๆ oโNเ[รfๆบ_PvRะหŽผ๚ๆŸ>ž๛๚Pผ’๎N๐ี3๓h%}H์`Xปdๆฤ†B๏ื ๔)pูรเณไ๕Y๒r€€ty!M o—2p}ั|Hฯ๓7ร ๛>฿Bˆpีp7r2๑E ธุ‡y ฎข4 ฯhเำšใ™ฟ)R!0lRะ‡ฌพ'ไO;‘ว=ˆf๏J“`เฏฎŠ˜ถDCฎ:#0๘uะฅๆU4uฦอzยพฐข$ธO่๒(…@ฮŠ#l^ฅพฃ‰็0ใM฿1nN&[JN2ง:ธGๆ~๒d_ะ๑vi๘xธอNVุ‡„ไบผJ&MลN&ๆ;่%‹(žศภ}๐ฐi?ฅXZไ*H ำŽŽiุLgณƒ‹A˜\A+ƒก๙ืิ฿pZ๘ค'Qy/โ@l% ]^ žฌ{lฺž ฮAHxR=™๑{"๚Ÿ =›~ฒ๏•›Tห‹^yไฌุL‚€•'โะQ˜่ฟ#๑๔5C”B ผค ——wอFห'c็1ฑปan\ภ’|‹€Ÿ8Œ{# ๛W๓&vvB sQ๋๛B Ÿ`๐ุ3(ˆุึX๛ดt สŠˆ HA—ื„2[?ญ๓7A|Ž2Q…B๎&: tฟใ๔฿™ฝ์ ฅEส#G#{S@์>rv‚G9ีำณDtA8ž™jaั* HAWISดtตGj!Gษภ-yฑvs"๗ภ๚ฌac๎ๆJฆMงc>@ำ+๔์๘๎3?าz๙โๆ“3๋kx)$ต! ฝF2ˆiไRัO1ำuดั฿ฟ=๑J=ศงค›ฟ+ๅnn mข๙`œฒป}\๙ >&โO…ใ™u! Ž€tu,Ei'Oaภฆ่โ*๔7"$ ภา‚ึุฑืฌฅชฤw`mv-ฑทV่Ÿอฬณ 4'ฐFท๋xnff.r ช" ฝช–ซzอฎoŸv@Asnpjeั `9ภ) ”าอŽซศ{ลฌฎ]2sฆB เฬ˜ย*fฆ๘ภฮ ~ุXเฏสYลC“‘ม" =X๋Q๓n๒–แnx๚.GTaฒY"<ๆ•ฤฺส๕ฃU“ฬๆ*ฬCฉๅWํ่ศ๑›q3ลภ<D'* เณ˜ป{Ž๓ลpbู๓>‡y!เ+)่พโ๑`ๅฌุ™D|€wU1ฅ~~OŒ_‚h%มYY๋gy๓ยyก๕cึ๊P่h€Ž†รGƒp€†j[G^vภ—H3•j[9๑ป+RะๅตQ1kอ=ฒaรฬ์žว\1#jฟเ ๚#/€๘`~A73ฏซ ใฏฺ›‹ข‘Bศูทภก@ด1ฟ„c็)ฒ๛ว๑VูซŒ+๕„ํสฺ่B@-)่jyŠZ w>ปอ-?ใKจ†)opฟƒ‘—‰eP—งซ๊jขฆฎ๑mK๒~'าHtBShbH+Ld‡๖$ข‰ ny๛๑ฌ@ผ@๛ฝ]๒พF๛ํฅ๚=oฟพnึถ๐ทๅ1ด ะ—พ‚๎;b P,ญ}ื ๘€ฑลฮซฑq๎ฃ d ุย ^oa`‹๛`ธ๕ถlC฿>กo๑@ะ ๗oAภH†3ยo5xb•ฝแe™฿|๛ะ-Rศฝ ”นี@@ z5ฌRy\฿>ฅน_k๚2?Wว…ฝฮV—tป‰่†B๏–Zๆ>ๅ!‘Kิ4)่5ฝผีk 5z์ศณภ๔€ฉ๎lฤ} ไ|3๗๕}W yฉKจА‚^๑%ล่NฦZยg>ฃ˜๑2ฆ 0^๘–พ†ฆ;ๅ@ :\IYฺงสk บ œ:ึ฿ท‰ว•s€รI+—|ะ"[`žžศ,š1๑#สI@>ก—“ถฤRF€,ะ๒ว>9L็``S˜\พ˜๕`X"อu|[tฎ2Rะซlมฤ๎; t=vยXญqฤm[FตL€ž'rn๐ึ–ป๗ฟrS-g*น แ‚>\b2>ะึ/i\่ื>IDg0๐@›sลศq7‡่H[zUฑ“dœจ7Rะ๋mล๋(฿bใ 0Ÿฆyภภ๑คrUfz”เะ๒8อฐP=ึลฉจ )่•แ.QหL sQ๋๛ด††ำษแำ@8ฌฬแ%\q๚,%ฦฝ6l~DnฉMF mค หkก๎tงข๏.ฐ6›˜@ˆUw‚“p'ฌ๑าF๑‹rœิล‰PK@ บZžขVexแผฆ๙ฉ ‚ Pภ๛ซ)…ภํ=ฦท฿D-๓Dกวร๑Ž็†ž"#„€(†€๔b(ษ˜บ!ฐพ}ฺโฉ >Ž€ฃชดoปโ๕๒๚ถWศ.8ฺ-ณ;lลๆDNARะๅฅ vCภ=~vฬฃŽfโใ˜q,h }hด rนgฆฏฐย!^3OO62›๏Z  ฝQR(/uMณก‡‚๘0">”๗ฟq €ฆ๒: Dดบล›ษC+"Oท>K 8p&&„@‚^g .้๚G Ÿl=šv;x?@€[|—A™ฟ๐'0^€†—5ฦŸ๚˜41‘Y[†๐B"HA/’ ^ ไฌ่>pด(ไ์ฆ<‰A‰แ๖)o =ษk๓_๘ŸDดฦaฌใ5Œ^า์<่สT! สD@ z™@K!P 7ฯ ๗ั–‰šƒLh4๋อŒ1ถฟ๛7ow†={มิ ` ศ›ท่%๐&๊%`‹ฺHŽณ–H[M่_ืธfฯน๋Š๑&c„€6)่ม^q'„€B (Rะ‹ย$ƒ„€B@›€๔`ฏธB@!P)่Ea’AB@! ‚M@ zฐืG ! „€(Š€๔ข0ษ ! „€ม& =ุ๋#๎„€B@E@ zQ˜dB@`‚์๕wB@! Š" ฝ(L2H! „@ฐ HA๖๚ˆ;! „€E‚^&$„€B ุFฤ Yช@IENDฎB`‚python-advanced-alchemy-1.9.3/docs/_static/syntax-highlighting.css000066400000000000000000000173001516556515500253300ustar00rootroot00000000000000/* Advanced Alchemy Syntax Highlighting * * This file provides syntax highlighting that integrates with Shibuya theme's accent color. * The accent color is configured in docs/conf.py (currently: amber) * * Features: * 1. Integrates with Shibuya's --accent-* CSS variables * 2. Meets WCAG AA compliance standards * 3. Provides proper backgrounds for both light and dark modes * 4. Offers rich color variation for better code readability * * Shibuya accent variables used: * - --accent-9: Primary accent color * - --accent-10: Slightly darker/lighter variant * - --accent-11: High-contrast text color * - --accent-a*: Transparent variants */ /* ===================================================== * Light Mode Syntax Highlighting * ===================================================== */ html.light .highlight { background-color: var(--gray-2) !important; } html.light .highlight pre { background-color: var(--gray-2) !important; color: var(--gray-12); } /* Light mode token colors - using accent colors where appropriate */ html.light .highlight .k, /* Keyword */ html.light .highlight .kc, /* Keyword.Constant */ html.light .highlight .kd, /* Keyword.Declaration */ html.light .highlight .kn, /* Keyword.Namespace */ html.light .highlight .kp, /* Keyword.Pseudo */ html.light .highlight .kr, /* Keyword.Reserved */ html.light .highlight .kt { /* Keyword.Type */ color: #8045e5 !important; font-weight: 600; } html.light .highlight .nf, /* Name.Function */ html.light .highlight .nc, /* Name.Class */ html.light .highlight .fm, /* Name.Function.Magic */ html.light .highlight .nb { /* Name.Builtin */ color: var(--sy-c-heading) !important; font-weight: 600; } html.light .highlight .s, /* String */ html.light .highlight .s1, /* String.Single */ html.light .highlight .s2, /* String.Double */ html.light .highlight .sd, /* String.Doc */ html.light .highlight .sa, /* String.Affix */ html.light .highlight .sb, /* String.Backtick */ html.light .highlight .sc { /* String.Char */ color: #008000 !important; } html.light .highlight .c, /* Comment */ html.light .highlight .c1, /* Comment.Single */ html.light .highlight .cm, /* Comment.Multiline */ html.light .highlight .ch { /* Comment.Hashbang */ color: var(--gray-11) !important; font-style: italic; } html.light .highlight .nd, /* Name.Decorator */ html.light .highlight .na { /* Name.Attribute */ color: var(--accent-11) !important; font-weight: 600; } html.light .highlight .m, /* Number */ html.light .highlight .mi, /* Number.Integer */ html.light .highlight .mf, /* Number.Float */ html.light .highlight .mh, /* Number.Hex */ html.light .highlight .mo { /* Number.Oct */ color: #0077aa !important; } html.light .highlight .o, /* Operator */ html.light .highlight .ow { /* Operator.Word */ color: var(--gray-12) !important; } html.light .highlight .nn, /* Name.Namespace */ html.light .highlight .bp, /* Name.Builtin.Pseudo */ html.light .highlight .n { /* Name (covers generic names) */ color: #00749c !important; } html.light .highlight .nv, /* Name.Variable */ html.light .highlight .vc, /* Name.Variable.Class */ html.light .highlight .vg, /* Name.Variable.Global */ html.light .highlight .vi { /* Name.Variable.Instance */ color: #d71835 !important; } html.light .highlight .nt { /* Name.Tag */ color: #22863a !important; } html.light .highlight .ne { /* Name.Exception */ color: #8045e5 !important; } html.light .highlight .si, /* String.Interpol */ html.light .highlight .sx { /* String.Other */ color: #d73a49 !important; } html.light .highlight .p { /* Punctuation */ color: var(--gray-12) !important; } /* ===================================================== * Dark Mode Syntax Highlighting * ===================================================== */ html.dark .highlight { background-color: var(--gray-2) !important; } html.dark .highlight pre { background-color: var(--gray-2) !important; color: var(--gray-12); } /* Dark mode token colors - using accent colors where appropriate */ html.dark .highlight .k, /* Keyword */ html.dark .highlight .kc, /* Keyword.Constant */ html.dark .highlight .kd, /* Keyword.Declaration */ html.dark .highlight .kn, /* Keyword.Namespace */ html.dark .highlight .kp, /* Keyword.Pseudo */ html.dark .highlight .kr, /* Keyword.Reserved */ html.dark .highlight .kt { /* Keyword.Type */ color: #ff7b72 !important; font-weight: 600; } html.dark .highlight .nf, /* Name.Function */ html.dark .highlight .nc, /* Name.Class */ html.dark .highlight .fm, /* Name.Function.Magic */ html.dark .highlight .nb { /* Name.Builtin */ color: var(--accent-11) !important; font-weight: 600; } html.dark .highlight .s, /* String */ html.dark .highlight .s1, /* String.Single */ html.dark .highlight .s2, /* String.Double */ html.dark .highlight .sd, /* String.Doc */ html.dark .highlight .sa, /* String.Affix */ html.dark .highlight .sb, /* String.Backtick */ html.dark .highlight .sc { /* String.Char */ color: #a5d6ff !important; } html.dark .highlight .c, /* Comment */ html.dark .highlight .c1, /* Comment.Single */ html.dark .highlight .cm, /* Comment.Multiline */ html.dark .highlight .ch { /* Comment.Hashbang */ color: var(--gray-11) !important; font-style: italic; } html.dark .highlight .nd, /* Name.Decorator */ html.dark .highlight .na { /* Name.Attribute */ color: var(--accent-10) !important; font-weight: 600; } html.dark .highlight .m, /* Number */ html.dark .highlight .mi, /* Number.Integer */ html.dark .highlight .mf, /* Number.Float */ html.dark .highlight .mh, /* Number.Hex */ html.dark .highlight .mo { /* Number.Oct */ color: #79c0ff !important; } html.dark .highlight .o, /* Operator */ html.dark .highlight .ow { /* Operator.Word */ color: #ff7b72 !important; } html.dark .highlight .nn, /* Name.Namespace */ html.dark .highlight .bp, /* Name.Builtin.Pseudo */ html.dark .highlight .n { /* Name (covers generic names) */ color: #79c0ff !important; } html.dark .highlight .nv, /* Name.Variable */ html.dark .highlight .vc, /* Name.Variable.Class */ html.dark .highlight .vg, /* Name.Variable.Global */ html.dark .highlight .vi { /* Name.Variable.Instance */ color: #ffa657 !important; } html.dark .highlight .nt { /* Name.Tag */ color: #7ee787 !important; } html.dark .highlight .ne { /* Name.Exception */ color: #ff7b72 !important; } html.dark .highlight .si, /* String.Interpol */ html.dark .highlight .sx { /* String.Other */ color: #a5d6ff !important; } html.dark .highlight .p { /* Punctuation */ color: var(--gray-12) !important; } /* ===================================================== * Common Styles (Both Themes) * ===================================================== */ .highlight { border-radius: 0.5em; padding: 1em; overflow-x: auto; margin: 1em 0; } .highlight pre { margin: 0; padding: 0; overflow: visible; font-family: var(--sy-f-mono), 'Courier New', Courier, monospace; font-size: 0.9em; line-height: 1.6; } /* Smooth transitions when switching themes */ .highlight, .highlight pre, .highlight .k, .highlight .nf, .highlight .s, .highlight .c, .highlight .nd, .highlight .m, .highlight .o, .highlight .nn, .highlight .nv, .highlight .nt, .highlight .ne, .highlight .si { transition: background-color 0.3s ease, color 0.3s ease; } /* Ensure line numbers (if present) have proper styling */ .highlight .linenos { opacity: 0.5; user-select: none; padding-right: 1em; } /* Highlighted lines */ .highlight .hll { background-color: var(--accent-a2); display: block; margin: 0 -1em; padding: 0 1em; } html.dark .highlight .hll { background-color: var(--accent-a4); } python-advanced-alchemy-1.9.3/docs/_static/versions.json000066400000000000000000000000571516556515500233710ustar00rootroot00000000000000{ "versions": ["1", "latest"], "latest": "1" } python-advanced-alchemy-1.9.3/docs/changelog.rst000066400000000000000000001441501516556515500216640ustar00rootroot00000000000000:orphan: 1.x Changelog ============= .. changelog:: 1.9.3 :date: 2026-04-08 .. change:: prevent session cleanup from masking original exceptions :type: bugfix :pr: 704 :issue: 705 - Replace `BaseHTTPMiddleware` with pure ASGI `SessionMiddleware` in Starlette/FastAPI extension โ€” fixes timing issue where `response_status` wasn't set before generator cleanup - Protect session cleanup (commit/rollback/close) across **all** framework extensions so cleanup failures never mask the original route handler exception - Each cleanup operation is individually wrapped; when an original exception exists, cleanup errors are logged but suppressed .. changelog:: 1.9.2 :date: 2026-04-03 .. change:: InvalidRequestError when calling create_session_maker :type: bugfix :pr: 701 Fixes `sqlalchemy.exc.InvalidRequestError: No such event 'before_flush' for target 'async_sessionmaker(...)'` raised when calling `SQLAlchemyAsyncConfig.create_session_maker()` .. changelog:: 1.9.1 :date: 2026-03-26 .. change:: add missing Any import to alembic migration templates :type: bugfix :pr: 697 The try/except ImportError fallback for optional password hashers (Argon2Hasher, PasslibHasher, PwdlibHasher) used `Any` as a type placeholder, but `Any` was never imported. This caused a NameError when running migrations without optional dependencies installed. .. changelog:: 1.9.0 :date: 2026-03-24 .. change:: add SQLModel compatibility :type: feature :pr: 686 SQLModel ``table=True`` models now work with Advanced Alchemy repositories and services without requiring Advanced Alchemy base classes. This release also adds ``model_to_dict()`` and updates schema detection so mapped SQLModel objects are handled as ORM models instead of transfer schemas. .. change:: add pre-release version support :type: feature :pr: 678 Adds PEP 440 pre-release support to the release workflow. ``bump-my-version``, ``make pre-release``, and ``tools/prepare_release.py`` now understand alpha, beta, and release-candidate versions and can mark GitHub draft releases as prereleases. .. change:: add composite primary key support :type: feature :pr: 640 :issue: 189 Adds composite primary key support throughout the repository and service layers. Tuple and mapping primary-key inputs are now supported for lookup and delete operations, including MSSQL-compatible filtering for bulk operations. .. change:: refactor serializers & code cleanup :type: feature :pr: 661 :issue: 606, 651 Refactors repository and cache serialization helpers into shared utilities, reduces duplication in the repository layer, and fixes related issues around exception classification, optional Alembic imports, and DTO descriptor inspection. .. change:: add support .csv files for open_fixture :type: feature :pr: 615 :issue: 536 Add comprehensive CSV support to ``open_fixture()`` and ``open_fixture_async()``, expanding fixture loading beyond JSON to include comma-separated value files. .. change:: initial support for dogpile caching :type: feature :pr: 636 Introduce support for dogpile caching, including a new caching configuration and a null region implementation for scenarios where caching is disabled or dogpile.cache is not installed. Add unit tests to validate the caching behavior and configuration options. .. change:: add read/write replica routing support :type: feature :pr: 635 Adds automatic query routing between primary and replica database connections. ``RoutingSession`` and ``RoutingAsyncSession`` now route reads to replicas, keep writes on the primary, support explicit ``primary_context()`` and ``replica_context()`` overrides, and integrate with the Litestar, FastAPI, Starlette, Flask, and Sanic extensions. .. change:: add NullFilter/NotNullFilter and with_for_update to get_one methods :type: feature :pr: 638 :issue: 187, 488, 623 Adds ``NullFilter`` and ``NotNullFilter`` for ``IS NULL`` and ``IS NOT NULL`` conditions, and extends ``get_one()`` and ``get_one_or_none()`` in the repository and service layers with ``with_for_update`` support for API parity with ``get()``. .. change:: add `was_attribute_set()` guard to relationship loop in `update()` :type: bugfix :pr: 685 Fixes partial updates with model instances where SQLAlchemy-initialized relationship attributes could be mistaken for explicit values and written back to the database. The relationship update loop now uses the same ``was_attribute_set()`` guard as the column loop so only explicitly assigned relationship values are copied during ``update()``. .. change:: nullable relationship detection and FileObject nested metadata :type: bugfix :pr: 679 :issue: 227, 676 Fixes nullable one-to-one DTO detection by correctly handling inverse scalar relationships, and fixes ``FileObject`` uploads by JSON serializing non-string obstore metadata values before they are passed to ``put()``. .. change:: make `model_from_dict` model parameter positional-only :type: bugfix :pr: 673 :issue: 668 Makes the ``model`` parameter positional-only in ``model_from_dict()`` so payloads containing a ``"model"`` key do not conflict with the function signature. Service-layer call sites now use the positional form. .. change:: use typing.List to avoid list() method shadowing on Python 3.14 :type: bugfix :pr: 674 :issue: 659 Replaces bare ``list[...]`` annotations with ``typing.List[...]`` in classes that also define a ``list()`` method. This avoids Python 3.14 lazy annotation evaluation resolving ``list`` to the method instead of the builtin type. .. change:: isolate in-filter query params for multi-field dependeโ€ฆ :type: bugfix :pr: 667 :issue: 666 Fixes a Litestar dependency collision where multiple ``*In`` query parameters generated by ``create_service_dependencies()`` could overwrite each other. Each generated in/not-in filter provider now binds its own query parameter name so fields like ``firstNameIn`` and ``lastNameIn`` remain independent. .. change:: Ensure ORM descriptor fields are not evaluated during DTO creation :type: bugfix :pr: 664 :issue: 578, 646 Fix #646. When processing property fields on SQLAlchemy models for the Litestar DTO, there was sometimes unexpected behaviour caused by the inspecting code evaluating the ORM descriptors such as `hybrid_property`. Fix this behaviour by skipping all known ORM fields (as reported by the ORM), and not using `inspect.getmembers`. .. change:: recursively convert nested dicts in model_from_dict :type: bugfix :pr: 637 :issue: 556 Fixes a regression where ``service.create()`` failed when relationship data was provided as nested dictionaries. ``model_from_dict()`` now detects relationship attributes and recursively converts nested dictionaries or lists of dictionaries into the appropriate related model instances while preserving existing non-nested behavior. .. change:: add click compatibility layer for CLI alias support :type: bugfix :pr: 645 :issue: 644 Adds a Click compatibility layer so CLI alias support works with plain Click and with older ``rich-click`` versions that do not accept the ``aliases`` parameter on command groups. The new helper provides ``AliasedGroup`` and wrapper decorators used across the core, FastAPI, Flask, and Litestar CLI integrations. .. change:: resolve session lifecycle timing with generator dependencies :type: bugfix :pr: 648 :issue: 647 Fixes the FastAPI and Starlette session lifecycle conflict that could leave asyncpg connections unreturned to the pool when generator-based ``provide_service()`` dependencies were cleaned up after middleware had already closed the session. Generator-managed sessions are now marked so middleware records response status but skips cleanup, allowing the generator to handle commit, rollback, and close at the correct time. .. change:: complete SQLAlchemy inheritance pattern support (STI, JTI, CTI) :type: bugfix :pr: 611 Completes SQLAlchemy inheritance handling in ``CommonTableAttributes`` so single-table, joined-table, and concrete-table inheritance patterns are all supported correctly. The implementation now detects STI subclasses, suppresses table generation for them when appropriate, and preserves proper table naming behavior for joined and concrete inheritance models. Supersedes PR #600 .. change:: add a call `set_async_context` to `_get_session_from_request` in Sanic extension :type: bugfix :pr: 643 Sanic now mirrors the other framework extensions by calling ``set_async_context()`` in ``_get_session_from_request()`` after loading or creating the session. This keeps async-context detection consistent across integrations. .. change:: linting changes related to latest Starlette :type: bugfix :pr: 634 Update the codebase to align with the latest changes in Starlette, ensuring compatibility and addressing type checking issues. .. changelog:: 1.8.2 :date: 2025-12-12 .. change:: add `db` group alias :type: feature :pr: 622 Add a `db` shorthand alias to the `database` group. This allows `litestar|alchemy db` or `litestar|alchemy database` to work interchangeably. .. change:: import error while generating migrations :type: bugfix :pr: 630 Fixes `passlib` and `pwdlib` import errors while creating migrations Cause: We added the `sa.PasslibHasher = PasslibHasher` and `sa.PwdlibHasher = PwdlibHasher` types in `script.py.mako`. As a result, when a user installs only Advanced Alchemy and creates a migration, these files are imported. Since they reference types from `passlib` and `pwdlib`, which are not installed by default, the import fails and triggers this error. .. change:: add missing type parameter to AsyncServiceT_co and SyncServiceT_co :type: bugfix :pr: 612 Discovered a runtime issue with an inconsistent type declaration when upgrading a litestar project to use version 1.8.0 introduced .. changelog:: 1.8.1 :date: 2025-12-06 .. change:: pin default installed python to 3.10 :type: bugfix :pr: 601 Update the installation process to set the default Python version to 3.10 instead of 3.9. There are some testing & docs packages we use that are causing issues. We can pin 3.10 until 3.9 support is removed. There is still a CI tests for 3.9 .. change:: adding string representation to PasswordHash and EncryptedString :type: bugfix :pr: 598 :issue: 596 Add string representation while generating migrations for models with `PasswordHash` or `EncryptedString` columns. .. change:: error message handling and isolation in repositories :type: bugfix :pr: 605 :issue: 597 Correct error message retrieval and ensure that error message overrides are isolated for different repository instances. This improves the clarity and reliability of error messages across the application. .. change:: correct race condition in `with_for_update` :type: bugfix :pr: 607 This corrects an issue in the `with_for_update` behavior: - Before the change, passing `with_for_update` to service.update() or repository.update() only affected the post-flush session.refresh() call. The row that gets copied and mutated was always retrieved with a plain SELECT, so two concurrent writers could both read the same version - Now the `with_for_update` flag is honored when the row is first fetched (both in the serviceโ€™s item_id branch and inside SQLAlchemyAsyncRepository.get()). When you call service.update(..., with_for_update=True) (or pass the richer dict form/ForUpdateArg), the initial SELECT ... FOR UPDATE runs, so the session holds the expected lock before any field copying or merges occur. .. changelog:: 1.8.0 :date: 2025-10-28 .. change:: ensure `has_dict_attribute` checks for `__dict__` attribute :type: bugfix :pr: 579 The previous implementation used `isinstance` against the `DictProtocol` type returned `True` for any object. The `isinstance` call is replaced with `hasattr` (which should also be faster). With this change, I believe the `DictProtocol` class could also be removed, but I kept the changes to a minimum. .. change:: adding string representation to StoredObject :type: bugfix :pr: 582 Issues running migration created for `advanced_alchemy.types.FileObject` column on a model. .. change:: surface FileObject session errors; align commit/rollback semantics :type: bugfix :pr: 580 :issue: 543 - Previously save/delete failures were only logged, so callers believed commits succeeded when storage ops had already failed. - Sync commit: processes saves sequentially, then deletes. Logs failures with tracebacks and re-raises. Ignores FileNotFoundError on delete. Stops on first save error. Clears internal state only on full success. - Async commit: runs saves and deletes concurrently via asyncio.gather. Logs each failure with exc_info. Raises the first real Exception, and lets BaseException (e.g., CancelledError) bubble. Attempts deletes even if a save fails. Clears state only on full success. - Rollback (sync/async): deletes only files saved during the current transaction. Ignores FileNotFoundError. Logs and re-raises other errors. Clears state after processing. - Tracking: records successful saves in _saved_in_transaction for targeted rollback cleanup; state is retained on error to allow inspection/retry. .. change:: add `sort_order` to mixin columns for consistent table layout :type: feature :pr: 581 - Add sort_order=-100 to primary key columns (id) across all mixins - Add sort_order=3001 to sentinel column - Add sort_order=3002 to created_at audit column - Add sort_order=3003 to updated_at audit column This ensures consistent column ordering in generated tables: primary keys first, user columns in middle, audit/sentinel columns last. .. change:: use `property` in `SQLAlchemyDTO` with `MappedAsDataclass` :type: feature :pr: 447 Allow better compatibility with `MappedAsDataclass` and the `SQLALchemyDTO` .. change:: add support for SQLAlchemy func expressions in filter classes :type: feature :pr: 585 :issue: 519 Adds support for SQLAlchemy func() expressions in filter classes to eliminate type checker errors when using database functions like `func.random()` or `func.lower()` - Updated `OrderBy`, `BeforeAfter`, `OnBeforeAfter`, `CollectionFilter`, `NotInCollectionFilter`, `ComparisonFilter` - Enhanced `_get_instrumented_attr()` to handle new types - Mock repositories extract field names from `InstrumentedAttribute`, raise helpful error for func expressions (can't execute SQL in-memory) - Added integration tests for func expressions Note: Different databases use different function names (PostgreSQL: `func.random()`, MySQL: `func.rand()`, SQL Server: `func.newid()`) .. changelog:: 1.7.0 :date: 2025-10-13 .. change:: handle and compare `numpy` arrays :type: feature :pr: 550 Direct equality comparisons (`!=`) with numpy arrays in repository update methods raised `ValueError: The truth value of an array with more than one element is ambiguous` Adds a safe comparison utility that handle numpy arrays gracefully .. change:: enhance visibility of syntax blocks in docs :type: feature :pr: 570 Migrates custom documentation styling from hardcoded colors to Shibuya theme's native CSS variable system. This ensures consistent theming across light/dark modes and leverages the configured `accent_color: "amber"` setting. .. change:: align CLI commands with complete Alembic API :type: feature :pr: 569 :issue: 566, 568 Implements complete API parity with Alembic 1.16.5 CLI by adding 9 missing commands and completing the stamp command with all options. .. change:: lazy attributes getting accessed on repository update method :type: bugfix :pr: 553 :issue: 552 This fixes `Still errors in SQLAlchemyRepository update method with lazy` by moving the guard condition up. .. change:: prevent update() from overwriting unset fields with None (#560) :type: bugfix :pr: 563 :issue: 560 Added `was_attribute_set()` helper function that uses SQLAlchemy's instance state inspection to check which attributes were actually modified/set on the input instance. The update() method now only copies attributes that have been explicitly set by the user. .. change:: updated_at not being updated :type: bugfix :pr: 551 `updated_at` was not correctly updated on some base models. .. change:: ensure to_model called with update operation for all data types :type: bugfix :pr: 575 :issue: 555 This PR fixes a bug in the Service layer's `update()` method where dict, Pydantic, msgspec, and attrs data bypassed the `to_model()` operation map, preventing custom `to_model()` implementations from being invoked during update operations. .. change:: handle deleted objects gracefully in auto_expunge :type: bugfix :pr: 574 :issue: 514 Resolves InvalidRequestError when deleting objects with `auto_expunge=True` and `auto_commit=True` enabled. .. change:: punctuation symbols not visible in dark mode syntax highlighting :type: bugfix :pr: 576 Add explicit color styling for `.highlight .p` (punctuation tokens) in both light and dark modes using Shibuya theme's `--gray-12` variable. This ensures brackets, parentheses, commas, and other punctuation symbols are properly visible when viewing code examples in dark mode. .. change:: correct closure variable capture in filter provider loops :type: bugfix :pr: 573 :issue: 507 Fixes a closure bug where multiple fields in `in_fields` and `not_in_fields` arrays resulted in only the last field working correctly. This was caused by loop variables being captured by reference rather than by value in nested function definitions. .. change:: agent workflow and knowledge repository :type: misc :pr: 567 Introduce a structured agent workflow and comprehensive development guides for the AI agent based development. This includes detailed instructions for agents, directory structures for requirements, and updates to existing documentation. .. changelog:: 1.6.3 :date: 2025-09-19 .. change:: additional update and update_many corrections. :type: bugfix :pr: 537 :issue: 464, 535 Updates `update` and `update_many` to properly handle relationships and returning support. .. change:: TypeError when initializing SQLAlchemyAsyncQueryRepository :type: bugfix :pr: 538 :issue: 534 Corrects a TypeError reported from the init method of `SQLAlchemyAsyncQueryRepository` .. change:: property correction :type: bugfix :pr: 539 Property correction for updates .. change:: improve `sync_tools` thread handling and update dependencies :type: bugfix :pr: 545 Enhance thread management in `sync_tools` to improve performance and reliability. .. changelog:: 1.6.2 :date: 2025-08-29 .. change:: enable loading config from working directory :type: feature :pr: 527 :issue: 491 Correctly allow loading configuration from the current directory .. change:: correctly handle lazy attributes on update :type: bugfix :pr: 533 Correctly handle `viewownly` and `lazy` loaded relationships during update. .. change:: prevent AttributeError when schema_dump receives None values :type: bugfix :pr: 530 - `advanced_alchemy/service/typing.py`: Fixed `has_dict_attribute()` function - `tests/unit/test_attrs_integration.py`: Added test case for `None` value handling .. change:: add warning message when using fallback value for a primary key :type: bugfix :pr: 529 - Add warning message when using `uuid` instead of `nanoid` - Add warning message when using `uuid4` instead of `uuid6` or `uuid7` .. change:: litestar fileobject example :type: bugfix :pr: 531 Fix litestar fileobject example .. change:: pass Content-Type and metadata to backend storage :type: bugfix :pr: 528 - Modified `save_object` and `save_object_async` methods to collect attributes from `FileObject` - Pass `content_type` as `"Content-Type"` in the `attributes` parameter - Include any custom metadata from `FileObject.metadata` - Added proper typing for the attributes dictionary .. changelog:: 1.6.1 :date: 2025-08-26 .. change:: `to_schema` and `attrs` type hint correction :type: bugfix :pr: 516 Corrects an issue where the type hint was incorrect when `attrs` or `cattrs` were not installed. .. change:: suppress `passlib` caused pytest warnings and other session warnings :type: bugfix :pr: 518 Suppress warnings caused by `passlib` during testing. .. change:: `IdentityPrimaryKey` correctly generates an `IDENTITY` DDL :type: bugfix :pr: 523 Ensure that the `IdentityPrimaryKey` correctly generates `IDENTITY` DDL across multiple database dialects, including PostgreSQL, Oracle, and SQL Server. Update dependencies and add tests to verify the functionality. .. changelog:: 1.6.0 :date: 2025-08-18 .. change:: server side session backend :type: feature :pr: 429 Implements a server side session backend using SQLAlchemy. Works with an Async or Sync configuration. .. change:: handle relationship data in model_from_dict for service.create() :type: bugfix :pr: 512 Fixed regression where service.create() method stopped handling relationship data correctly when passed SQLAlchemy model instances. Changed model_from_dict() in _util.py to use `__mapper__.attrs.keys()` instead of `__mapper__.columns.keys()` to include relationship attributes alongside column attributes. - Use `attrs.keys()` to include both columns and relationships - Add comprehensive tests for relationship handling in model_from_dict - Verify unknown attributes are still ignored .. changelog:: 1.5.0 :date: 2025-08-13 .. change:: correct typing issue in `litestar` example :type: bugfix :pr: 498 Fixed typing issue in `Litestar` usage documentation .. change:: correctly handle `id_attribute` with `update` :type: bugfix :pr: 502 Correctly merge attributes onto existing instance when using `id_attribute` and `update` .. change:: gzip and zipped fixture support :type: feature :pr: 500 Contains support for automatically extracting and loading data from zipped fixture files .. change:: match against complex types :type: feature :pr: 501 Correctly handle complex data types for matching fields .. change:: `attrs` integration :type: feature :pr: 503 Adds `attrs` support into the `ResultConverter` mixin. This enables `to_schema` and `schema_dump` to natively understand `attrs`. .. changelog:: 1.4.5 :date: 2025-06-28 .. change:: add the DefaultBase class to __all__ :type: feature :pr: 482 :issue: 481 Adds [`DefaultBase`](https://github.com/litestar-org/advanced-alchemy/blob/6cc26ef8d53bc04f89a070337f8b0ab07a1bac46/advanced_alchemy/base.py#L517) class to `__all__` to match other public classes in the module. .. change:: Update list and count :type: bugfix :pr: 487 Minor adjustment to the list and count method .. changelog:: 1.4.4 :date: 2025-05-26 .. change:: support for alembic 1.16 `toml_file` configuration :type: bugfix :pr: 479 Updates the AlembicCommand to use named arguments and support Alembic 1.16's new `toml_file` parameter. .. changelog:: 1.4.3 :date: 2025-05-12 .. change:: add __all__ exports for password hashing backends :type: feature :pr: 471 This update adds __all__ exports for the Argon2, Passlib, and Pwdlib hashing backends, improving module visibility and usability. .. change:: Add identity Mixin for Primary Keys :type: feature :pr: 473 :issue: 441 The sequences based BigInt key offers the most compatibility, but many would prefer to use the Identity column when the database supports it. This changes implements a basic Identity primary key mixin .. change:: `wrap_exceptions` is re-enabled :type: bugfix :pr: 475 :issue: 472 `wrap_exceptions` is now correctly passed into the exception handler context manager. .. changelog:: 1.4.2 :date: 2025-05-04 .. change:: correct type hints for with_for_update to ForUpdateParameter :type: bugfix :pr: 465 This change fixes the type hint for the `with_for_update` parameter in the repositories. .. change:: BigIntPrimaryKey does not respect schema names :type: bugfix :pr: 469 :issue: 466 BigIntPrimaryKey will now respect schema names. .. changelog:: 1.4.1 :date: 2025-04-28 .. change:: raise if filter operator is not in `operators_map` :type: bugfix :pr: 463 :issue: 453 Raise exception if filter operator does not exist in operators_map .. change:: `uniquify` respects init method override :type: bugfix :pr: 462 Passing `uniquify` as an `__init__` argument now works as expected. .. changelog:: 1.4.0 :date: 2025-04-27 .. change:: PasswordHash field type :type: feature :pr: 452 Implements a PasswordHash field type with multiple supported backends. Includes built-in backends for: - `passlib` - `argon2` - `pwdlib` .. changelog:: 1.3.2 :date: 2025-04-25 .. change:: remove stringified type hint :type: bugfix :pr: 457 "De-stringifies" the Filter type hints to prevent runtime type resolutions in some cases .. change:: FileObject native Pydantic Core integration :type: bugfix :pr: 458 File object will now serialize properly in pydantic. More complete FastAPI examples added. .. changelog:: 1.3.1 :date: 2025-04-21 .. change:: updated example `litestar_service.py` model :type: bugfix :pr: 450 :issue: 449 Updates the ``litestar_service.py`` example models to correctly handle relationship updates for ``AuthorModel`` and ``BookModel``. .. change:: `create_service_provider` supports any configuration now :type: bugfix :pr: 451 The Litestar service provider now allows a user to specify the specific dependency key to use for the session. Previously the factory only worked with the `db_session` key. .. change:: update service provider to use dynamic session dependency key :type: bugfix :pr: 454 Update the Litestar service provider to use dynamic session dependency key .. change:: allows positional args for session :type: feature :pr: 455 This change allows for arguments to also be matched when generating a service provider closure. .. changelog:: 1.3.0 :date: 2025-04-18 .. change:: btn ui :type: bugfix :pr: 446 Corrects the button UI in the documentation under certain viewport sizes. .. change:: add dependency provider :type: feature :pr: 431 Add dependency factories for filters. .. changelog:: 1.2.0 :date: 2025-04-15 .. change:: migration generation produces duplicated unique constraints :type: bugfix :pr: 434 :issue: 427 Removes column re-ordering component was incorrectly causing incorrect constraints to be genreated. .. change:: make `SentinelMixin` compatible with `MappedAsDataclass` :type: bugfix :pr: 442 `MappedAsDataclass` is a mixin introduced in SQLAlchemy 2.0. It introduces massive DX improvements to SQLAlchemy by introducing dataclass type validation to SQLAlchemy models. However, this mixin is incompatible with SQLAlchemy's recommended method of implementing a sentinel column as written in their [documentation](https://docs.sqlalchemy.org/en/20/core/connections.html#configuring-sentinel-columns). This PR fixes this incompatibility as suggested by the SQLAlchemy maintainer in this [discussion](https://github.com/sqlalchemy/sqlalchemy/discussions/12519#discussioncomment-12804658). .. change:: enable standard order by :type: feature :pr: 438 Enables the standard `UnaryOperator` order by support in addition to the existing `OrderingPair` .. change:: additional filter configuration options :type: feature :pr: 444 Implements the following filters as configurable options: - NotInCollection - Collection Search now also accepts a set of strings in addition to a comma delimmited list. .. changelog:: 1.1.1 :date: 2025-04-07 .. change:: fsspec is not installed :type: bugfix :pr: 432 Corrects an import issue when `fsspec` and `obstore` are both missing. .. changelog:: 1.1.0 :date: 2025-04-06 .. change:: add stamp command :type: feature :pr: 428 Adds the Alembic `stamp` command to the CLI that will stamp the current database state into the migrations directory. .. change:: adds an `ExistsFilter` and `NotExists` filter :type: feature :pr: 336 :issue: 331 Implements new `Exists` and `NotExists` filters to more easily apply this type of logic to queries. .. change:: fully migrate to `pytest-databases` :type: feature :pr: 430 Migrates all database fixtures to `pytest-database` .. change:: file object data type :type: feature :pr: 291 :issue: 24 Implement a file data type that leverages `obstore` or `fsspec`. Supports any supported FSSpec or Obstore backend it including `sftp`, `gcs`, `s3`, `local`, and more. .. change:: Implements a `MultiFilter` type for complex searches :type: feature :pr: 311 This PR implements a "Multi-Filter" Filter type. It allows: - Create a collection of filters from an input - Allows filters to be groups with and/or logic .. changelog:: 1.0.2 :date: 2025-04-01 .. change:: prevent forward resolution issues :type: bugfix :pr: 423 Removes some stringified representations to help with the forward resolution of `datetime` and `Collection`. .. change:: correctly set `uniquify` from `new` :type: bugfix :pr: 424 Unquify is now correctly set when passed into the `new`/`init` methods. Introduced tests for `sync_tools` utilities, including `maybe_async_`, `maybe_async_context`, `SoonValue`, `TaskGroup`, and others. Improves coverage for async and sync function handling, context managers, and value management. .. change:: remove accidental litestar import :type: bugfix :pr: 426 Remove an incorrect import of `console` from `litestar.cli._utils` and replace it with a correct import from `rich`. This change ensures proper functionality without unnecessary dependencies. .. changelog:: 1.0.1 :date: 2025-03-19 .. change:: properly serialize `Relationship` type hints :type: bugfix :pr: 422 Adds `sqlalchemy.orm.Relationship` to the supported type hints for the `SQLAlchemyDTO` .. changelog:: 1.0.0 :date: 2025-03-18 .. change:: remove deprecated packages removed in `v1.0.0` :type: misc :pr: 419 Removes deprecated packages and prepares for 1.0 release. .. change:: logic correction for window function :type: bugfix :pr: 421 Corrects the logic for using a count with a window function. .. changelog:: 0.34.0 :date: 2025-03-10 .. change:: allow custom `not_found` error messages :type: feature :pr: 417 :issue: 391 Enhance the SQLAlchemy exception wrapper to handle NotFoundError with custom error messages and improved error handling. This includes: - Adding a 'not_found' key to ErrorMessages type - Extending wrap_sqlalchemy_exception to catch and handle NotFoundError - Updating default error message templates with a not_found message - Adding unit tests for custom NotFoundError handling .. change:: Refactor Sanic extension for multi-config support :type: feature :pr: 415 :issue: 375 This commit refactors the Sanic extension for Advanced Alchemy: - Refactored configuration handling with support for multiple database configurations - Added methods for retrieving async and sync sessions, engines, and configs - Improved dependency injection with new provider methods - Simplified extension initialization and registration - Updated example and test files to reflect new extension structure - Removed deprecated methods and simplified the extension interface .. changelog:: 0.33.2 :date: 2025-03-09 .. change:: simplify session type hints in service providers :type: bugfix :pr: 414 Remove unnecessary scoped session type hints from service provider functions. Prevents the following exception from being incorrectly raised: `TypeError: Type unions may not contain more than one custom type - type typing.Union[sqlalchemy.ext.asyncio.session.AsyncSession, sqlalchemy.ext.asyncio.scoping.async_scoped_session[sqlalchemy.ext.asyncio.session.AsyncSession], NoneType] is not supported.` .. changelog:: 0.33.1 :date: 2025-03-07 .. change:: add session to namespace signature :type: feature :pr: 412 The new filter providers expect that the sessions are in the signature namespace. This ensures there are no issues when configuring the plugin. .. changelog:: 0.33.0 :date: 2025-03-07 .. change:: Add dependency factory utilities :type: feature :pr: 405 Introduces a new module `advanced_alchemy.extensions.litestar.providers` with comprehensive dependency injection utilities for SQLAlchemy services in Litestar. The module provides: - Dynamic filter configuration generation - Dependency caching mechanism - Flexible filter and pagination support - Singleton metaclass for dependency management - Configurable filter and search dependencies .. changelog:: 0.32.2 :date: 2025-02-26 .. change:: Litestar extension: Use ``SerializationPlugin`` instead of ``SerializationPluginProtocol`` :type: misc :pr: 401 Use ``SerializationPlugin`` instead of ``SerializationPluginProtocol`` .. changelog:: 0.32.1 :date: 2025-02-26 .. change:: Litestar extension: Use ``CLIPlugin`` instead of ``CLIPluginProtocol`` :type: misc :pr: 399 Internal change migrating from using Litestar's ``CLIPluginProtocol`` to ``CLIPlugin``. .. changelog:: 0.32.0 :date: 2025-02-23 .. change:: remove `limit` and `offset` from count statement :type: bugfix :pr: 395 Remove `limit` and `offset` from count statement .. change:: rename `force_basic_query_mode` :type: misc :pr: 396 Renames `force_basic_query_mode` to `count_with_window_function`. This is also exposed as a class/init parameter for the service and repository. .. change:: add Enum to default type decoders :type: feature :pr: 397 Extends the default `msgspec` type decoders to handle Enum types by converting them to their underlying value during serialization .. changelog:: 0.31.0 :date: 2025-02-18 .. change:: Fix reference in `changelog.py` :type: bugfix :pr: 383 Should link to the AA repo, not litestar :) .. change:: Query repository list method for custom queries :type: bugfix :pr: 379 :issue: 338 Fix query repositories list method according to [documentation](https://advanced-alchemy.litestar.dev/latest/usage/repositories.html#query-repository). Now its return a list of tuples with values instead of first column of the query. .. change:: remove 3.8 support :type: misc :pr: 386 Removes 3.8 support and removes future annotations in a few places for better compatibility .. change:: remove future annotations :type: feature :pr: 387 This removes the usage of future annotations. .. change:: add `uniquify` to service and repo :type: feature :pr: 389 Exposes the `uniquify` flag in all functions on the repository and add to the service .. change:: improved default serializer :type: feature :pr: 390 Improves the default serializer so that it handles various types a bit better .. changelog:: 0.30.3 :date: 2025-01-26 .. change:: add `wrap_exceptions` option to exception handler. :type: feature :pr: 363 :issue: 356 When `wrap_exceptions` is `False`, the original SQLAlchemy error message will be raised instead of the wrapped Repository error (Bug: `wrap_sqlalchemy_exception` masks db errors) .. change:: simplify configuration hash :type: feature :pr: 366 The hashing method on the SQLAlchemy configs can be simplified. This should be enough to define a unique configuration. .. change:: use `lifespan` context manager in Starlette and FastAPI :type: bugfix :pr: 368 :issue: 367 Modifies the Starlette and FastAPI integrations to use the `lifespan` context manager instead of the `startup`\`shutdown` hooks. If the application already has a lifespan set, it is wrapped so that both execute. .. changelog:: 0.30.2 :date: 2025-01-21 .. change:: add hash to config classes :type: feature :pr: 358 :issue: 357 Adds hash function to `SQLAlchemySyncConfig` and `SQLAlchemyAsyncConfig` classes. .. changelog:: 0.30.1 :date: 2025-01-20 .. change:: Using init db CLI command creates migrations directory in unexpected place :type: bugfix :pr: 354 :issue: 351 When initializing migrations with the CLI, if no directory is specified, the directory from the configuration will be used. .. changelog:: 0.30.0 :date: 2025-01-19 .. change:: standardize on `autocommit_include_redirect` :type: bugfix :pr: 349 The flask plugin incorrectly used the term `autocommit_with_redirect` instead of the existing `autocommit_include_redirect`. This changes makes the name consistent before we bump to a `1.x` release .. change:: implement default schema serializer :type: bugfix :pr: 350 This corrects an issue that caused the Flask extension to use the incorrect serializer for encoding JSON .. change:: refactored integration with CLI support :type: feature :pr: 352 Refactored the Starlette and FastAPI integration to support multiple configurations and sessions. Additionally, FastAPI will now have the database commands automatically registered with the FastAPI CLI. .. change:: reorganize Sanic extension :type: feature :pr: 353 The Sanic integration now aligns with the structure and idioms used in the other integrations. .. changelog:: 0.29.1 :date: 2025-01-17 .. change:: add convenience hooks for `to_model` operations :type: feature :pr: 347 The service layer has always has a `to_model` function that accepts data and optionally an operation name. It would return a SQLAlchemy model no matter the input you gave it. It is possible to move business logic into this `to_model` layer for populating fields on insert. (i.e. slug fields or tags, etc.). When having logic for `insert`, `update`, `delete`, and `upsert`, that function can be a bit overwhelcoming. Now, there are helper functions that you can use that is specific to each DML hook: * `to_model_on_create` * `to_model_on_update` * `to_model_on_delete` * `to_model_on_upsert` .. changelog:: 0.29.0 :date: 2025-01-17 .. change:: fully qualify all `datetime` module references :type: bugfix :pr: 341 All date time references are now full qualified to prevent any forward resolution issues with `from datetime import datetime` and `import datetime` .. change:: disabled `timezone` in alembic.ini :type: bugfix :pr: 344 Disabled `timezone` in alembic.ini to fix `alembic.util.exc.CommandError: Can't locate timezone: UTC` error while applying migrations Reference: https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file .. change:: various typing improvements for services :type: feature :pr: 342 :issue: 261 Improved typing in the service layer and adds a additional type guards. .. change:: Auto extend Flask CLI and add session integration :type: feature :pr: 111 The Advanced Alchemy alembic CLI is now auto-extended to your Flask application. The Flask extension now also has a session handling middleware for handling auto-commits. Last, but not least, there's an experimental async portal that integrates a long running asyncio loop for running async operations in Flask. Using `foo = portal.call()` you can get the result of an asynchronous function from a sync context. .. changelog:: 0.28.0 :date: 2025-01-13 .. change:: add `bind-key` option to CLI :type: feature :pr: 339 Adds a `bind-key` option to the Advance Alchemy CLI groups. When present, the Alembic configs will be injected with the corresponding key. .. changelog:: 0.27.1 :date: 2025-01-11 .. change:: correction for `3.8` and `3.9` type hints :type: bugfix :pr: 330 Makes a few corrections to type hints in examples and tests to ensure 3.8 and 3.9 support .. changelog:: 0.27.0 :date: 2025-01-11 .. change:: add `error_messages` as class level configuration :type: feature :pr: 315 Exposes ``error_messages`` as a class level configuration in the repository and service classes. .. change:: implement reusable CLI :type: feature :pr: 320 Exposes a reusable CLI for creating and updating releases. This can be used to extend any existing Click or Typer CLI. .. change:: adds additional type guard helpers :type: feature :pr: 322 Addition typing utilities to help with type checking and validation. .. changelog:: 0.26.0 :date: 2025-01-11 .. change:: `AsyncAttrs` & remove `noload` default :type: feature :pr: 305 This PR adds the `AsyncAttrs` to the default declarative bases for convenience. It also changes the `inherit_lazy_relationships == False` behavior to use `lazyload`. SQLAlchemy will be deprecating `noload` in version 2.1 .. change:: `litestar` DTO enhancements :type: feature :pr: 310 :issue: 306 The Litestar DTO has been enhanced with: - The SQLAlchemyDTOConfig's `exclude`, `include`, and `rename_fields` fields will now accept string or `InstrumentedAttributes` - DTO supports `WriteOnlyMapped` and `DynamicMapped` .. change:: add default exception handler for `litestar` integration :type: feature :pr: 308 :issue: 275 This adds a configuration option to automatically enable an exception handler for Repository errors. This will update the exception handler if you do not have one already configured for the RepositoryException class .. changelog:: 0.25.0 :date: 2025-01-11 .. change:: add max length for encrypted string :type: feature :pr: 290 The EncryptedString field now has the ability to validate against a set length. .. change:: `AsyncAttrs` & remove `noload` default :type: feature :pr: 305 This PR adds the `AsyncAttrs` to the default declarative bases for convenience. It also changes the `inherit_lazy_relationships == False` behavior to use `lazyload`. SQLAlchemy will be deprecating `noload` in version 2.1 .. changelog:: 0.24.0 :date: 2025-01-11 .. change:: remove lambda statement usage :type: feature :pr: 288 :issue: 286, 287 Removes the use of lambda statements in the repository and service classes. This has no change on the end user API, however, it should remove strange queries errors seen. .. changelog:: 0.23.0 :date: 2025-01-11 .. change:: regression caused by conditional import Sequence for pagination.py :type: bugfix :pr: 274 :issue: 272 Import Sequence directly from collections.abc Remove conditional import using TYPE_CHECKING Add noqa comment to suppress potential linter warnings .. change:: make sure `anyio` is optional :type: bugfix :pr: 278 When running standalone or with a synchronous web framework, `anyio` is not required. This PR ensures that there are no module loading failures due to the missing import. .. change:: Improved typing of `ModelDictT` :type: feature :pr: 277 Fixes typing issues in service https://github.com/litestar-org/advanced-alchemy/issues/265 This still doesn't solve the problem of UnknownVariableType if the subtypes of ModelDictT are not installed (eg: Pydantic) But at least it solves the problem of incompatibilities when they are installed .. changelog:: 0.22.0 :date: 2025-01-11 .. change:: CLI argument adjustment :type: bugfix :pr: 270 Changes the argument name so that it matches the name given in `click.option`. .. changelog:: 0.21.0 :date: 2025-01-11 .. change:: bind session to session class instead of to the session maker :type: bugfix :pr: 268 :issue: 267 binds session into sanic extension as expected in the original code, session maker was defined and then the dependency for session overwrites it with a session maker as the type. this seems non-ideal -- you can't get the session maker and when you ask for the session maker you get a session object instead, this looks at the sessionmaker `class_` property for adding the sanic dependency .. change:: correct regex mappings for duplicate and foreign key errors :type: bugfix :pr: 266 :issue: 262 Swap the variable names for DUPLICATE_KEY_REGEXES and FOREIGN_KEY_REGEXES to correctly match their contents. This ensures that the error detection for duplicate keys and foreign key violations works as intended across different database backends. .. change:: Dump all tables as JSON :type: feature :pr: 259 Adds a new CLI command to export tables to JSON. Similar to a Django dumpdata command. .. changelog:: <=0.20.0 :date: 2025-01-11 .. change:: CollectionFilter returns all entries if values is empty :type: bugfix :pr: 52 :issue: 51 Bug: CollectionFilter returns all entries if values is empty a simple `1=-1` is appended into the `where` clause when an empty list is passed into the `in` statement. .. change:: better handle empty collection filters :type: bugfix :pr: 62 Currently, [this](https://github.com/cofin/litestar-fullstack/blob/main/src/app/lib/dependencies.py#L169) is how you can inject these filters in your app. When using the `id_filter` dependency on it's own, you have to have an additional not-null check before passing it into the repository. This change handles that and allows you to pass in all filters into the repository function without checking their nullability. .. change:: service `exists` should use `exists` from repository :type: bugfix :pr: 68 The service should use the repository's implementation of `exists` instead of a new one with a `count`. .. change:: do not set `id` with `item_id` when `None` :type: bugfix :pr: 67 This PR prevents the primary key from being overrwitten with `None` when using the service without the `item_id` parameter. .. change:: sqlalchemy dto for models non `Column` fields :type: bugfix :pr: 75 Examples of such fields are `ColumnClause` and `Label`, these are generated when using `sqlalchemy.func` - Fix SQLAlchemy dto generation for litestar when using models that have fields that are not instances of `Column`. Such fields arise from using expressions such as `func`. python-advanced-alchemy-1.9.3/docs/conf.py000066400000000000000000000327301516556515500205020ustar00rootroot00000000000000# Configuration file for the Sphinx documentation builder. # ruff: noqa: FIX002 from __future__ import annotations import datetime import os import warnings from functools import partial from typing import TYPE_CHECKING, Any from sqlalchemy.exc import SAWarning from advanced_alchemy.__metadata__ import __project__, __version__ if TYPE_CHECKING: from typing import Any from sphinx.addnodes import document # type: ignore[attr-defined,unused-ignore] from sphinx.application import Sphinx # -- Environmental Data ------------------------------------------------------ warnings.filterwarnings("ignore", category=SAWarning) # -- Project information ----------------------------------------------------- current_year = datetime.datetime.now().year # noqa: DTZ005 project = __project__ copyright = f"{current_year}, Litestar Organization" # noqa: A001 release = os.getenv("_ADVANCED-ALCHEMY_DOCS_BUILD_VERSION", __version__.rsplit(".")[0]) suppress_warnings = [ "autosectionlabel.*", "ref.python", # TODO: remove when https://github.com/sphinx-doc/sphinx/issues/4961 is fixed ] # -- General configuration --------------------------------------------------- extensions = [ # Sphinx core extensions "sphinx.ext.autodoc", "sphinx.ext.autosectionlabel", "sphinx.ext.githubpages", "sphinx.ext.intersphinx", "sphinx.ext.napoleon", "sphinx.ext.todo", "sphinx.ext.viewcode", # Custom extensions "tools.sphinx_ext.missing_references", "tools.sphinx_ext.changelog", # Third-party extensions "sphinx_autodoc_typehints", "myst_parser", "auto_pytabs.sphinx_ext", "sphinx_copybutton", "sphinx_click", "sphinx_design", "sphinx_togglebutton", "sphinx_paramlinks", ] intersphinx_mapping = { "python": ("https://docs.python.org/3", None), "msgspec": ("https://jcristharif.com/msgspec/", None), "sqlalchemy": ("https://docs.sqlalchemy.org/en/20/", None), "alembic": ("https://alembic.sqlalchemy.org/en/latest/", None), "litestar": ("https://docs.litestar.dev/latest/", None), "click": ("https://click.palletsprojects.com/en/stable/", None), "anyio": ("https://anyio.readthedocs.io/en/stable/", None), "multidict": ("https://multidict.aio-libs.org/en/stable/", None), "cryptography": ("https://cryptography.io/en/latest/", None), "pydantic": ("https://docs.pydantic.dev/latest/", None), "sanic": ("https://sanic.readthedocs.io/en/latest/", None), "flask": ("https://flask.palletsprojects.com/en/stable/", None), "typing_extensions": ("https://typing-extensions.readthedocs.io/en/stable/", None), "attrs": ("https://www.attrs.org/en/stable/", None), "pytest": ("https://docs.pytest.org/en/stable/", None), } nitpicky = True nitpick_ignore: list[str] = [] nitpick_ignore_regex: list[str] = [] auto_pytabs_min_version = (3, 9) auto_pytabs_max_version = (3, 13) napoleon_google_docstring = True napoleon_include_special_with_doc = True napoleon_use_admonition_for_examples = True napoleon_use_admonition_for_notes = True napoleon_use_admonition_for_references = False napoleon_attr_annotations = True autoclass_content = "class" autodoc_class_signature = "separated" autodoc_default_options = {"special-members": "__init__", "show-inheritance": True, "members": True} autodoc_member_order = "bysource" autodoc_typehints_format = "short" autodoc_typehints = "both" autodoc_preserve_defaults = True autodoc_type_aliases = { "ModelT": "advanced_alchemy.repository.typing.ModelT", "FilterTypeT": "advanced_alchemy.filters.FilterTypeT", "StatementTypeT": "advanced_alchemy.filters.StatementTypeT", "EngineT": "sqlalchemy.engine.Engine", "AsyncEngineT": "sqlalchemy.ext.asyncio.AsyncEngine", "SessionT": "sqlalchemy.orm.Session", "AsyncSessionT": "sqlalchemy.ext.asyncio.AsyncSession", "ConnectionT": "sqlalchemy.engine.Connection", "AsyncConnectionT": "sqlalchemy.ext.asyncio.AsyncConnection", "Mapper": "sqlalchemy.orm.Mapper", "Registry": "sqlalchemy.orm.registry", "RegistryType": "sqlalchemy.orm.registry", "Table": "sqlalchemy.schema.Table", "MetaData": "sqlalchemy.schema.MetaData", "FilterableRepository": "advanced_alchemy.repository._util.FilterableRepository", "SQLAlchemyAsyncRepositoryProtocol": "advanced_alchemy.repository._async.SQLAlchemyAsyncRepositoryProtocol", "SQLAlchemyAsyncRepository": "advanced_alchemy.repository.SQLAlchemyAsyncRepository", "SQLAlchemySyncRepositoryProtocol": "advanced_alchemy.repository._sync.SQLAlchemySyncRepositoryProtocol", "SQLAlchemySyncRepository": "advanced_alchemy.repository.SQLAlchemySyncRepository", "SQLAlchemyAsyncSlugRepositoryProtocol": "advanced_alchemy.repository._async.SQLAlchemyAsyncSlugRepositoryProtocol", "SQLAlchemySyncSlugRepositoryProtocol": "advanced_alchemy.repository._sync.SQLAlchemySyncSlugRepositoryProtocol", "ModelOrRowMappingT": "advanced_alchemy.repository.ModelOrRowMappingT", "ModelDTOT": "advanced_alchemy.service.ModelDTOT", "DTOData": "litestar.dto.data_structures.DTOData", "InstrumentedAttribute": "sqlalchemy.orm.attributes.InstrumentedAttribute", "BaseModel": "pydantic.BaseModel", "Struct": "msgspec.Struct", "TableArgsType": "sqlalchemy.orm.decl_base._TableArgsType", "DateTimeUTC": "advanced_alchemy.types.DateTimeUTC", "TypeEngine": "sqlalchemy.types.TypeEngine", "DeclarativeBase": "sqlalchemy.orm.DeclarativeBase", "UUIDBase": "advanced_alchemy.base.UUIDBase", "NanoIDBase": "advanced_alchemy.base.NanoIDBase", "BigIntBase": "advanced_alchemy.base.BigIntBase", "BigIntAuditBase": "advanced_alchemy.base.BigIntAuditBase", "DefaultBase": "advanced_alchemy.base.DefaultBase", "SQLQuery": "advanced_alchemy.base.SQLQuery", "UUIDv6PrimaryKey": "advanced_alchemy.base.UUIDv6PrimaryKey", "UUIDv7PrimaryKey": "advanced_alchemy.base.UUIDv7PrimaryKey", "NanoIDPrimaryKey": "advanced_alchemy.base.NanoIDPrimaryKey", "BigIntPrimaryKey": "advanced_alchemy.base.BigIntPrimaryKey", "CommonTableAttributes": "advanced_alchemy.base.CommonTableAttributes", "AuditColumns": "advanced_alchemy.base.AuditColumns", "UUIDPrimaryKey": "advanced_alchemy.base.UUIDPrimaryKey", "EngineConfig": "advanced_alchemy.config.EngineConfig", "AsyncSessionConfig": "advanced_alchemy.config.AsyncSessionConfig", "SyncSessionConfig": "advanced_alchemy.config.SyncSessionConfig", "EmptyType": "advanced_alchemy.utils.dataclass.EmptyType", "async_sessionmaker": "sqlalchemy.ext.asyncio.async_sessionmaker", "sessionmaker": "sqlalchemy.orm.sessionmaker", "SlugMixin": "advanced_alchemy.mixins.slug.SlugKey", "UniqueMixin": "advanced_alchemy.mixins.unique.UniqueMixin", "AsyncEngine": "sqlalchemy.ext.asyncio.AsyncEngine", "Engine": "sqlalchemy.engine.Engine", "sqlalchemy": "sqlalchemy", "RenameStrategy": "litestar.dto.types.RenameStrategy", "Union": "typing.Union", "Callable": "typing.Callable", "Any": "typing.Any", "Optional": "typing.Optional", "_EchoFlagType": "advanced_alchemy.config._EchoFlagType", "advanced_alchemy.types.encrypted_string.PGCryptoBackend": "advanced_alchemy.types.encrypted_string.PGCryptoBackend", "advanced_alchemy.types.password_hash.pwdlib.PwdlibHasher": "advanced_alchemy.types.password_hash.pwdlib.PwdlibHasher", "advanced_alchemy.types.password_hash.argon2.Argon2Hasher": "advanced_alchemy.types.password_hash.argon2.Argon2Hasher", "advanced_alchemy.types.password_hash.passlib.PasslibHasher": "advanced_alchemy.types.password_hash.passlib.PasslibHasher", "ForUpdateArg": "sqlalchemy.sql.selectable.ForUpdateArg", } autodoc_mock_imports = [ "alembic", "sanic_ext.Extend", "sanic", "sqlalchemy.ext.asyncio.engine.create_async_engine", "_sa.create_engine._sphinx_paramlinks_creator", "sqlalchemy.Dialect", "sqlalchemy.orm.MetaData", "advanced_alchemy.config.engine.EngineConfig", "advanced_alchemy.config.asyncio.AsyncSessionConfig", "advanced_alchemy.config.sync.SyncSessionConfig", "advanced_alchemy.utils.dataclass.EmptyType", "advanced_alchemy.extensions.litestar.plugins.init.config.engine.EngineConfig", "sqlalchemy.ext.asyncio", "sqlalchemy.engine", "sqlalchemy.orm", "advanced_alchemy.types.encrypted_string.PGCryptoBackend", "advanced_alchemy.types.password_hash.pwdlib.PwdlibHasher", "advanced_alchemy.types.password_hash.argon2.Argon2Hasher", "advanced_alchemy.types.password_hash.passlib.PasslibHasher", ] autosectionlabel_prefix_document = True # Strip the dollar prompt when copying code # https://sphinx-copybutton.readthedocs.io/en/latest/use.html#strip-and-configure-input-prompts-for-code-cells copybutton_prompt_text = "$ " # -- Style configuration ----------------------------------------------------- html_theme = "shibuya" html_title = "Advanced Alchemy" html_short_title = "AA" # Pygments syntax highlighting configuration # Using accessible-pygments for WCAG AA/AAA compliant syntax highlighting # Light mode: a11y-light provides excellent readability on light backgrounds (WCAG AA) # Dark mode: a11y-high-contrast-dark provides maximum readability on dark backgrounds (WCAG AAA) pygments_style = "a11y-light" pygments_dark_style = "a11y-high-contrast-dark" todo_include_todos = True html_static_path = ["_static"] html_favicon = "_static/favicon.png" templates_path = ["_templates"] html_css_files = ["custom.css", "syntax-highlighting.css"] exclude_patterns = ["_build", "Thumbs.db", ".DS_Store", "PYPI_README.md", "guides/**"] html_show_sourcelink = True html_copy_source = True # Add SEO-friendly meta tags html_meta = { "description": "Advanced Alchemy - A carefully crafted, thoroughly tested, optimized companion library for SQLAlchemy", "keywords": "sqlalchemy, orm, alembic, python, database, litestar, repository-pattern, fastapi, starlette", "author": "Litestar Organization", "og:title": "Advanced Alchemy Documentation", "og:type": "website", "og:description": "Advanced Alchemy - A carefully crafted, thoroughly tested, optimized companion library for SQLAlchemy", "og:site_name": "Advanced Alchemy", "twitter:card": "summary", } html_context = { "source_type": "github", "source_user": "litestar-org", "source_repo": "advanced-alchemy", "current_version": "latest", "version": release, } html_theme_options = { "logo_target": "/", "accent_color": "amber", "github_url": "https://github.com/litestar-org/advanced-alchemy", "discord_url": "https://discord.gg/dSDXd4mKhp", "navigation_with_keys": True, "globaltoc_expand_depth": 2, "light_logo": "_static/logo-default.png", "dark_logo": "_static/logo-default.png", "discussion_url": "https://discord.gg/dSDXd4mKhp", "nav_links": [ {"title": "Home", "url": "index"}, { "title": "About", "children": [ { "title": "Changelog", "url": "changelog", "summary": "All changes for Advanced Alchemy", }, { "title": "Litestar Organization", "summary": "Details about the Litestar organization, the team behind Advanced Alchemy", "url": "https://litestar.dev/about/organization", "icon": "org", }, { "title": "Releases", "summary": "Explore the release process, versioning, and deprecation policy for Advanced Alchemy", "url": "releases", "icon": "releases", }, { "title": "Contributing", "summary": "Learn how to contribute to the Advanced Alchemy project", "url": "contribution-guide", "icon": "contributing", }, { "title": "Code of Conduct", "summary": "Review the etiquette for interacting with the Advanced Alchemy community", "url": "https://github.com/litestar-org/.github?tab=coc-ov-file", "icon": "coc", }, { "title": "Security", "summary": "Overview of Advanced Alchemy's security protocols", "url": "https://github.com/litestar-org/.github?tab=coc-ov-file#security-ov-file", "icon": "coc", }, {"title": "Sponsor", "url": "https://github.com/sponsors/Litestar-Org", "icon": "heart"}, ], }, { "title": "Help", "children": [ { "title": "Discord Help Forum", "summary": "Dedicated Discord help forum", "url": "https://discord.gg/dSDXd4mKhp", "icon": "coc", }, { "title": "GitHub Discussions", "summary": "GitHub Discussions", "url": "https://github.com/litestar-org/advanced-alchemy/discussions", "icon": "coc", }, ], }, ], } def update_html_context( _app: Sphinx, _pagename: str, _templatename: str, context: dict[str, Any], _doctree: document, ) -> None: context["generate_toctree_html"] = partial(context["generate_toctree_html"], startdepth=0) def setup(app: Sphinx) -> dict[str, bool]: app.setup_extension("shibuya") return {"parallel_read_safe": True, "parallel_write_safe": True} python-advanced-alchemy-1.9.3/docs/conftest.py000066400000000000000000000030331516556515500213740ustar00rootroot00000000000000from collections.abc import AsyncGenerator import pytest from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine from sqlalchemy.orm import sessionmaker from sybil import Sybil from sybil.parsers.rest import PythonCodeBlockParser EXECUTABLE_DOCS = ( "usage/modeling/basics.rst", "usage/modeling/inheritance.rst", "usage/modeling/sqlmodel.rst", "usage/modeling/types.rst", "usage/repositories/advanced.rst", "usage/repositories/basics.rst", "usage/repositories/filtering.rst", "usage/database_seeding.rst", "usage/services.rst", ) NON_EXECUTABLE_DOCS = ( "usage/caching.rst", "usage/cli.rst", "usage/frameworks/fastapi.rst", "usage/frameworks/flask.rst", "usage/frameworks/litestar.rst", "usage/frameworks/sanic.rst", "usage/frameworks/starlette.rst", "usage/routing.rst", ) @pytest.fixture(name="engine") async def engine_fixture() -> AsyncGenerator[AsyncEngine, None]: engine = create_async_engine("sqlite+aiosqlite:///:memory:") yield engine await engine.dispose() @pytest.fixture(name="db_session") async def db_session_fixture(engine: AsyncEngine) -> AsyncGenerator[AsyncSession, None]: async_session_factory: sessionmaker[AsyncSession] = sessionmaker( engine, class_=AsyncSession, expire_on_commit=False ) async with async_session_factory() as session: yield session pytest_collect_file = Sybil( parsers=[PythonCodeBlockParser()], patterns=EXECUTABLE_DOCS, fixtures=["db_session", "engine"], ).pytest() python-advanced-alchemy-1.9.3/docs/contribution-guide.rst000066400000000000000000000000531516556515500235400ustar00rootroot00000000000000:orphan: .. include:: ../CONTRIBUTING.rst python-advanced-alchemy-1.9.3/docs/getting-started.rst000066400000000000000000000043521516556515500230410ustar00rootroot00000000000000=============== Getting Started =============== Advanced Alchemy is a carefully crafted, thoroughly tested, optimized companion library for :doc:`SQLAlchemy `. It provides :doc:`base classes `, :doc:`mixins `, :doc:`custom column types `, and implementations of the :doc:`repository ` and :doc:`service layer ` patterns to simplify your database operations. .. seealso:: It is built on: * `SQLAlchemy `_ * `Alembic `_ * `Typing Extensions `_ It's designed to work on its own or with your favorite web framework. We've built extensions for some of the most popular frameworks, so you can get the most out of Advanced Alchemy with minimal effort. * `Litestar `_ * `FastAPI `_ * `Starlette `_ * `Flask `_ * `Sanic `_ If your framework is not listed, don't worry! Advanced Alchemy is designed to be modular and easily integrated with any Python web framework. `Join our Discord `_ and we'll help you get started. Installation ------------ Install ``advanced-alchemy`` with your favorite Python package manager: .. tab-set:: .. tab-item:: pip :sync: key1 .. code-block:: bash :caption: Using pip python3 -m pip install advanced-alchemy .. tab-item:: uv .. code-block:: bash :caption: Using `UV `_ uv add advanced-alchemy .. tab-item:: pipx :sync: key2 .. code-block:: bash :caption: Using `pipx `_ pipx install advanced-alchemy .. tab-item:: pdm .. code-block:: bash :caption: Using `PDM `_ pdm add advanced-alchemy .. tab-item:: Poetry .. code-block:: bash :caption: Using `Poetry `_ poetry add advanced-alchemy python-advanced-alchemy-1.9.3/docs/index.rst000066400000000000000000000076651516556515500210550ustar00rootroot00000000000000:layout: landing :description: Advanced Alchemy is a carefully crafted, thoroughly tested, optimized companion library for SQLAlchemy. .. container:: :name: home-head .. container:: .. raw:: html .. container:: badges :name: badges .. image:: https://img.shields.io/github/actions/workflow/status/litestar-org/advanced-alchemy/publish.yml?labelColor=202235&logo=github&logoColor=edb641&label=Release :alt: GitHub Actions Latest Release Workflow Status .. image:: https://img.shields.io/github/actions/workflow/status/litestar-org/advanced-alchemy/ci.yml?labelColor=202235&logo=github&logoColor=edb641&label=Tests%20And%20Linting :alt: GitHub Actions CI Workflow Status .. image:: https://img.shields.io/github/actions/workflow/status/litestar-org/advanced-alchemy/docs.yml?labelColor=202235&logo=github&logoColor=edb641&label=Docs%20Build :alt: GitHub Actions Docs Build Workflow Status .. image:: https://img.shields.io/codecov/c/github/litestar-org/advanced-alchemy?labelColor=202235&logo=codecov&logoColor=edb641&label=Coverage :alt: Coverage .. image:: https://img.shields.io/pypi/v/advanced-alchemy?labelColor=202235&color=edb641&logo=python&logoColor=edb641 :alt: PyPI Version .. image:: https://img.shields.io/pypi/dm/advanced-alchemy?logo=python&label=advanced-alchemy%20downloads&labelColor=202235&color=edb641&logoColor=edb641 :alt: PyPI Downloads .. image:: https://img.shields.io/pypi/pyversions/advanced-alchemy?labelColor=202235&color=edb641&logo=python&logoColor=edb641 :alt: Supported Python Versions .. rst-class:: lead Advanced Alchemy is a carefully crafted, thoroughly tested, optimized companion library for :doc:`SQLAlchemy `. It provides :doc:`base classes `, :doc:`mixins `, :doc:`custom column types `, and implementations of the :doc:`repository ` and :doc:`service layer ` patterns to simplify your database operations. .. container:: buttons wrap .. raw:: html Get Started Usage Docs API Docs .. grid:: 1 1 2 2 :padding: 0 :gutter: 2 .. grid-item-card:: :octicon:`versions` Changelog :link: changelog :link-type: doc The latest updates and enhancements to Advanced-Alchemy .. grid-item-card:: :octicon:`comment-discussion` Discussions :link: https://github.com/litestar-org/advanced-alchemy/discussions Join discussions, pose questions, or share insights. .. grid-item-card:: :octicon:`issue-opened` Issues :link: https://github.com/litestar-org/advanced-alchemy/issues Report issues or suggest new features. .. grid-item-card:: :octicon:`beaker` Contributing :link: contribution-guide :link-type: doc Contribute to Advanced Alchemy's growth with code, docs, and more. .. _sponsor-github: https://github.com/sponsors/litestar-org .. _sponsor-oc: https://opencollective.com/litestar .. _sponsor-polar: https://polar.sh/litestar-org .. toctree:: :titlesonly: :caption: Documentation :hidden: getting-started usage/index reference/index .. toctree:: :titlesonly: :caption: Contributing :hidden: changelog contribution-guide Available Issues Code of Conduct python-advanced-alchemy-1.9.3/docs/reference/000077500000000000000000000000001516556515500211345ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/docs/reference/alembic/000077500000000000000000000000001516556515500225305ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/docs/reference/alembic/commands.rst000066400000000000000000000001341516556515500250610ustar00rootroot00000000000000======== commands ======== .. automodule:: advanced_alchemy.alembic.commands :members: python-advanced-alchemy-1.9.3/docs/reference/alembic/index.rst000066400000000000000000000003751516556515500243760ustar00rootroot00000000000000======= alembic ======= API Reference for the ``Alembic`` module .. note:: Private methods and attributes are not included in the API reference. Available API References ------------------------ .. toctree:: :titlesonly: commands utils python-advanced-alchemy-1.9.3/docs/reference/alembic/utils.rst000066400000000000000000000001201516556515500244130ustar00rootroot00000000000000===== utils ===== .. automodule:: advanced_alchemy.alembic.utils :members: python-advanced-alchemy-1.9.3/docs/reference/base.rst000066400000000000000000000003721516556515500226020ustar00rootroot00000000000000==== base ==== .. automodule:: advanced_alchemy.base :members: :imported-members: :undoc-members: :show-inheritance: :no-index: advanced_alchemy.base.AdvancedDeclarativeBase.registry advanced_alchemy.base.BasicAttributes.to_dict python-advanced-alchemy-1.9.3/docs/reference/cache.rst000066400000000000000000000022471516556515500227360ustar00rootroot00000000000000===== Cache ===== .. module:: advanced_alchemy.cache The cache module provides optional integration with `dogpile.cache`_ for caching SQLAlchemy model instances. It supports multiple backends and provides automatic cache invalidation when models are modified. .. _dogpile.cache: https://dogpilecache.sqlalchemy.org/ Installation ------------ The cache module requires the optional ``dogpile.cache`` dependency: .. code-block:: bash pip install advanced-alchemy[dogpile] Without this dependency, the cache manager will use a ``NullRegion`` that provides the same interface but doesn't actually cache anything. Configuration ------------- .. autoclass:: advanced_alchemy.cache.CacheConfig :members: :show-inheritance: Cache Manager ------------- .. autoclass:: advanced_alchemy.cache.CacheManager :members: :show-inheritance: Serialization ------------- .. autofunction:: advanced_alchemy.cache.default_serializer .. autofunction:: advanced_alchemy.cache.default_deserializer Setup ----- .. autofunction:: advanced_alchemy._listeners.setup_cache_listeners Constants --------- .. autodata:: advanced_alchemy.cache.DOGPILE_CACHE_INSTALLED :annotation: python-advanced-alchemy-1.9.3/docs/reference/config/000077500000000000000000000000001516556515500224015ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/docs/reference/config/asyncio.rst000066400000000000000000000002311516556515500245740ustar00rootroot00000000000000======= asyncio ======= .. automodule:: advanced_alchemy.config.asyncio :members: :imported-members: :undoc-members: :show-inheritance: python-advanced-alchemy-1.9.3/docs/reference/config/common.rst000066400000000000000000000002251516556515500244220ustar00rootroot00000000000000====== common ====== .. automodule:: advanced_alchemy.config.common :members: :imported-members: :undoc-members: :show-inheritance: python-advanced-alchemy-1.9.3/docs/reference/config/engine.rst000066400000000000000000000002251516556515500243770ustar00rootroot00000000000000====== engine ====== .. automodule:: advanced_alchemy.config.engine :members: :imported-members: :undoc-members: :show-inheritance: python-advanced-alchemy-1.9.3/docs/reference/config/index.rst000066400000000000000000000004431516556515500242430ustar00rootroot00000000000000====== config ====== API Reference for the ``Config`` module .. note:: Private methods and attributes are not included in the API reference. Available API References ------------------------ .. toctree:: :titlesonly: asyncio common engine routing sync types python-advanced-alchemy-1.9.3/docs/reference/config/routing.rst000066400000000000000000000007141516556515500246240ustar00rootroot00000000000000======= routing ======= Configuration classes for read/write replica routing. RoutingConfig ------------- .. autoclass:: advanced_alchemy.config.routing.RoutingConfig :members: :undoc-members: ReplicaConfig ------------- .. autoclass:: advanced_alchemy.config.routing.ReplicaConfig :members: :undoc-members: RoutingStrategy --------------- .. autoclass:: advanced_alchemy.config.routing.RoutingStrategy :members: :undoc-members: python-advanced-alchemy-1.9.3/docs/reference/config/sync.rst000066400000000000000000000002151516556515500241050ustar00rootroot00000000000000==== sync ==== .. automodule:: advanced_alchemy.config.sync :members: :imported-members: :undoc-members: :show-inheritance: python-advanced-alchemy-1.9.3/docs/reference/config/types.rst000066400000000000000000000002211516556515500242720ustar00rootroot00000000000000===== types ===== .. automodule:: advanced_alchemy.config.types :members: :imported-members: :undoc-members: :show-inheritance: python-advanced-alchemy-1.9.3/docs/reference/exceptions.rst000066400000000000000000000001631516556515500240470ustar00rootroot00000000000000========== exceptions ========== .. automodule:: advanced_alchemy.exceptions :members: :show-inheritance: python-advanced-alchemy-1.9.3/docs/reference/extensions/000077500000000000000000000000001516556515500233335ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/docs/reference/extensions/fastapi/000077500000000000000000000000001516556515500247625ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/docs/reference/extensions/fastapi/index.rst000066400000000000000000000015061516556515500266250ustar00rootroot00000000000000======= fastapi ======= API Reference for the ``FastAPI`` extensions module .. note:: Private methods and attributes are not included in the API reference. ------------------------ .. automodule:: advanced_alchemy.extensions.fastapi :imported-members: advanced_alchemy.utils advanced_alchemy.base advanced_alchemy.exceptions advanced_alchemy.filters advanced_alchemy.mixins advanced_alchemy.operations advanced_alchemy.repository advanced_alchemy.service advanced_alchemy.types advanced_alchemy.alembic.commands.AlembicCommands advanced_alchemy.extensions.starlette.EngineConfig advanced_alchemy.extensions.starlette.SQLAlchemyAsyncConfig advanced_alchemy.extensions.starlette.SQLAlchemySyncConfig :members: :noindex: python-advanced-alchemy-1.9.3/docs/reference/extensions/flask/000077500000000000000000000000001516556515500244335ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/docs/reference/extensions/flask/index.rst000066400000000000000000000014641516556515500263010ustar00rootroot00000000000000===== flask ===== API Reference for the ``Flask`` extensions module .. note:: Private methods and attributes are not included in the API reference. ------------------------ .. automodule:: advanced_alchemy.extensions.flask :imported-members: advanced_alchemy.utils advanced_alchemy.base advanced_alchemy.exceptions advanced_alchemy.filters advanced_alchemy.mixins advanced_alchemy.operations advanced_alchemy.repository advanced_alchemy.service advanced_alchemy.types advanced_alchemy.alembic.commands.AlembicCommands :members: AlembicAsyncConfig AlembicSyncConfig AsyncSessionConfig EngineConfig SQLAlchemyAsyncConfig SQLAlchemySyncConfig SyncSessionConfig :noindex: python-advanced-alchemy-1.9.3/docs/reference/extensions/index.rst000066400000000000000000000005131516556515500251730ustar00rootroot00000000000000========== Extensions ========== API Reference for the ``Extensions`` module .. note:: Private methods and attributes are not included in the API reference. Available API References ------------------------ .. toctree:: :titlesonly: litestar/index flask/index sanic/index starlette/index fastapi/index python-advanced-alchemy-1.9.3/docs/reference/extensions/litestar/000077500000000000000000000000001516556515500251625ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/docs/reference/extensions/litestar/cli.rst000066400000000000000000000001241516556515500264600ustar00rootroot00000000000000=== cli === .. automodule:: advanced_alchemy.extensions.litestar.cli :members: python-advanced-alchemy-1.9.3/docs/reference/extensions/litestar/dto.rst000066400000000000000000000002751516556515500265060ustar00rootroot00000000000000=== dto === .. automodule:: advanced_alchemy.extensions.litestar.dto :members: :exclude-members: ModelDTOT ImproperConfigurationError DTOFieldDefinition ImproperConfigurationError python-advanced-alchemy-1.9.3/docs/reference/extensions/litestar/index.rst000066400000000000000000000032051516556515500270230ustar00rootroot00000000000000======== litestar ======== API Reference for the ``Litestar`` extensions module .. note:: Private methods and attributes are not included in the API reference. ------------------------ .. automodule:: advanced_alchemy.extensions.litestar :imported-members: advanced_alchemy.utils advanced_alchemy.base advanced_alchemy.exceptions advanced_alchemy.filters advanced_alchemy.mixins advanced_alchemy.operations advanced_alchemy.repository advanced_alchemy.service advanced_alchemy.types advanced_alchemy.alembic.commands.AlembicCommands advanced_alchemy.extensions.litestar.plugins.init.plugin.SQLAlchemyPlugin advanced_alchemy.extensions.litestar.plugins.init.plugin.SQLAlchemySerializationPlugin advanced_alchemy.extensions.litestar.dto.SQLAlchemyDTO advanced_alchemy.extensions.litestar.dto.SQLAlchemyDTOConfig :members: AlembicAsyncConfig AlembicSyncConfig AsyncSessionConfig EngineConfig SQLAlchemyAsyncConfig SQLAlchemyInitPlugin SQLAlchemyPlugin SQLAlchemySerializationPlugin SQLAlchemySyncConfig SyncSessionConfig async_autocommit_before_send_handler async_autocommit_handler_maker async_default_before_send_handler async_default_handler_maker sync_autocommit_before_send_handler sync_autocommit_handler_maker sync_default_before_send_handler sync_default_handler_maker Additional API References ------------------------- .. toctree:: :titlesonly: dto plugins cli session python-advanced-alchemy-1.9.3/docs/reference/extensions/litestar/plugins.rst000066400000000000000000000003001516556515500273660ustar00rootroot00000000000000====== plugin ====== .. automodule:: advanced_alchemy.extensions.litestar.plugins :members: :exclude-members: :no-index: EngineConfig, SQLAlchemyAsyncConfig, SQLAlchemySyncConfig python-advanced-alchemy-1.9.3/docs/reference/extensions/litestar/session.rst000066400000000000000000000013051516556515500273760ustar00rootroot00000000000000================ Session Backends ================ .. currentmodule:: advanced_alchemy.extensions.litestar.session This module provides SQLAlchemy-based session backends for Litestar's server-side session middleware. Session Model Mixin =================== .. autoclass:: SessionModelMixin :members: :show-inheritance: Session Backend Base ==================== .. autoclass:: SQLAlchemySessionBackendBase :members: :show-inheritance: Async Session Backend ===================== .. autoclass:: SQLAlchemyAsyncSessionBackend :members: :show-inheritance: Sync Session Backend ==================== .. autoclass:: SQLAlchemySyncSessionBackend :members: :show-inheritance: python-advanced-alchemy-1.9.3/docs/reference/extensions/sanic/000077500000000000000000000000001516556515500244305ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/docs/reference/extensions/sanic/index.rst000066400000000000000000000014641516556515500262760ustar00rootroot00000000000000===== sanic ===== API Reference for the ``Sanic`` extensions module .. note:: Private methods and attributes are not included in the API reference. ------------------------ .. automodule:: advanced_alchemy.extensions.sanic :imported-members: advanced_alchemy.utils advanced_alchemy.base advanced_alchemy.exceptions advanced_alchemy.filters advanced_alchemy.mixins advanced_alchemy.operations advanced_alchemy.repository advanced_alchemy.service advanced_alchemy.types advanced_alchemy.alembic.commands.AlembicCommands :members: AlembicAsyncConfig AlembicSyncConfig AsyncSessionConfig EngineConfig SQLAlchemyAsyncConfig SQLAlchemySyncConfig SyncSessionConfig :noindex: python-advanced-alchemy-1.9.3/docs/reference/extensions/starlette/000077500000000000000000000000001516556515500253425ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/docs/reference/extensions/starlette/index.rst000066400000000000000000000015101516556515500272000ustar00rootroot00000000000000========= starlette ========= API Reference for the ``Starlette`` extensions module .. note:: Private methods and attributes are not included in the API reference. ------------------------ .. automodule:: advanced_alchemy.extensions.starlette :imported-members: advanced_alchemy.utils advanced_alchemy.base advanced_alchemy.exceptions advanced_alchemy.filters advanced_alchemy.mixins advanced_alchemy.operations advanced_alchemy.repository advanced_alchemy.service advanced_alchemy.types advanced_alchemy.alembic.commands.AlembicCommands :members: AlembicAsyncConfig AlembicSyncConfig AsyncSessionConfig EngineConfig SQLAlchemyAsyncConfig SQLAlchemySyncConfig SyncSessionConfig :noindex: python-advanced-alchemy-1.9.3/docs/reference/filters.rst000066400000000000000000000002071516556515500233350ustar00rootroot00000000000000======= filters ======= .. automodule:: advanced_alchemy.filters :members: :undoc-members: FilterTypes :show-inheritance: python-advanced-alchemy-1.9.3/docs/reference/index.rst000066400000000000000000000013751516556515500230030ustar00rootroot00000000000000============= API Reference ============= The API reference provides detailed documentation for all public classes, functions, and modules in Advanced Alchemy. Each section includes complete type information, usage examples, and links to related documentation. Core Components --------------- .. toctree:: :maxdepth: 2 :caption: Core API base mixins/index config/index cache operations types exceptions utils Repository & Services --------------------- .. toctree:: :maxdepth: 2 :caption: Repository & Services repository filters service routing Framework Integration --------------------- .. toctree:: :maxdepth: 2 :caption: Framework Support extensions/index alembic/index python-advanced-alchemy-1.9.3/docs/reference/mixins/000077500000000000000000000000001516556515500224435ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/docs/reference/mixins/audit.rst000066400000000000000000000001211516556515500242750ustar00rootroot00000000000000====== audit ====== .. automodule:: advanced_alchemy.mixins.audit :members: python-advanced-alchemy-1.9.3/docs/reference/mixins/bigint.rst000066400000000000000000000001231516556515500244450ustar00rootroot00000000000000====== bigint ====== .. automodule:: advanced_alchemy.mixins.bigint :members: python-advanced-alchemy-1.9.3/docs/reference/mixins/index.rst000066400000000000000000000004301516556515500243010ustar00rootroot00000000000000====== mixins ====== Mixins that provide common columns and behavior for SQLAlchemy models. .. note:: Private methods and attributes are not included in the API reference. .. toctree:: :titlesonly: unique nanoid uuid sentinel bigint audit slug python-advanced-alchemy-1.9.3/docs/reference/mixins/nanoid.rst000066400000000000000000000001231516556515500244410ustar00rootroot00000000000000====== nanoid ====== .. automodule:: advanced_alchemy.mixins.nanoid :members: python-advanced-alchemy-1.9.3/docs/reference/mixins/sentinel.rst000066400000000000000000000001331516556515500250130ustar00rootroot00000000000000======== sentinel ======== .. automodule:: advanced_alchemy.mixins.sentinel :members: python-advanced-alchemy-1.9.3/docs/reference/mixins/slug.rst000066400000000000000000000001131516556515500241420ustar00rootroot00000000000000==== slug ==== .. automodule:: advanced_alchemy.mixins.slug :members: python-advanced-alchemy-1.9.3/docs/reference/mixins/unique.rst000066400000000000000000000001231516556515500244770ustar00rootroot00000000000000====== unique ====== .. automodule:: advanced_alchemy.mixins.unique :members: python-advanced-alchemy-1.9.3/docs/reference/mixins/uuid.rst000066400000000000000000000001131516556515500241360ustar00rootroot00000000000000==== uuid ==== .. automodule:: advanced_alchemy.mixins.uuid :members: python-advanced-alchemy-1.9.3/docs/reference/operations.rst000066400000000000000000000002361516556515500240520ustar00rootroot00000000000000========== operations ========== .. automodule:: advanced_alchemy.operations :members: :imported-members: :undoc-members: :show-inheritance: python-advanced-alchemy-1.9.3/docs/reference/repository.rst000066400000000000000000000003101516556515500240770ustar00rootroot00000000000000============ repositories ============ .. automodule:: advanced_alchemy.repository :members: :imported-members: :undoc-members: :show-inheritance: :no-index: Empty, ErrorMessages python-advanced-alchemy-1.9.3/docs/reference/routing.rst000066400000000000000000000052231516556515500233570ustar00rootroot00000000000000======= routing ======= API Reference for the ``routing`` module .. note:: Private methods and attributes are not included in the API reference. Read/Write Routing ------------------ The routing module provides automatic routing of read operations to read replicas while directing write operations to the primary database. This enables better scalability by distributing read load across multiple replica databases. Key Features ~~~~~~~~~~~~ - **Automatic Routing**: SELECT queries route to replicas, INSERT/UPDATE/DELETE to primary - **Sticky-After-Write**: Ensures read-your-writes consistency by routing reads to primary after writes - **FOR UPDATE Detection**: Automatically routes ``SELECT ... FOR UPDATE`` to primary - **Multiple Replica Support**: Round-robin or random selection across multiple replicas - **Agnostic Bind Group Routing**: Define and route to arbitrary groups (e.g., "analytics", "reporting") - **Context Managers**: Explicit control with ``primary_context()``, ``replica_context()``, and ``use_bind_group()`` - **Framework Integration**: Built-in support for Litestar, FastAPI, Flask, Sanic, Starlette Configuration Classes ~~~~~~~~~~~~~~~~~~~~~ .. autoclass:: advanced_alchemy.config.routing.RoutingConfig :no-index: .. autoclass:: advanced_alchemy.config.routing.ReplicaConfig :no-index: .. autoclass:: advanced_alchemy.config.routing.RoutingStrategy :no-index: Session Classes ~~~~~~~~~~~~~~~ .. autoclass:: advanced_alchemy.routing.RoutingSyncSession :members: :undoc-members: :special-members: __init__ .. autoclass:: advanced_alchemy.routing.RoutingAsyncSession :members: :undoc-members: :special-members: __init__ Session Makers ~~~~~~~~~~~~~~ .. autoclass:: advanced_alchemy.routing.RoutingAsyncSessionMaker :members: :undoc-members: .. autoclass:: advanced_alchemy.routing.RoutingSyncSessionMaker :members: :undoc-members: Replica Selectors ~~~~~~~~~~~~~~~~~ .. autoclass:: advanced_alchemy.routing.ReplicaSelector :members: :undoc-members: .. autoclass:: advanced_alchemy.routing.RoundRobinSelector :members: :undoc-members: .. autoclass:: advanced_alchemy.routing.RandomSelector :members: :undoc-members: Context Managers ~~~~~~~~~~~~~~~~ .. autofunction:: advanced_alchemy.routing.primary_context .. autofunction:: advanced_alchemy.routing.replica_context .. autofunction:: advanced_alchemy.routing.use_bind_group .. autofunction:: advanced_alchemy.routing.reset_routing_context Context Variables ~~~~~~~~~~~~~~~~~ .. autodata:: advanced_alchemy.routing.stick_to_primary_var :annotation: .. autodata:: advanced_alchemy.routing.force_primary_var :annotation: python-advanced-alchemy-1.9.3/docs/reference/service.rst000066400000000000000000000002261516556515500233260ustar00rootroot00000000000000======== services ======== .. automodule:: advanced_alchemy.service :members: :imported-members: :undoc-members: :show-inheritance: python-advanced-alchemy-1.9.3/docs/reference/types.rst000066400000000000000000000006571516556515500230420ustar00rootroot00000000000000===== types ===== .. automodule:: advanced_alchemy.types :members: :imported-members: :undoc-members: :show-inheritance: .. autoclass:: advanced_alchemy.types.encrypted_string.PGCryptoBackend .. autoclass:: advanced_alchemy.types.password_hash.argon2.Argon2Hasher .. autoclass:: advanced_alchemy.types.password_hash.passlib.PasslibHasher .. autoclass:: advanced_alchemy.types.password_hash.pwdlib.PwdlibHasher python-advanced-alchemy-1.9.3/docs/reference/utils.rst000066400000000000000000000003011516556515500230200ustar00rootroot00000000000000===== utils ===== .. automodule:: advanced_alchemy.utils :members: :imported-members: :undoc-members: :show-inheritance: :no-index: FilterableRepositoryProtocol.model_type python-advanced-alchemy-1.9.3/docs/releases.rst000066400000000000000000000054241516556515500215400ustar00rootroot00000000000000:orphan: ========================= Advanced Alchemy Releases ========================= Version Numbering ----------------- This library follows the `Semantic Versioning standard `_, using the ``..`` schema: **Major** Backwards incompatible changes have been made **Minor** Functionality was added in a backwards compatible manner **Patch** Bugfixes were applied in a backwards compatible manner Pre-release Versions ++++++++++++++++++++ Before a new major release, we will make ``alpha``, ``beta``, and release candidate (``rc``) releases, numbered as ``..`` following `PEP 440 `_. For example, ``2.0.0a1``, ``2.0.0b1``, ``2.0.0rc1``. - ``alpha`` Early developer preview. Features may not be complete and breaking changes can occur. - ``beta`` More stable preview release. Feature complete, no major breaking changes expected. - ``rc`` Release candidate. Feature freeze, only bugfixes until final release. Suitable for testing migration to the upcoming major release. Long-term Support Releases (LTS) -------------------------------- Major releases are designated as LTS releases for the life of that major release series. These releases will receive bugfixes for a guaranteed period of time as defined in `Supported Versions <#supported-versions>`_. Deprecation Policy ------------------ When a feature is going to be removed, a deprecation warning will be added in a **minor** release. The feature will continue to work for all releases in that major series, and will be removed in the next major release. For example, if a deprecation warning is added in ``1.1``, the feature will work throughout all ``1.x`` releases, and be removed in ``2.0``. Supported Versions ------------------ At any time, the Litestar organization will actively support: - The current major release series - The previous major release series - Any other designated LTS releases (Special cases) For example, if the current release is ``2.0``, we will actively support ``2.x`` and ``1.x``. When ``3.0`` is released, we will drop support for ``1.x``. Bugfixes will be applied to the current major release, and selectively backported to older supported versions based on severity and feasibility. Release Process --------------- Each major release cycle consists of a few phases: #. **Planning**: Define roadmap, spec out major features. Work should begin on implementation. #. **Development**: Active development on planned features. Ends with an alpha release and branch of ``A.B.x`` branch from `main`. #. **Bugfixes**: Only bugfixes, no new features. Progressively release beta, release candidates. Feature freeze at RC. Become more selective with backports to avoid regressions. python-advanced-alchemy-1.9.3/docs/usage/000077500000000000000000000000001516556515500203025ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/docs/usage/caching.rst000066400000000000000000000227541516556515500224420ustar00rootroot00000000000000======= Caching ======= Advanced Alchemy provides optional caching support through integration with `dogpile.cache`_. This allows you to cache SQLAlchemy model instances using various backends (Redis, Memcached, file, memory) with automatic cache invalidation when models are modified. .. _dogpile.cache: https://dogpilecache.sqlalchemy.org/ Installation ------------ Install the optional caching dependency: .. code-block:: bash pip install advanced-alchemy[dogpile] Quick Start ----------- Basic setup with in-memory caching using the config system: .. code-block:: python from advanced_alchemy.cache import CacheConfig from advanced_alchemy.config import SQLAlchemyAsyncConfig from advanced_alchemy.repository import SQLAlchemyAsyncRepository # Configure caching via SQLAlchemy config db_config = SQLAlchemyAsyncConfig( connection_string="sqlite+aiosqlite:///app.db", cache_config=CacheConfig( backend="dogpile.cache.memory", expiration_time=300, # 5 minutes ), ) # Cache listeners are automatically registered when cache_config is set. # The cache_manager is stored in session.info and auto-retrieved by repositories. class UserRepository(SQLAlchemyAsyncRepository[User]): model_type = User # Repository automatically uses cache_manager from session.info async with db_config.get_session() as session: repo = UserRepository(session=session) # First call hits database and caches the result user = await repo.get(user_id) # Second call returns cached result user = await repo.get(user_id) Configuration Options --------------------- The ``CacheConfig`` dataclass provides several configuration options: .. code-block:: python from advanced_alchemy.cache import CacheConfig config = CacheConfig( # Cache backend (see Backend Configuration below) backend="dogpile.cache.redis", # Default TTL in seconds (default: 3600) expiration_time=3600, # Backend-specific arguments arguments={ "host": "localhost", "port": 6379, "db": 0, }, # Key prefix to avoid collisions (default: "aa:") key_prefix="myapp:", # Enable/disable caching globally (default: True) enabled=True, ) Backend Configuration --------------------- Memory Backend ~~~~~~~~~~~~~~ Best for development and testing: .. code-block:: python config = CacheConfig( backend="dogpile.cache.memory", expiration_time=300, ) Redis Backend ~~~~~~~~~~~~~ Recommended for production with distributed systems: .. code-block:: python config = CacheConfig( backend="dogpile.cache.redis", expiration_time=3600, arguments={ "host": "localhost", "port": 6379, "db": 0, "distributed_lock": True, # Enable distributed locking }, ) Memcached Backend ~~~~~~~~~~~~~~~~~ Alternative for high-performance caching: .. code-block:: python config = CacheConfig( backend="dogpile.cache.memcached", expiration_time=3600, arguments={ "url": ["127.0.0.1:11211"], }, ) Null Backend ~~~~~~~~~~~~ Disables caching (useful for testing): .. code-block:: python config = CacheConfig( backend="dogpile.cache.null", ) # Or simply disable caching config = CacheConfig(enabled=False) Repository Integration ---------------------- The cache manager integrates with repositories through the ``cache_manager`` parameter: .. code-block:: python from advanced_alchemy.repository import SQLAlchemyAsyncRepository class UserRepository(SQLAlchemyAsyncRepository[User]): model_type = User # Create repository with caching repo = UserRepository( session=session, cache_manager=cache_manager, auto_expunge=True, # Recommended with caching ) # These methods support caching: user = await repo.get(user_id) # Cached by entity ID users = await repo.list() # Cached with version-based invalidation users, count = await repo.list_and_count() # Cached with version-based invalidation Bypassing the Cache ~~~~~~~~~~~~~~~~~~~ You can bypass the cache for specific queries: .. code-block:: python # Force database fetch, skip cache user = await repo.get(user_id, use_cache=False) users = await repo.list(use_cache=False) Automatic Cache Invalidation ---------------------------- When using the config system with ``cache_config``, cache listeners are automatically registered (controlled by ``enable_cache_listener=True``, the default). Cache entries are automatically invalidated when models are created, updated, or deleted. The invalidation is transaction-aware: - Invalidations are deferred until the transaction commits - If the transaction rolls back, invalidations are discarded - Entity caches are invalidated by ID - List caches use version-based invalidation (bumps a version token) Version-Based List Invalidation ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ List queries use version-based invalidation. When any entity of a model type is modified, a version token is bumped, which invalidates all list caches for that model: .. code-block:: python # First call caches with version token "abc123" users = await repo.list() # Modify any user user.name = "New Name" await repo.update(user) await session.commit() # Version token bumped to "def456" # Next call sees new version, fetches fresh data users = await repo.list() Singleflight (Stampede Protection) ---------------------------------- The cache manager includes per-process singleflight to prevent cache stampedes. When multiple concurrent requests try to fetch the same uncached data, only one request hits the database: .. code-block:: python import asyncio # All 10 concurrent calls result in only 1 database query results = await asyncio.gather(*[ repo.get(user_id) for _ in range(10) ]) Custom Serialization -------------------- By default, models are serialized to JSON. You can provide custom serializers for different serialization formats: .. code-block:: python import msgpack def msgpack_serializer(model): # Convert model to dict and serialize with msgpack from sqlalchemy import inspect mapper = inspect(model.__class__) data = {col.key: getattr(model, col.key) for col in mapper.columns} return msgpack.packb(data) def msgpack_deserializer(data, model_class): unpacked = msgpack.unpackb(data) return model_class(**unpacked) config = CacheConfig( backend="dogpile.cache.redis", serializer=msgpack_serializer, deserializer=msgpack_deserializer, ) .. warning:: The default JSON serializer only serializes column values, not relationships. Cached instances are detached and accessing lazy-loaded relationships will raise ``DetachedInstanceError``. Use ``session.merge()`` if you need relationship access. Performance Considerations -------------------------- 1. **Use auto_expunge=True**: When using caching, set ``auto_expunge=True`` on your repository to ensure cached entities are properly detached. 2. **Choose the right backend**: Use Redis or Memcached for production with multiple application instances. Memory backend is only suitable for single-instance deployments or development. 3. **Set appropriate TTLs**: Balance between cache hit rate and data freshness. Shorter TTLs mean more database queries but fresher data. 4. **Key prefix**: Use unique key prefixes when sharing a cache backend with other applications to avoid key collisions. 5. **Graceful degradation**: If dogpile.cache is not installed, the cache manager automatically falls back to a no-op implementation. Example: Full Application Setup ------------------------------- .. code-block:: python from litestar import Litestar from litestar.contrib.sqlalchemy.plugins import SQLAlchemyPlugin from advanced_alchemy.cache import CacheConfig from advanced_alchemy.config import SQLAlchemyAsyncConfig from advanced_alchemy.repository import SQLAlchemyAsyncRepository # Configure database with caching db_config = SQLAlchemyAsyncConfig( connection_string="postgresql+asyncpg://user:pass@localhost/db", cache_config=CacheConfig( backend="dogpile.cache.redis", expiration_time=3600, arguments={"host": "localhost", "port": 6379, "db": 0}, key_prefix="myapp:", ), ) class UserRepository(SQLAlchemyAsyncRepository[User]): model_type = User async def get_user(session: AsyncSession, user_id: int) -> User: # Repository auto-retrieves cache_manager from session.info repo = UserRepository(session=session, auto_expunge=True) return await repo.get(user_id) app = Litestar( plugins=[SQLAlchemyPlugin(config=db_config)], ... ) Manual Setup (Without Config) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ If you're not using the config system, you can set up caching manually: .. code-block:: python from advanced_alchemy.cache import CacheConfig, CacheManager, setup_cache_listeners # Create cache manager cache_manager = CacheManager(CacheConfig(backend="dogpile.cache.memory")) # Register listeners once at startup setup_cache_listeners() # Pass cache_manager explicitly to repositories repo = UserRepository(session=session, cache_manager=cache_manager) python-advanced-alchemy-1.9.3/docs/usage/cli.rst000066400000000000000000000335501516556515500216110ustar00rootroot00000000000000================= Command Line Tool ================= Advanced Alchemy provides a command-line interface (CLI) for common database operations and project management tasks. Installation ------------ The CLI is installed with Advanced Alchemy with the extra ``cli``: .. tab-set:: .. tab-item:: pip :sync: key1 .. code-block:: bash :caption: Using pip python3 -m pip install advanced-alchemy[cli] .. tab-item:: uv .. code-block:: bash :caption: Using `UV `_ uv add advanced-alchemy[cli] .. tab-item:: pipx :sync: key2 .. code-block:: bash :caption: Using `pipx `_ pipx install advanced-alchemy[cli] .. tab-item:: pdm .. code-block:: bash :caption: Using `PDM `_ pdm add advanced-alchemy[cli] .. tab-item:: Poetry .. code-block:: bash :caption: Using `Poetry `_ poetry add advanced-alchemy[cli] Basic Usage ----------- The CLI can be invoked using the ``alchemy`` command: .. code-block:: bash alchemy --help Global Options -------------- The following options are available for all commands: .. list-table:: Global options :header-rows: 1 :widths: 20 80 * - Option - Explanation * - ``--config`` TEXT - **Required**. Dotted path to SQLAlchemy config(s), it's an instance of ``SQLAlchemyConfig`` (sync or async). Example: ``--config path.to.alchemy-config.config`` * - ``--bind-key`` TEXT - Optional. Specify which SQLAlchemy config to use * - ``--no-prompt`` - Optional. Skip confirmation prompts * - ``--verbose`` - Optional. Enable verbose output Config ------ Here is an example of what **config** looks like. If the file is named ``alchemy-config.py``, you would need to use it like this ``--config path.to.alchemy-config.config`` .. code-block:: python :caption: alchemy-config.py from sqlalchemy import create_engine from advanced_alchemy.config import SQLAlchemyConfig # Create a test config using SQLite config = SQLAlchemyConfig( connection_url="sqlite:///test.db" ) Available Commands ------------------ Migration Commands ~~~~~~~~~~~~~~~~~~ These commands manage database migrations and revisions. show-current-revision ^^^^^^^^^^^^^^^^^^^^^ Show the current revision of the database: .. code-block:: bash alchemy show-current-revision --config path.to.alchemy-config.config .. list-table:: Options :header-rows: 1 :widths: 20 80 * - Option - Explanation * - ``--verbose`` - Display detailed revision information downgrade ^^^^^^^^^ Downgrade database to a specific revision: .. code-block:: bash alchemy downgrade --config path.to.alchemy-config.config [REVISION] .. list-table:: Options :header-rows: 1 :widths: 20 80 * - Option - Explanation * - ``--sql`` - Generate SQL output for offline migrations * - ``--tag`` TEXT - Arbitrary tag for custom env.py scripts * - ``REVISION`` - Target revision (default: "-1") upgrade ^^^^^^^ Upgrade database to a specific revision: .. code-block:: bash alchemy upgrade --config path.to.alchemy-config.config [REVISION] .. list-table:: Options :header-rows: 1 :widths: 20 80 * - Option - Explanation * - ``--sql`` - Generate SQL output for offline migrations * - ``--tag`` TEXT - Arbitrary tag for custom env.py scripts * - ``REVISION`` - Target revision (default: "head") stamp ^^^^^ Stamp the revision table with a specific revision without running migrations: .. code-block:: bash alchemy stamp --config path.to.alchemy-config.config REVISION .. list-table:: Options :header-rows: 1 :widths: 20 80 * - Option - Explanation * - ``--sql`` - Generate SQL output for offline migrations * - ``--tag`` TEXT - Arbitrary tag for custom env.py scripts * - ``--purge`` - Delete all entries in version table before stamping * - ``REVISION`` - Target revision to stamp (required) **Use cases:** - Initialize version table for existing database - Mark migrations as applied without running them - Reset migration history (with ``--purge``) - Generate SQL for manual database stamping (with ``--sql``) init ^^^^ Initialize migrations for the project: .. code-block:: bash alchemy init --config path.to.alchemy-config.config [DIRECTORY] .. list-table:: Options :header-rows: 1 :widths: 20 80 * - Option - Explanation * - ``--multidb`` - Support multiple databases * - ``--package`` - Create __init__.py for created folder (default: True) * - ``DIRECTORY`` - Directory for migration files (optional) make-migrations ^^^^^^^^^^^^^^^ Create a new migration revision: .. code-block:: bash alchemy make-migrations --config path.to.alchemy-config.config .. list-table:: Options :header-rows: 1 :widths: 30 70 * - Option - Explanation * - ``-m``, ``--message`` TEXT - Revision message * - ``--autogenerate``/ ``--no-autogenerate`` - Automatically detect changes (default: True) * - ``--sql`` - Export to .sql instead of writing to database * - ``--head`` TEXT - Base revision for new revision (default: "head") * - ``--splice`` - Allow non-head revision as the "head" * - ``--branch-label`` TEXT - Branch label for new revision * - ``--version-path`` TEXT - Specific path for version file * - ``--rev-id`` TEXT - Specific revision ID Inspection Commands ~~~~~~~~~~~~~~~~~~~ These commands inspect migration history and database state. check ^^^^^ Check if the database is up to date with the current migration revision: .. code-block:: bash alchemy check --config path.to.alchemy-config.config Returns exit code 0 if database is current, non-zero otherwise. **Use cases:** - CI/CD validation before deployment - Pre-deployment smoke tests - Health checks heads ^^^^^ Show current available heads in the migration script directory: .. code-block:: bash alchemy heads --config path.to.alchemy-config.config .. list-table:: Options :header-rows: 1 :widths: 20 80 * - Option - Explanation * - ``--verbose`` - Display detailed head information * - ``--resolve-dependencies`` - Resolve dependencies between heads **Use cases:** - Detect multiple heads (branch conflicts) - Verify migration graph state - Branch development coordination history ^^^^^^^ List migration changesets in chronological order: .. code-block:: bash alchemy history --config path.to.alchemy-config.config .. list-table:: Options :header-rows: 1 :widths: 20 80 * - Option - Explanation * - ``--verbose`` - Display detailed revision information * - ``--rev-range`` TEXT - Revision range to display (e.g., 'base:head', 'abc:def') * - ``--indicate-current`` - Indicate the current revision in output **Use cases:** - Audit migration history - Generate migration documentation - Review changes between revisions show ^^^^ Show details of a specific revision: .. code-block:: bash alchemy show --config path.to.alchemy-config.config REVISION **Examples:** .. code-block:: bash # Show head revision alchemy show head --config path.to.alchemy-config.config # Show specific revision alchemy show abc123def --config path.to.alchemy-config.config # Show base revision alchemy show base --config path.to.alchemy-config.config branches ^^^^^^^^ Show current branch points in the migration history: .. code-block:: bash alchemy branches --config path.to.alchemy-config.config .. list-table:: Options :header-rows: 1 :widths: 20 80 * - Option - Explanation * - ``--verbose`` - Display detailed branch information **Use cases:** - Identify branch points in migration graph - Multi-team development coordination - Branch-based development workflows Branch Management Commands ~~~~~~~~~~~~~~~~~~~~~~~~~~ These commands manage branched migration workflows. merge ^^^^^ Merge two revisions together, creating a new migration file: .. code-block:: bash alchemy merge --config path.to.alchemy-config.config REVISIONS .. list-table:: Options :header-rows: 1 :widths: 20 80 * - Option - Explanation * - ``-m``, ``--message`` TEXT - Merge message * - ``--branch-label`` TEXT - Branch label for merge revision * - ``--rev-id`` TEXT - Specify custom revision ID * - ``REVISIONS`` - Revisions to merge (e.g., 'abc123+def456' or 'heads') **Examples:** .. code-block:: bash # Merge all heads alchemy merge heads -m "merge feature branches" --config path.to.alchemy-config.config # Merge specific revisions alchemy merge abc123+def456 -m "merge database changes" --config path.to.alchemy-config.config **Use cases:** - Resolve multiple heads (branch conflicts) - Consolidate parallel development branches - Team coordination for database changes Utility Commands ~~~~~~~~~~~~~~~~ These commands provide additional migration utilities. edit ^^^^ Edit a revision file using the system editor (set via ``$EDITOR`` environment variable): .. code-block:: bash alchemy edit --config path.to.alchemy-config.config REVISION **Examples:** .. code-block:: bash # Edit latest revision alchemy edit head --config path.to.alchemy-config.config # Edit specific revision alchemy edit abc123def --config path.to.alchemy-config.config ensure-version ^^^^^^^^^^^^^^ Create the Alembic version table if it doesn't exist: .. code-block:: bash alchemy ensure-version --config path.to.alchemy-config.config .. list-table:: Options :header-rows: 1 :widths: 20 80 * - Option - Explanation * - ``--sql`` - Generate SQL output instead of executing **Use cases:** - Database initialization workflows - Manual database setup - Generate SQL for DBA review (with ``--sql``) list-templates ^^^^^^^^^^^^^^ List available Alembic migration templates: .. code-block:: bash alchemy list-templates --config path.to.alchemy-config.config **Use cases:** - Discover available templates for ``init`` command - Template selection for new projects Database Commands ~~~~~~~~~~~~~~~~~ These commands manage database tables and data. drop-all ^^^^^^^^ Drop all tables from the database: .. code-block:: bash alchemy drop-all --config path.to.alchemy-config.config .. warning:: This command is destructive and will delete all data. Use with caution. dump-data ^^^^^^^^^ Dump specified tables from the database to JSON files: .. code-block:: bash alchemy dump-data --config path.to.alchemy-config.config --table TABLE_NAME .. list-table:: Options :header-rows: 1 :widths: 20 80 * - Option - Explanation * - ``--table`` TEXT - Name of table to dump (use '*' for all tables) * - ``--dir`` PATH - Directory to save JSON files (default: ./fixtures) Extending the CLI ----------------- If you're using Click in your project, you can extend Advanced Alchemy's CLI with your own commands. The CLI provides two main functions for integration: - ``get_alchemy_group()``: Get the base CLI group - ``add_migration_commands()``: Add migration-related commands to a group Basic Extension ~~~~~~~~~~~~~~~ Here's how to extend the CLI with your own commands: .. code-block:: python from advanced_alchemy.cli import get_alchemy_group, add_migration_commands import click # Get the base group alchemy_group = get_alchemy_group() # Add your custom commands @alchemy_group.command(name="my-command") @click.option("--my-option", help="Custom option") def my_command(my_option): """My custom command.""" click.echo(f"Running my command with option: {my_option}") # Add migration commands to your group add_migration_commands(alchemy_group) Custom Group Integration ~~~~~~~~~~~~~~~~~~~~~~~~ You can also integrate Advanced Alchemy's commands into your existing Click group: .. code-block:: python import click from advanced_alchemy.cli import add_migration_commands @click.group() def cli(): """My application CLI.""" pass # Add migration commands to your CLI group add_migration_commands(cli) @cli.command() def my_command(): """Custom command in your CLI.""" pass if __name__ == "__main__": cli() Typer integration ----------------- You can integrate Advanced Alchemy's CLI commands into your existing ``Typer`` application. Here's how: .. code-block:: python :caption: cli.py import typer from advanced_alchemy.cli import get_alchemy_group, add_migration_commands app = typer.Typer() @app.command() def hello(name: str) -> None: """Says hello to the world.""" typer.echo(f"Hello {name}") @app.callback() def callback(): """ Typer app, including Click subapp """ pass def create_cli() -> typer.Typer: """Create the CLI application with both Typer and Click commands.""" # Get the Click group from advanced_alchemy alchemy_group = get_alchemy_group() # Convert our Typer app to a Click command object typer_click_object = typer.main.get_command(app) # Add all migration commands from the alchemy group to our CLI typer_click_object.add_command(add_migration_commands(alchemy_group)) return typer_click_object if __name__ == "__main__": cli = create_cli() cli() After setting up the integration, you can use both your ``Typer`` commands and Advanced Alchemy commands: .. code-block:: bash # Use your Typer commands python cli.py hello Cody # Use Advanced Alchemy commands python cli.py alchemy upgrade --config path.to.config python cli.py alchemy make-migrations --config path.to.config python-advanced-alchemy-1.9.3/docs/usage/database_seeding.rst000066400000000000000000000164001516556515500242770ustar00rootroot00000000000000==================================== Database Seeding and Fixture Loading ==================================== Advanced Alchemy provides ``open_fixture()`` and ``open_fixture_async()`` helpers for loading JSON and CSV fixtures from disk. Use them to keep seed data in version-controlled files while leaving the actual upsert logic in your application code. Creating Fixtures ----------------- Fixtures can be stored as JSON or CSV. JSON preserves native types, while CSV returns a list of string-valued dictionaries that you should coerce before creating typed models. **Example JSON fixture:** .. code-block:: json :caption: fixtures/products.json [ { "name": "Laptop", "description": "High-performance laptop with 16GB RAM and 1TB SSD", "price": 999.99, "in_stock": true }, { "name": "Smartphone", "description": "Latest smartphone model with 5G and advanced camera", "price": 699.99, "in_stock": true } ] Loading Fixtures ---------------- Synchronous Loading ~~~~~~~~~~~~~~~~~~~ .. code-block:: python from pathlib import Path from typing import Optional from sqlalchemy import String from sqlalchemy.orm import Mapped, mapped_column from advanced_alchemy.base import UUIDBase from advanced_alchemy.config import SQLAlchemySyncConfig, SyncSessionConfig from advanced_alchemy.repository import SQLAlchemySyncRepository from advanced_alchemy.utils.fixtures import open_fixture DATABASE_URL = "sqlite:///db.sqlite3" fixtures_path = Path("fixtures") alchemy_config = SQLAlchemySyncConfig( connection_string=DATABASE_URL, session_config=SyncSessionConfig(expire_on_commit=False), ) class SyncProduct(UUIDBase): __tablename__ = "sync_products" name: Mapped[str] = mapped_column(String(length=100)) description: Mapped[Optional[str]] = mapped_column(String(length=500)) price: Mapped[float] in_stock: Mapped[bool] = mapped_column(default=True) class SyncProductRepository(SQLAlchemySyncRepository[SyncProduct]): model_type = SyncProduct def initialize_database() -> None: with alchemy_config.get_engine().begin() as conn: UUIDBase.metadata.create_all(conn) def seed_database() -> None: with alchemy_config.get_session() as db_session: repository = SyncProductRepository(session=db_session) fixture_data = open_fixture(fixtures_path, "products") repository.add_many([SyncProduct(**item) for item in fixture_data], auto_commit=True) Asynchronous Loading ~~~~~~~~~~~~~~~~~~~~ .. code-block:: python from pathlib import Path from typing import Optional from sqlalchemy import String from sqlalchemy.orm import Mapped, mapped_column from advanced_alchemy.base import UUIDBase from advanced_alchemy.config import AsyncSessionConfig, SQLAlchemyAsyncConfig from advanced_alchemy.repository import SQLAlchemyAsyncRepository from advanced_alchemy.utils.fixtures import open_fixture_async DATABASE_URL = "sqlite+aiosqlite:///db.sqlite3" fixtures_path = Path("fixtures") alchemy_config = SQLAlchemyAsyncConfig( connection_string=DATABASE_URL, session_config=AsyncSessionConfig(expire_on_commit=False), ) class AsyncProduct(UUIDBase): __tablename__ = "async_products" name: Mapped[str] = mapped_column(String(length=100)) description: Mapped[Optional[str]] = mapped_column(String(length=500)) price: Mapped[float] in_stock: Mapped[bool] = mapped_column(default=True) class AsyncProductRepository(SQLAlchemyAsyncRepository[AsyncProduct]): model_type = AsyncProduct async def initialize_async_database() -> None: async with alchemy_config.get_engine().begin() as conn: await conn.run_sync(UUIDBase.metadata.create_all) async def seed_async_database() -> None: async with alchemy_config.get_session() as db_session: repository = AsyncProductRepository(session=db_session) fixture_data = await open_fixture_async(fixtures_path, "products") await repository.add_many([AsyncProduct(**item) for item in fixture_data], auto_commit=True) CSV Fixtures ~~~~~~~~~~~~ .. versionadded:: 1.9.0 CSV fixtures use the header row as dictionary keys, but each value is returned as a string. Coerce those values before constructing models or sending data into a service layer. **Example CSV (products.csv):** .. code-block:: text name,price,in_stock Widget,9.99,true Gadget,19.99,true Thingy,4.99,false **Loading CSV Fixtures:** .. code-block:: python from pathlib import Path from typing import Any from advanced_alchemy.utils.fixtures import open_fixture_async def coerce_product_row(row: dict[str, str]) -> dict[str, Any]: return { "name": row["name"], "price": float(row["price"]), "in_stock": row["in_stock"].lower() == "true", } async def seed_from_csv(repository: AsyncProductRepository, fixtures_path: Path) -> None: raw_rows = await open_fixture_async(fixtures_path, "products") products = [AsyncProduct(**coerce_product_row(item)) for item in raw_rows] await repository.add_many(products, auto_commit=True) Application Integration ----------------------- The Litestar fullstack reference applications keep schema migration and fixture loading as separate application commands. Apply migrations first, then run an app-level command that loads or upserts fixtures for your domain services. .. code-block:: text uv run app database upgrade For the fixture loader itself, keep the mapping logic close to the service that owns the data: .. code-block:: python from pathlib import Path from typing import Any from advanced_alchemy.utils.fixtures import open_fixture_async async def load_database_fixtures(role_service: Any, fixtures_path: Path) -> None: fixture_data = await open_fixture_async(fixtures_path, "role") await role_service.upsert_many(match_fields=["name"], data=fixture_data, auto_commit=True) Best Practices -------------- 1. Keep fixtures in a dedicated directory such as ``fixtures/``. 2. Keep migration commands and fixture-loading commands separate. 3. Use ``upsert_many()`` when your seed data should be re-runnable without creating duplicates. 4. Coerce CSV values before creating strongly typed models. 5. Seed parent tables before child tables when relationships are involved. 6. Keep fixtures under version control alongside your application code. Tips for Efficient Seeding -------------------------- - Use ``add_many()`` or ``upsert_many()`` instead of inserting one row at a time. - Use JSON when you need native numbers, booleans, nested objects, or UUIDs preserved. - Use CSV for flatter datasets when string-to-type coercion is straightforward. - Large fixture files can be stored as ``.json.gz``, ``.json.zip``, ``.csv.gz``, or ``.csv.zip``. - Application startup hooks, background jobs, and CLI commands are all reasonable places to invoke fixture loaders. - Consider using `Polyfactory `__ when you need generated data rather than static fixtures. python-advanced-alchemy-1.9.3/docs/usage/frameworks/000077500000000000000000000000001516556515500224625ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/docs/usage/frameworks/fastapi.rst000066400000000000000000000247051516556515500246530ustar00rootroot00000000000000=================== FastAPI Integration =================== Advanced Alchemy's repository and service patterns work well within FastAPI applications. Basic Setup ----------- Configure SQLAlchemy with FastAPI: .. code-block:: python from typing import AsyncGenerator from fastapi import FastAPI from advanced_alchemy.extensions.fastapi import AdvancedAlchemy, AsyncSessionConfig, SQLAlchemyAsyncConfig alchemy_config = SQLAlchemyAsyncConfig( connection_string="sqlite+aiosqlite:///test.sqlite", session_config=AsyncSessionConfig(expire_on_commit=False), create_all=True, commit_mode="autocommit", ) app = FastAPI() alchemy = AdvancedAlchemy(config=alchemy_config, app=app) Models and Schemas ------------------ Define your SQLAlchemy models and Pydantic schemas: .. code-block:: python import datetime from datetime import date from typing import Optional, Union from uuid import UUID from pydantic import BaseModel as _BaseModel from sqlalchemy import ForeignKey from sqlalchemy.orm import Mapped, mapped_column, relationship from advanced_alchemy.base import UUIDAuditBase, UUIDBase class BaseModel(_BaseModel): """Extend Pydantic's BaseModel to enable ORM mode""" model_config = {"from_attributes": True} class AuthorModel(UUIDBase): __tablename__ = "author" name: Mapped[str] dob: Mapped[Optional[date]] books: Mapped[list["BookModel"]] = relationship(back_populates="author", lazy="selectin") class BookModel(UUIDAuditBase): __tablename__ = "book" title: Mapped[str] author_id: Mapped[UUID] = mapped_column(ForeignKey("author.id")) author: Mapped["AuthorModel"] = relationship(lazy="joined", innerjoin=True, viewonly=True) class Author(BaseModel): id: Optional[UUID] name: str dob: Optional[datetime.date] = None class AuthorCreate(BaseModel): name: str dob: Optional[datetime.date] = None class AuthorUpdate(BaseModel): name: Optional[str] = None dob: Optional[datetime.date] = None Repository and Service ---------------------- Create repository and service classes: .. code-block:: python from typing import Annotated, AsyncGenerator from fastapi import Depends, Query from sqlalchemy.ext.asyncio import AsyncSession from advanced_alchemy.extensions.fastapi import filters, repository, service class AuthorService(service.SQLAlchemyAsyncRepositoryService[AuthorModel]): """Author service.""" class Repo(repository.SQLAlchemyAsyncRepository[AuthorModel]): """Author repository.""" model_type = AuthorModel repository_type = Repo Dependency Injection -------------------- Set up dependency injected into the request context. .. code-block:: python DatabaseSession = Annotated[AsyncSession, Depends(alchemy.provide_session())] Authors = Annotated[AuthorService, Depends(provide_authors_service)] async def provide_authors_service(db_session: DatabaseSession) -> AsyncGenerator[AuthorService, None]: """This provides the default Authors repository.""" async with AuthorService.new(session=db_session) as service: yield service def provide_limit_offset_pagination( current_page: int = Query(default=1, ge=1, alias="currentPage"), page_size: int = Query(default=20, ge=1, alias="pageSize"), ) -> filters.LimitOffset: return filters.LimitOffset(limit=page_size, offset=page_size * (current_page - 1)) The session providers accept an optional bind key when you configure multiple database connections: .. code-block:: python DatabaseSession = Annotated[AsyncSession, Depends(alchemy.provide_session())] ReportingSession = Annotated[AsyncSession, Depends(alchemy.provide_session("reporting"))] The same optional key is available on ``provide_async_session()``, ``provide_sync_session()``, and ``provide_service(...)`` for multi-database setups. Controllers ----------- Create controllers using the service: .. code-block:: python from fastapi import APIRouter, Depends from uuid import UUID from advanced_alchemy.extensions.fastapi import filters author_router = APIRouter() @author_router.get(path="/authors", response_model=filters.OffsetPagination[Author]) async def list_authors( authors_service: Authors, limit_offset: Annotated[filters.LimitOffset, Depends(provide_limit_offset_pagination)], ) -> filters.OffsetPagination[Author]: """List authors.""" results, total = await authors_service.list_and_count(limit_offset) return authors_service.to_schema(results, total, filters=[limit_offset], schema_type=Author) @author_router.post(path="/authors", response_model=Author) async def create_author( authors_service: Authors, data: AuthorCreate, ) -> Author: """Create a new author.""" obj = await authors_service.create(data) return authors_service.to_schema(obj, schema_type=Author) @author_router.get(path="/authors/{author_id}", response_model=Author) async def get_author( authors_service: Authors, author_id: UUID, ) -> Author: """Get an existing author.""" obj = await authors_service.get(author_id) return authors_service.to_schema(obj, schema_type=Author) @author_router.patch(path="/authors/{author_id}", response_model=Author) async def update_author( authors_service: Authors, data: AuthorUpdate, author_id: UUID, ) -> Author: """Update an author.""" obj = await authors_service.update(data, item_id=author_id) return authors_service.to_schema(obj, schema_type=Author) @author_router.delete(path="/authors/{author_id}") async def delete_author( authors_service: Authors, author_id: UUID, ) -> None: """Delete an author from the system.""" _ = await authors_service.delete(author_id) Application Configuration ------------------------- Finally, configure your FastAPI application with the router: .. code-block:: python app.include_router(author_router) File Object Storage ------------------- FastAPI works well with ``FileObject``-backed models when you keep the upload flow explicit: 1. Register a storage backend 2. Map a ``StoredObject`` column on the model 3. Expose a signed URL from the response schema 4. Translate multipart uploads into ``FileObject`` values before they reach the repository service Start by registering a backend and mapping the file column: .. code-block:: python from typing import Optional from uuid import UUID from pydantic import BaseModel, Field, computed_field from sqlalchemy.orm import Mapped, mapped_column from advanced_alchemy.extensions.fastapi import base, repository, service from advanced_alchemy.types import FileObject, storages from advanced_alchemy.types.file_object.backends.obstore import ObstoreBackend from advanced_alchemy.types.file_object.data_type import StoredObject documents_backend = ObstoreBackend( key="documents", fs="s3://company-documents-prod/", ) storages.register_backend(documents_backend) class DocumentModel(base.UUIDBase): __tablename__ = "document" name: Mapped[str] file: Mapped[FileObject] = mapped_column(StoredObject(backend="documents")) class DocumentService(service.SQLAlchemyAsyncRepositoryService[DocumentModel]): class Repo(repository.SQLAlchemyAsyncRepository[DocumentModel]): model_type = DocumentModel repository_type = Repo Use a response schema that hides the raw ``FileObject`` but exposes a signed URL: .. code-block:: python class Document(BaseModel): id: Optional[UUID] = None name: str file: Optional[FileObject] = Field(default=None, exclude=True) @computed_field def file_url(self) -> Optional[Union[str, list[str]]]: if self.file is None: return None return self.file.sign() Then accept multipart form data and construct ``FileObject`` values explicitly: .. code-block:: python from typing import Annotated from fastapi import APIRouter, Depends, File, Form, UploadFile document_router = APIRouter() Documents = Annotated[DocumentService, Depends(alchemy.provide_service(DocumentService))] @document_router.post(path="/documents", response_model=Document) async def create_document( documents_service: Documents, name: Annotated[str, Form()], file: Annotated[Optional[UploadFile], File()] = None, ) -> Document: obj = await documents_service.create( DocumentModel( name=name, file=FileObject( backend="documents", filename=file.filename or "uploaded_file", content_type=file.content_type, content=await file.read(), ) if file is not None else None, ) ) return documents_service.to_schema(obj, schema_type=Document) @document_router.patch(path="/documents/{document_id}", response_model=Document) async def update_document( documents_service: Documents, document_id: UUID, name: Annotated[Optional[str], Form()] = None, file: Annotated[Optional[UploadFile], File()] = None, ) -> Document: update_data: dict[str, object] = {} if name is not None: update_data["name"] = name if file is not None: update_data["file"] = FileObject( backend="documents", filename=file.filename or "uploaded_file", content_type=file.content_type, content=await file.read(), ) obj = await documents_service.update(update_data, item_id=document_id) return documents_service.to_schema(obj, schema_type=Document) In production, keep object storage credentials out of application code. Point ``ObstoreBackend`` at the real bucket or prefix you use in production and let the runtime provide credentials through the platform's normal mechanism, such as an IAM role, IRSA, ECS task role, or other ambient credentials. The storage backend key must match in both places: the ``StoredObject(backend="...")`` column definition and the ``FileObject(backend="...")`` values you create from incoming uploads. python-advanced-alchemy-1.9.3/docs/usage/frameworks/flask.rst000066400000000000000000000130131516556515500243120ustar00rootroot00000000000000================= Flask Integration ================= Advanced Alchemy integrates with Flask through an extension that manages application-context sessions, supports sync and async SQLAlchemy configs, and registers database migration commands on the Flask CLI. Basic Setup ----------- Use ``SQLAlchemySyncConfig`` for standard Flask applications: .. code-block:: python from flask import Flask from sqlalchemy import select from sqlalchemy.orm import Mapped, mapped_column from advanced_alchemy.extensions.flask import AdvancedAlchemy, SQLAlchemySyncConfig, base class User(base.BigIntBase): __tablename__ = "flask_user_account" name: Mapped[str] app = Flask(__name__) alchemy = AdvancedAlchemy( SQLAlchemySyncConfig( connection_string="sqlite:///local.db", commit_mode="autocommit", create_all=True, ), app, ) @app.route("/users") def list_users() -> dict[str, list[dict[str, object]]]: session = alchemy.get_sync_session() users = session.execute(select(User)).scalars().all() return {"users": [{"id": user.id, "name": user.name} for user in users]} Sessions are cached on Flask's application context, so repeated ``get_session()`` calls within the same request reuse the same SQLAlchemy session. Multiple Databases ------------------ Provide a sequence of configs when you need more than one bind key: .. code-block:: python configs = [ SQLAlchemySyncConfig(connection_string="sqlite:///users.db", bind_key="users"), SQLAlchemySyncConfig(connection_string="sqlite:///products.db", bind_key="products"), ] alchemy = AdvancedAlchemy(configs, app) users_session = alchemy.get_sync_session("users") products_session = alchemy.get_sync_session("products") If you register multiple configs, call ``get_session()`` or ``get_sync_session()`` with an explicit bind key unless you also have a ``default`` bind configured. Async Support ------------- Flask can also work with async SQLAlchemy sessions: .. code-block:: python from advanced_alchemy.extensions.flask import SQLAlchemyAsyncConfig alchemy = AdvancedAlchemy( SQLAlchemyAsyncConfig( connection_string="sqlite+aiosqlite:///local.db", create_all=True, ), app, ) @app.route("/users") async def list_users_async() -> dict[str, list[dict[str, object]]]: session = alchemy.get_async_session() users = (await session.execute(select(User))).scalars().all() return {"users": [{"id": user.id, "name": user.name} for user in users]} For sync routes that need to call an async session explicitly, use the extension portal: .. code-block:: python @app.route("/users/sync-bridge") def list_users_via_portal() -> dict[str, list[dict[str, object]]]: session = alchemy.get_async_session() users = alchemy.portal.call(session.execute, select(User)).scalars().all() return {"users": [{"id": user.id, "name": user.name} for user in users]} Service Integration ------------------- ``FlaskServiceMixin`` adds a ``jsonify()`` helper that serializes service results using Advanced Alchemy's configured serializer: .. code-block:: python import datetime from typing import Optional from uuid import UUID from flask import request from msgspec import Struct from sqlalchemy.orm import Mapped, mapped_column from advanced_alchemy.extensions.flask import ( FlaskServiceMixin, SQLAlchemySyncConfig, AdvancedAlchemy, base, filters, repository, service, ) class Author(base.UUIDBase): __tablename__ = "flask_author" name: Mapped[str] dob: Mapped[Optional[datetime.date]] class AuthorSchema(Struct): id: Optional[UUID] = None name: str dob: Optional[datetime.date] = None class AuthorService(service.SQLAlchemySyncRepositoryService[Author], FlaskServiceMixin): class Repo(repository.SQLAlchemySyncRepository[Author]): model_type = Author repository_type = Repo alchemy = AdvancedAlchemy( SQLAlchemySyncConfig(connection_string="sqlite:///local.db", commit_mode="autocommit"), app, ) @app.route("/authors", methods=["GET"]) def list_authors(): current_page = request.args.get("currentPage", 1, type=int) page_size = request.args.get("pageSize", 10, type=int) limit_offset = filters.LimitOffset(limit=page_size, offset=page_size * (current_page - 1)) author_service = AuthorService(session=alchemy.get_sync_session()) results, total = author_service.list_and_count(limit_offset) payload = author_service.to_schema( results, total, filters=[limit_offset], schema_type=AuthorSchema, ) return author_service.jsonify(payload) @app.route("/authors", methods=["POST"]) def create_author(): author_service = AuthorService(session=alchemy.get_sync_session()) author = author_service.create(data=request.get_json()) return author_service.jsonify(author_service.to_schema(author, schema_type=AuthorSchema)) Database Migrations ------------------- When the Flask extension is initialized, database commands are added to the Flask CLI: .. code-block:: bash flask database init flask database revision --autogenerate -m "add users table" flask database upgrade flask database downgrade flask database history python-advanced-alchemy-1.9.3/docs/usage/frameworks/litestar.rst000066400000000000000000001272231516556515500250520ustar00rootroot00000000000000==================== Litestar Integration ==================== .. seealso:: :external+litestar:doc:`Litestar's documentation for SQLAlchemy integration ` Advanced Alchemy provides first-class integration with Litestar through its SQLAlchemy plugin, which re-exports many of the modules within Advanced Alchemy. This guide demonstrates building a complete CRUD API for a book management system. Key Features ------------ - SQLAlchemy plugin for session and transaction management - Repository pattern for database operations - Service layer for business logic and data transformation - Built-in pagination and filtering - SQLAlchemy-backed server-side session backend for Litestar middleware - FileObject integration for multipart uploads and signed URLs - CLI tools for database migrations Basic Setup ----------- First, configure the SQLAlchemy plugin with Litestar. The plugin handles database connection, session management, and dependency injection: .. code-block:: python from litestar import Litestar from advanced_alchemy.extensions.litestar import ( AsyncSessionConfig, SQLAlchemyAsyncConfig, SQLAlchemyPlugin, ) session_config = AsyncSessionConfig(expire_on_commit=False) alchemy_config = SQLAlchemyAsyncConfig( connection_string="sqlite+aiosqlite:///test.sqlite", before_send_handler="autocommit", session_config=session_config, create_all=True, ) alchemy = SQLAlchemyPlugin(config=alchemy_config) SQLAlchemy Models ----------------- Define your SQLAlchemy models using Advanced Alchemy's enhanced base classes: .. code-block:: python import datetime from typing import Optional from uuid import UUID from sqlalchemy import ForeignKey from sqlalchemy.orm import Mapped, mapped_column, relationship from advanced_alchemy.extensions.litestar import base class AuthorModel(base.UUIDBase): __tablename__ = "author" name: Mapped[str] dob: Mapped[Optional[datetime.date]] books: Mapped[list["BookModel"]] = relationship(back_populates="author", lazy="selectin") class BookModel(base.UUIDAuditBase): __tablename__ = "book" title: Mapped[str] author_id: Mapped[UUID] = mapped_column(ForeignKey("author.id")) author: Mapped["AuthorModel"] = relationship(lazy="joined", innerjoin=True, viewonly=True) Using Properties with DTOs --------------------------- SQLAlchemyDTO includes Python ``@property`` and ``@functools.cached_property`` decorated methods as read-only fields. .. code-block:: python from functools import cached_property from sqlalchemy.orm import Mapped, mapped_column, MappedAsDataclass from advanced_alchemy.extensions.litestar import base, SQLAlchemyDTO class UserModel(base.UUIDAuditBase, MappedAsDataclass): __tablename__ = "user" first_name: Mapped[str] last_name: Mapped[str] @property def full_name(self) -> str: return f"{self.first_name} {self.last_name}" @cached_property def name_length(self) -> int: return len(self.full_name) # DTO includes: id, created_at, updated_at, first_name, last_name, # full_name (read-only), name_length (read-only) UserDTO = SQLAlchemyDTO[UserModel] Property handling characteristics: - Detected from model class and mixins - Marked as ``READ_ONLY`` (cannot be set via DTO) - Type inferred from return type annotations - Private properties (starting with ``_``) excluded - Skipped if already handled by SQLAlchemy descriptors (e.g., ``hybrid_property``) .. note:: Properties with setters (``@property.setter``) are marked ``READ_ONLY``. Setter support is not implemented. Pydantic Schemas ---------------- Define Pydantic schemas for input validation and response serialization: .. code-block:: python import datetime from pydantic import BaseModel from uuid import UUID from typing import Optional class Author(BaseModel): """Author response schema.""" id: Optional[UUID] = None name: str dob: Optional[datetime.date] = None class AuthorCreate(BaseModel): """Schema for creating authors.""" name: str dob: Optional[datetime.date] = None class AuthorUpdate(BaseModel): """Schema for updating authors.""" name: Optional[str] = None dob: Optional[datetime.date] = None Repository and Service Layer ---------------------------- Create repository and service classes to interact with the model: .. code-block:: python from advanced_alchemy.extensions.litestar import repository, service class AuthorService(service.SQLAlchemyAsyncRepositoryService[AuthorModel]): """Author service.""" class Repo(repository.SQLAlchemyAsyncRepository[AuthorModel]): """Author repository.""" model_type = AuthorModel repository_type = Repo Controllers ----------- Create a controller class to handle HTTP endpoints. The controller uses dependency injection for services and includes built-in pagination: .. code-block:: python from typing import Annotated from litestar import Controller, get, post, patch, delete from litestar.params import Dependency, Parameter from advanced_alchemy.extensions.litestar import filters, providers, service class AuthorController(Controller): """Author CRUD endpoints.""" dependencies = providers.create_service_dependencies( AuthorService, "authors_service", load=[AuthorModel.books], filters={"pagination_type": "limit_offset", "id_filter": UUID, "search": "name", "search_ignore_case": True}, ) @get(path="/authors") async def list_authors( self, authors_service: AuthorService, filters: Annotated[list[filters.FilterTypes], Dependency(skip_validation=True)], ) -> service.OffsetPagination[Author]: """List all authors with pagination.""" results, total = await authors_service.list_and_count(*filters) return authors_service.to_schema(results, total, filters=filters, schema_type=Author) @post(path="/authors") async def create_author( self, authors_service: AuthorService, data: AuthorCreate, ) -> Author: """Create a new author.""" obj = await authors_service.create(data) return authors_service.to_schema(obj, schema_type=Author) @get(path="/authors/{author_id:uuid}") async def get_author( self, authors_service: AuthorService, author_id: UUID = Parameter( title="Author ID", description="The author to retrieve.", ), ) -> Author: """Get an existing author.""" obj = await authors_service.get(author_id) return authors_service.to_schema(obj, schema_type=Author) @patch(path="/authors/{author_id:uuid}") async def update_author( self, authors_service: AuthorService, data: AuthorUpdate, author_id: UUID = Parameter( title="Author ID", description="The author to update.", ), ) -> Author: """Update an author.""" obj = await authors_service.update(data, item_id=author_id, auto_commit=True) return authors_service.to_schema(obj, schema_type=Author) @delete(path="/authors/{author_id:uuid}") async def delete_author( self, authors_service: AuthorService, author_id: UUID = Parameter( title="Author ID", description="The author to delete.", ), ) -> None: """Delete an author from the system.""" _ = await authors_service.delete(author_id) Application Configuration ------------------------- Finally, configure your Litestar application with the plugin and dependencies: .. code-block:: python from litestar import Litestar from advanced_alchemy.extensions.litestar import ( AsyncSessionConfig, SQLAlchemyAsyncConfig, SQLAlchemyPlugin, ) alchemy_config = SQLAlchemyAsyncConfig( connection_string="sqlite+aiosqlite:///test.sqlite", before_send_handler="autocommit", session_config=AsyncSessionConfig(expire_on_commit=False), create_all=True, ) app = Litestar( route_handlers=[AuthorController], plugins=[SQLAlchemyPlugin(config=alchemy_config)], ) Database Sessions ----------------- Sessions in Controllers ^^^^^^^^^^^^^^^^^^^^^^^ You can access the database session from the controller by using the session parameter, which is automatically injected by the SQLAlchemy plugin. The session is automatically committed at the end of the request. If an exception occurs, the session is rolled back: By default, the session key is named "db_session". You can change this by setting the `session_dependency_key` parameter in the SQLAlchemyAsyncConfig. When you register multiple Litestar SQLAlchemy configs, session selection is done through distinct dependency keys rather than a runtime ``provide_session("reporting")`` argument. Give each config a unique ``session_dependency_key`` and ``engine_dependency_key``, then inject the matching parameter in your handlers. .. code-block:: python from litestar import Litestar, get from advanced_alchemy.extensions.litestar import ( AsyncSessionConfig, SQLAlchemyAsyncConfig, SQLAlchemyPlugin, ) session_config = AsyncSessionConfig(expire_on_commit=False) alchemy_config = SQLAlchemyAsyncConfig( connection_string="sqlite+aiosqlite:///test.sqlite", before_send_handler="autocommit", session_config=session_config, create_all=True, ) # Auto creates 'db_session' dependency. @get("/my-endpoint") async def my_controller(db_session: AsyncSession) -> str: # Access the database session here. return "Hello, World!" app = Litestar( route_handlers=[my_controller], plugins=[SQLAlchemyPlugin(config=alchemy_config)], ) .. code-block:: python primary_config = SQLAlchemyAsyncConfig( connection_string="sqlite+aiosqlite:///primary.sqlite", bind_key="primary", session_dependency_key="primary_session", engine_dependency_key="primary_engine", ) reporting_config = SQLAlchemyAsyncConfig( connection_string="sqlite+aiosqlite:///reporting.sqlite", bind_key="reporting", session_dependency_key="reporting_session", engine_dependency_key="reporting_engine", ) app = Litestar( route_handlers=[], plugins=[SQLAlchemyPlugin(config=[primary_config, reporting_config])], ) @get("/reports") async def get_reports(reporting_session: AsyncSession) -> str: return "Reporting database is available" Sessions in Application ^^^^^^^^^^^^^^^^^^^^^^^ You can use either ``provide_session`` or ``get_session`` to get session instances in your application. Each of these functions are useful for providing sessions in various places within your application, whether you are in the request/response scope or not. ``provide_session`` provides a session instance from request state if it exists, or creates a new session if it doesn't, while ``get_session`` always returns a new instance from the session maker. - ``provide_session`` is useful in places where you are already in the request/response context such as guards and middleware. .. code-block:: python from litestar import Litestar, get from litestar.connection import ASGIConnection from litestar.handlers.base import BaseRouteHandler from advanced_alchemy.extensions.litestar import ( AsyncSessionConfig, SQLAlchemyAsyncConfig, SQLAlchemyPlugin, ) from sqlalchemy import text session_config = AsyncSessionConfig(expire_on_commit=False) alchemy_config = SQLAlchemyAsyncConfig( connection_string="sqlite+aiosqlite:///test.sqlite", before_send_handler="autocommit", session_config=session_config, create_all=True, ) alchemy = SQLAlchemyPlugin(config=alchemy_config) async def my_guard(connection: ASGIConnection[Any, Any, Any, Any], _: BaseRouteHandler) -> None: db_session = alchemy_config.provide_session(connection.app.state, connection.scope) a_value = await db_session.execute(text("SELECT 1")) @get("/", guards=[my_guard]) async def hello() -> str: return "Hello, world!" app = Litestar( route_handlers=[hello], plugins=[alchemy], ) - ``get_session`` is useful anywhere outside of the request lifecycle in your application. This includes command line tasks and background jobs. .. code-block:: python from click import Group from litestar import Litestar, get from litestar.plugins import CLIPluginProtocol, InitPluginProtocol from advanced_alchemy.extensions.litestar import ( AsyncSessionConfig, SQLAlchemyAsyncConfig, SQLAlchemyPlugin, ) class ApplicationCore(CLIPluginProtocol): def on_cli_init(self, cli: Group) -> None: @cli.command('check-db-status') def check_db_status() -> None: import anyio async def _check_db_status() -> None: async with alchemy_config.get_session() as db_session: a_value = await db_session.execute(text("SELECT 1")) if a_value.scalar_one() == 1: print("Database is healthy") else: print("Database is not healthy") anyio.run(_check_db_status) alchemy_config = SQLAlchemyAsyncConfig( connection_string="sqlite+aiosqlite:///test.sqlite", before_send_handler="autocommit", session_config=AsyncSessionConfig(expire_on_commit=False), create_all=True, ) alchemy = SQLAlchemyPlugin(config=alchemy_config) app = Litestar(plugins=[alchemy, ApplicationCore()]) Database Migrations ------------------- Advanced Alchemy integrates with Litestar's CLI to provide database migration tools powered by Alembic. All alembic commands are integrated directly into the Litestar CLI. Command List ^^^^^^^^^^^^ To get a listing of available commands, run the following: .. code-block:: bash litestar database .. code-block:: bash Usage: app database [OPTIONS] COMMAND [ARGS]... Manage SQLAlchemy database components. โ•ญโ”€ Options โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ โ”‚ --help -h Show this message and exit. โ”‚ โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ โ•ญโ”€ Commands โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ โ”‚ downgrade Downgrade database to a specific revision. โ”‚ โ”‚ drop-all Drop all tables from the database. โ”‚ โ”‚ dump-data Dump specified tables from the database to JSON โ”‚ โ”‚ files. โ”‚ โ”‚ init Initialize migrations for the project. โ”‚ โ”‚ make-migrations Create a new migration revision. โ”‚ โ”‚ merge-migrations Merge multiple revisions into a single new revision. โ”‚ โ”‚ show-current-revision Shows the current revision for the database. โ”‚ โ”‚ stamp-migration Mark (Stamp) a specific revision as current without โ”‚ โ”‚ applying the migrations. โ”‚ โ”‚ upgrade Upgrade database to a specific revision. โ”‚ โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ Initializing a new project ^^^^^^^^^^^^^^^^^^^^^^^^^^ If you would like to initial set of alembic migrations, you can easily scaffold out new templates to setup a project. Assuming that you are using the default configuration for the SQLAlchemy configuration, you can run the following to initialize the migrations directory. .. code-block:: shell-session $ litestar database init ./migrations If you use a different path than `./migrations`, be sure to also set this in your SQLAlchemy config. For instance, if you'd like to use `./alembic`: .. code-block:: python config = SQLAlchemyAsyncConfig( alembic_config=AlembicAsyncConfig( script_location="./alembic/", ), ) And then run the following to initialize the migrations directory: .. code-block:: shell-session $ litestar database init ./alembic You will now be configured to use the alternate directory for migrations. Generate New Migrations ^^^^^^^^^^^^^^^^^^^^^^^ Once configured, you can run the following command to auto-generate new alembic migrations: .. code-block:: shell-session $ litestar database make-migrations Upgrading a Database ^^^^^^^^^^^^^^^^^^^^ You can upgrade a database to the latest version by running the following command: .. code-block:: shell-session $ litestar database upgrade Server-Side Session Backend --------------------------- Advanced Alchemy provides SQLAlchemy-based session backends for Litestar's server-side session middleware. This allows you to store session data in your existing SQLAlchemy database instead of using external stores like Redis or file-based storage. If you need a general-purpose Litestar store outside the session middleware itself, the same extension package also exposes ``advanced_alchemy.extensions.litestar.store.SQLAlchemyStore`` and ``StoreModelMixin``. The examples below focus on the session backend used with ``ServerSideSessionConfig``. Overview ^^^^^^^^ The SQLAlchemy session backend provides: - **Database persistence**: Session data is stored in your SQLAlchemy database - **Automatic expiration**: Built-in session expiration handling - **Both sync and async support**: Works with both sync and async SQLAlchemy configurations - **UUID-based sessions**: Uses UUIDv7 for session identifiers - **Timezone-aware timestamps**: Proper handling of session expiration times Quick Setup ^^^^^^^^^^^ To use the SQLAlchemy session backend, you need to: 1. Create a session model using the provided mixin 2. Configure the SQLAlchemy session backend 3. Register the session middleware with your Litestar application .. code-block:: python from litestar import Litestar from litestar.middleware.session.server_side import ServerSideSessionConfig from advanced_alchemy.extensions.litestar import SQLAlchemyAsyncConfig, SQLAlchemyPlugin from advanced_alchemy.extensions.litestar.session import ( SessionModelMixin, SQLAlchemyAsyncSessionBackend, ) # 1. Create your session model class UserSession(SessionModelMixin): __tablename__ = "user_sessions" # 2. Configure SQLAlchemy alchemy_config = SQLAlchemyAsyncConfig( connection_string="postgresql+asyncpg://user:password@localhost/mydb", create_all=True, ) # 3. Configure session backend session_config = ServerSideSessionConfig( max_age=3600, # 1 hour ) # 4. Create the session backend session_backend = SQLAlchemyAsyncSessionBackend( config=session_config, alchemy_config=alchemy_config, model=UserSession, ) # 5. Create your Litestar app app = Litestar( route_handlers=[], plugins=[SQLAlchemyPlugin(config=alchemy_config)], middleware=[session_config.middleware], ) Session Model Configuration ^^^^^^^^^^^^^^^^^^^^^^^^^^^ The session model must inherit from ``SessionModelMixin``, which provides the required fields and database constraints: .. code-block:: python from advanced_alchemy.extensions.litestar.session import SessionModelMixin class UserSession(SessionModelMixin): __tablename__ = "user_sessions" # The mixin provides these fields automatically: # - id: UUIDv7 primary key # - session_id: String(255) session identifier # - data: LargeBinary session data # - expires_at: DateTime expiration timestamp # - created_at, updated_at: Audit timestamps The ``SessionModelMixin`` automatically creates: - A unique constraint on ``session_id`` (or unique index for Spanner) - An index on ``expires_at`` for efficient cleanup - Hybrid properties for checking expiration status Advanced Configuration ^^^^^^^^^^^^^^^^^^^^^^ **Custom Table Arguments** You can customize table arguments while keeping the mixin's constraints: .. code-block:: python from sqlalchemy import Index from advanced_alchemy.extensions.litestar.session import SessionModelMixin class UserSession(SessionModelMixin): __tablename__ = "user_sessions" @declared_attr.directive @classmethod def __table_args__(cls): # Get the mixin's default constraints base_args = super().__table_args__() # Add your custom indexes/constraints return base_args + ( Index("ix_user_sessions_custom", cls.session_id, cls.created_at), ) **Sync vs Async Configuration** For synchronous SQLAlchemy configurations, use ``SQLAlchemySyncSessionBackend``: .. code-block:: python from advanced_alchemy.extensions.litestar import SQLAlchemySyncConfig from advanced_alchemy.extensions.litestar.session import SQLAlchemySyncSessionBackend # Sync configuration alchemy_config = SQLAlchemySyncConfig( connection_string="postgresql://user:password@localhost/mydb", create_all=True, ) session_backend = SQLAlchemySyncSessionBackend( config=session_config, alchemy_config=alchemy_config, model=UserSession, ) **Session Cleanup** Both session backends provide automatic cleanup of expired sessions: .. code-block:: python # Clean up expired sessions await session_backend.delete_expired() # For async backend # or await session_backend.delete_expired() # For sync backend (wrapped with async_) You can set up periodic cleanup using Litestar's task system or external schedulers. Using Sessions in Routes ^^^^^^^^^^^^^^^^^^^^^^^^ Once configured, sessions work exactly like other Litestar session backends: .. code-block:: python from litestar import Litestar, get, post from litestar.connection import ASGIConnection from litestar.response import Response @get("/login") async def login_form() -> str: return "
" @post("/login") async def login(request: ASGIConnection) -> Response: form = await request.form() username = form.get("username") # Set session data request.set_session({"user_id": 123, "username": username}) return Response("Logged in!", status_code=200) @get("/profile") async def profile(request: ASGIConnection) -> dict: # Access session data user_id = request.session.get("user_id") username = request.session.get("username") if not user_id: return {"error": "Not logged in"} return {"user_id": user_id, "username": username} @post("/logout") async def logout(request: ASGIConnection) -> str: # Clear session request.clear_session() return "Logged out!" Database Schema ^^^^^^^^^^^^^^^ The session table created by ``SessionModelMixin`` has the following structure: .. code-block:: sql CREATE TABLE user_sessions ( id UUID PRIMARY KEY, session_id VARCHAR(255) NOT NULL, data BYTEA NOT NULL, expires_at TIMESTAMP WITH TIME ZONE, created_at TIMESTAMP WITH TIME ZONE NOT NULL, updated_at TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT uq_user_sessions_session_id UNIQUE (session_id) ); CREATE INDEX ix_user_sessions_expires_at ON user_sessions (expires_at); CREATE INDEX ix_user_sessions_session_id_unique ON user_sessions (session_id); **Session ID Handling** - Session IDs are limited to 255 characters and automatically truncated if longer - UUIDv7 is used for the primary key, providing time-ordered identifiers - Expired sessions are automatically filtered out during retrieval Security Considerations ^^^^^^^^^^^^^^^^^^^^^^^ **Session Expiration** Configure appropriate session timeouts: .. code-block:: python # Sessions are automatically renewed on each request session_config = ServerSideSessionConfig( max_age=1800, # 30 minutes https_only=True, # Require HTTPS in production samesite="strict", # CSRF protection ) **Database Security** Ensure your database connection uses proper security: - Use encrypted connections (SSL/TLS) - Restrict database user permissions - Regular security updates - Consider encrypting session data at rest Performance Optimization ^^^^^^^^^^^^^^^^^^^^^^^^ **Indexing Strategy** The mixin automatically creates optimal indexes, but you can add application-specific indexes: .. code-block:: python class UserSession(SessionModelMixin): __tablename__ = "user_sessions" # Add indexes for common query patterns __table_args__ = SessionModelMixin.__table_args__ + ( Index("ix_user_sessions_created_user", "created_at", "session_id"), ) **Connection Pooling** Configure appropriate connection pooling for session workloads: .. code-block:: python from sqlalchemy.pool import QueuePool alchemy_config = SQLAlchemyAsyncConfig( connection_string="postgresql+asyncpg://user:password@localhost/mydb", engine_config=EngineConfig( poolclass=QueuePool, pool_size=20, max_overflow=30, pool_pre_ping=True, ), ) **Cleanup Strategy** Implement regular cleanup of expired sessions: .. code-block:: python from litestar import Litestar from litestar.events import BaseEventEmitter async def cleanup_expired_sessions(): """Background task to clean expired sessions.""" await session_backend.delete_expired() # Schedule cleanup every hour app = Litestar( # ... your configuration on_startup=[cleanup_expired_sessions], ) Complete Example ^^^^^^^^^^^^^^^^ Here's a complete working example: .. code-block:: python from litestar import Litestar, get, post from litestar.connection import ASGIConnection from litestar.middleware.session.server_side import ServerSideSessionConfig from advanced_alchemy.extensions.litestar import ( AsyncSessionConfig, SQLAlchemyAsyncConfig, SQLAlchemyPlugin, ) from litestar.response import Template from advanced_alchemy.extensions.litestar.session import ( SessionModelMixin, SQLAlchemyAsyncSessionBackend, ) # Session model class WebSession(SessionModelMixin): __tablename__ = "web_sessions" # Database configuration alchemy_config = SQLAlchemyAsyncConfig( connection_string="sqlite+aiosqlite:///sessions.db", session_config=AsyncSessionConfig(expire_on_commit=False), create_all=True, ) # Session configuration session_config = ServerSideSessionConfig( max_age=3600, # 1 hour ) # Session backend session_backend = SQLAlchemyAsyncSessionBackend( config=session_config, alchemy_config=alchemy_config, model=WebSession, ) # Routes @get("/") async def home(request: ASGIConnection) -> dict: username = request.session.get("username") return {"message": f"Hello {username}!" if username else "Hello stranger!"} @post("/login") async def login(request: ASGIConnection) -> dict: form = await request.form() username = form.get("username") if username: request.set_session({"username": username, "login_time": "now"}) return {"message": f"Welcome {username}!"} return {"error": "Username required"} @post("/logout") async def logout(request: ASGIConnection) -> dict: request.clear_session() return {"message": "Logged out successfully"} # Application app = Litestar( route_handlers=[home, login, logout], plugins=[SQLAlchemyPlugin(config=alchemy_config)], middleware=[session_config.middleware], ) This example provides a complete session-enabled application using SQLAlchemy for session storage. File Object Storage ------------------- Advanced Alchemy provides built-in support for file storage with various backends. Here's how to handle file uploads and storage: The pattern below mirrors ``examples/litestar/litestar_fileobject.py``: 1. Register a storage backend 2. Map a ``StoredObject`` column 3. Expose a signed URL from the response schema 4. Translate multipart uploads into ``FileObject`` instances before persisting them .. code-block:: python from typing import Annotated, Any, Optional, Union from uuid import UUID from litestar import Controller, Litestar, delete, get, patch, post from litestar.datastructures import UploadFile from litestar.enums import RequestEncodingType from litestar.params import Body, Dependency from pydantic import BaseModel, Field, computed_field from sqlalchemy.orm import Mapped, mapped_column from advanced_alchemy.extensions.litestar import ( AsyncSessionConfig, SQLAlchemyAsyncConfig, SQLAlchemyPlugin, base, filters, providers, repository, service, ) from advanced_alchemy.types import FileObject, storages from advanced_alchemy.types.file_object.backends.obstore import ObstoreBackend from advanced_alchemy.types.file_object.data_type import StoredObject # Configure file storage backend documents_backend = ObstoreBackend( key="documents", fs="s3://company-documents-prod/", ) storages.register_backend(documents_backend) # Model with file storage class DocumentModel(base.UUIDBase): __tablename__ = "document" name: Mapped[str] file: Mapped[FileObject] = mapped_column(StoredObject(backend="documents")) # Schema with file URL generation class Document(BaseModel): id: Optional[UUID] = None name: str file: Optional[FileObject] = Field(default=None, exclude=True) @computed_field def file_url(self) -> Optional[Union[str, list[str]]]: if self.file is None: return None return self.file.sign() # Schema for creating and updating documents class CreateDocument(BaseModel): model_config = {"arbitrary_types_allowed": True} name: str file: Optional[UploadFile] = None class PatchDocument(BaseModel): model_config = {"arbitrary_types_allowed": True} name: Optional[str] = None file: Optional[UploadFile] = None # Service class DocumentService(service.SQLAlchemyAsyncRepositoryService[DocumentModel]): """Document repository.""" class Repo(repository.SQLAlchemyAsyncRepository[DocumentModel]): """Document repository.""" model_type = DocumentModel repository_type = Repo # Controller with file handling class DocumentController(Controller): path = "/documents" dependencies = providers.create_service_dependencies( DocumentService, "documents_service", load=[DocumentModel.file], filters={ "pagination_type": "limit_offset", "id_filter": UUID, "search": "name", "search_ignore_case": True }, ) @get(path="/", response_model=service.OffsetPagination[Document]) async def list_documents( self, documents_service: DocumentService, filters: Annotated[list[filters.FilterTypes], Dependency(skip_validation=True)], ) -> service.OffsetPagination[Document]: results, total = await documents_service.list_and_count(*filters) return documents_service.to_schema(results, total, filters=filters, schema_type=Document) @post(path="/") async def create_document( self, data: Annotated[CreateDocument, Body(media_type=RequestEncodingType.MULTI_PART)], documents_service: DocumentService, ) -> Document: obj = await documents_service.create( DocumentModel( name=data.name, file=FileObject( backend="documents", filename=data.file.filename or "uploaded_file", content_type=data.file.content_type, content=await data.file.read(), ) if data.file else None, ) ) return documents_service.to_schema(obj, schema_type=Document) @get(path="/{document_id:uuid}") async def get_document( self, documents_service: DocumentService, document_id: UUID, ) -> Document: obj = await documents_service.get(document_id) return documents_service.to_schema(obj, schema_type=Document) @patch(path="/{document_id:uuid}") async def update_document( self, document_id: UUID, data: Annotated[PatchDocument, Body(media_type=RequestEncodingType.MULTI_PART)], documents_service: DocumentService, ) -> Document: update_data: dict[str, Any] = {} if data.name: update_data["name"] = data.name if data.file: update_data["file"] = FileObject( backend="documents", filename=data.file.filename or "uploaded_file", content_type=data.file.content_type, content=await data.file.read(), ) obj = await documents_service.update(update_data, item_id=document_id) return documents_service.to_schema(obj, schema_type=Document) @delete(path="/{document_id:uuid}") async def delete_document( self, documents_service: DocumentService, document_id: UUID, ) -> None: _ = await documents_service.delete(document_id) # Application setup alchemy_config = SQLAlchemyAsyncConfig( connection_string="sqlite+aiosqlite:///test.sqlite", session_config=AsyncSessionConfig(expire_on_commit=False), before_send_handler="autocommit", create_all=True, ) app = Litestar( route_handlers=[DocumentController], plugins=[SQLAlchemyPlugin(config=alchemy_config)] ) File storage features: - **Multiple backends**: Local filesystem, S3, GCS, Azure and other object storage - **Automatic URL signing**: Generate secure, time-limited URLs for file access - **Content type detection**: Automatic MIME type handling - **File validation**: Built-in validation for file types and sizes - **Metadata storage**: Store file metadata alongside binary data For production deployments, avoid development-only endpoints and hardcoded object-store credentials in application code. Configure the backend with the real bucket or prefix and rely on the platform's ambient credentials, such as IAM roles or workload identity. **Supported Storage Backends**: - **Local filesystem**: For development and simple deployments - **Cloud Storage Integration**: For production object storage - **Memory**: For testing and temporary storage - **Custom backends**: Implement your own storage backend Alternative Patterns -------------------- .. dropdown:: Repository-Only Pattern If for some reason you don't want to use the service layer abstraction, you can use repositories directly. This approach removes the services abstraction, but still offers the benefits of Advanced Alchemy's repository features: .. code-block:: python import datetime from typing import TYPE_CHECKING, Optional from uuid import UUID from litestar import Controller, Litestar, delete, get, patch, post from litestar.di import Provide from litestar.pagination import OffsetPagination from litestar.params import Parameter from pydantic import BaseModel, TypeAdapter from sqlalchemy import ForeignKey from sqlalchemy.orm import Mapped, mapped_column, relationship from advanced_alchemy.base import UUIDAuditBase, UUIDBase from advanced_alchemy.config import AsyncSessionConfig from advanced_alchemy.extensions.litestar import SQLAlchemyAsyncConfig, SQLAlchemyPlugin from advanced_alchemy.filters import LimitOffset from advanced_alchemy.repository import SQLAlchemyAsyncRepository if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncSession class BaseModel(BaseModel): """Extend Pydantic's BaseModel to enable ORM mode""" model_config = {"from_attributes": True} # Models class AuthorModel(UUIDBase): __tablename__ = "author" name: Mapped[str] dob: Mapped[Optional[datetime.date]] books: Mapped[list["BookModel"]] = relationship(back_populates="author", lazy="selectin") class BookModel(UUIDAuditBase): __tablename__ = "book" title: Mapped[str] author_id: Mapped[UUID] = mapped_column(ForeignKey("author.id")) author: Mapped["AuthorModel"] = relationship(lazy="joined", innerjoin=True, viewonly=True) class Author(BaseModel): id: Optional[UUID] = None name: str dob: Optional[datetime.date] = None model_config = {"from_attributes": True} class AuthorCreate(BaseModel): name: str dob: Optional[datetime.date] = None class AuthorUpdate(BaseModel): name: Optional[str] = None dob: Optional[datetime.date] = None # Repository class AuthorRepository(SQLAlchemyAsyncRepository[AuthorModel]): """Author repository.""" model_type = AuthorModel # Dependency providers async def provide_authors_repo(db_session: AsyncSession) -> AuthorRepository: """This provides the default Authors repository.""" return AuthorRepository(session=db_session) async def provide_author_details_repo(db_session: AsyncSession) -> AuthorRepository: """Repository with eager loading for author details.""" return AuthorRepository(load=[AuthorModel.books], session=db_session) def provide_limit_offset_pagination( current_page: int = Parameter(ge=1, query="currentPage", default=1, required=False), page_size: int = Parameter(query="pageSize", ge=1, default=10, required=False), ) -> LimitOffset: """Add offset/limit pagination.""" return LimitOffset(page_size, page_size * (current_page - 1)) # Controller class AuthorController(Controller): """Author CRUD using repository pattern.""" dependencies = {"authors_repo": Provide(provide_authors_repo)} @get(path="/authors") async def list_authors( self, authors_repo: AuthorRepository, limit_offset: LimitOffset, ) -> OffsetPagination[Author]: """List authors with pagination.""" results, total = await authors_repo.list_and_count(limit_offset) type_adapter = TypeAdapter(list[Author]) return OffsetPagination[Author]( items=type_adapter.validate_python(results), total=total, limit=limit_offset.limit, offset=limit_offset.offset, ) @post(path="/authors") async def create_author( self, authors_repo: AuthorRepository, data: AuthorCreate, ) -> Author: """Create a new author.""" obj = await authors_repo.add( AuthorModel(**data.model_dump(exclude_unset=True, exclude_none=True)), ) await authors_repo.session.commit() return Author.model_validate(obj) @get( path="/authors/{author_id:uuid}", dependencies={"authors_repo": Provide(provide_author_details_repo)} ) async def get_author( self, authors_repo: AuthorRepository, author_id: UUID = Parameter(title="Author ID", description="The author to retrieve."), ) -> Author: """Get an existing author with details.""" obj = await authors_repo.get(author_id) return Author.model_validate(obj) @patch( path="/authors/{author_id:uuid}", dependencies={"authors_repo": Provide(provide_author_details_repo)}, ) async def update_author( self, authors_repo: AuthorRepository, data: AuthorUpdate, author_id: UUID = Parameter(title="Author ID", description="The author to update."), ) -> Author: """Update an author.""" raw_obj = data.model_dump(exclude_unset=True, exclude_none=True) raw_obj.update({"id": author_id}) obj = await authors_repo.update(AuthorModel(**raw_obj)) await authors_repo.session.commit() return Author.model_validate(obj) @delete(path="/authors/{author_id:uuid}") async def delete_author( self, authors_repo: AuthorRepository, author_id: UUID = Parameter(title="Author ID", description="The author to delete."), ) -> None: """Delete an author from the system.""" _ = await authors_repo.delete(author_id) await authors_repo.session.commit() # Application setup session_config = AsyncSessionConfig(expire_on_commit=False) alchemy_config = SQLAlchemyAsyncConfig( connection_string="sqlite+aiosqlite:///test.sqlite", session_config=session_config, create_all=True, ) sqlalchemy_plugin = SQLAlchemyPlugin(config=alchemy_config) app = Litestar( route_handlers=[AuthorController], plugins=[sqlalchemy_plugin], dependencies={"limit_offset": Provide(provide_limit_offset_pagination, sync_to_thread=False)}, ) This pattern is useful when you: - Need direct control over database transactions - Want to avoid the service layer abstraction - Have complex repository logic that doesn't fit the service pattern - Are building a smaller application with simpler data access patterns python-advanced-alchemy-1.9.3/docs/usage/frameworks/sanic.rst000066400000000000000000000051571516556515500243210ustar00rootroot00000000000000================== Sanic Integration ================== Advanced Alchemy integrates with Sanic through an extension that manages engines, request-scoped sessions, and bind-key lookups for both async and sync SQLAlchemy configurations. Basic Setup ----------- Configure a Sanic app with ``SQLAlchemyAsyncConfig`` or ``SQLAlchemySyncConfig`` and register the extension: .. code-block:: python from sanic import Sanic from advanced_alchemy.extensions.sanic import ( AdvancedAlchemy, AsyncSessionConfig, SQLAlchemyAsyncConfig, ) alchemy_config = SQLAlchemyAsyncConfig( connection_string="sqlite+aiosqlite:///:memory:", session_config=AsyncSessionConfig(expire_on_commit=False), create_all=True, ) app = Sanic("advanced-alchemy-example") alchemy = AdvancedAlchemy(sqlalchemy_config=alchemy_config) alchemy.register(app) Accessing the Engine and Session -------------------------------- The extension stores configured engines on ``app.ctx`` and provides helpers for request-scoped sessions: .. code-block:: python from sanic import HTTPResponse, Request from sqlalchemy import text @app.get("/health") async def healthcheck(request: Request) -> HTTPResponse: engine = getattr(request.app.ctx, alchemy.get_config().engine_key) session = alchemy.get_async_session(request) await session.execute(text("SELECT 1")) assert engine is alchemy.get_async_engine() return HTTPResponse(status=200) Use ``alchemy.get_session(request)`` when you want the same code path to support either sync or async configs. Multiple Binds -------------- You can register more than one SQLAlchemy configuration and select them by bind key: .. code-block:: python from advanced_alchemy.extensions.sanic import SQLAlchemySyncConfig alchemy = AdvancedAlchemy( sqlalchemy_config=[ SQLAlchemyAsyncConfig(connection_string="sqlite+aiosqlite:///:memory:"), SQLAlchemySyncConfig(connection_string="sqlite+pysqlite:///:memory:", bind_key="reporting"), ], sanic_app=app, ) default_config = alchemy.get_config() reporting_config = alchemy.get_config("reporting") assert default_config.bind_key == "default" assert reporting_config.bind_key == "reporting" Notes ----- - Register the extension once per application with ``alchemy.register(app)`` or by passing ``sanic_app=app``. - Request sessions are created lazily through ``get_session()`` / ``get_async_session()`` and tracked on ``request.ctx``. - Engine objects are stored on ``app.ctx`` using each config's ``engine_key``. python-advanced-alchemy-1.9.3/docs/usage/frameworks/starlette.rst000066400000000000000000000053501516556515500252260ustar00rootroot00000000000000====================== Starlette Integration ====================== Advanced Alchemy integrates with Starlette through an application helper that initializes SQLAlchemy configs, manages request-scoped sessions, and exposes engine/session accessors for route handlers. Basic Setup ----------- Initialize the extension with a Starlette app and one or more SQLAlchemy configs: .. code-block:: python from starlette.applications import Starlette from advanced_alchemy.extensions.starlette import ( AdvancedAlchemy, AsyncSessionConfig, SQLAlchemyAsyncConfig, ) alchemy_config = SQLAlchemyAsyncConfig( connection_string="sqlite+aiosqlite:///:memory:", session_config=AsyncSessionConfig(expire_on_commit=False), commit_mode="autocommit", ) app = Starlette() alchemy = AdvancedAlchemy(config=alchemy_config, app=app) Working with Sessions in Routes ------------------------------- Use the helper methods to access request-scoped sessions and the configured engine: .. code-block:: python from sqlalchemy import text from starlette.requests import Request from starlette.responses import JSONResponse from starlette.routing import Route async def healthcheck(request: Request) -> JSONResponse: session = alchemy.get_async_session(request) await session.execute(text("SELECT 1")) engine = alchemy.get_async_engine() return JSONResponse({"dialect": engine.dialect.name, "status": "ok"}) app.router.routes.append(Route("/health", endpoint=healthcheck)) Commit Modes ------------ Starlette integration supports the same commit strategies as the core SQLAlchemy configs: .. code-block:: python write_config = SQLAlchemyAsyncConfig( connection_string="sqlite+aiosqlite:///:memory:", commit_mode="autocommit_include_redirect", ) alchemy = AdvancedAlchemy(config=write_config, app=app) Multiple Binds -------------- Provide a sequence of configs when you need more than one bind key: .. code-block:: python from advanced_alchemy.extensions.starlette import SQLAlchemySyncConfig alchemy = AdvancedAlchemy( config=[ SQLAlchemyAsyncConfig(connection_string="sqlite+aiosqlite:///:memory:"), SQLAlchemySyncConfig(connection_string="sqlite+pysqlite:///:memory:", bind_key="reporting"), ], app=app, ) async_session = alchemy.get_async_config() reporting_session = alchemy.get_sync_config("reporting") Notes ----- - ``AdvancedAlchemy(config=..., app=app)`` calls ``init_app()`` for you. - Sessions are stored on ``request.state`` and are reused within the same request. - Use ``get_session(request)`` when your code should support either sync or async configs. python-advanced-alchemy-1.9.3/docs/usage/index.rst000066400000000000000000000025011516556515500221410ustar00rootroot00000000000000===== Usage ===== This guide demonstrates building a complete blog system using Advanced Alchemy's features. We'll create a system that supports: - Posts with tags and slugs - Tag management with automatic deduplication - Efficient querying and pagination - Type-safe database operations - Schema validation and transformation .. toctree:: :maxdepth: 2 :caption: Core Features modeling/index repositories/index services routing caching cli database_seeding .. toctree:: :maxdepth: 2 :caption: Framework Integration frameworks/litestar frameworks/flask frameworks/fastapi frameworks/sanic frameworks/starlette The guide follows a practical approach: 1. **Modeling**: Define SQLAlchemy models with Advanced Alchemy's enhanced base classes 2. **Repositories**: Implement type-safe database operations using repositories 3. **Services**: Build business logic with automatic schema validation 4. **Framework Integration**: Integrate with Litestar and FastAPI Each section includes: - Concepts and usage overview - Complete code examples - Best practices - Performance considerations - Error handling strategies Prerequisites ------------- - Python 3.9+ - SQLAlchemy 2.0+ - Basic understanding of SQLAlchemy and async programming - Basic understanding of Pydantic or Msgspec python-advanced-alchemy-1.9.3/docs/usage/modeling/000077500000000000000000000000001516556515500221005ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/docs/usage/modeling/basics.rst000066400000000000000000000224441516556515500241040ustar00rootroot00000000000000=============== Modeling Basics =============== Advanced Alchemy enhances SQLAlchemy's modeling capabilities with production-ready base classes, mixins, and specialized types. This guide demonstrates modeling for a blog system with posts and tags, showcasing key features and best practices. Base Classes ------------ Advanced Alchemy provides several declarative bases optimized for different use cases. The common ID and audit combinations are ready to use, while the lower-level bases let you assemble your own model hierarchy without rebuilding SQLAlchemy's declarative setup from scratch. .. list-table:: Base Classes and Features :header-rows: 1 :widths: 20 80 * - Base Class - Features * - ``AdvancedDeclarativeBase`` - Low-level registry-aware base for building custom declarative hierarchies * - ``DefaultBase`` - Automatic table naming and bind-aware metadata without a predefined primary key * - ``BigIntBase`` - BIGINT primary keys for tables * - ``BigIntAuditBase`` - BIGINT primary keys for tables, Automatic created_at/updated_at timestamps * - ``IdentityBase`` - Primary keys using database IDENTITY feature instead of sequences * - ``IdentityAuditBase`` - Primary keys using database IDENTITY feature, Automatic created_at/updated_at timestamps * - ``UUIDBase`` - UUID primary keys * - ``UUIDv6Base`` - UUIDv6 primary keys * - ``UUIDv7Base`` - UUIDv7 primary keys * - ``UUIDAuditBase`` - UUID primary keys, Automatic created_at/updated_at timestamps * - ``UUIDv6AuditBase`` - UUIDv6 primary keys, Automatic created_at/updated_at timestamps * - ``UUIDv7AuditBase`` - Time-sortable UUIDv7 primary keys, Automatic created_at/updated_at timestamps * - ``NanoIDBase`` - URL-friendly unique identifiers, Shorter than UUIDs, collision resistant * - ``NanoIDAuditBase`` - URL-friendly IDs with audit timestamps, Combines Nanoid benefits with audit trails * - ``SQLQuery`` - Registry-backed base for custom mapped query objects and other specialized mapped constructs For most applications, start with one of the opinionated bases such as ``BigIntAuditBase`` or ``UUIDAuditBase``. Reach for ``DefaultBase`` when you want Advanced Alchemy's table naming and metadata handling but need to define your own primary key fields. Mixins ------- Additionally, Advanced Alchemy provides mixins to enhance model functionality: .. list-table:: Available Mixins :header-rows: 1 :widths: 20 80 * - Mixin - Features * - ``SlugKey`` - Adds URL-friendly slug field * - ``AuditColumns`` - Automatic created_at/updated_at timestamps. Tracks record modifications. * - ``BigIntPrimaryKey`` - Adds BigInt primary key with sequence * - ``IdentityPrimaryKey`` - Adds primary key using database IDENTITY feature * - ``UniqueMixin`` - Automatic Select or Create for many-to-many relationships Basic Model Example ------------------- Let's start with a simple blog post model: .. code-block:: python import datetime from typing import Optional from advanced_alchemy.base import BigIntAuditBase from sqlalchemy.orm import Mapped, mapped_column class BasicBlogPost(BigIntAuditBase): """Blog post model with auto-incrementing ID and audit fields.""" __tablename__ = "basic_blog_post" title: Mapped[str] = mapped_column(index=True) content: Mapped[str] published: Mapped[bool] = mapped_column(default=False) published_at: Mapped[Optional[datetime.datetime]] = mapped_column(default=None) .. _many_to_many_relationships: Many-to-Many Relationships -------------------------- Let's implement a tagging system using a many-to-many relationship. .. code-block:: python from sqlalchemy import Column, ForeignKey, Table from sqlalchemy.orm import Mapped, mapped_column, relationship from advanced_alchemy.base import BigIntAuditBase, orm_registry from advanced_alchemy.mixins import SlugKey from typing import List # Association table for post-topic relationships blog_post_topic = Table( "blog_post_topic", orm_registry.metadata, Column("post_id", ForeignKey("tagged_blog_post.id", ondelete="CASCADE"), primary_key=True), Column("topic_id", ForeignKey("blog_topic.id", ondelete="CASCADE"), primary_key=True), ) class TaggedBlogPost(BigIntAuditBase): __tablename__ = "tagged_blog_post" title: Mapped[str] = mapped_column(index=True) content: Mapped[str] published: Mapped[bool] = mapped_column(default=False) # Many-to-many relationship with topics topics: Mapped[List["BlogTopic"]] = relationship( secondary=blog_post_topic, back_populates="posts", lazy="selectin", ) class BlogTopic(BigIntAuditBase, SlugKey): """Topic model with automatic slug generation.""" __tablename__ = "blog_topic" name: Mapped[str] = mapped_column(unique=True, index=True) posts: Mapped[List["TaggedBlogPost"]] = relationship( secondary=blog_post_topic, back_populates="topics", lazy="selectin", ) .. _using_unique_mixin: Using ``UniqueMixin`` --------------------- ``UniqueMixin`` provides automatic handling of unique constraints and merging of duplicate records. .. code-block:: python from advanced_alchemy.base import BigIntAuditBase from advanced_alchemy.mixins import SlugKey, UniqueMixin from advanced_alchemy.utils.text import slugify from sqlalchemy.sql.elements import ColumnElement from sqlalchemy.orm import Mapped, mapped_column, relationship from typing import Hashable, Optional class UniqueTopic(BigIntAuditBase, SlugKey, UniqueMixin): """Topic model with unique name constraint.""" __tablename__ = "unique_topic" name: Mapped[str] = mapped_column(unique=True, index=True) @classmethod def unique_hash(cls, name: str, slug: Optional[str] = None) -> Hashable: """Generate a unique hash for deduplication.""" return slugify(name) @classmethod def unique_filter( cls, name: str, slug: Optional[str] = None, ) -> ColumnElement[bool]: """SQL filter for finding existing records.""" return cls.slug == slugify(name) We can now use ``as_unique_async`` to simplify creation: .. code-block:: python from sqlalchemy.ext.asyncio import AsyncSession from advanced_alchemy.utils.text import slugify async def get_or_create_topics( db_session: AsyncSession, topic_names: list[str], ) -> list[UniqueTopic]: """Create or fetch topic rows without duplicating existing slugs.""" return [ await UniqueTopic.as_unique_async(db_session, name=topic_name, slug=slugify(topic_name)) for topic_name in topic_names ] Using ``MappedAsDataclass`` --------------------------- Advanced Alchemy's built-in bases can also be combined with SQLAlchemy's ``MappedAsDataclass`` helper. ``DefaultBase`` is the best starting point when you want dataclass-style construction but need to define your own primary key fields. .. code-block:: python from typing import Optional from advanced_alchemy.base import DefaultBase from sqlalchemy.orm import Mapped, MappedAsDataclass, mapped_column class DataclassAuthor(MappedAsDataclass, DefaultBase): __tablename__ = "dataclass_author" id: Mapped[int] = mapped_column(primary_key=True, init=False) name: Mapped[str] bio: Mapped[Optional[str]] = mapped_column(default=None) If a field is generated by the database or SQLAlchemy itself, mark it ``init=False`` or provide a default so the generated dataclass constructor remains valid. Customizing Declarative Base ----------------------------- If the built-in primary key strategies are close but not exact, start from ``DefaultBase`` and add your own columns or mixins. That keeps the Advanced Alchemy registry and table-name behavior while letting you replace the primary key strategy. .. code-block:: python import datetime from typing import Optional from uuid import UUID, uuid4 from advanced_alchemy.base import DefaultBase from sqlalchemy import text from sqlalchemy.orm import ( Mapped, declared_attr, mapped_column, orm_insert_sentinel, ) class ServerSideUUIDPrimaryKey: """UUID Primary Key Field Mixin.""" id: Mapped[UUID] = mapped_column( default=uuid4, primary_key=True, server_default=text("gen_random_uuid()"), ) @declared_attr def _sentinel(cls) -> Mapped[int]: """Sentinel value required for bulk DML.""" return orm_insert_sentinel(name="sa_orm_sentinel") class ServerSideUUIDBase(ServerSideUUIDPrimaryKey, DefaultBase): __abstract__ = True class ServerSideUser(ServerSideUUIDBase): __tablename__ = "server_side_user" username: Mapped[str] = mapped_column(unique=True, index=True) email: Mapped[str] = mapped_column(unique=True) full_name: Mapped[str] is_active: Mapped[bool] = mapped_column(default=True) last_login: Mapped[Optional[datetime.datetime]] = mapped_column(default=None) python-advanced-alchemy-1.9.3/docs/usage/modeling/index.rst000066400000000000000000000003561516556515500237450ustar00rootroot00000000000000======== Modeling ======== Advanced Alchemy enhances SQLAlchemy's modeling capabilities with production-ready base classes, mixins, and specialized types. .. toctree:: :maxdepth: 1 basics inheritance sqlmodel types python-advanced-alchemy-1.9.3/docs/usage/modeling/inheritance.rst000066400000000000000000000100301516556515500251150ustar00rootroot00000000000000==================== Inheritance Patterns ==================== Advanced Alchemy provides robust support for SQLAlchemy's inheritance patterns, ensuring that mixins and common attributes are correctly applied across the hierarchy. Common Table Attributes ----------------------- When using inheritance, it is recommended to use the ``CommonTableAttributes`` mixin. This ensures that table names are correctly generated and that shared attributes (like primary keys or audit columns) are inherited properly. .. code-block:: python from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column from advanced_alchemy.base import CommonTableAttributes, orm_registry class Base(CommonTableAttributes, DeclarativeBase): registry = orm_registry Single Table Inheritance (STI) ------------------------------ In STI, multiple classes are mapped to a single table. A "discriminator" column is used to determine which class a particular row represents. .. code-block:: python from typing import Optional from sqlalchemy.orm import Mapped, mapped_column from advanced_alchemy.base import UUIDAuditBase class Employee(UUIDAuditBase): __tablename__ = "employee" name: Mapped[str] type: Mapped[str] __mapper_args__ = { "polymorphic_on": "type", "polymorphic_identity": "employee", } class Manager(Employee): __mapper_args__ = { "polymorphic_identity": "manager", } manager_data: Mapped[Optional[str]] class Engineer(Employee): __mapper_args__ = { "polymorphic_identity": "engineer", } engineer_info: Mapped[Optional[str]] Joined Table Inheritance (JTI) ------------------------------ In JTI, each class in the hierarchy is mapped to its own table. Sub-tables contain only the columns specific to that class and a foreign key to the parent table. .. code-block:: python from uuid import UUID from sqlalchemy import ForeignKey from sqlalchemy.orm import Mapped, mapped_column from advanced_alchemy.base import UUIDAuditBase class Person(UUIDAuditBase): __tablename__ = "person" name: Mapped[str] type: Mapped[str] __mapper_args__ = { "polymorphic_on": "type", "polymorphic_identity": "person", } class Staff(Person): __tablename__ = "staff" id: Mapped[UUID] = mapped_column(ForeignKey("person.id"), primary_key=True) staff_no: Mapped[str] __mapper_args__ = { "polymorphic_identity": "staff", } Concrete Table Inheritance (CTI) -------------------------------- In CTI, each class is mapped to a completely independent table containing all columns for that class. .. code-block:: python from sqlalchemy.orm import Mapped, mapped_column from advanced_alchemy.base import UUIDAuditBase class Vehicle(UUIDAuditBase): __abstract__ = True name: Mapped[str] class Car(Vehicle): __tablename__ = "car" engine_type: Mapped[str] class Bicycle(Vehicle): __tablename__ = "bicycle" has_basket: Mapped[bool] Repository Usage with Inheritance --------------------------------- Advanced Alchemy's repositories work seamlessly with inheritance. You can create a repository for the base class to query across all types, or for a specific subclass. .. code-block:: python from sqlalchemy.ext.asyncio import AsyncSession from advanced_alchemy.repository import SQLAlchemyAsyncRepository class EmployeeRepository(SQLAlchemyAsyncRepository[Employee]): model_type = Employee async def list_employees(db_session: AsyncSession) -> list[Employee]: repository = EmployeeRepository(session=db_session) return await repository.list() class ManagerRepository(SQLAlchemyAsyncRepository[Manager]): model_type = Manager async def list_managers(db_session: AsyncSession) -> list[Manager]: repository = ManagerRepository(session=db_session) return await repository.list() python-advanced-alchemy-1.9.3/docs/usage/modeling/sqlmodel.rst000066400000000000000000000040251516556515500244530ustar00rootroot00000000000000===================== SQLModel Integration ===================== Advanced Alchemy provides built-in compatibility for `SQLModel `_, allowing you to use SQLModel's elegant syntax for defining models while leveraging Advanced Alchemy's powerful repositories and services. Basic Setup ----------- To use SQLModel with Advanced Alchemy, ensure your models are defined with ``table=True``. .. code-block:: python from typing import Optional from sqlmodel import Field, SQLModel from advanced_alchemy.repository import SQLAlchemyAsyncRepository class Hero(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) name: str secret_name: str age: Optional[int] = None class HeroRepository(SQLAlchemyAsyncRepository[Hero]): model_type = Hero Usage with Repositories ----------------------- Repositories automatically detect SQLModel classes and handle them correctly during CRUD operations. .. code-block:: python from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine async def create_and_list_heroes() -> list[Hero]: engine = create_async_engine("sqlite+aiosqlite:///:memory:") async_session_factory = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession) async with engine.begin() as conn: await conn.run_sync(SQLModel.metadata.create_all) try: async with async_session_factory() as session: repo = HeroRepository(session=session) hero = Hero(name="Deadpond", secret_name="Dive Wilson") await repo.add(hero) await session.commit() return await repo.list() finally: await engine.dispose() Limitations ----------- While SQLModel is supported, some Advanced Alchemy features that rely on specific SQLAlchemy base class behaviors (like some automated mixin detections) may require explicit configuration when used with SQLModel. python-advanced-alchemy-1.9.3/docs/usage/modeling/types.rst000066400000000000000000000166361516556515500240120ustar00rootroot00000000000000============== Advanced Types ============== Advanced Alchemy provides several custom SQLAlchemy types that handle common requirements like encryption, UTC datetimes, and file storage. All types include: - Proper Python type annotations for modern IDE support - Automatic dialect-specific implementations - Consistent behavior across different database backends - Integration with SQLAlchemy's type system .. code-block:: python from datetime import datetime from typing import Optional from sqlalchemy.orm import Mapped, mapped_column from advanced_alchemy.base import UUIDBase from advanced_alchemy.types import ( DateTimeUTC, EncryptedString, FileObject, JsonB, StoredObject, storages, ) storages.register_backend("file:///tmp/", key="avatars") class UserRecord(UUIDBase): __tablename__ = "users" created_at: Mapped[datetime] = mapped_column(DateTimeUTC) password: Mapped[str] = mapped_column(EncryptedString(key="secret-key")) preferences: Mapped[dict[str, str]] = mapped_column(JsonB) avatar: Mapped[Optional[FileObject]] = mapped_column(StoredObject(backend="avatars")) DateTime UTC ------------ - Ensures all datetime values are stored in UTC - Requires timezone information for input values - Automatically converts stored values to UTC timezone - Returns timezone-aware datetime objects .. code-block:: python from datetime import datetime from sqlalchemy.orm import Mapped, mapped_column from advanced_alchemy.base import BigIntBase from advanced_alchemy.types import DateTimeUTC class AuditLogRecord(BigIntBase): __tablename__ = "audit_log" created_at: Mapped[datetime] = mapped_column(DateTimeUTC) Encrypted Types --------------- Advanced Alchemy supports two types for storing encrypted data with multiple encryption backends. EncryptedString ~~~~~~~~~~~~~~~ For storing encrypted string values with configurable length. .. code-block:: python from sqlalchemy.orm import Mapped, mapped_column from advanced_alchemy.base import BigIntBase from advanced_alchemy.types import EncryptedString class SecretRecord(BigIntBase): __tablename__ = "secret_record" secret: Mapped[str] = mapped_column(EncryptedString(key="my-secret-key")) EncryptedText ~~~~~~~~~~~~~ For storing larger encrypted text content (CLOB). .. code-block:: python from sqlalchemy.orm import Mapped, mapped_column from advanced_alchemy.base import BigIntBase from advanced_alchemy.types import EncryptedText class LongSecretRecord(BigIntBase): __tablename__ = "long_secret_record" large_secret: Mapped[str] = mapped_column(EncryptedText(key="my-secret-key")) Encryption Backends ~~~~~~~~~~~~~~~~~~~ Two encryption backends are available: - :class:`FernetBackend `: Uses Python's ``cryptography`` library with Fernet encryption. - :class:`PGCryptoBackend `: Uses PostgreSQL's ``pgcrypto`` extension (PostgreSQL only). GUID ---- A platform-independent GUID/UUID type that adapts to different database backends: - **PostgreSQL/DuckDB/CockroachDB**: Uses native UUID type - **MSSQL**: Uses UNIQUEIDENTIFIER - **Oracle**: Uses RAW(16) - **Others**: Uses BINARY(16) or CHAR(32) .. code-block:: python from uuid import UUID from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column from advanced_alchemy.base import CommonTableAttributes, orm_registry from advanced_alchemy.types import GUID class Base(CommonTableAttributes, DeclarativeBase): registry = orm_registry class ExternalIdentity(Base): __tablename__ = "external_identity" id: Mapped[UUID] = mapped_column(GUID, primary_key=True) JsonB ----- A JSON type that uses the most efficient JSON storage for each database: - **PostgreSQL/CockroachDB**: Uses native JSONB - **Oracle**: Uses Binary JSON (BLOB with JSON constraint) - **Others**: Uses standard JSON type .. code-block:: python from sqlalchemy.orm import Mapped, mapped_column from advanced_alchemy.base import BigIntBase from advanced_alchemy.types import JsonB class SettingsRecord(BigIntBase): __tablename__ = "settings_record" data: Mapped[dict[str, str]] = mapped_column(JsonB) Password Hash ------------- A type for storing password hashes with configurable backends. Currently supports: - :class:`~advanced_alchemy.types.password_hash.pwdlib.PwdlibHasher`: Uses ``pwdlib`` - :class:`~advanced_alchemy.types.password_hash.argon2.Argon2Hasher`: Uses ``argon2-cffi`` - :class:`~advanced_alchemy.types.password_hash.passlib.PasslibHasher`: Uses ``passlib`` .. code-block:: python from sqlalchemy.orm import Mapped, mapped_column from advanced_alchemy.base import BigIntBase from advanced_alchemy.types import PasswordHash from advanced_alchemy.types.password_hash.pwdlib import PwdlibHasher from pwdlib.hashers.argon2 import Argon2Hasher as PwdlibArgon2Hasher class CredentialRecord(BigIntBase): __tablename__ = "credential_record" password: Mapped[str] = mapped_column( PasswordHash(backend=PwdlibHasher(hasher=PwdlibArgon2Hasher())) ) File Object Storage ------------------- Advanced Alchemy provides a powerful file object storage system through the :class:`StoredObject` type. This system supports multiple storage backends and provides automatic file cleanup. The Litestar fullstack reference applications register a named storage backend during application startup and reference that key from :class:`StoredObject`. Basic Usage ~~~~~~~~~~~ .. code-block:: python from typing import Optional from sqlalchemy.orm import Mapped, mapped_column from advanced_alchemy.base import UUIDBase from advanced_alchemy.types import FileObject, FileObjectList, StoredObject, storages storages.register_backend("file:///tmp/", key="documents") class Document(UUIDBase): __tablename__ = "documents" # Single file storage attachment: Mapped[Optional[FileObject]] = mapped_column( StoredObject(backend="documents"), nullable=True, ) # Multiple file storage images: Mapped[Optional[FileObjectList]] = mapped_column( StoredObject(backend="documents", multiple=True), nullable=True, ) Storage Backends ~~~~~~~~~~~~~~~~ - **FSSpec Backend**: Supports various storage systems using the ``fsspec`` library. - **Obstore Backend**: Provides a simple interface for object storage (S3, GCS, etc). Metadata ~~~~~~~~ File objects support metadata storage: .. code-block:: python file_obj = FileObject( backend="documents", filename="test.txt", metadata={ "category": "document", "tags": ["important", "review"], }, ) # Update metadata file_obj.update_metadata({"priority": "high"}) Automatic Cleanup ~~~~~~~~~~~~~~~~~ When a file object is removed from a model or the model is deleted, the associated file is automatically saved or deleted from storage. .. note:: File object listeners are wired through the SQLAlchemy config and framework integrations while ``enable_file_object_listener`` remains enabled, which is the default. Disable that flag only if your application is taking full responsibility for saving and deleting file objects. python-advanced-alchemy-1.9.3/docs/usage/repositories/000077500000000000000000000000001516556515500230315ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/docs/usage/repositories/advanced.rst000066400000000000000000000102221516556515500253250ustar00rootroot00000000000000===================== Advanced Repository ===================== This section covers advanced repository features including composite primary keys and row locking. .. _composite-primary-keys: Composite Primary Keys ---------------------- Advanced Alchemy supports models with composite primary keys. For these models, the repository methods accept several formats for identifying records. .. code-block:: python from collections.abc import Sequence from sqlalchemy import ForeignKey from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Mapped, mapped_column from advanced_alchemy.base import BigIntBase, DefaultBase from advanced_alchemy.repository import SQLAlchemyAsyncRepository class AdvancedUser(BigIntBase): __tablename__ = "advanced_user_account" username: Mapped[str] class AdvancedRole(BigIntBase): __tablename__ = "advanced_role" name: Mapped[str] class AdvancedUserRole(DefaultBase): __tablename__ = "advanced_user_role" user_id: Mapped[int] = mapped_column(ForeignKey("advanced_user_account.id"), primary_key=True) role_id: Mapped[int] = mapped_column(ForeignKey("advanced_role.id"), primary_key=True) permissions: Mapped[str] = mapped_column(default="member") class AdvancedPost(BigIntBase): __tablename__ = "advanced_post" title: Mapped[str] published: Mapped[bool] = mapped_column(default=False) class AdvancedUserRoleRepository(SQLAlchemyAsyncRepository[AdvancedUserRole]): model_type = AdvancedUserRole class AdvancedUserRepository(SQLAlchemyAsyncRepository[AdvancedUser]): model_type = AdvancedUser class AdvancedPostRepository(SQLAlchemyAsyncRepository[AdvancedPost]): model_type = AdvancedPost **Tuple Format** Pass primary key values as a tuple in the order they are defined on the model. .. code-block:: python async def get_user_role_by_tuple( db_session: AsyncSession, user_id: int, role_id: int, ) -> AdvancedUserRole: repository = AdvancedUserRoleRepository(session=db_session) return await repository.get((user_id, role_id)) **Dict Format** Pass primary key values as a dictionary with column names as keys. This is more explicit and avoids ordering issues. .. code-block:: python async def get_user_role_by_mapping( db_session: AsyncSession, user_id: int, role_id: int, ) -> AdvancedUserRole: repository = AdvancedUserRoleRepository(session=db_session) return await repository.get({"user_id": user_id, "role_id": role_id}) **Bulk Operations** You can use sequences of tuples or dicts for bulk operations like ``delete_many``. .. code-block:: python async def delete_user_roles( db_session: AsyncSession, role_ids: Sequence[dict[str, int]], ) -> Sequence[AdvancedUserRole]: repository = AdvancedUserRoleRepository(session=db_session) return await repository.delete_many(list(role_ids)) Row Locking (FOR UPDATE) ------------------------ .. versionadded:: 1.9.0 The ``get_one`` and ``get_one_or_none`` methods support a ``with_for_update`` parameter, allowing you to emit a ``SELECT ... FOR UPDATE`` query for row-level locking. .. code-block:: python async def get_user_for_update(db_session: AsyncSession, user_id: int) -> AdvancedUser: repository = AdvancedUserRepository(session=db_session) return await repository.get_one(id=user_id, with_for_update=True) async def get_user_for_update_nowait(db_session: AsyncSession, user_id: int) -> AdvancedUser: repository = AdvancedUserRepository(session=db_session) return await repository.get_one( id=user_id, with_for_update={"nowait": True, "of": AdvancedUser.id}, ) Custom DELETE WHERE -------------------- For deleting multiple records matching a specific criteria: .. code-block:: python async def delete_unpublished_posts(db_session: AsyncSession) -> Sequence[AdvancedPost]: repository = AdvancedPostRepository(session=db_session) return await repository.delete_where(AdvancedPost.published.is_(False)) python-advanced-alchemy-1.9.3/docs/usage/repositories/basics.rst000066400000000000000000000114651516556515500250360ustar00rootroot00000000000000=================== Repository Basics =================== Advanced Alchemy's repository pattern provides a clean, consistent interface for database operations. This pattern abstracts away the complexity of SQLAlchemy sessions and query-building while providing type-safe operations. Understanding Repositories -------------------------- A repository acts as a collection-like interface to your database models, providing: - Type-safe CRUD operations - Filtering and pagination - Bulk operations - Transaction management - Specialized repository types for common patterns Base Repository Types --------------------- .. list-table:: Repository Types :header-rows: 1 :widths: 30 70 * - Repository Class - Features * - ``SQLAlchemyAsyncRepository`` - Async session support, basic CRUD, filtering, and bulk operations. * - ``SQLAlchemyAsyncSlugRepository`` - All base features plus slug-based lookups. * - ``SQLAlchemyAsyncQueryRepository`` - Custom query execution and complex aggregations. * - ``SQLAlchemySyncRepository`` - Synchronous version of the base repository. * - ``SQLAlchemySyncSlugRepository`` - Synchronous version of the slug repository. * - ``SQLAlchemySyncQueryRepository`` - Synchronous version of the query repository. Basic Usage ----------- Let's implement a basic repository for a blog post model: .. code-block:: python from advanced_alchemy.base import BigIntAuditBase from advanced_alchemy.mixins import SlugKey from sqlalchemy.orm import Mapped, mapped_column class Post(BigIntAuditBase): __tablename__ = "post" title: Mapped[str] content: Mapped[str] published: Mapped[bool] = mapped_column(default=False) class Tag(BigIntAuditBase, SlugKey): __tablename__ = "tag" name: Mapped[str] .. code-block:: python from advanced_alchemy.repository import SQLAlchemyAsyncRepository from sqlalchemy.ext.asyncio import AsyncSession class PostRepository(SQLAlchemyAsyncRepository[Post]): """Repository for managing blog posts.""" model_type = Post async def create_post(db_session: AsyncSession, title: str, content: str) -> Post: repository = PostRepository(session=db_session) return await repository.add(Post(title=title, content=content), auto_commit=True) Bulk Operations --------------- Repositories support efficient bulk operations for adding, updating, and deleting multiple records. Add Many ~~~~~~~~ .. code-block:: python from collections.abc import Sequence from sqlalchemy.ext.asyncio import AsyncSession async def create_posts(db_session: AsyncSession, data: list[tuple[str, str]]) -> Sequence[Post]: repository = PostRepository(session=db_session) return await repository.add_many( [Post(title=title, content=content) for title, content in data], auto_commit=True, ) Update Many ~~~~~~~~~~~ .. code-block:: python from sqlalchemy.ext.asyncio import AsyncSession async def publish_posts(db_session: AsyncSession, post_ids: list[int]) -> list[Post]: repository = PostRepository(session=db_session) posts = await repository.list(Post.id.in_(post_ids), published=False) for post in posts: post.published = True return await repository.update_many(posts) Delete Many ~~~~~~~~~~~ .. code-block:: python from collections.abc import Sequence from sqlalchemy.ext.asyncio import AsyncSession async def delete_posts(db_session: AsyncSession, post_ids: list[int]) -> Sequence[Post]: repository = PostRepository(session=db_session) return await repository.delete_many(post_ids) Specialized Repositories ------------------------ Advanced Alchemy provides specialized repositories for common patterns. Slug Repository ~~~~~~~~~~~~~~~ For models using the ``SlugKey`` mixin, the ``SQLAlchemyAsyncSlugRepository`` adds a ``get_by_slug`` method: .. code-block:: python from advanced_alchemy.repository import SQLAlchemyAsyncSlugRepository class TagRepository(SQLAlchemyAsyncSlugRepository[Tag]): model_type = Tag async def get_tag_by_slug(db_session: AsyncSession, slug: str) -> Tag: repository = TagRepository(session=db_session) return await repository.get_by_slug(slug) Query Repository ~~~~~~~~~~~~~~~~ For complex custom queries or aggregations: .. code-block:: python from typing import Any from advanced_alchemy.repository import SQLAlchemyAsyncQueryRepository from sqlalchemy import select, func, Row async def get_posts_count_by_status(db_session: AsyncSession) -> list[Row[Any]]: repository = SQLAlchemyAsyncQueryRepository(session=db_session) return await repository.list( select(Post.published, func.count(Post.id)).group_by(Post.published) ) python-advanced-alchemy-1.9.3/docs/usage/repositories/filtering.rst000066400000000000000000000100321516556515500255420ustar00rootroot00000000000000======================== Filtering and Pagination ======================== Advanced Alchemy provides a powerful and flexible system for filtering and paginating your database queries. .. code-block:: python import datetime from typing import Optional from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Mapped, mapped_column from advanced_alchemy.base import BigIntAuditBase from advanced_alchemy.filters import CollectionFilter, LimitOffset, NotNullFilter, NullFilter, SearchFilter from advanced_alchemy.repository import SQLAlchemyAsyncRepository class FilteringPost(BigIntAuditBase): __tablename__ = "filtering_post" title: Mapped[str] content: Mapped[str] published: Mapped[bool] = mapped_column(default=False) published_at: Mapped[Optional[datetime.datetime]] = mapped_column(default=None) class FilteringPostRepository(SQLAlchemyAsyncRepository[FilteringPost]): model_type = FilteringPost Basic Filtering --------------- You can pass SQLAlchemy expressions directly to repository methods like ``list``, ``list_and_count``, and ``count``. .. code-block:: python async def get_recent_posts(db_session: AsyncSession) -> list[FilteringPost]: repository = FilteringPostRepository(session=db_session) return await repository.list( FilteringPost.published.is_(True), FilteringPost.created_at > (datetime.datetime.now(tz=datetime.timezone.utc) - datetime.timedelta(days=7)), ) Filter Constructs ----------------- Advanced Alchemy includes several pre-defined filter constructs located in ``advanced_alchemy.filters``. Collection Filter ~~~~~~~~~~~~~~~~~ Filters records where a column's value is (or is not) in a collection of values. .. code-block:: python async def get_posts_by_ids(db_session: AsyncSession, post_ids: list[int]) -> list[FilteringPost]: repository = FilteringPostRepository(session=db_session) return await repository.list(CollectionFilter(field_name="id", values=post_ids)) Search Filter ~~~~~~~~~~~~~ Provides basic string search capabilities. .. code-block:: python async def search_posts(db_session: AsyncSession, query: str) -> list[FilteringPost]: repository = FilteringPostRepository(session=db_session) return await repository.list(SearchFilter(field_name="title", value=query, ignore_case=True)) Null and Not Null Filters ~~~~~~~~~~~~~~~~~~~~~~~~~ .. versionadded:: 1.9.0 Filters records based on whether a column is ``NULL`` or ``NOT NULL``. .. code-block:: python async def get_unpublished_posts(db_session: AsyncSession) -> list[FilteringPost]: repository = FilteringPostRepository(session=db_session) return await repository.list(NullFilter(field_name="published_at")) async def get_published_posts(db_session: AsyncSession) -> list[FilteringPost]: repository = FilteringPostRepository(session=db_session) return await repository.list(NotNullFilter(field_name="published_at")) Pagination ---------- The ``LimitOffset`` filter is used for standard limit/offset pagination. The ``list_and_count`` method is particularly useful here as it returns both the page of results and the total record count. .. code-block:: python async def get_paginated_posts( db_session: AsyncSession, page: int = 1, page_size: int = 20, ) -> tuple[list[FilteringPost], int]: repository = FilteringPostRepository(session=db_session) offset = (page - 1) * page_size return await repository.list_and_count( LimitOffset(offset=offset, limit=page_size), ) Explicit Routing ---------------- All read and count operations support an optional ``bind_group`` parameter for explicit routing control when using read replicas. .. code-block:: python async def get_posts_from_analytics_replica(db_session: AsyncSession) -> list[FilteringPost]: repository = FilteringPostRepository(session=db_session) return await repository.list(bind_group="analytics") python-advanced-alchemy-1.9.3/docs/usage/repositories/index.rst000066400000000000000000000003241516556515500246710ustar00rootroot00000000000000============ Repositories ============ Advanced Alchemy's repository pattern provides a clean, consistent interface for database operations. .. toctree:: :maxdepth: 1 basics filtering advanced python-advanced-alchemy-1.9.3/docs/usage/routing.rst000066400000000000000000000342431516556515500225310ustar00rootroot00000000000000================== Read/Write Routing ================== Advanced Alchemy provides automatic routing of read operations to read replicas while directing write operations to the primary database. This enables better scalability by distributing read load across multiple replica databases. Why Use Read/Write Routing? ---------------------------- Read/write routing is essential for scaling read-heavy applications: - **Scalability**: Distribute read load across multiple replica databases - **Performance**: Reduce primary database load by offloading read queries - **High Availability**: Continue serving reads even if primary is under maintenance - **Cloud-Native**: Leverage managed database replicas (AWS Aurora, Google Cloud SQL, AlloyDB, etc.) Quick Start ----------- Basic configuration with a single replica: .. code-block:: python from advanced_alchemy.config import SQLAlchemyAsyncConfig from advanced_alchemy.config.routing import RoutingConfig config = SQLAlchemyAsyncConfig( routing_config=RoutingConfig( primary_connection_string="postgresql+asyncpg://user:pass@primary:5432/db", read_replicas=[ "postgresql+asyncpg://user:pass@replica1:5432/db", ], ), ) # Create session factory session_maker = config.create_session_maker() # Use with repository - reads automatically go to replica async with session_maker() as session: repo = UserRepository(session=session) users = await repo.list() # Routes to replica Configuration ------------- Routing Strategy ~~~~~~~~~~~~~~~~ Choose how replicas are selected for read operations: .. code-block:: python from advanced_alchemy.config.routing import RoutingConfig, RoutingStrategy # Round-robin (default) - distributes load evenly config = RoutingConfig( primary_connection_string="postgresql+asyncpg://...", read_replicas=["postgresql+asyncpg://replica1:5432/db", "..."], routing_strategy=RoutingStrategy.ROUND_ROBIN, ) # Random - randomly selects replica config = RoutingConfig( primary_connection_string="postgresql+asyncpg://...", read_replicas=["postgresql+asyncpg://replica1:5432/db", "..."], routing_strategy=RoutingStrategy.RANDOM, ) Multiple Replicas ~~~~~~~~~~~~~~~~~ Configure multiple replicas with custom weights: .. code-block:: python from advanced_alchemy.config.routing import RoutingConfig, ReplicaConfig config = RoutingConfig( primary_connection_string="postgresql+asyncpg://user:pass@primary:5432/db", read_replicas=[ ReplicaConfig( connection_string="postgresql+asyncpg://user:pass@replica1:5432/db", weight=2, # Gets 2x traffic name="replica-1-us-east", ), ReplicaConfig( connection_string="postgresql+asyncpg://user:pass@replica2:5432/db", weight=1, name="replica-2-us-west", ), ], routing_strategy=RoutingStrategy.ROUND_ROBIN, ) Sticky-After-Write ~~~~~~~~~~~~~~~~~~ By default, routing ensures **read-your-writes consistency**. After a write operation, all subsequent reads use the primary database until the transaction is committed: .. code-block:: python async with session_maker() as session: repo = UserRepository(session=session) # Read routes to replica users = await repo.list() # Write routes to primary new_user = await repo.add(User(name="Alice")) # Read now routes to primary (sticky-after-write) user = await repo.get(new_user.id) # Commit resets stickiness await session.commit() # Read can use replica again users = await repo.list() To disable sticky-after-write: .. code-block:: python config = RoutingConfig( primary_connection_string="postgresql+asyncpg://...", read_replicas=["..."], sticky_after_write=False, # Reads may not see recent writes ) Routing Rules ------------- The routing layer follows these rules: 1. **INSERT/UPDATE/DELETE** โ†’ Primary 2. **SELECT with FOR UPDATE** โ†’ Primary 3. **SELECT after write** (if sticky-after-write enabled) โ†’ Primary 4. **SELECT (no writes)** โ†’ Replica (round-robin/random) 5. **After commit** โ†’ Reset stickiness, replicas available again FOR UPDATE Detection ~~~~~~~~~~~~~~~~~~~~ Queries with ``FOR UPDATE`` are automatically routed to the primary: .. code-block:: python from sqlalchemy import select async with session_maker() as session: # Routes to primary (FOR UPDATE detected) stmt = select(User).where(User.id == user_id).with_for_update() result = await session.execute(stmt) user = result.scalar_one() Advanced Routing with Bind Groups --------------------------------- While the primary/read-replica pattern is common, you might need more complex routing scenarios, such as: - Dedicated analytics database - Region-specific replicas - Separate reporting databases - Multiple primary databases (sharding) You can achieve this by defining **Bind Groups** in your configuration. Configuration ~~~~~~~~~~~~~ Use the ``engines`` dictionary to define named groups of engines: .. code-block:: python from advanced_alchemy.config import SQLAlchemyAsyncConfig from advanced_alchemy.config.routing import RoutingConfig config = SQLAlchemyAsyncConfig( routing_config=RoutingConfig( # Define multiple engine groups engines={ "default": ["postgresql+asyncpg://primary:5432/db"], "read": ["postgresql+asyncpg://replica1:5432/db"], "analytics": ["postgresql+asyncpg://analytics:5432/db"], "reporting": [ "postgresql+asyncpg://report-1:5432/db", "postgresql+asyncpg://report-2:5432/db", ], }, default_group="default", read_group="read", ), ) Using Bind Groups ~~~~~~~~~~~~~~~~~ You can route operations to specific groups using context managers or explicit parameters. **Context Manager** Use ``use_bind_group`` to route all operations within a block to a specific group: .. code-block:: python from advanced_alchemy.routing import use_bind_group async with session_maker() as session: repo = UserRepository(session=session) # Route to analytics database with use_bind_group("analytics"): stats = await repo.count() # Route to reporting group (load balanced if multiple engines) with use_bind_group("reporting"): report = await repo.list() **Explicit Parameter** All repository methods accept a ``bind_group`` parameter: .. code-block:: python # Query directly from analytics group users = await repo.list(bind_group="analytics") # Count from reporting group count = await repo.count(bind_group="reporting") Context Managers ---------------- Use context managers for explicit control over routing: Primary Context ~~~~~~~~~~~~~~~ Force operations to use the default (primary) group. This is an alias for ``use_bind_group("default")``: .. code-block:: python from advanced_alchemy.routing import primary_context async with session_maker() as session: repo = UserRepository(session=session) # Force this read to use primary (e.g. for critical consistency) with primary_context(): critical_user = await repo.get(user_id) Replica Context ~~~~~~~~~~~~~~~ Force operations to use the read group. This is an alias for ``use_bind_group("read")``: .. code-block:: python from advanced_alchemy.routing import replica_context async with session_maker() as session: repo = UserRepository(session=session) # Force read from replica (even if sticky-primary is active) with replica_context(): users = await repo.list() Temporarily Disable Routing ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Disable routing to send all traffic to the default primary engine: .. code-block:: python config = RoutingConfig( engines={"default": ["..."], "read": ["..."]}, enabled=False, # All traffic to default group's first engine ) Framework Integration --------------------- Routing automatically integrates with all supported frameworks. Litestar ~~~~~~~~ .. code-block:: python from litestar import Litestar from advanced_alchemy.extensions.litestar import SQLAlchemyAsyncConfig, SQLAlchemyPlugin from advanced_alchemy.config.routing import RoutingConfig config = SQLAlchemyAsyncConfig( routing_config=RoutingConfig( primary_connection_string="postgresql+asyncpg://primary:5432/db", read_replicas=["postgresql+asyncpg://replica1:5432/db"], ), ) app = Litestar(plugins=[SQLAlchemyPlugin(config=config)]) Routing context is automatically reset per request. FastAPI ~~~~~~~ .. code-block:: python from typing import Annotated from fastapi import Depends, FastAPI from sqlalchemy.ext.asyncio import AsyncSession from advanced_alchemy.config.routing import RoutingConfig from advanced_alchemy.extensions.fastapi import AdvancedAlchemy, SQLAlchemyAsyncConfig config = SQLAlchemyAsyncConfig( routing_config=RoutingConfig( primary_connection_string="postgresql+asyncpg://primary:5432/db", read_replicas=["postgresql+asyncpg://replica1:5432/db"], ), ) app = FastAPI() alchemy = AdvancedAlchemy(config=config, app=app) @app.get("/users") async def list_users( session: Annotated[AsyncSession, Depends(alchemy.provide_session())], ): repo = UserRepository(session=session) return await repo.list() # Routes to replica Flask ~~~~~ .. code-block:: python from flask import Flask from advanced_alchemy.extensions.flask import SQLAlchemyExtension from advanced_alchemy.config.routing import RoutingConfig app = Flask(__name__) app.config["SQLALCHEMY_ROUTING_CONFIG"] = RoutingConfig( primary_connection_string="postgresql://primary:5432/db", read_replicas=["postgresql://replica1:5432/db"], ) db = SQLAlchemyExtension(app=app) Managed PostgreSQL Examples --------------------------- Managed PostgreSQL providers expose primary and read endpoints differently. Advanced Alchemy's routing config stays the same: put the writer endpoint in ``primary_connection_string`` and the replica or read-pool endpoints in ``read_replicas``. .. tab-set:: .. tab-item:: Google Cloud SQL for PostgreSQL Cloud SQL typically gives you one primary instance endpoint plus one endpoint per read replica. Use the primary instance IP or DNS name as the writer and list each replica explicitly. .. code-block:: python config = RoutingConfig( primary_connection_string="postgresql+asyncpg://user:pass@primary-instance-ip:5432/mydb", read_replicas=[ "postgresql+asyncpg://user:pass@replica-1-ip:5432/mydb", "postgresql+asyncpg://user:pass@replica-2-ip:5432/mydb", ], ) .. tab-item:: Google AlloyDB for PostgreSQL AlloyDB exposes a primary instance endpoint and one or more read-pool or read replica endpoints. Point writes at the primary instance and route reads to the read pool. .. code-block:: python config = RoutingConfig( primary_connection_string=( "postgresql+asyncpg://user:pass@my-primary.alloydb-xxx.gcp.cloudprovider.example:5432/mydb" ), read_replicas=[ "postgresql+asyncpg://user:pass@my-read-pool.alloydb-xxx.gcp.cloudprovider.example:5432/mydb", ], ) .. tab-item:: AWS Aurora PostgreSQL Aurora provides a cluster writer endpoint plus a reader endpoint that load-balances across replicas. .. code-block:: python config = RoutingConfig( primary_connection_string=( "postgresql+asyncpg://user:pass@mydb-cluster.cluster-xxx.us-east-1.rds.amazonaws.com:5432/mydb" ), read_replicas=[ "postgresql+asyncpg://user:pass@mydb-cluster.cluster-ro-xxx.us-east-1.rds.amazonaws.com:5432/mydb", ], ) Best Practices -------------- 1. **Use Sticky-After-Write**: Keep ``sticky_after_write=True`` (default) to avoid read-after-write inconsistency 2. **Monitor Replica Lag**: Ensure replicas stay close to primary (< 1 second lag) 3. **Test Failover**: Verify behavior when replicas are unavailable 4. **Use Context Managers**: Use ``primary_context()`` for critical reads that must be up-to-date 5. **Connection Pooling**: Configure appropriate pool sizes for primary and replicas 6. **Health Checks**: Implement health checks to detect unhealthy replicas (future feature) Troubleshooting --------------- Reads Not Using Replicas ~~~~~~~~~~~~~~~~~~~~~~~~~ Check if sticky-after-write is active: .. code-block:: python from advanced_alchemy.routing import stick_to_primary_var # Check current state if stick_to_primary_var.get(): print("Currently stuck to primary") Reset routing context manually: .. code-block:: python from advanced_alchemy.routing import reset_routing_context reset_routing_context() Stale Reads from Replicas ~~~~~~~~~~~~~~~~~~~~~~~~~~ If replicas have significant lag, use ``primary_context()`` for critical reads: .. code-block:: python from advanced_alchemy.routing import primary_context # Force primary for latest data with primary_context(): user = await repo.get(user_id) Temporarily Disable Routing ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ For debugging, disable routing to send all traffic to primary: .. code-block:: python config = RoutingConfig( primary_connection_string="postgresql+asyncpg://...", read_replicas=["..."], enabled=False, # All to primary ) See Also -------- - :doc:`/reference/routing` - API Reference - :doc:`/usage/repositories/index` - Repository Pattern - :doc:`/usage/services` - Service Layer - :doc:`/reference/config/asyncio` - Async Configuration python-advanced-alchemy-1.9.3/docs/usage/services.rst000066400000000000000000000277661516556515500227010ustar00rootroot00000000000000======== Services ======== Services in Advanced Alchemy build on repositories to provide higher-level business logic, data transformation, and schema validation. While repositories handle raw database operations, services coordinate application rules and schema conversion. Understanding Services ---------------------- Services provide: - Business logic abstraction - Data transformation using Pydantic, Msgspec, or attrs models - Input validation and type-safe schema conversion - Complex operations involving multiple repositories - Consistent error handling - Automatic schema validation and transformation - Support for SQLAlchemy query results (Row types) and RowMapping objects .. note:: The examples below define a minimal ``Post`` / ``Tag`` model inline so the service examples stay self-contained. Basic Service Usage ------------------- Let's build upon a blog example by creating services for posts: .. code-block:: python import datetime from typing import Hashable, Optional from pydantic import BaseModel, Field from sqlalchemy import Column, ForeignKey, Table from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.sql.elements import ColumnElement from advanced_alchemy.base import BigIntAuditBase, orm_registry from advanced_alchemy.mixins import SlugKey, UniqueMixin from advanced_alchemy.repository import SQLAlchemyAsyncRepository, SQLAlchemyAsyncSlugRepository from advanced_alchemy.service import ( SQLAlchemyAsyncRepositoryService, is_dict_with_field, is_dict_without_field, schema_dump, ) from advanced_alchemy.service.typing import ModelDictT from advanced_alchemy.utils.text import slugify blog_post_tag = Table( "blog_service_post_tag", orm_registry.metadata, Column("post_id", ForeignKey("blog_service_post.id", ondelete="CASCADE"), primary_key=True), Column("tag_id", ForeignKey("blog_service_tag.id", ondelete="CASCADE"), primary_key=True), ) class BlogPost(BigIntAuditBase): __tablename__ = "blog_service_post" title: Mapped[str] = mapped_column(index=True) content: Mapped[str] published: Mapped[bool] = mapped_column(default=False) tags: Mapped[list["BlogTag"]] = relationship( secondary=blog_post_tag, back_populates="posts", lazy="selectin", ) class BlogTag(BigIntAuditBase, SlugKey, UniqueMixin): __tablename__ = "blog_service_tag" name: Mapped[str] = mapped_column(unique=True, index=True) posts: Mapped[list["BlogPost"]] = relationship( secondary=blog_post_tag, back_populates="tags", viewonly=True, ) @classmethod def unique_hash(cls, name: str, slug: Optional[str] = None) -> Hashable: return slugify(name) @classmethod def unique_filter( cls, name: str, slug: Optional[str] = None, ) -> ColumnElement[bool]: return cls.slug == slugify(name) class BlogPostCreate(BaseModel): title: str content: str tags: list[str] = Field(default_factory=list) class BlogPostUpdate(BaseModel): title: Optional[str] = None content: Optional[str] = None published: Optional[bool] = None tags: Optional[list[str]] = None class BlogPostResponse(BaseModel): id: int title: str content: str published: bool created_at: datetime.datetime updated_at: datetime.datetime model_config = {"from_attributes": True} class BlogPostService(SQLAlchemyAsyncRepositoryService[BlogPost]): """Post service.""" class Repo(SQLAlchemyAsyncRepository[BlogPost]): model_type = BlogPost repository_type = Repo Service Operations ------------------ Services provide high-level methods for common operations: .. code-block:: python async def create_post(post_service: BlogPostService, data: BlogPostCreate) -> BlogPostResponse: post = await post_service.create(data=data, auto_commit=True) return post_service.to_schema(post, schema_type=BlogPostResponse) async def update_post( post_service: BlogPostService, post_id: int, data: BlogPostUpdate, ) -> BlogPostResponse: post = await post_service.update(data=data, item_id=post_id, auto_commit=True) return post_service.to_schema(post, schema_type=BlogPostResponse) .. versionadded:: 1.9.0 Advanced Alchemy's service layer automatically handles recursive model creation from nested dictionaries. When you pass a dictionary containing nested dictionaries that match a model's relationships, the service will instantiate the related models. .. code-block:: python from typing import Any async def create_user_with_profile(user_service: Any) -> Any: user_data = { "username": "cody", "email": "cody@litestar.dev", "profile": { "bio": "Software Engineer", "twitter": "@cofin", }, } return await user_service.create(data=user_data) Row Locking (FOR UPDATE) ************************ .. versionadded:: 1.9.0 Service retrieval methods like ``get`` support the ``with_for_update`` parameter, which is passed through to the underlying repository. .. code-block:: python from typing import Any async def get_user_for_update(user_service: Any, user_id: Any) -> Any: return await user_service.get(item_id=user_id, with_for_update=True) Composite Primary Keys ********************** Services fully support models with composite primary keys using the same formats as repositories. Pass primary key values as tuples or dictionaries when using ``get``, ``update``, or ``delete`` methods: .. code-block:: python from typing import Any, Sequence async def update_user_role_permissions(user_role_service: Any, user_id: int, role_id: int) -> Any: _current = await user_role_service.get((user_id, role_id)) return await user_role_service.update( data={"permissions": "admin"}, item_id={"user_id": user_id, "role_id": role_id}, ) async def delete_user_roles(user_role_service: Any) -> Sequence[Any]: return await user_role_service.delete_many([(1, 5), (1, 6), (2, 5)]) See :ref:`composite-primary-keys` in the Repositories documentation for more details on supported formats. Complex Operations ------------------ Services can handle complex business logic involving multiple models. The code below shows a service coordinating posts and tags. .. code-block:: python class TaggedBlogPostService(SQLAlchemyAsyncRepositoryService[BlogPost]): """Post service for handling post operations with tag management.""" class Repo(SQLAlchemyAsyncRepository[BlogPost]): model_type = BlogPost loader_options = [BlogPost.tags] repository_type = Repo match_fields = ["title"] async def to_model_on_create(self, data: "ModelDictT[BlogPost]") -> "ModelDictT[BlogPost]": data = schema_dump(data) tags_added = data.pop("tags", []) post = await super().to_model(data) if tags_added: post.tags.extend( [ await BlogTag.as_unique_async(self.repository.session, name=tag, slug=slugify(tag)) for tag in tags_added ], ) return post async def to_model_on_update(self, data: "ModelDictT[BlogPost]") -> "ModelDictT[BlogPost]": data = schema_dump(data) tags_updated = data.pop("tags", []) post = await super().to_model(data) if tags_updated is not None: existing_tags = [tag.name for tag in post.tags] tags_to_remove = [tag for tag in post.tags if tag.name not in tags_updated] tags_to_add = [tag for tag in tags_updated if tag not in existing_tags] for tag_to_remove in tags_to_remove: post.tags.remove(tag_to_remove) post.tags.extend( [ await BlogTag.as_unique_async(self.repository.session, name=tag, slug=slugify(tag)) for tag in tags_to_add ], ) return post Working with Slugs ------------------ Services can automatically generate URL-friendly slugs using the ``SQLAlchemyAsyncSlugRepository``. Here's an example service for managing tags with automatic slug generation: .. code-block:: python class BlogTagService(SQLAlchemyAsyncRepositoryService[BlogTag]): """Tag service with automatic slug generation.""" class Repo(SQLAlchemyAsyncSlugRepository[BlogTag]): model_type = BlogTag repository_type = Repo match_fields = ["name"] async def to_model_on_create(self, data: "ModelDictT[BlogTag]") -> "ModelDictT[BlogTag]": data = schema_dump(data) if is_dict_without_field(data, "slug") and is_dict_with_field(data, "name"): data["slug"] = await self.repository.get_available_slug(data["name"]) return data async def to_model_on_update(self, data: "ModelDictT[BlogTag]") -> "ModelDictT[BlogTag]": data = schema_dump(data) if is_dict_without_field(data, "slug") and is_dict_with_field(data, "name"): data["slug"] = await self.repository.get_available_slug(data["name"]) return data async def to_model_on_upsert(self, data: "ModelDictT[BlogTag]") -> "ModelDictT[BlogTag]": data = schema_dump(data) if is_dict_without_field(data, "slug") and is_dict_with_field(data, "name"): data["slug"] = await self.repository.get_available_slug(data["name"]) return data Schema Integration ------------------ Advanced Alchemy services support multiple schema libraries for data transformation and validation: Pydantic Models *************** .. code-block:: python class BlogPostSchema(BaseModel): id: int title: str content: str published: bool model_config = {"from_attributes": True} def to_pydantic_schema(post_service: BlogPostService, post_model: BlogPost) -> BlogPostSchema: return post_service.to_schema(post_model, schema_type=BlogPostSchema) Msgspec Structs *************** .. code-block:: python try: from msgspec import Struct except ModuleNotFoundError: # pragma: no cover - optional dependency in docs examples class Struct: # type: ignore[no-redef] pass class BlogPostStruct(Struct): id: int title: str content: str published: bool def to_msgspec_schema(post_service: BlogPostService, post_model: BlogPost) -> BlogPostStruct: return post_service.to_schema(post_model, schema_type=BlogPostStruct) Attrs Classes ************* .. code-block:: python try: from attrs import define except ModuleNotFoundError: # pragma: no cover - optional dependency in docs examples def define(cls): # type: ignore[misc] return cls @define class BlogPostAttrs: id: int title: str content: str published: bool def to_attrs_schema(post_service: BlogPostService, post_model: BlogPost) -> BlogPostAttrs: return post_service.to_schema(post_model, schema_type=BlogPostAttrs) .. note:: **Enhanced attrs Support with cattrs**: When both ``attrs`` and ``cattrs`` are installed, Advanced Alchemy automatically uses ``cattrs.structure()`` and ``cattrs.unstructure()`` for improved performance and type-aware serialization. This provides better handling of complex types, nested structures, and custom converters. Framework Integration --------------------- Services integrate seamlessly with both Litestar and FastAPI. - :doc:`frameworks/litestar` - :doc:`frameworks/fastapi` python-advanced-alchemy-1.9.3/examples/000077500000000000000000000000001516556515500200645ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/examples/__init__.py000066400000000000000000000000001516556515500221630ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/examples/fastapi/000077500000000000000000000000001516556515500215135ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/examples/fastapi/__init__.py000066400000000000000000000000001516556515500236120ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/examples/fastapi/fastapi_fileobject.py000066400000000000000000000122741516556515500257100ustar00rootroot00000000000000# /// script # dependencies = [ # "advanced_alchemy[obstore,uuid]", # "aiosqlite", # "fastapi[standard]", # "orjson" # "obstore" # ] # /// from typing import Annotated, Any, Optional, Union from uuid import UUID import uvicorn from fastapi import APIRouter, Depends, FastAPI, File, Form, UploadFile from pydantic import BaseModel, Field, computed_field from sqlalchemy.orm import Mapped, mapped_column from advanced_alchemy.extensions.fastapi import ( AdvancedAlchemy, AsyncSessionConfig, SQLAlchemyAsyncConfig, base, filters, repository, service, ) from advanced_alchemy.types import FileObject, storages from advanced_alchemy.types.file_object.backends.obstore import ObstoreBackend from advanced_alchemy.types.file_object.data_type import StoredObject alchemy_config = SQLAlchemyAsyncConfig( connection_string="sqlite+aiosqlite:///test.sqlite", session_config=AsyncSessionConfig(expire_on_commit=False), commit_mode="autocommit", create_all=True, ) app = FastAPI() alchemy = AdvancedAlchemy(config=alchemy_config, app=app) document_router = APIRouter() s3_backend = ObstoreBackend( key="local", fs="s3://static-files/", aws_endpoint="http://localhost:9000", aws_access_key_id="minioadmin", aws_secret_access_key="minioadmin", # noqa: S106 ) storages.register_backend(s3_backend) class DocumentModel(base.UUIDBase): # we can optionally provide the table name instead of auto-generating it __tablename__ = "document" name: Mapped[str] file: Mapped[FileObject] = mapped_column(StoredObject(backend="local")) class DocumentService(service.SQLAlchemyAsyncRepositoryService[DocumentModel]): """Document repository.""" class Repo(repository.SQLAlchemyAsyncRepository[DocumentModel]): """Document repository.""" model_type = DocumentModel repository_type = Repo # Pydantic Models class Document(BaseModel): id: Optional[UUID] name: str file: Optional[FileObject] = Field(default=None, exclude=True) @computed_field def file_url(self) -> Optional[Union[str, list[str]]]: if self.file is None: return None return self.file.sign() @document_router.get(path="/documents", response_model=service.OffsetPagination[Document]) async def list_documents( documents_service: Annotated[ DocumentService, Depends(alchemy.provide_service(DocumentService, load=[DocumentModel.file])) ], filters: Annotated[ list[filters.FilterTypes], Depends( alchemy.provide_filters( { "id_filter": UUID, "pagination_type": "limit_offset", "search": "name", "search_ignore_case": True, } ) ), ], ) -> service.OffsetPagination[Document]: results, total = await documents_service.list_and_count(*filters) return documents_service.to_schema(results, total, filters=filters, schema_type=Document) @document_router.post(path="/documents") async def create_document( documents_service: Annotated[DocumentService, Depends(alchemy.provide_service(DocumentService))], name: Annotated[str, Form()], file: Annotated[Optional[UploadFile], File()] = None, ) -> Document: obj = await documents_service.create( DocumentModel( name=name, file=FileObject( backend="local", filename=file.filename or "uploaded_file", content_type=file.content_type, content=await file.read(), ) if file else None, ) ) return documents_service.to_schema(obj, schema_type=Document) @document_router.get(path="/documents/{document_id}") async def get_document( documents_service: Annotated[DocumentService, Depends(alchemy.provide_service(DocumentService))], document_id: UUID, ) -> Document: obj = await documents_service.get(document_id) return documents_service.to_schema(obj, schema_type=Document) @document_router.patch(path="/documents/{document_id}") async def update_document( documents_service: Annotated[DocumentService, Depends(alchemy.provide_service(DocumentService))], document_id: UUID, name: Annotated[Optional[str], Form()] = None, file: Annotated[Optional[UploadFile], File()] = None, ) -> Document: update_data: dict[str, Any] = {} if name is not None: update_data["name"] = name if file is not None: update_data["file"] = FileObject( backend="local", filename=file.filename or "uploaded_file", content_type=file.content_type, content=await file.read(), ) obj = await documents_service.update(update_data, item_id=document_id) return documents_service.to_schema(obj, schema_type=Document) @document_router.delete(path="/documents/{document_id}") async def delete_document( documents_service: Annotated[DocumentService, Depends(alchemy.provide_service(DocumentService))], document_id: UUID, ) -> None: _ = await documents_service.delete(document_id) app.include_router(document_router) if __name__ == "__main__": uvicorn.run(app, host="0.0.0.0", port=8000) # noqa: S104 python-advanced-alchemy-1.9.3/examples/fastapi/fastapi_filters.py000066400000000000000000000051321516556515500252450ustar00rootroot00000000000000# /// script # dependencies = [ # "advanced_alchemy", # "fastapi[standard]", # "orjson" # ] # /// import datetime from typing import Annotated, Optional from uuid import UUID from fastapi import APIRouter, Depends, FastAPI from pydantic import BaseModel from sqlalchemy.orm import Mapped from advanced_alchemy.extensions.fastapi import ( AdvancedAlchemy, AsyncSessionConfig, SQLAlchemyAsyncConfig, base, filters, repository, service, ) alchemy_config = SQLAlchemyAsyncConfig( connection_string="sqlite+aiosqlite:///test.sqlite", session_config=AsyncSessionConfig(expire_on_commit=False), create_all=True, ) app = FastAPI() alchemy = AdvancedAlchemy(config=alchemy_config, app=app) author_router = APIRouter() class AuthorModel(base.UUIDBase): __tablename__ = "author" name: Mapped[str] dob: Mapped[Optional[datetime.date]] class Author(BaseModel): id: Optional[UUID] name: str dob: Optional[datetime.date] class AuthorService(service.SQLAlchemyAsyncRepositoryService[AuthorModel]): """Author repository.""" class Repo(repository.SQLAlchemyAsyncRepository[AuthorModel]): """Author repository.""" model_type = AuthorModel repository_type = Repo @author_router.get(path="/authors", response_model=service.OffsetPagination[Author]) async def list_authors( authors_service: Annotated[AuthorService, Depends(alchemy.provide_service(AuthorService))], filters: Annotated[ list[filters.FilterTypes], Depends( alchemy.provide_filters( { "id_filter": UUID, "pagination_type": "limit_offset", "search": "name", "search_ignore_case": True, } ) ), ], ) -> service.OffsetPagination[AuthorModel]: results, total = await authors_service.list_and_count(*filters) return authors_service.to_schema(results, total, filters=filters) app.include_router(author_router) if __name__ == "__main__": """Launches the FastAPI CLI with the database commands registered Run `uv run examples/fastapi/fastapi_service.py` to launch the FastAPI CLI with the database commands registered """ from fastapi_cli.cli import app as fastapi_cli_app # pyright: ignore[reportUnknownVariableType] from typer.main import get_group from advanced_alchemy.extensions.fastapi.cli import register_database_commands click_app = get_group(fastapi_cli_app) # pyright: ignore[reportUnknownArgumentType] click_app.add_command(register_database_commands(app)) click_app() python-advanced-alchemy-1.9.3/examples/fastapi/fastapi_service.py000066400000000000000000000110151516556515500252320ustar00rootroot00000000000000# /// script # dependencies = [ # "advanced_alchemy", # "aiosqlite", # "fastapi[standard]", # "orjson" # ] # /// import datetime from typing import Annotated, Optional from uuid import UUID from fastapi import APIRouter, Depends, FastAPI from pydantic import BaseModel from sqlalchemy.orm import Mapped from advanced_alchemy.extensions.fastapi import ( AdvancedAlchemy, AsyncSessionConfig, SQLAlchemyAsyncConfig, base, filters, repository, service, ) alchemy_config = SQLAlchemyAsyncConfig( connection_string="sqlite+aiosqlite:///test.sqlite", session_config=AsyncSessionConfig(expire_on_commit=False), commit_mode="autocommit", create_all=True, ) app = FastAPI() alchemy = AdvancedAlchemy(config=alchemy_config, app=app) author_router = APIRouter() # the SQLAlchemy base includes a declarative model for you to use in your models. # The `Base` class includes a `UUID` based primary key (`id`) class AuthorModel(base.UUIDBase): # we can optionally provide the table name instead of auto-generating it __tablename__ = "author" name: Mapped[str] dob: Mapped[Optional[datetime.date]] class AuthorService(service.SQLAlchemyAsyncRepositoryService[AuthorModel]): """Author repository.""" class Repo(repository.SQLAlchemyAsyncRepository[AuthorModel]): """Author repository.""" model_type = AuthorModel repository_type = Repo # Pydantic Models class Author(BaseModel): id: Optional[UUID] name: str dob: Optional[datetime.date] class AuthorCreate(BaseModel): name: str dob: Optional[datetime.date] class AuthorUpdate(BaseModel): name: Optional[str] dob: Optional[datetime.date] @author_router.get(path="/authors", response_model=service.OffsetPagination[Author]) async def list_authors( authors_service: Annotated[AuthorService, Depends(alchemy.provide_service(AuthorService))], filters: Annotated[ list[filters.FilterTypes], Depends( alchemy.provide_filters( { "id_filter": UUID, "pagination_type": "limit_offset", "search": "name", "search_ignore_case": True, "sort_field": "dob", "sort_order": "desc", } ) ), ], ) -> service.OffsetPagination[AuthorModel]: results, total = await authors_service.list_and_count(*filters) return authors_service.to_schema(results, total, filters=filters) @author_router.post(path="/authors") async def create_author( authors_service: Annotated[AuthorService, Depends(alchemy.provide_service(AuthorService))], data: AuthorCreate, ) -> Author: obj = await authors_service.create(data) # if you want to have the service return a pydantic model instead of the sqlalchemy model, # you can do so by passing the model to the schema_type argument of the to_schema method return authors_service.to_schema(obj, schema_type=Author) @author_router.get(path="/authors/{author_id}", response_model=Author) async def get_author( authors_service: Annotated[AuthorService, Depends(alchemy.provide_service(AuthorService))], author_id: UUID, ) -> AuthorModel: obj = await authors_service.get(author_id) return authors_service.to_schema(obj) @author_router.patch(path="/authors/{author_id}", response_model=Author) async def update_author( authors_service: Annotated[AuthorService, Depends(alchemy.provide_service(AuthorService))], data: AuthorUpdate, author_id: UUID, ) -> AuthorModel: obj = await authors_service.update(data, item_id=author_id) return authors_service.to_schema(obj) @author_router.delete(path="/authors/{author_id}") async def delete_author( authors_service: Annotated[AuthorService, Depends(alchemy.provide_service(AuthorService))], author_id: UUID ) -> None: _ = await authors_service.delete(author_id) app.include_router(author_router) if __name__ == "__main__": """Launches the FastAPI CLI with the database commands registered Run `uv run examples/fastapi/fastapi_service.py --help` to launch the FastAPI CLI with the database commands registered """ from fastapi_cli.cli import app as fastapi_cli_app # pyright: ignore[reportUnknownVariableType] from typer.main import get_group from advanced_alchemy.extensions.fastapi.cli import register_database_commands click_app = get_group(fastapi_cli_app) # pyright: ignore[reportUnknownArgumentType] click_app.add_command(register_database_commands(app)) click_app() python-advanced-alchemy-1.9.3/examples/fastapi/fastapi_service_full.py000066400000000000000000000150361516556515500262630ustar00rootroot00000000000000# /// script # dependencies = [ # "advanced_alchemy", # "aiosqlite", # "fastapi[standard]", # "orjson" # ] # /// import datetime from typing import Annotated, Optional from uuid import UUID from fastapi import APIRouter, Depends, FastAPI from pydantic import BaseModel from sqlalchemy import ForeignKey from sqlalchemy.orm import Mapped, mapped_column, relationship from advanced_alchemy.extensions.fastapi import ( AdvancedAlchemy, AsyncSessionConfig, SQLAlchemyAsyncConfig, base, filters, repository, service, ) from advanced_alchemy.service.typing import ModelDictT, is_dict, schema_dump alchemy_config = SQLAlchemyAsyncConfig( connection_string="sqlite+aiosqlite:///test.sqlite", session_config=AsyncSessionConfig(expire_on_commit=False), commit_mode="autocommit", create_all=True, ) app = FastAPI() alchemy = AdvancedAlchemy(config=alchemy_config, app=app) author_router = APIRouter() class BookModel(base.UUIDAuditBase): __tablename__ = "book" title: Mapped[str] author_id: Mapped[UUID] = mapped_column(ForeignKey("author.id", ondelete="CASCADE"), nullable=False) author: Mapped["AuthorModel"] = relationship(back_populates="books", lazy="joined", innerjoin=True, uselist=False) class AuthorModel(base.UUIDBase): # we can optionally provide the table name instead of auto-generating it __tablename__ = "author" name: Mapped[str] dob: Mapped[Optional[datetime.date]] books: Mapped[list[BookModel]] = relationship( back_populates="author", lazy="selectin", cascade="all, delete-orphan", uselist=True ) class AuthorService(service.SQLAlchemyAsyncRepositoryService[AuthorModel]): """Author Service.""" class Repo(repository.SQLAlchemyAsyncRepository[AuthorModel]): """Author repository.""" model_type = AuthorModel repository_type = Repo match_fields = ["name"] async def to_model_on_create(self, data: "ModelDictT[AuthorModel]") -> "ModelDictT[AuthorModel]": data = schema_dump(data) return await self._add_books(data) async def to_model_on_update(self, data: "ModelDictT[AuthorModel]") -> "ModelDictT[AuthorModel]": data = schema_dump(data) return await self._update_books(data) async def _add_books(self, data: "ModelDictT[AuthorModel]") -> "ModelDictT[AuthorModel]": if is_dict(data): books = data.pop("books", None) data = await super().to_model(data) if books is not None: data.books.extend([BookModel(title=book) for book in books]) return data async def _update_books(self, data: "ModelDictT[AuthorModel]") -> "ModelDictT[AuthorModel]": if is_dict(data): books: list[str] = data.pop("books", []) data = await super().to_model(data) if books: existing_books = [book.title for book in data.books] books_to_remove = [book for book in data.books if book.title not in books] books_to_add = [book for book in books if book not in existing_books] # First mark books for deletion for book_rm in books_to_remove: self.repository.session.delete(book_rm) data.books.remove(book_rm) # Finally add new books data.books.extend([BookModel(title=book) for book in books_to_add]) return data class AuthorBooks(BaseModel): id: UUID title: str class Author(BaseModel): id: Optional[UUID] name: str dob: Optional[datetime.date] books: list[AuthorBooks] class AuthorCreate(BaseModel): name: str dob: Optional[datetime.date] = None books: Optional[list[str]] = None class AuthorUpdate(BaseModel): name: Optional[str] = None dob: Optional[datetime.date] = None books: Optional[list[str]] = None @author_router.get(path="/authors", response_model=service.OffsetPagination[Author]) async def list_authors( authors_service: Annotated[ AuthorService, Depends(alchemy.provide_service(AuthorService, load=[AuthorModel.books])) ], filters: Annotated[ list[filters.FilterTypes], Depends( alchemy.provide_filters( { "id_filter": UUID, "pagination_type": "limit_offset", "search": "name", "search_ignore_case": True, } ) ), ], ) -> service.OffsetPagination[Author]: results, total = await authors_service.list_and_count(*filters) return authors_service.to_schema(results, total, filters=filters, schema_type=Author) @author_router.post(path="/authors", response_model=Author) async def create_author( authors_service: Annotated[AuthorService, Depends(alchemy.provide_service(AuthorService))], data: AuthorCreate, ) -> AuthorModel: obj = await authors_service.create(data) return authors_service.to_schema(obj) # we override the authors_repo to use the version that joins the Books in @author_router.get(path="/authors/{author_id}", response_model=Author) async def get_author( authors_service: Annotated[AuthorService, Depends(alchemy.provide_service(AuthorService))], author_id: UUID, ) -> AuthorModel: obj = await authors_service.get(author_id) return authors_service.to_schema(obj) @author_router.patch(path="/authors/{author_id}", response_model=Author) async def update_author( authors_service: Annotated[AuthorService, Depends(alchemy.provide_service(AuthorService))], data: AuthorUpdate, author_id: UUID, ) -> AuthorModel: obj = await authors_service.update(data, item_id=author_id) return authors_service.to_schema(obj) @author_router.delete(path="/authors/{author_id}") async def delete_author( authors_service: Annotated[AuthorService, Depends(alchemy.provide_service(AuthorService))], author_id: UUID ) -> None: _ = await authors_service.delete(author_id) app.include_router(author_router) if __name__ == "__main__": """Launches the FastAPI CLI with the database commands registered Run `uv run examples/fastapi/fastapi_service.py --help` to launch the FastAPI CLI with the database commands registered """ from fastapi_cli.cli import app as fastapi_cli_app # pyright: ignore[reportUnknownVariableType] from typer.main import get_group from advanced_alchemy.extensions.fastapi.cli import register_database_commands click_app = get_group(fastapi_cli_app) # pyright: ignore[reportUnknownArgumentType] click_app.add_command(register_database_commands(app)) click_app() python-advanced-alchemy-1.9.3/examples/flask/000077500000000000000000000000001516556515500211645ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/examples/flask/__init__.py000066400000000000000000000000001516556515500232630ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/examples/flask/flask_services.py000066400000000000000000000061441516556515500245460ustar00rootroot00000000000000from __future__ import annotations import datetime # noqa: TC003 import os from uuid import UUID # noqa: TC003 from flask import Flask, request from msgspec import Struct from sqlalchemy import ForeignKey from sqlalchemy.orm import Mapped, mapped_column, relationship from advanced_alchemy.extensions.flask import ( AdvancedAlchemy, FlaskServiceMixin, SQLAlchemySyncConfig, base, filters, repository, service, ) class Author(base.UUIDBase): """Author model.""" name: Mapped[str] dob: Mapped[datetime.date | None] books: Mapped[list[Book]] = relationship(back_populates="author", lazy="noload") class Book(base.UUIDAuditBase): """Book model.""" title: Mapped[str] author_id: Mapped[UUID] = mapped_column(ForeignKey("author.id")) author: Mapped[Author] = relationship(lazy="joined", innerjoin=True, viewonly=True) class AuthorService(service.SQLAlchemySyncRepositoryService[Author], FlaskServiceMixin): """Author service.""" class Repo(repository.SQLAlchemySyncRepository[Author]): """Author repository.""" model_type = Author repository_type = Repo class AuthorSchema(Struct): """Author schema.""" name: str id: UUID | None = None dob: datetime.date | None = None app = Flask(__name__) alchemy_config = SQLAlchemySyncConfig(connection_string="sqlite:///local.db", commit_mode="autocommit", create_all=True) alchemy = AdvancedAlchemy(alchemy_config, app) @app.route("/authors", methods=["GET"]) def list_authors(): """List authors with pagination.""" page, page_size = request.args.get("currentPage", 1, type=int), request.args.get("pageSize", 10, type=int) limit_offset = filters.LimitOffset(limit=page_size, offset=page_size * (page - 1)) service = AuthorService(session=alchemy.get_sync_session()) results, total = service.list_and_count(limit_offset) response = service.to_schema(results, total, filters=[limit_offset], schema_type=AuthorSchema) return service.jsonify(response) @app.route("/authors", methods=["POST"]) def create_author(): """Create a new author.""" service = AuthorService(session=alchemy.get_sync_session()) obj = service.create(**request.get_json()) return service.jsonify(obj) @app.route("/authors/", methods=["GET"]) def get_author(author_id: UUID): """Get an existing author.""" service = AuthorService(session=alchemy.get_sync_session(), load=[Author.books]) obj = service.get(author_id) return service.jsonify(obj) @app.route("/authors/", methods=["PATCH"]) def update_author(author_id: UUID): """Update an author.""" service = AuthorService(session=alchemy.get_sync_session(), load=[Author.books]) obj = service.update(**request.get_json(), item_id=author_id) return service.jsonify(obj) @app.route("/authors/", methods=["DELETE"]) def delete_author(author_id: UUID): """Delete an author.""" service = AuthorService(session=alchemy.get_sync_session()) service.delete(author_id) return "", 204 if __name__ == "__main__": app.run(debug=os.environ["ENV"] == "dev") python-advanced-alchemy-1.9.3/examples/litestar/000077500000000000000000000000001516556515500217135ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/examples/litestar/__init__.py000066400000000000000000000000001516556515500240120ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/examples/litestar/litestar_fileobject.py000066400000000000000000000125531516556515500263100ustar00rootroot00000000000000from typing import Annotated, Any, Optional, Union from uuid import UUID import uvicorn from litestar import Controller, Litestar, delete, get, patch, post from litestar.datastructures import UploadFile from litestar.enums import RequestEncodingType from litestar.params import Body, Dependency from pydantic import BaseModel, Field, computed_field from sqlalchemy.orm import Mapped, mapped_column from advanced_alchemy.extensions.litestar import ( AsyncSessionConfig, SQLAlchemyAsyncConfig, SQLAlchemyPlugin, base, filters, providers, repository, service, ) from advanced_alchemy.types import FileObject, storages from advanced_alchemy.types.file_object.backends.obstore import ObstoreBackend from advanced_alchemy.types.file_object.data_type import StoredObject # Object storage backend s3_backend = ObstoreBackend( key="local", fs="s3://static-files/", aws_endpoint="http://localhost:9000", aws_access_key_id="minioadmin", aws_secret_access_key="minioadmin", # noqa: S106 ) storages.register_backend(s3_backend) # SQLAlchemy Model class DocumentModel(base.UUIDBase): __tablename__ = "document" name: Mapped[str] file: Mapped[FileObject] = mapped_column(StoredObject(backend="local")) # Pydantic Schema class Document(BaseModel): id: Optional[UUID] name: str file: Optional[FileObject] = Field(default=None, exclude=True) @computed_field def file_url(self) -> Optional[Union[str, list[str]]]: if self.file is None: return None return self.file.sign() class CreateDocument(BaseModel): model_config = {"arbitrary_types_allowed": True} name: str file: Optional[UploadFile] = None class PatchDocument(BaseModel): model_config = {"arbitrary_types_allowed": True} name: Optional[str] = None file: Optional[UploadFile] = None # Advanced Alchemy Service class DocumentService(service.SQLAlchemyAsyncRepositoryService[DocumentModel]): """Document repository.""" class Repo(repository.SQLAlchemyAsyncRepository[DocumentModel]): """Document repository.""" model_type = DocumentModel repository_type = Repo # Litestar Controller class DocumentController(Controller): path = "/documents" dependencies = providers.create_service_dependencies( DocumentService, "documents_service", load=[DocumentModel.file], filters={"pagination_type": "limit_offset", "id_filter": UUID, "search": "name", "search_ignore_case": True}, ) @get(path="/", response_model=service.OffsetPagination[Document]) async def list_documents( self, documents_service: DocumentService, filters: Annotated[list[filters.FilterTypes], Dependency(skip_validation=True)], ) -> service.OffsetPagination[Document]: results, total = await documents_service.list_and_count(*filters) return documents_service.to_schema(results, total, filters=filters, schema_type=Document) @post(path="/") async def create_document( self, data: Annotated[CreateDocument, Body(media_type=RequestEncodingType.MULTI_PART)], documents_service: DocumentService, ) -> Document: obj = await documents_service.create( DocumentModel( name=data.name, file=FileObject( backend="local", filename=data.file.filename or "uploaded_file", content_type=data.file.content_type, content=await data.file.read(), ) if data.file else None, ) ) return documents_service.to_schema(obj, schema_type=Document) @get(path="/{document_id:uuid}") async def get_document( self, documents_service: DocumentService, document_id: UUID, ) -> Document: obj = await documents_service.get(document_id) return documents_service.to_schema(obj, schema_type=Document) @patch(path="/{document_id:uuid}") async def update_document( self, document_id: UUID, data: Annotated[PatchDocument, Body(media_type=RequestEncodingType.MULTI_PART)], documents_service: DocumentService, ) -> Document: update_data: dict[str, Any] = {} if data.name: update_data["name"] = data.name if data.file: update_data["file"] = FileObject( backend="local", filename=data.file.filename or "uploaded_file", content_type=data.file.content_type, content=await data.file.read(), ) obj = await documents_service.update(update_data, item_id=document_id) return documents_service.to_schema(obj, schema_type=Document) @delete(path="/{document_id:uuid}") async def delete_document( self, documents_service: DocumentService, document_id: UUID, ) -> None: _ = await documents_service.delete(document_id) alchemy_config = SQLAlchemyAsyncConfig( connection_string="sqlite+aiosqlite:///test.sqlite", session_config=AsyncSessionConfig(expire_on_commit=False), before_send_handler="autocommit", create_all=True, ) app = Litestar(route_handlers=[DocumentController], plugins=[SQLAlchemyPlugin(config=alchemy_config)]) if __name__ == "__main__": uvicorn.run(app, host="0.0.0.0", port=8000) # noqa: S104 python-advanced-alchemy-1.9.3/examples/litestar/litestar_repo_only.py000066400000000000000000000154121516556515500262050ustar00rootroot00000000000000from __future__ import annotations import datetime # noqa: TC003 from typing import TYPE_CHECKING, Optional from uuid import UUID # noqa: TC003 from litestar import Litestar from litestar.controller import Controller from litestar.di import Provide from litestar.handlers.http_handlers.decorators import delete, get, patch, post from litestar.pagination import OffsetPagination from litestar.params import Parameter from pydantic import BaseModel as _BaseModel from pydantic import TypeAdapter from sqlalchemy import ForeignKey from sqlalchemy.orm import Mapped, mapped_column, relationship from advanced_alchemy.base import UUIDAuditBase, UUIDBase from advanced_alchemy.config import AsyncSessionConfig from advanced_alchemy.extensions.litestar.plugins import SQLAlchemyAsyncConfig, SQLAlchemyPlugin from advanced_alchemy.filters import LimitOffset from advanced_alchemy.repository import SQLAlchemyAsyncRepository if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncSession class BaseModel(_BaseModel): """Extend Pydantic's BaseModel to enable ORM mode""" model_config = {"from_attributes": True} # the SQLAlchemy base includes a declarative model for you to use in your models. # The `Base` class includes a `UUID` based primary key (`id`) class AuthorModel(UUIDBase): # we can optionally provide the table name instead of auto-generating it __tablename__ = "author" name: Mapped[str] dob: Mapped[Optional[datetime.date]] # noqa: UP045 books: Mapped[list[BookModel]] = relationship(back_populates="author", lazy="noload") # The `AuditBase` class includes the same UUID` based primary key (`id`) and 2 # additional columns: `created` and `updated`. `created` is a timestamp of when the # record created, and `updated` is the last time the record was modified. class BookModel(UUIDAuditBase): __tablename__ = "book" title: Mapped[str] author_id: Mapped[UUID] = mapped_column(ForeignKey("author.id")) author: Mapped[AuthorModel] = relationship(lazy="joined", innerjoin=True, viewonly=True) # we will explicitly define the schema instead of using DTO objects for clarity. class Author(BaseModel): id: UUID | None name: str dob: datetime.date | None = None class AuthorCreate(BaseModel): name: str dob: datetime.date | None = None class AuthorUpdate(BaseModel): name: str | None = None dob: datetime.date | None = None class AuthorRepository(SQLAlchemyAsyncRepository[AuthorModel]): """Author repository.""" model_type = AuthorModel async def provide_authors_repo(db_session: AsyncSession) -> AuthorRepository: """This provides the default Authors repository.""" return AuthorRepository(session=db_session) # we can optionally override the default `select` used for the repository to pass in # specific SQL options such as join details async def provide_author_details_repo(db_session: AsyncSession) -> AuthorRepository: """This provides a simple example demonstrating how to override the join options for the repository.""" return AuthorRepository(load=[AuthorModel.books], session=db_session) def provide_limit_offset_pagination( current_page: int = Parameter(ge=1, query="currentPage", default=1, required=False), page_size: int = Parameter( query="pageSize", ge=1, default=10, required=False, ), ) -> LimitOffset: """Add offset/limit pagination. Return type consumed by `Repository.apply_limit_offset_pagination()`. Parameters ---------- current_page : int LIMIT to apply to select. page_size : int OFFSET to apply to select. """ return LimitOffset(page_size, page_size * (current_page - 1)) class AuthorController(Controller): """Author CRUD""" dependencies = {"authors_repo": Provide(provide_authors_repo)} @get(path="/authors") async def list_authors( self, authors_repo: AuthorRepository, limit_offset: LimitOffset, ) -> OffsetPagination[Author]: """List authors.""" results, total = await authors_repo.list_and_count(limit_offset) type_adapter = TypeAdapter(list[Author]) return OffsetPagination[Author]( items=type_adapter.validate_python(results), total=total, limit=limit_offset.limit, offset=limit_offset.offset, ) @post(path="/authors") async def create_author( self, authors_repo: AuthorRepository, data: AuthorCreate, ) -> Author: """Create a new author.""" obj = await authors_repo.add( AuthorModel(**data.model_dump(exclude_unset=True, exclude_none=True)), ) await authors_repo.session.commit() return Author.model_validate(obj) # we override the authors_repo to use the version that joins the Books in @get(path="/authors/{author_id:uuid}", dependencies={"authors_repo": Provide(provide_author_details_repo)}) async def get_author( self, authors_repo: AuthorRepository, author_id: UUID = Parameter( title="Author ID", description="The author to retrieve.", ), ) -> Author: """Get an existing author.""" obj = await authors_repo.get(author_id) return Author.model_validate(obj) @patch( path="/authors/{author_id:uuid}", dependencies={"authors_repo": Provide(provide_author_details_repo)}, ) async def update_author( self, authors_repo: AuthorRepository, data: AuthorUpdate, author_id: UUID = Parameter( title="Author ID", description="The author to update.", ), ) -> Author: """Update an author.""" raw_obj = data.model_dump(exclude_unset=True, exclude_none=True) raw_obj.update({"id": author_id}) obj = await authors_repo.update(AuthorModel(**raw_obj)) await authors_repo.session.commit() return Author.model_validate(obj) @delete(path="/authors/{author_id:uuid}") async def delete_author( self, authors_repo: AuthorRepository, author_id: UUID = Parameter( title="Author ID", description="The author to delete.", ), ) -> None: """Delete a author from the system.""" _ = await authors_repo.delete(author_id) await authors_repo.session.commit() session_config = AsyncSessionConfig(expire_on_commit=False) alchemy_config = SQLAlchemyAsyncConfig( connection_string="sqlite+aiosqlite:///test.sqlite", session_config=session_config, create_all=True, ) # Auto creates 'db_session' dependency. app = Litestar( route_handlers=[AuthorController], plugins=[SQLAlchemyPlugin(config=alchemy_config)], dependencies={"limit_offset": Provide(provide_limit_offset_pagination, sync_to_thread=False)}, ) python-advanced-alchemy-1.9.3/examples/litestar/litestar_service.py000066400000000000000000000113471516556515500256420ustar00rootroot00000000000000import datetime from typing import Annotated, Optional from uuid import UUID from litestar import Controller, Litestar, delete, get, patch, post from litestar.params import Dependency, Parameter from pydantic import BaseModel from sqlalchemy import ForeignKey from sqlalchemy.orm import Mapped, mapped_column, relationship from advanced_alchemy.extensions.litestar import ( AsyncSessionConfig, SQLAlchemyAsyncConfig, SQLAlchemyPlugin, base, filters, providers, repository, service, ) # The `AuditBase` class includes the same UUID` based primary key (`id`) and 2 # additional columns: `created` and `updated`. `created` is a timestamp of when the # record created, and `updated` is the last time the record was modified. class BookModel(base.UUIDAuditBase): __tablename__ = "book" title: Mapped[str] author_id: Mapped[UUID] = mapped_column(ForeignKey("author.id")) author: Mapped["AuthorModel"] = relationship(lazy="joined", innerjoin=True, viewonly=True) # the SQLAlchemy base includes a declarative model for you to use in your models. # The `Base` class includes a `UUID` based primary key (`id`) class AuthorModel(base.UUIDBase): # we can optionally provide the table name instead of auto-generating it __tablename__ = "author" name: Mapped[str] dob: Mapped[Optional[datetime.date]] books: Mapped[list[BookModel]] = relationship(back_populates="author", lazy="selectin") # we will explicitly define the schema instead of using DTO objects for clarity. class Author(BaseModel): id: Optional[UUID] = None name: str dob: Optional[datetime.date] = None class AuthorCreate(BaseModel): name: str dob: Optional[datetime.date] = None class AuthorUpdate(BaseModel): name: Optional[str] = None dob: Optional[datetime.date] = None class AuthorService(service.SQLAlchemyAsyncRepositoryService[AuthorModel]): """Author repository.""" class Repo(repository.SQLAlchemyAsyncRepository[AuthorModel]): """Author repository.""" model_type = AuthorModel repository_type = Repo class AuthorController(Controller): """Author CRUD""" dependencies = providers.create_service_dependencies( AuthorService, "authors_service", load=[AuthorModel.books], filters={"pagination_type": "limit_offset", "id_filter": UUID, "search": "name", "search_ignore_case": True}, ) @get(path="/authors") async def list_authors( self, authors_service: AuthorService, filters: Annotated[list[filters.FilterTypes], Dependency(skip_validation=True)], ) -> service.OffsetPagination[Author]: """List authors.""" results, total = await authors_service.list_and_count(*filters) return authors_service.to_schema(results, total, filters=filters, schema_type=Author) @post(path="/authors") async def create_author(self, authors_service: AuthorService, data: AuthorCreate) -> Author: """Create a new author.""" obj = await authors_service.create(data) return authors_service.to_schema(obj, schema_type=Author) # we override the authors_repo to use the version that joins the Books in @get(path="/authors/{author_id:uuid}") async def get_author( self, authors_service: AuthorService, author_id: UUID = Parameter( title="Author ID", description="The author to retrieve.", ), ) -> Author: """Get an existing author.""" obj = await authors_service.get(author_id) return authors_service.to_schema(obj, schema_type=Author) @patch(path="/authors/{author_id:uuid}") async def update_author( self, authors_service: AuthorService, data: AuthorUpdate, author_id: UUID = Parameter( title="Author ID", description="The author to update.", ), ) -> Author: """Update an author.""" obj = await authors_service.update(data, item_id=author_id, auto_commit=True) return authors_service.to_schema(obj, schema_type=Author) @delete(path="/authors/{author_id:uuid}") async def delete_author( self, authors_service: AuthorService, author_id: UUID = Parameter( title="Author ID", description="The author to delete.", ), ) -> None: """Delete a author from the system.""" _ = await authors_service.delete(author_id) alchemy_config = SQLAlchemyAsyncConfig( connection_string="sqlite+aiosqlite:///test.sqlite", before_send_handler="autocommit", session_config=AsyncSessionConfig(expire_on_commit=False), create_all=True, ) app = Litestar( route_handlers=[AuthorController], plugins=[SQLAlchemyPlugin(config=alchemy_config)], ) python-advanced-alchemy-1.9.3/examples/sanic.py000066400000000000000000000062011516556515500215320ustar00rootroot00000000000000from __future__ import annotations import datetime # noqa: TC003 from uuid import UUID # noqa: TC003 from sanic import Sanic from sqlalchemy import ForeignKey from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Mapped, mapped_column, relationship from advanced_alchemy.extensions.sanic import ( AdvancedAlchemy, AsyncSessionConfig, SQLAlchemyAsyncConfig, base, filters, repository, service, ) # the SQLAlchemy base includes a declarative model for you to use in your models. # The `Base` class includes a `UUID` based primary key (`id`) class AuthorModel(base.UUIDBase): # we can optionally provide the table name instead of auto-generating it __tablename__ = "author" name: Mapped[str] dob: Mapped[datetime.date | None] books: Mapped[list[BookModel]] = relationship(back_populates="author", lazy="noload") # The `AuditBase` class includes the same UUID` based primary key (`id`) and 2 # additional columns: `created` and `updated`. `created` is a timestamp of when the # record created, and `updated` is the last time the record was modified. class BookModel(base.UUIDAuditBase): __tablename__ = "book" title: Mapped[str] author_id: Mapped[UUID] = mapped_column(ForeignKey("author.id")) author: Mapped[AuthorModel] = relationship(lazy="joined", innerjoin=True, viewonly=True) class AuthorService(service.SQLAlchemyAsyncRepositoryService[AuthorModel]): """Author service.""" class Repo(repository.SQLAlchemyAsyncRepository[AuthorModel]): """Author repository.""" model_type = AuthorModel repository_type = Repo # ####################### # Dependencies # ####################### async def provide_authors_service(db_session: AsyncSession) -> AuthorService: """This provides the default Authors repository.""" return AuthorService(session=db_session) # we can optionally override the default `select` used for the repository to pass in # specific SQL options such as join details async def provide_author_details_service( db_session: AsyncSession, ) -> AuthorService: """This provides a simple example demonstrating how to override the join options for the repository.""" return AuthorService(load=[AuthorModel.books], session=db_session) def provide_limit_offset_pagination( current_page: int = 1, page_size: int = 10, ) -> filters.LimitOffset: """Add offset/limit pagination. Return type consumed by `Repository.apply_limit_offset_pagination()`. Parameters ---------- current_page : int LIMIT to apply to select. page_size : int OFFSET to apply to select. """ return filters.LimitOffset(page_size, page_size * (current_page - 1)) # ####################### # Application # ####################### session_config = AsyncSessionConfig(expire_on_commit=False) alchemy_config = SQLAlchemyAsyncConfig( connection_string="sqlite+aiosqlite:///test.sqlite", session_config=session_config, ) # Create 'db_session' dependency. app = Sanic("AlchemySanicApp") alchemy = AdvancedAlchemy(sqlalchemy_config=alchemy_config) alchemy.register(app) alchemy.add_session_dependency(AsyncSession) python-advanced-alchemy-1.9.3/examples/standalone.py000066400000000000000000000043611516556515500225720ustar00rootroot00000000000000from __future__ import annotations import pprint from pathlib import Path from typing import TYPE_CHECKING from sqlalchemy import create_engine from advanced_alchemy.base import UUIDBase from advanced_alchemy.config import SQLAlchemySyncConfig, SyncSessionConfig from advanced_alchemy.filters import LimitOffset from advanced_alchemy.repository import SQLAlchemySyncRepository from advanced_alchemy.utils.fixtures import open_fixture if TYPE_CHECKING: from sqlalchemy.orm import Mapped here = Path(__file__).parent alchemy_config = SQLAlchemySyncConfig( engine_instance=create_engine("duckdb:///:memory:"), session_config=SyncSessionConfig(expire_on_commit=False) ) class USState(UUIDBase): # you can optionally override the generated table name by manually setting it. __tablename__ = "us_state_lookup" abbreviation: Mapped[str] name: Mapped[str] class USStateRepository(SQLAlchemySyncRepository[USState]): """US State repository.""" model_type = USState def run_script() -> None: """Load data from a fixture.""" # Initializes the database. with alchemy_config.get_engine().begin() as conn: USState.metadata.create_all(conn) with alchemy_config.get_session() as db_session: # 1) Load the JSON data into the US States table. repo = USStateRepository(session=db_session) fixture = open_fixture(here, USStateRepository.model_type.__tablename__) objs = repo.add_many([USStateRepository.model_type(**raw_obj) for raw_obj in fixture]) db_session.commit() pprint.pp(f"Created {len(objs)} new objects.") # 2) Select paginated data and total row count. created_objs, total_objs = repo.list_and_count(LimitOffset(limit=10, offset=0)) pprint.pp(f"Selected {len(created_objs)} records out of a total of {total_objs}.") # 3) Let's remove the batch of records selected. deleted_objs = repo.delete_many([new_obj.id for new_obj in created_objs]) pprint.pp(f"Removed {len(deleted_objs)} records out of a total of {total_objs}.") # 4) Let's count the remaining rows remaining_count = repo.count() pprint.pp(f"Found {remaining_count} remaining records after delete.") if __name__ == "__main__": run_script() python-advanced-alchemy-1.9.3/examples/standalone_json.py000066400000000000000000000044331516556515500236230ustar00rootroot00000000000000# ruff: noqa: PLR2004, S101 from __future__ import annotations import asyncio from typing import TYPE_CHECKING, Any from sqlalchemy.ext.asyncio import create_async_engine from advanced_alchemy.base import UUIDBase from advanced_alchemy.config import AsyncSessionConfig, SQLAlchemyAsyncConfig from advanced_alchemy.repository import SQLAlchemyAsyncRepository if TYPE_CHECKING: from sqlalchemy.orm import Mapped class Item(UUIDBase): name: Mapped[str] # using ``Mapped[dict]`` with an AA provided base will map it to ``JSONB`` data: Mapped[dict[str, Any]] class ItemRepository(SQLAlchemyAsyncRepository[Item]): """Item repository.""" model_type = Item alchemy_config = SQLAlchemyAsyncConfig( engine_instance=create_async_engine("postgresql+psycopg://app:super-secret@localhost:5432/app"), session_config=AsyncSessionConfig(expire_on_commit=False), ) async def run_script() -> None: # Initializes the database. async with alchemy_config.get_engine().begin() as conn: await conn.run_sync(Item.metadata.create_all) async with alchemy_config.get_session() as db_session: repo = ItemRepository(session=db_session) # Add some data await repo.add_many( [ Item( name="Smartphone", data={"price": 599.99, "brand": "XYZ"}, ), Item( name="Laptop", data={"price": 1299.99, "brand": "ABC"}, ), Item( name="Headphones", data={"not_price": 149.99, "brand": "DEF"}, ), ], auto_commit=True, ) async with alchemy_config.get_session() as db_session: repo = ItemRepository(session=db_session) # Do some queries with JSON operations assert await repo.exists(Item.data["price"].as_float() == 599.99, Item.data["brand"].as_string() == "XYZ") assert await repo.count(Item.data.op("?")("price")) == 2 products, total_products = await repo.list_and_count(Item.data.op("?")("not_price")) assert len(products) == 1 assert total_products == 1 assert products[0].name == "Headphones" if __name__ == "__main__": asyncio.run(run_script()) python-advanced-alchemy-1.9.3/examples/us_state_lookup.json000066400000000000000000000061351516556515500242040ustar00rootroot00000000000000[ { "name": "Alabama", "abbreviation": "AL" }, { "name": "Alaska", "abbreviation": "AK" }, { "name": "Arizona", "abbreviation": "AZ" }, { "name": "Arkansas", "abbreviation": "AR" }, { "name": "California", "abbreviation": "CA" }, { "name": "Colorado", "abbreviation": "CO" }, { "name": "Connecticut", "abbreviation": "CT" }, { "name": "Delaware", "abbreviation": "DE" }, { "name": "District Of Columbia", "abbreviation": "DC" }, { "name": "Florida", "abbreviation": "FL" }, { "name": "Georgia", "abbreviation": "GA" }, { "name": "Guam", "abbreviation": "GU" }, { "name": "Hawaii", "abbreviation": "HI" }, { "name": "Idaho", "abbreviation": "ID" }, { "name": "Illinois", "abbreviation": "IL" }, { "name": "Indiana", "abbreviation": "IN" }, { "name": "Iowa", "abbreviation": "IA" }, { "name": "Kansas", "abbreviation": "KS" }, { "name": "Kentucky", "abbreviation": "KY" }, { "name": "Louisiana", "abbreviation": "LA" }, { "name": "Maine", "abbreviation": "ME" }, { "name": "Maryland", "abbreviation": "MD" }, { "name": "Massachusetts", "abbreviation": "MA" }, { "name": "Michigan", "abbreviation": "MI" }, { "name": "Minnesota", "abbreviation": "MN" }, { "name": "Mississippi", "abbreviation": "MS" }, { "name": "Missouri", "abbreviation": "MO" }, { "name": "Montana", "abbreviation": "MT" }, { "name": "Nebraska", "abbreviation": "NE" }, { "name": "Nevada", "abbreviation": "NV" }, { "name": "New Hampshire", "abbreviation": "NH" }, { "name": "New Jersey", "abbreviation": "NJ" }, { "name": "New Mexico", "abbreviation": "NM" }, { "name": "New York", "abbreviation": "NY" }, { "name": "North Carolina", "abbreviation": "NC" }, { "name": "North Dakota", "abbreviation": "ND" }, { "name": "Ohio", "abbreviation": "OH" }, { "name": "Oklahoma", "abbreviation": "OK" }, { "name": "Oregon", "abbreviation": "OR" }, { "name": "Palau", "abbreviation": "PW" }, { "name": "Pennsylvania", "abbreviation": "PA" }, { "name": "Puerto Rico", "abbreviation": "PR" }, { "name": "Rhode Island", "abbreviation": "RI" }, { "name": "South Carolina", "abbreviation": "SC" }, { "name": "South Dakota", "abbreviation": "SD" }, { "name": "Tennessee", "abbreviation": "TN" }, { "name": "Texas", "abbreviation": "TX" }, { "name": "Utah", "abbreviation": "UT" }, { "name": "Vermont", "abbreviation": "VT" }, { "name": "Virginia", "abbreviation": "VA" }, { "name": "Washington", "abbreviation": "WA" }, { "name": "West Virginia", "abbreviation": "WV" }, { "name": "Wisconsin", "abbreviation": "WI" }, { "name": "Wyoming", "abbreviation": "WY" } ] python-advanced-alchemy-1.9.3/pyproject.toml000066400000000000000000000504131516556515500211650ustar00rootroot00000000000000[project] authors = [ { name = "Cody Fincher", email = "cody.fincher@gmail.com" }, { name = "Peter Schutt", email = "peter.github@proton.me" }, { name = "Janek Nouvertnรฉ", email = "j.a.nouvertne@posteo.de" }, { name = "Jacob Coffee", email = "jacob@z7x.org" }, ] classifiers = [ "Development Status :: 3 - Alpha", "Environment :: Web Environment", "License :: OSI Approved :: MIT License", "Natural Language :: English", "Operating System :: OS Independent", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python", "Topic :: Software Development", "Typing :: Typed", "Intended Audience :: Developers", "Intended Audience :: System Administrators", "Topic :: Database", "Topic :: Database :: Database Engines/Servers", ] dependencies = [ "sqlalchemy>=2.0.20", "alembic>=1.12.0", "typing-extensions>=4.0.0", "greenlet", "eval-type-backport ; python_full_version < '3.10'", "exceptiongroup ; python_full_version < '3.11'", ] description = "Ready-to-go SQLAlchemy concoctions." keywords = ["sqlalchemy", "alembic", "litestar", "sanic", "fastapi", "flask"] license = { text = "MIT" } maintainers = [ { name = "Litestar Developers", email = "hello@litestar.dev" }, { name = "Cody Fincher", email = "cody@litestar.dev" }, { name = "Jacob Coffee", email = "jacob@litestar.dev" }, { name = "Janek Nouvertnรฉ", email = "janek@litestar.dev" }, { name = "Julien Courtes", email = "julien@litestar.dev" }, ] name = "advanced_alchemy" readme = "docs/PYPI_README.md" requires-python = ">=3.9" version = "1.9.3" [project.urls] Changelog = "https://advanced-alchemy.litestar.dev/latest/changelog" Discord = "https://discord.gg/litestar" Documentation = "https://advanced-alchemy.litestar.dev/latest/" Funding = "https://github.com/sponsors/litestar-org" Homepage = "https://advanced-alchemy.litestar.dev/latest/" Issue = "https://github.com/litestar-org/advanced-alchemy/issues/" Source = "https://github.com/litestar-org/advanced-alchemy" [project.optional-dependencies] argon2 = ["argon2-cffi"] cli = ["rich-click"] dogpile = ["dogpile.cache"] fsspec = ["fsspec"] nanoid = ["fastnanoid>=0.4.1"] obstore = ["obstore"] passlib = ["passlib[argon2]"] pwdlib = ["pwdlib[argon2]"] uuid = ["uuid-utils>=0.6.1"] [project.scripts] alchemy = "advanced_alchemy.__main__:run_cli" [dependency-groups] build = ["bump-my-version"] cockroachdb = [ "asyncpg>=0.29.0", "psycopg2-binary>=2.9.10", "psycopg[binary,pool]>=3.2.3", "sqlalchemy-cockroachdb>=2.0.2", ] dev = [ { include-group = "build" }, { include-group = "lint" }, { include-group = "doc" }, { include-group = "test" }, { include-group = "litestar" }, { include-group = "fastapi" }, { include-group = "flask" }, { include-group = "sanic" }, { include-group = "sqlite" }, { include-group = "oracle" }, { include-group = "duckdb" }, { include-group = "mssql" }, { include-group = "mysql" }, { include-group = "spanner" }, { include-group = "cockroachdb" }, { include-group = "postgres" }, ] doc = [ "accessible-pygments>=0.0.5", "auto-pytabs[sphinx]>=0.5.0", "shibuya", "sphinx>=7.0.0; python_version <= \"3.9\"", "sphinx>=8.0.0; python_version >= \"3.10\"", "sphinx-autobuild>=2021.3.14", "sphinx-copybutton>=0.5.2", "sphinx-click>=6.0.0", "sphinx-design>=0.5.0", "sphinxcontrib-mermaid>=0.9.2", "sphinx-paramlinks>=0.6.0", "sphinx-togglebutton>=0.3.2", "myst-parser", "sphinx-autodoc-typehints", "sybil", ] duckdb = ["duckdb>=1.1.2", "duckdb-engine>=0.13.4", "pytz>=2024.2"] fastapi = ["fastapi[all]>=0.115.3", "starlette"] flask = ["flask-sqlalchemy>=3.1.1", "flask[async]"] fsspec = ["fsspec>=2024.10.0"] lint = [ "mypy>=1.13.0", "pre-commit>=3.5.0", "pyright>=1.1.386", "ruff>=0.7.1", "slotscheck>=0.16.5", "asyncpg-stubs", "types-Pillow", "types-PyMySQL", "types-PyYAML", "types-Pygments", "types-aiofiles", "types-colorama", "types-docutils", "types-psycopg2", "types-python-dateutil", "types-pytz", "types-ujson", "types-cryptography", "types-passlib", ] litestar = ["litestar[cli]>=2.15.0"] mssql = ["aioodbc>=0.5.0", "pyodbc>=5.2.0"] mysql = ["asyncmy>=0.2.9"] oracle = ["oracledb>=2.4.1"] postgres = ["asyncpg>=0.29.0", "psycopg2-binary>=2.9.10", "psycopg[binary,pool]>=3.2.3"] sanic = ["sanic", "sanic-testing>=24.6.0", "sanic[ext]>=24.6.0"] spanner = ["sqlalchemy-spanner>=1.7.0"] sqlite = ["aiosqlite>=0.20.0"] test = [ "attrs", "cattrs", "sqlmodel", "dishka ; python_version >= \"3.10\"", "dogpile.cache", "pydantic-extra-types", "numpy", "pgvector", "rich-click", "coverage>=7.6.1", "fsspec[s3]", "pytest>=7.4.4", "pytest-asyncio>=0.23.8", "pytest-cov>=5.0.0", "pytest-databases[postgres,oracle,cockroachdb,mssql,bigquery,spanner,mysql,minio]", "pytest-lazy-fixtures>=1.1.1", "pytest-rerunfailures", "pytest-mock>=3.14.0", "pytest-sugar>=1.0.0", "pytest-xdist>=3.6.1", "pytest-click", "asgi-lifespan", "click", "time-machine>=2.15.0", ] [tool.bumpversion] allow_dirty = true commit = false commit_args = "--no-verify" current_version = "1.9.3" ignore_missing_files = false ignore_missing_version = false message = "chore(release): bump to v{new_version}" parse = """(?x) (?P\\d+)\\.(?P\\d+)\\.(?P\\d+) ((?P
a|b|rc)(?P\\d+))?
"""
regex = false
replace = "{new_version}"
search = "{current_version}"
serialize = ["{major}.{minor}.{patch}{pre}{pre_n}", "{major}.{minor}.{patch}"]
sign_tags = false
tag = false
tag_message = "chore(release): v{new_version}"
tag_name = "v{new_version}"

[tool.bumpversion.parts.pre]
first_value = "stable"
optional_value = "stable"
values = ["a", "b", "rc", "stable"]

[tool.bumpversion.parts.pre_n]
first_value = "1"

[[tool.bumpversion.files]]
filename = "pyproject.toml"
replace = 'version = "{new_version}"'
search = 'version = "{current_version}"'

[tool.uv]
# NOTE: mysql-connector-python 9.6.0 (released 2026-01-21) removed Python 3.12+ wheels.
# This is an Oracle packaging bug. Pinning to versions before that date until fixed.
# Check https://pypi.org/project/mysql-connector-python/#files for updates.
exclude-newer-package = { mysql-connector-python = "2026-01-20" }

[build-system]
build-backend = "hatchling.build"
requires = ["hatchling"]

[tool.hatch.build.targets.wheel]
packages = [
  "advanced_alchemy",
  "advanced_alchemy.extensions.litestar",
  "advanced_alchemy.extensions.sanic",
  "advanced_alchemy.extensions.starlette",
  "advanced_alchemy.extensions.fastapi",
  "advanced_alchemy.extensions.flask",
]

[tool.pytest.ini_options]
addopts = ["-q", "-ra"]
asyncio_default_fixture_loop_scope = "function"
asyncio_mode = "auto"
filterwarnings = [
  "ignore::DeprecationWarning:pkg_resources.*",
  "ignore:pkg_resources is deprecated as an API:DeprecationWarning",
  "ignore::DeprecationWarning:pkg_resources",
  "ignore::DeprecationWarning:google.rpc",
  "ignore::DeprecationWarning:google.gcloud",
  "ignore::DeprecationWarning:google.iam",
  "ignore::DeprecationWarning:google",
  "ignore:You are using a Python version \\(.*\\) which Google will stop supporting.*:FutureWarning:google.api_core._python_version_support",
  "ignore::DeprecationWarning:websockets.connection",
  "ignore::DeprecationWarning:websockets.legacy",
  "ignore:Accessing argon2.__version__ is deprecated:DeprecationWarning:passlib.handlers.argon2",
]
markers = [
  "integration: SQLAlchemy integration tests",
  "unit: Unit tests",
  "asyncmy: SQLAlchemy MySQL (asyncmy) Tests",
  "asyncpg: SQLAlchemy Postgres (asyncpg) Tests",
  "psycopg_async: SQLAlchemy Postgres (psycopg async) Tests",
  "psycopg_sync: SQLAlchemy Postgres (psycopg sync) Tests",
  "aiosqlite: SQLAlchemy SQLite (aiosqlite) Tests",
  "sqlite: SQLAlchemy SQLite (sqlite) Tests",
  "oracledb_sync: SQLAlchemy Oracle (oracledb sync) Tests",
  "oracledb_async: SQLAlchemy Oracle (oracledb async) Tests",
  "spanner: SQLAlchemy Google Cloud Spanner (sqlalchemy-spanner) Tests",
  "duckdb: SQLAlchemy DuckDB (duckdb-engine) Tests",
  "mssql_sync: SQLAlchemy Microsoft SQL Server (pyodbc) Tests",
  "mssql_async: SQLAlchemy Microsoft SQL Server (aioodbc) Tests",
  "mock_async: SQLAlchemy async mock Tests",
  "mock_sync: SQLAlchemy sync mock Tests",
  "cockroachdb_sync: SQLAlchemy CockroachDB (psycopg2) Tests",
  "cockroachdb_async: SQLAlchemy CockroachDB (asyncpg) Tests",
]
testpaths = ["tests", "docs"]

[tool.coverage.run]
branch = true
concurrency = ["multiprocessing"]
omit = [
  "*/tests/*",
  "advanced_alchemy/alembic/templates/asyncio/env.py",
  "advanced_alchemy/alembic/templates/sync/env.py",
  "advanced_alchemy/extensions/litestar/cli.py",
  "advanced_alchemy/alembic/commands.py",
  "advanced_alchemy/types.py",
  "advanced_alchemy/operations.py",
  "advanced_alchemy/service/*",
]
parallel = true
relative_files = true

[tool.coverage.report]
exclude_lines = [
  'pragma: no cover',
  'if TYPE_CHECKING:',
  'except ImportError as e:',
  'except ImportError:',
  '\.\.\.',
  'raise NotImplementedError',
  'if VERSION.startswith("1"):',
  'if pydantic.VERSION.startswith("1"):',
]

[tool.black]
line-length = 120

[tool.ruff]
exclude = [".venv", "node_modules"]
line-length = 120
src = ["advanced_alchemy", "tests", "docs", "tools"]
target-version = "py39"

[tool.ruff.format]
docstring-code-format = true
docstring-code-line-length = 60

[tool.ruff.lint]
extend-safe-fixes = ["TC"]
fixable = ["ALL"]
ignore = [
  "A003",    # flake8-builtins - class attribute {name} is shadowing a python builtin
  "A005",    # flake8-builtins - module {name} shadows a Python standard-library module
  "B010",    # flake8-bugbear - do not call setattr with a constant attribute value
  "D100",    # pydocstyle - missing docstring in public module
  "D101",    # pydocstyle - missing docstring in public class
  "D102",    # pydocstyle - missing docstring in public method
  "D103",    # pydocstyle - missing docstring in public function
  "D104",    # pydocstyle - missing docstring in public package
  "D105",    # pydocstyle - missing docstring in magic method
  "D106",    # pydocstyle - missing docstring in public nested class
  "D107",    # pydocstyle - missing docstring in __init__
  "D202",    # pydocstyle - no blank lines allowed after function docstring
  "D205",    # pydocstyle - 1 blank line required between summary line and description
  "D415",    # pydocstyle - first line should end with a period, question mark, or exclamation point
  "E501",    # pydocstyle line too long, handled by black
  "PLW2901", # pylint - for loop variable overwritten by assignment target
  "RUF012",  # Ruff-specific rule - annotated with classvar
  "ANN401",
  "FBT",
  "PLR0913", # too many arguments
  "PT",
  "TD",
  "ARG002",  # ignore for now; investigate
  "ARG003",  # ignore for now; investigate
  "PERF203", # ignore for now; investigate
  "PD011",   # pandas
  "PLR0912",
  "ISC001",
  "COM812",
  "CPY001",
  "PGH003",
  "FA100",
  "PLC0415", # import should be at the top of the file
  "PLR0904", # too many public methods
  "PLR0917",
  "PGH003",
  "PLC2701",
  "PLW3201",
]
select = ["ALL"]

[tool.ruff.lint.pydocstyle]
convention = "google"

[tool.ruff.lint.pyupgrade]
# Preserve types, even if a file imports `from __future__ import annotations`.
# keep-runtime-typing = true

[tool.ruff.lint.mccabe]
max-complexity = 14

[tool.ruff.lint.pep8-naming]
classmethod-decorators = [
  "sqlalchemy.ext.declarative.declared_attr",
  "sqlalchemy.orm.declared_attr.directive",
  "sqlalchemy.orm.declared_attr",
]

[tool.ruff.lint.per-file-ignores]
"advanced_alchemy/alembic/templates/*/env.py" = ["INP001"]
"advanced_alchemy/repository/*.py" = ['C901', 'UP006', 'UP035']
"advanced_alchemy/repository/memory/*.py" = ['UP006', 'UP035']
"advanced_alchemy/service/*.py" = ["PLR0911", "UP006", "UP035"]
"examples/flask.py" = ["ANN"]
"examples/flask/*.py" = ["ANN"]
"examples/litestar/*.py" = ["PLR6301", "DOC", "B008"]
"tests/**/*.*" = [
  "A",
  "ARG",
  "B",
  "BLE",
  "C901",
  "D",
  "DTZ",
  "EM",
  "FBT",
  "G",
  "N",
  "PGH",
  "PIE",
  "PLR",
  "PLW",
  "PTH",
  "RSE",
  "S",
  "S101",
  "SIM",
  "TC",
  "TRY",
  "SLF001",
  "DOC201",
  "ANN",
  "RUF029",
  "DOC",
  "UP007",
  "UP045",
  "ASYNC230",
]

[tool.ruff.lint.flake8-tidy-imports]
# Disallow all relative imports.
ban-relative-imports = "all"

[tool.ruff.lint.isort]
known-first-party = ["advanced_alchemy", "tests"]

[tool.slotscheck]
exclude-modules = '''
(
  (^|\.)test_
  |^tests\.*
  |^tools\.*
  |^docs\.*
  |^examples\.*
  |^sqlalchemy\.(
    testing
    |ext\.mypy  # see slotscheck/issues/178
  )
  |^alembic\.testing\.suite.*  # Add this line to exclude Alembic test suite
)
'''
include-modules = "advanced_alchemy.*"
require-superclass = false
strict-imports = true

[tool.mypy]
disallow_any_generics = false
disallow_untyped_decorators = true
exclude = ["docs/conftest.py"]
implicit_reexport = false
packages = ["advanced_alchemy", "tests", "docs", "examples"]
python_version = "3.9"
show_error_codes = true
strict = true
warn_redundant_casts = true
warn_return_any = true
warn_unreachable = true
warn_unused_configs = true
warn_unused_ignores = true

[[tool.mypy.overrides]]
disable_error_code = "attr-defined,type-var,union-attr"
disallow_untyped_decorators = false
module = "tests.*"
warn_unused_ignores = false

[[tool.mypy.overrides]]
ignore_missing_imports = true
module = [
  "asyncmy",
  "greenlet",
  "google.auth.*",
  "google.cloud.*",
  "pyarrow.*",
  "uuid_utils",
  "fsspec",
  "fsspec.*",
  "s3fs",
  "argon2",
  "argon2.*",
]

[[tool.mypy.overrides]]
follow_imports = "skip"
ignore_missing_imports = true
module = ["dishka", "dishka.*"]

[[tool.mypy.overrides]]
follow_imports = "skip"
ignore_missing_imports = true
module = ["pytest", "_pytest.*"]

[[tool.mypy.overrides]]
follow_imports = "skip"
ignore_missing_imports = true
module = ["sphinx.*"]

[[tool.mypy.overrides]]
module = "advanced_alchemy._serialization"
warn_unused_ignores = false

[[tool.mypy.overrides]]
disallow_untyped_decorators = false
module = "advanced_alchemy.extensions.litestar.cli"

[[tool.mypy.overrides]]
disallow_untyped_decorators = false
module = "advanced_alchemy.types.json"

[[tool.mypy.overrides]]
module = "advanced_alchemy.service.typing"
warn_unused_ignores = false

[[tool.mypy.overrides]]
module = "advanced_alchemy.alembic.templates.*.env"
warn_unreachable = false

[[tool.mypy.overrides]]
disable_error_code = "no-untyped-call"
disallow_untyped_decorators = false
module = "advanced_alchemy.extensions.sanic"
warn_unused_ignores = false

[[tool.mypy.overrides]]
module = "advanced_alchemy.base"
warn_unused_ignores = false

[[tool.mypy.overrides]]
disallow_untyped_decorators = false
module = "advanced_alchemy.extensions.litestar.cli"

[[tool.mypy.overrides]]
disable_error_code = "arg-type,no-any-return,no-untyped-def"
disallow_untyped_decorators = false
disallow_untyped_defs = false
module = "examples.flask.*"

[[tool.mypy.overrides]]
disable_error_code = "unreachable"
module = "tests.integration.test_repository"

[[tool.mypy.overrides]]
module = "tests.unit.test_exceptions"
warn_unreachable = false


[tool.pyright]
disableBytesTypePromotions = true
exclude = [
  "docs",
  "tests/unit/test_extensions",
  "tests/unit/test_repository.py",
  "tests/helpers.py",
  "tests/docker_service_fixtures.py",
  "examples/flask/flask_services.py",
]
include = ["advanced_alchemy"]
pythonVersion = "3.9"
reportMissingTypeStubs = false
reportPrivateImportUsage = false
reportUnknownMemberType = false
reportUnusedFunction = false
strict = ["advanced_alchemy/**/*"]
venv = ".venv"
venvPath = "."

[tool.unasyncd]
add_editors_note = true
cache = true
ruff_fix = true
ruff_format = true
update_docstrings = true

[tool.unasyncd.files]
"advanced_alchemy/repository/_async.py" = "advanced_alchemy/repository/_sync.py"
"advanced_alchemy/repository/memory/_async.py" = "advanced_alchemy/repository/memory/_sync.py"
"advanced_alchemy/service/_async.py" = "advanced_alchemy/service/_sync.py"

[tool.unasyncd.per_file_add_replacements."advanced_alchemy/repository/_async.py"]
SQLAlchemyAsyncMockRepository = "SQLAlchemySyncMockRepository"
"SQLAlchemyAsyncQueryRepository" = "SQLAlchemySyncQueryRepository"
SQLAlchemyAsyncRepository = "SQLAlchemySyncRepository"
SQLAlchemyAsyncRepositoryProtocol = "SQLAlchemySyncRepositoryProtocol"
"SQLAlchemyAsyncSlugRepository" = "SQLAlchemySyncSlugRepository"
SQLAlchemyAsyncSlugRepositoryProtocol = "SQLAlchemySyncSlugRepositoryProtocol"
"async_scoped_session" = "scoped_session"
"bump_model_version_async" = "bump_model_version_sync"
"get_entity_async" = "get_entity_sync"
"get_list_and_count_async" = "get_list_and_count_sync"
"get_list_async" = "get_list_sync"
"get_model_version_async" = "get_model_version_sync"
"invalidate_entity_async" = "invalidate_entity_sync"
"set_entity_async" = "set_entity_sync"
"set_list_and_count_async" = "set_list_and_count_sync"
"set_list_async" = "set_list_sync"
"singleflight_async" = "singleflight_sync"
"sqlalchemy.ext.asyncio.AsyncSession" = "sqlalchemy.orm.Session"
"sqlalchemy.ext.asyncio.scoping.async_scoped_session" = "sqlalchemy.orm.scoping.scoped_session"

[tool.unasyncd.per_file_add_replacements."advanced_alchemy/repository/memory/_async.py"]
SQLAlchemyAsyncMockRepository = "SQLAlchemySyncMockRepository"
"SQLAlchemyAsyncMockSlugRepository" = "SQLAlchemySyncMockSlugRepository"
SQLAlchemyAsyncRepository = "SQLAlchemySyncRepository"
SQLAlchemyAsyncRepositoryProtocol = "SQLAlchemySyncRepositoryProtocol"
"SQLAlchemyAsyncSlugRepository" = "SQLAlchemySyncSlugRepository"
SQLAlchemyAsyncSlugRepositoryProtocol = "SQLAlchemySyncSlugRepositoryProtocol"
"advanced_alchemy.repository._async.SQLAlchemyAsyncRepositoryProtocol" = "advanced_alchemy.repository._sync.SQLAlchemySyncRepositoryProtocol"
"advanced_alchemy.repository._async.SQLAlchemyAsyncSlugRepositoryProtocol" = "advanced_alchemy.repository._sync.SQLAlchemySyncSlugRepositoryProtocol"
"async_scoped_session" = "scoped_session"
"sqlalchemy.ext.asyncio.AsyncEngine" = "sqlalchemy.Engine"
"sqlalchemy.ext.asyncio.AsyncSession" = "sqlalchemy.orm.Session"
"sqlalchemy.ext.asyncio.scoping.async_scoped_session" = "sqlalchemy.orm.scoping.scoped_session"

[tool.unasyncd.per_file_add_replacements."advanced_alchemy/service/_async.py"]
"AsyncIterator" = "Iterator"
"SQLAlchemyAsyncConfigT" = "SQLAlchemySyncConfigT"
SQLAlchemyAsyncMockRepository = "SQLAlchemySyncMockRepository"
SQLAlchemyAsyncMockSlugRepository = "SQLAlchemySyncMockSlugRepository"
SQLAlchemyAsyncQueryService = "SQLAlchemySyncQueryService"
SQLAlchemyAsyncRepository = "SQLAlchemySyncRepository"
SQLAlchemyAsyncRepositoryReadService = "SQLAlchemySyncRepositoryReadService"
SQLAlchemyAsyncRepositoryService = "SQLAlchemySyncRepositoryService"
"SQLAlchemyAsyncRepositoryT" = "SQLAlchemySyncRepositoryT"
SQLAlchemyAsyncSlugRepository = "SQLAlchemySyncSlugRepository"
"advanced_alchemy.config.asyncio.SQLAlchemyAsyncConfig" = "advanced_alchemy.config.sync.SQLAlchemySyncConfig"
"advanced_alchemy.repository.SQLAlchemyAsyncQueryRepository" = "advanced_alchemy.repository.SQLAlchemySyncQueryRepository"
"advanced_alchemy.repository.SQLAlchemyAsyncRepository" = "advanced_alchemy.repository.SQLAlchemySyncRepository"
"advanced_alchemy.repository.SQLAlchemyAsyncRepositoryProtocol" = "advanced_alchemy.repository.SQLAlchemySyncRepositoryProtocol"
"advanced_alchemy.repository.SQLAlchemyAsyncSlugRepository" = "advanced_alchemy.repository.SQLAlchemySyncSlugRepository"
"advanced_alchemy.repository.SQLAlchemyAsyncSlugRepositoryProtocol" = "advanced_alchemy.repository.SQLAlchemySyncSlugRepositoryProtocol"
"advanced_alchemy.repository.memory.SQLAlchemyAsyncMockRepository" = "advanced_alchemy.repository.memory.SQLAlchemySyncMockRepository"
"advanced_alchemy.repository.memory.SQLAlchemyAsyncMockSlugRepository" = "advanced_alchemy.repository.memory.SQLAlchemySyncMockSlugRepository"
"advanced_alchemy.repository.typing.SQLAlchemyAsyncRepositoryT" = "advanced_alchemy.repository.typing.SQLAlchemySyncRepositoryT"
"async_scoped_session" = "scoped_session"
"collections.abc.AsyncIterator" = "collections.abc.Iterator"
"sqlalchemy.ext.asyncio.AsyncSession" = "sqlalchemy.orm.Session"
"sqlalchemy.ext.asyncio.scoping.async_scoped_session" = "sqlalchemy.orm.scoping.scoped_session"

[tool.codespell]
ignore-words-list = "selectin,fo,froms,notin,capfd"
skip = 'pdm.lock, uv.lock, examples/us_state_lookup.json, docs/_static/favicon.svg, docs/changelog.rst, advanced_alchemy/types/file_object/file.py, docs/_static/syntax-highlighting.css'
python-advanced-alchemy-1.9.3/sonar-project.properties000066400000000000000000000033531516556515500231560ustar00rootroot00000000000000sonar.organization=litestar-api
sonar.projectKey=litestar-org_advanced-alchemy
sonar.python.coverage.reportPaths=coverage.xml
sonar.python.version=3.9, 3.10, 3.11, 3.12, 3.13
sonar.sourceEncoding=UTF-8
sonar.sources=advanced_alchemy
sonar.tests=tests
sonar.coverage.exclusions=\
  examples/*.py, \
  tests/*.py, \
  tests/**/*.py, \
  advanced_alchemy/cli.py, \
  advanced_alchemy/operations.py, \
  advanced_alchemy/extensions/litestar/cli.py, \
  advanced_alchemy/filters.py, \
  advanced_alchemy/service/typing.py, \
  advanced_alchemy/service/_typing.py, \
  advanced_alchemy/service/_util.py, \
  advanced_alchemy/alembic/templates/asyncio/env.py, \
  advanced_alchemy/alembic/templates/sync/env.py, \
  tests/integration/conftest.py, \
  advanced_alchemy/service/_sync.py, \
  advanced_alchemy/service/_async.py, \
  advanced_alchemy/service/pagination.py, \
  advanced_alchemy/extensions/litestar/plugins/init/config/*.py, \
  advanced_alchemy/extensions/sanic/config.py, \
  advanced_alchemy/extensions/sanic/extension.py \
  advanced_alchemy/mixins/bigint.py
sonar.cpd.exclusions=\
  advanced_alchemy/repository/memory/_sync.py, \
  advanced_alchemy/repository/memory/_async.py, \
  advanced_alchemy/filters.py, \
  advanced_alchemy/repository/_sync.py, \
  advanced_alchemy/repository/_async.py, \
  advanced_alchemy/service/_sync.py, \
  advanced_alchemy/service/_async.py, \
  advanced_alchemy/alembic/templates/sync/env.py, \
  examples/*.py, \
  examples/fastapi.py, \
  tests/integration/conftest.py, \
  advanced_alchemy/extensions/litestar/plugins/init/config/*.py, \
  advanced_alchemy/extensions/sanic/config.py, \
  advanced_alchemy/extensions/sanic/extension.py \
  advanced_alchemy/extensions/fastapi/providers.py
sonar.projectName=Advanced Alchemy
python-advanced-alchemy-1.9.3/tests/000077500000000000000000000000001516556515500174105ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/tests/__init__.py000066400000000000000000000000001516556515500215070ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/tests/conftest.py000066400000000000000000000232741516556515500216170ustar00rootroot00000000000000import contextlib
import logging
from collections.abc import Generator
from typing import TYPE_CHECKING

import pytest
from google.cloud import spanner  # pyright: ignore
from pytest import MonkeyPatch
from pytest_databases.docker.cockroachdb import CockroachDBService
from pytest_databases.docker.mssql import MSSQLService
from pytest_databases.docker.mysql import MySQLService
from pytest_databases.docker.oracle import OracleService
from pytest_databases.docker.postgres import PostgresService
from pytest_databases.docker.spanner import SpannerService

if TYPE_CHECKING:
    pass

pytest_plugins = [
    "pytest_databases.docker",
    "pytest_databases.docker.minio",
    "pytest_databases.docker.mysql",
    "pytest_databases.docker.oracle",
    "pytest_databases.docker.postgres",
    "pytest_databases.docker.spanner",
    "pytest_databases.docker.cockroachdb",
    "pytest_databases.docker.mssql",
    "pytest_databases.docker.bigquery",
]


@pytest.fixture(autouse=True, scope="session")
def configure_logging() -> None:
    """Configure logging levels to suppress verbose database output."""
    # Suppress Spanner multiplexed session creation messages - try broader patterns
    logging.getLogger().setLevel(logging.WARNING)  # Set root logger to WARNING to suppress INFO messages

    # Specifically target known Spanner loggers
    for logger_name in [
        "projects.emulator-test-project.instances.emulator-test-instance.databases.emulator-test-database",
        "google.cloud.spanner_v1.session",
        "google.cloud.spanner_v1",
        "google.cloud.spanner",
        "google.cloud.spanner_dbapi",
        "google.cloud.spanner_dbapi.connection",
        "google.cloud.spanner_dbapi.cursor",
        # Try to catch the database sessions manager
        "database_sessions_manager",
    ]:
        logging.getLogger(logger_name).setLevel(logging.WARNING)


@pytest.fixture(scope="session")
def monkeypatch_session() -> Generator[MonkeyPatch, None, None]:
    mpatch = MonkeyPatch()
    yield mpatch
    mpatch.undo()


@pytest.fixture(scope="session")
def cockroachdb_psycopg_url(cockroachdb_service: CockroachDBService) -> str:
    """Use the running cockroachdb service to return a URL for connecting."""
    # Uses psycopg (sync) driver by default for CockroachDB in SQLAlchemy
    opts = (
        "&".join(f"{k}={v}" for k, v in cockroachdb_service.driver_opts.items())
        if cockroachdb_service.driver_opts
        else ""
    )
    dsn = "cockroachdb+psycopg://{user}@{host}:{port}/{database}?{opts}"
    return dsn.format(
        user="root",
        host=cockroachdb_service.host,
        database="defaultdb",
        port=cockroachdb_service.port,
        opts=opts,
    )


@pytest.fixture(scope="session")
def cockroachdb_asyncpg_url(cockroachdb_service: CockroachDBService) -> str:
    """Use the running cockroachdb service to return a URL for connecting."""
    # Uses asyncpg driver for CockroachDB async
    opts = (
        "&".join(f"{k}={v}" for k, v in cockroachdb_service.driver_opts.items())
        if cockroachdb_service.driver_opts
        else ""
    )
    dsn = "cockroachdb+asyncpg://{user}@{host}:{port}/{database}"
    return dsn.format(
        user="root",
        host=cockroachdb_service.host,
        database="defaultdb",
        port=cockroachdb_service.port,
        opts=opts,
    )


@pytest.fixture(scope="session")
def mssql_pyodbc_url(mssql_service: MSSQLService) -> str:
    """Use the running mssql service to return a URL for connecting using pyodbc."""
    # Uses pyodbc driver (sync)
    host = mssql_service.host
    port = mssql_service.port
    user = mssql_service.user
    password = mssql_service.password
    database = mssql_service.database  # Often 'master' by default if not specified
    # Note: Driver name might need adjustment based on environment
    driver = "ODBC Driver 18 for SQL Server".replace(" ", "+")  # or 17
    return f"mssql+pyodbc://{user}:{password}@{host}:{port}/{database}?driver={driver}&encrypt=no&TrustServerCertificate=yes"


@pytest.fixture(scope="session")
def mssql_aioodbc_url(mssql_service: MSSQLService) -> str:
    """Use the running mssql service to return a URL for connecting using aioodbc."""
    # Uses aioodbc driver (async)
    host = mssql_service.host
    port = mssql_service.port
    user = mssql_service.user
    password = mssql_service.password
    database = mssql_service.database  # Often 'master' by default if not specified
    driver = "ODBC Driver 18 for SQL Server".replace(" ", "+")  # or 17
    # Note: Ensure correct DSN format for aioodbc if different from pyodbc
    return f"mssql+aioodbc://{user}:{password}@{host}:{port}/{database}?driver={driver}&encrypt=no&TrustServerCertificate=yes"


@pytest.fixture(scope="session")
def mysql_asyncmy_url(mysql_service: MySQLService) -> str:
    """Use the running mysql service to return a URL for connecting using asyncmy."""
    # Uses asyncmy driver (async)
    dsn = "mysql+asyncmy://{user}:{password}@{host}:{port}/{database}"
    return dsn.format(
        user=mysql_service.user,
        password=mysql_service.password,
        host=mysql_service.host,
        port=mysql_service.port,
        database=mysql_service.db,
    )


@pytest.fixture(scope="session")
def oracle18c_url(oracle_18c_service: OracleService) -> str:
    """Use the running oracle service to return a URL for connecting using oracledb."""
    # Uses python-oracledb driver (supports sync and async)
    # Determine service name - adjust default 'FREEPDB1' if necessary for your setup

    dsn = "oracle+oracledb://{user}:{password}@{host}:{port}/?service_name={service_name}"
    return dsn.format(
        user=oracle_18c_service.user,
        password=oracle_18c_service.password,
        host=oracle_18c_service.host,
        port=oracle_18c_service.port,
        service_name=oracle_18c_service.service_name,
    )


@pytest.fixture(scope="session")
def oracle23ai_url(oracle_23ai_service: OracleService) -> str:
    """Use the running oracle service to return a URL for connecting using oracledb."""
    # Uses python-oracledb driver (supports sync and async)
    # Determine service name - adjust default 'FREEPDB1' if necessary for your setup

    dsn = "oracle+oracledb://{user}:{password}@{host}:{port}/?service_name={service_name}"
    return dsn.format(
        user=oracle_23ai_service.user,
        password=oracle_23ai_service.password,
        host=oracle_23ai_service.host,
        port=oracle_23ai_service.port,
        service_name=oracle_23ai_service.service_name,
    )


@pytest.fixture(scope="session")
def postgres14_asyncpg_url(postgres14_service: PostgresService) -> str:
    """Use the running postgres service to return a URL for connecting using asyncpg."""
    # Uses asyncpg driver (async)
    dsn = "postgresql+asyncpg://{user}:{password}@{host}:{port}/{database}"
    return dsn.format(
        user=postgres14_service.user,
        password=postgres14_service.password,
        host=postgres14_service.host,
        port=postgres14_service.port,
        database=postgres14_service.database,
    )


@pytest.fixture(scope="session")
def postgres_asyncpg_url(postgres_service: PostgresService) -> str:
    """Use the running postgres service to return a URL for connecting using asyncpg."""
    # Uses asyncpg driver (async)
    dsn = "postgresql+asyncpg://{user}:{password}@{host}:{port}/{database}"
    return dsn.format(
        user=postgres_service.user,
        password=postgres_service.password,
        host=postgres_service.host,
        port=postgres_service.port,
        database=postgres_service.database,
    )


@pytest.fixture(scope="session")
def postgres14_psycopg_url(postgres14_service: PostgresService) -> str:
    """Use the running postgres service to return a URL for connecting using psycopg."""
    # Uses psycopg driver (sync)
    dsn = "postgresql+psycopg://{user}:{password}@{host}:{port}/{database}"
    return dsn.format(
        user=postgres14_service.user,
        password=postgres14_service.password,
        host=postgres14_service.host,
        port=postgres14_service.port,
        database=postgres14_service.database,
    )


@pytest.fixture(scope="session")
def postgres_psycopg_url(postgres_service: PostgresService) -> str:
    """Use the running postgres service to return a URL for connecting using psycopg."""
    # Uses psycopg driver (sync)
    dsn = "postgresql+psycopg://{user}:{password}@{host}:{port}/{database}"
    return dsn.format(
        user=postgres_service.user,
        password=postgres_service.password,
        host=postgres_service.host,
        port=postgres_service.port,
        database=postgres_service.database,
    )


@pytest.fixture(scope="session")
def spanner_url(
    spanner_service: SpannerService, monkeypatch_session: MonkeyPatch, spanner_connection: spanner.Client
) -> str:
    """Use the running spanner service to return a URL for connecting using spanner."""
    monkeypatch_session.setenv("SPANNER_EMULATOR_HOST", f"{spanner_service.host}:{spanner_service.port}")
    monkeypatch_session.setenv("GOOGLE_CLOUD_PROJECT", spanner_service.project)
    instance = spanner_connection.instance(spanner_service.instance_name)  # pyright: ignore
    with contextlib.suppress(Exception):
        instance.create()

    database = instance.database(spanner_service.database_name)  # pyright: ignore
    with contextlib.suppress(Exception):
        database.create()

    with database.snapshot() as snapshot:  # pyright: ignore
        resp = next(iter(snapshot.execute_sql("SELECT 1")))  # pyright: ignore
    assert resp[0] == 1
    dsn = f"spanner+spanner:///projects/{spanner_service.project}/instances/{spanner_service.instance_name}/databases/{spanner_service.database_name}"
    return dsn.format(
        project=spanner_service.project, instance=spanner_service.instance_name, database=spanner_service.database_name
    )
python-advanced-alchemy-1.9.3/tests/fixtures/000077500000000000000000000000001516556515500212615ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/tests/fixtures/__init__.py000066400000000000000000000000001516556515500233600ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/tests/fixtures/bigint/000077500000000000000000000000001516556515500225355ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/tests/fixtures/bigint/__init__.py000066400000000000000000000000001516556515500246340ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/tests/fixtures/bigint/models.py000066400000000000000000000107671516556515500244050ustar00rootroot00000000000000"""Example domain objects for testing."""

import datetime

from sqlalchemy import Column, FetchedValue, ForeignKey, String, Table, func
from sqlalchemy.orm import Mapped, declared_attr, mapped_column, relationship
from sqlalchemy.orm.decl_base import _TableArgsType as TableArgsType  # pyright: ignore[reportPrivateUsage]

from advanced_alchemy.base import BigIntAuditBase, BigIntBase, merge_table_arguments
from advanced_alchemy.mixins import SlugKey
from advanced_alchemy.types import EncryptedString, EncryptedText, FileObject, FileObjectList, StoredObject
from advanced_alchemy.types.file_object import storages


class BigIntAuthor(BigIntAuditBase):
    """The Author domain object."""

    name: Mapped[str] = mapped_column(String(length=100))
    string_field: Mapped[str] = mapped_column(String(20), default="static value", nullable=True)
    dob: Mapped[datetime.date] = mapped_column(nullable=True)
    books: Mapped[list["BigIntBook"]] = relationship(
        lazy="selectin",
        back_populates="author",
        cascade="all, delete",
    )


class BigIntBook(BigIntBase):
    """The Book domain object."""

    title: Mapped[str] = mapped_column(String(length=250))  # pyright: ignore
    author_id: Mapped[int] = mapped_column(ForeignKey("big_int_author.id"))  # pyright: ignore
    author: Mapped[BigIntAuthor] = relationship(  # pyright: ignore
        lazy="joined",
        innerjoin=True,
        back_populates="books",
    )


class BigIntSlugBook(BigIntBase, SlugKey):
    """The Book domain object with a slug key."""

    title: Mapped[str] = mapped_column(String(length=250))  # pyright: ignore
    author_id: Mapped[str] = mapped_column(String(length=250))  # pyright: ignore

    @declared_attr.directive
    @classmethod
    def __table_args__(cls) -> TableArgsType:
        return merge_table_arguments(
            cls,
            table_args={"comment": "Slugbook"},
        )


class BigIntEventLog(BigIntAuditBase):
    """The event log domain object."""

    logged_at: Mapped[datetime.datetime] = mapped_column(default=datetime.datetime.now())  # pyright: ignore
    payload: Mapped[dict] = mapped_column(default=lambda: {})  # pyright: ignore


class BigIntModelWithFetchedValue(BigIntBase):
    """The ModelWithFetchedValue BigIntBase."""

    val: Mapped[int]  # pyright: ignore
    updated: Mapped[datetime.datetime] = mapped_column(  # pyright: ignore
        server_default=func.current_timestamp(),
        onupdate=func.current_timestamp(),
        server_onupdate=FetchedValue(),
    )


bigint_item_tag = Table(
    "bigint_item_tag",
    BigIntBase.metadata,
    Column("item_id", ForeignKey("big_int_item.id"), primary_key=True),  # pyright: ignore
    Column("tag_id", ForeignKey("big_int_tag.id"), primary_key=True),  # pyright: ignore
)


class BigIntItem(BigIntBase):
    name: Mapped[str] = mapped_column(String(length=50))  # pyright: ignore
    description: Mapped[str] = mapped_column(String(length=100), nullable=True)  # pyright: ignore
    tags: Mapped[list["BigIntTag"]] = relationship(secondary=lambda: bigint_item_tag, back_populates="items")


class BigIntTag(BigIntBase):
    """The event log domain object."""

    name: Mapped[str] = mapped_column(String(length=50))  # pyright: ignore
    items: Mapped[list[BigIntItem]] = relationship(secondary=lambda: bigint_item_tag, back_populates="tags")


class BigIntRule(BigIntAuditBase):
    """The rule domain object."""

    name: Mapped[str] = mapped_column(String(length=250))  # pyright: ignore
    config: Mapped[dict] = mapped_column(default=lambda: {})  # pyright: ignore


class BigIntSecret(BigIntBase):
    """The secret domain model."""

    secret: Mapped[str] = mapped_column(
        EncryptedString(key="super_secret"),
    )
    long_secret: Mapped[str] = mapped_column(
        EncryptedText(key="super_secret"),
    )
    length_validated_secret: Mapped[str] = mapped_column(
        EncryptedString(key="super_secret", length=10),
        nullable=True,
    )


class BigIntFileDocument(BigIntBase):
    """The file document domain model."""

    title: Mapped[str] = mapped_column(String(length=100))
    attachment: Mapped[FileObject] = mapped_column(
        StoredObject(backend="memory"),
        nullable=True,
    )
    required_file: Mapped[FileObject] = mapped_column(StoredObject(backend="memory"), nullable=True)
    required_files: Mapped[FileObjectList] = mapped_column(
        StoredObject(backend="memory", multiple=True),
        nullable=True,
    )


if not storages.is_registered("memory"):
    storages.register_backend("memory://", "memory")
python-advanced-alchemy-1.9.3/tests/fixtures/bigint/repositories.py000066400000000000000000000130471516556515500256430ustar00rootroot00000000000000"""Example domain objects for testing."""

from __future__ import annotations

from advanced_alchemy.repository import (
    SQLAlchemyAsyncRepository,
    SQLAlchemyAsyncSlugRepository,
    SQLAlchemySyncRepository,
    SQLAlchemySyncSlugRepository,
)
from advanced_alchemy.repository.memory import (
    SQLAlchemyAsyncMockRepository,
    SQLAlchemyAsyncMockSlugRepository,
    SQLAlchemySyncMockRepository,
    SQLAlchemySyncMockSlugRepository,
)
from tests.fixtures.bigint.models import (
    BigIntAuthor,
    BigIntBook,
    BigIntEventLog,
    BigIntFileDocument,
    BigIntItem,
    BigIntModelWithFetchedValue,
    BigIntRule,
    BigIntSecret,
    BigIntSlugBook,
    BigIntTag,
)


class RuleAsyncRepository(SQLAlchemyAsyncRepository[BigIntRule]):
    """Rule repository."""

    model_type = BigIntRule


class RuleAsyncMockRepository(SQLAlchemyAsyncMockRepository[BigIntRule]):
    """Rule repository."""

    model_type = BigIntRule


class RuleSyncMockRepository(SQLAlchemySyncMockRepository[BigIntRule]):
    """Rule repository."""

    model_type = BigIntRule


class AuthorAsyncRepository(SQLAlchemyAsyncRepository[BigIntAuthor]):
    """Author repository."""

    model_type = BigIntAuthor


class AuthorAsyncMockRepository(SQLAlchemyAsyncMockRepository[BigIntAuthor]):
    model_type = BigIntAuthor


class AuthorSyncMockRepository(SQLAlchemySyncMockRepository[BigIntAuthor]):
    model_type = BigIntAuthor


class BookAsyncRepository(SQLAlchemyAsyncRepository[BigIntBook]):
    """Book repository."""

    model_type = BigIntBook


class BookAsyncMockRepository(SQLAlchemyAsyncMockRepository[BigIntBook]):
    """Book repository."""

    model_type = BigIntBook


class BookSyncMockRepository(SQLAlchemySyncMockRepository[BigIntBook]):
    """Book repository."""

    model_type = BigIntBook


class EventLogAsyncRepository(SQLAlchemyAsyncRepository[BigIntEventLog]):
    """Event log repository."""

    model_type = BigIntEventLog


class ModelWithFetchedValueAsyncRepository(SQLAlchemyAsyncRepository[BigIntModelWithFetchedValue]):
    """BigIntModelWithFetchedValue repository."""

    model_type = BigIntModelWithFetchedValue


class SecretAsyncRepository(SQLAlchemyAsyncRepository[BigIntSecret]):
    """Secret repository."""

    model_type = BigIntSecret


class TagAsyncRepository(SQLAlchemyAsyncRepository[BigIntTag]):
    """Tag repository."""

    model_type = BigIntTag


class TagAsyncMockRepository(SQLAlchemyAsyncMockRepository[BigIntTag]):
    """Tag repository."""

    model_type = BigIntTag


class TagSyncMockRepository(SQLAlchemySyncMockRepository[BigIntTag]):
    """Tag repository."""

    model_type = BigIntTag


class SecretSyncRepository(SQLAlchemySyncRepository[BigIntSecret]):
    """Secret repository."""

    model_type = BigIntSecret


class ItemAsyncRepository(SQLAlchemyAsyncRepository[BigIntItem]):
    """Item repository."""

    model_type = BigIntItem


class ItemAsyncMockRepository(SQLAlchemyAsyncMockRepository[BigIntItem]):
    """Item repository."""

    model_type = BigIntItem


class ItemSyncMockRepository(SQLAlchemySyncMockRepository[BigIntItem]):
    """Item repository."""

    model_type = BigIntItem


class SecretAsyncMockRepository(SQLAlchemyAsyncMockRepository[BigIntSecret]):
    """Secret repository."""

    model_type = BigIntSecret


class SecretSyncMockRepository(SQLAlchemySyncMockRepository[BigIntSecret]):
    """Secret repository."""

    model_type = BigIntSecret


class AuthorSyncRepository(SQLAlchemySyncRepository[BigIntAuthor]):
    """Author repository."""

    model_type = BigIntAuthor


class BookSyncRepository(SQLAlchemySyncRepository[BigIntBook]):
    """Book repository."""

    model_type = BigIntBook


class EventLogSyncRepository(SQLAlchemySyncRepository[BigIntEventLog]):
    """Event log repository."""

    model_type = BigIntEventLog


class RuleSyncRepository(SQLAlchemySyncRepository[BigIntRule]):
    """Rule repository."""

    model_type = BigIntRule


class ModelWithFetchedValueSyncRepository(SQLAlchemySyncRepository[BigIntModelWithFetchedValue]):
    """ModelWithFetchedValue repository."""

    model_type = BigIntModelWithFetchedValue


class TagSyncRepository(SQLAlchemySyncRepository[BigIntTag]):
    """Tag repository."""

    model_type = BigIntTag


class ItemSyncRepository(SQLAlchemySyncRepository[BigIntItem]):
    """Item repository."""

    model_type = BigIntItem


class SlugBookAsyncRepository(SQLAlchemyAsyncSlugRepository[BigIntSlugBook]):
    """Slug Book repository."""

    _uniquify_results = True
    model_type = BigIntSlugBook


class SlugBookSyncRepository(SQLAlchemySyncSlugRepository[BigIntSlugBook]):
    """Slug Book repository."""

    _uniquify_results = True
    model_type = BigIntSlugBook


class SlugBookAsyncMockRepository(SQLAlchemyAsyncMockSlugRepository[BigIntSlugBook]):
    """Book repository."""

    model_type = BigIntSlugBook


class SlugBookSyncMockRepository(SQLAlchemySyncMockSlugRepository[BigIntSlugBook]):
    """Book repository."""

    model_type = BigIntSlugBook


class FileDocumentAsyncRepository(SQLAlchemyAsyncRepository[BigIntFileDocument]):
    """FileDocument repository."""

    model_type = BigIntFileDocument


class FileDocumentSyncRepository(SQLAlchemySyncRepository[BigIntFileDocument]):
    """FileDocument repository."""

    model_type = BigIntFileDocument


class FileDocumentAsyncMockRepository(SQLAlchemyAsyncMockRepository[BigIntFileDocument]):
    """FileDocument repository."""

    model_type = BigIntFileDocument


class FileDocumentSyncMockRepository(SQLAlchemySyncMockRepository[BigIntFileDocument]):
    """FileDocument repository."""

    model_type = BigIntFileDocument
python-advanced-alchemy-1.9.3/tests/fixtures/bigint/services.py000066400000000000000000000216221516556515500247350ustar00rootroot00000000000000"""Example domain objects for testing."""

from __future__ import annotations

from advanced_alchemy.service import (
    SQLAlchemyAsyncRepositoryService,
    SQLAlchemySyncRepositoryService,
)
from advanced_alchemy.service.typing import ModelDictT, is_dict_with_field, is_dict_without_field, schema_dump
from tests.fixtures.bigint.models import (
    BigIntAuthor,
    BigIntBook,
    BigIntEventLog,
    BigIntItem,
    BigIntModelWithFetchedValue,
    BigIntRule,
    BigIntSecret,
    BigIntSlugBook,
    BigIntTag,
)
from tests.fixtures.bigint.repositories import (
    AuthorAsyncMockRepository,
    AuthorAsyncRepository,
    AuthorSyncMockRepository,
    AuthorSyncRepository,
    BookAsyncMockRepository,
    BookAsyncRepository,
    BookSyncMockRepository,
    BookSyncRepository,
    EventLogAsyncRepository,
    EventLogSyncRepository,
    ItemAsyncMockRepository,
    ItemAsyncRepository,
    ItemSyncMockRepository,
    ItemSyncRepository,
    ModelWithFetchedValueAsyncRepository,
    ModelWithFetchedValueSyncRepository,
    RuleAsyncMockRepository,
    RuleAsyncRepository,
    RuleSyncMockRepository,
    RuleSyncRepository,
    SecretAsyncRepository,
    SecretSyncRepository,
    SlugBookAsyncMockRepository,
    SlugBookAsyncRepository,
    SlugBookSyncMockRepository,
    SlugBookSyncRepository,
    TagAsyncMockRepository,
    TagAsyncRepository,
    TagSyncMockRepository,
    TagSyncRepository,
)


# Services
class SecretAsyncService(SQLAlchemyAsyncRepositoryService[BigIntSecret, SecretAsyncRepository]):
    """Rule repository."""

    repository_type = SecretAsyncRepository


class RuleAsyncService(SQLAlchemyAsyncRepositoryService[BigIntRule, RuleAsyncRepository]):
    """Rule repository."""

    repository_type = RuleAsyncRepository


class RuleAsyncMockService(SQLAlchemyAsyncRepositoryService[BigIntRule, RuleAsyncMockRepository]):
    """Rule repository."""

    repository_type = RuleAsyncMockRepository


class RuleSyncMockService(SQLAlchemySyncRepositoryService[BigIntRule, RuleSyncMockRepository]):
    """Rule repository."""

    repository_type = RuleSyncMockRepository


class AuthorAsyncService(SQLAlchemyAsyncRepositoryService[BigIntAuthor, AuthorAsyncRepository]):
    """Author repository."""

    repository_type = AuthorAsyncRepository


class AuthorAsyncMockService(SQLAlchemyAsyncRepositoryService[BigIntAuthor, AuthorAsyncMockRepository]):
    """Author repository."""

    repository_type = AuthorAsyncMockRepository


class AuthorSyncMockService(SQLAlchemySyncRepositoryService[BigIntAuthor, AuthorSyncMockRepository]):
    """Author repository."""

    repository_type = AuthorSyncMockRepository


class BookAsyncService(SQLAlchemyAsyncRepositoryService[BigIntBook, BookAsyncRepository]):
    """Book repository."""

    repository_type = BookAsyncRepository


class BookAsyncMockService(SQLAlchemyAsyncRepositoryService[BigIntBook, BookAsyncMockRepository]):
    """Book repository."""

    repository_type = BookAsyncMockRepository


class BookSyncMockService(SQLAlchemySyncRepositoryService[BigIntBook, BookSyncMockRepository]):
    """Book repository."""

    repository_type = BookSyncMockRepository


class EventLogAsyncService(SQLAlchemyAsyncRepositoryService[BigIntEventLog, EventLogAsyncRepository]):
    """Event log repository."""

    repository_type = EventLogAsyncRepository


class ModelWithFetchedValueAsyncService(
    SQLAlchemyAsyncRepositoryService[BigIntModelWithFetchedValue, ModelWithFetchedValueAsyncRepository]
):
    """BigIntModelWithFetchedValue repository."""

    repository_type = ModelWithFetchedValueAsyncRepository


class TagAsyncService(SQLAlchemyAsyncRepositoryService[BigIntTag, TagAsyncRepository]):
    """Tag repository."""

    repository_type = TagAsyncRepository


class TagAsyncMockService(SQLAlchemyAsyncRepositoryService[BigIntTag, TagAsyncMockRepository]):
    """Tag repository."""

    repository_type = TagAsyncMockRepository


class TagSyncMockService(SQLAlchemySyncRepositoryService[BigIntTag, TagSyncMockRepository]):
    """Tag repository."""

    repository_type = TagSyncMockRepository


class ItemAsyncService(SQLAlchemyAsyncRepositoryService[BigIntItem, ItemAsyncRepository]):
    """Item repository."""

    repository_type = ItemAsyncRepository


class ItemAsyncMockService(SQLAlchemyAsyncRepositoryService[BigIntItem, ItemAsyncMockRepository]):
    """Item repository."""

    repository_type = ItemAsyncMockRepository


class ItemSyncMockService(SQLAlchemySyncRepositoryService[BigIntItem, ItemSyncMockRepository]):
    """Item repository."""

    repository_type = ItemSyncMockRepository


class RuleSyncService(SQLAlchemySyncRepositoryService[BigIntRule, RuleSyncRepository]):
    """Rule repository."""

    repository_type = RuleSyncRepository


class AuthorSyncService(SQLAlchemySyncRepositoryService[BigIntAuthor, AuthorSyncRepository]):
    """Author repository."""

    repository_type = AuthorSyncRepository


class BookSyncService(SQLAlchemySyncRepositoryService[BigIntBook, BookSyncRepository]):
    """Book repository."""

    repository_type = BookSyncRepository


class EventLogSyncService(SQLAlchemySyncRepositoryService[BigIntEventLog, EventLogSyncRepository]):
    """Event log repository."""

    repository_type = EventLogSyncRepository


class ModelWithFetchedValueSyncService(
    SQLAlchemySyncRepositoryService[BigIntModelWithFetchedValue, ModelWithFetchedValueSyncRepository]
):
    """BigIntModelWithFetchedValue repository."""

    repository_type = ModelWithFetchedValueSyncRepository


class SecretSyncService(SQLAlchemySyncRepositoryService[BigIntSecret, SecretSyncRepository]):
    """Rule repository."""

    repository_type = SecretSyncRepository


class TagSyncService(SQLAlchemySyncRepositoryService[BigIntTag, TagSyncRepository]):
    """Tag repository."""

    repository_type = TagSyncRepository


class ItemSyncService(SQLAlchemySyncRepositoryService[BigIntItem, ItemSyncRepository]):
    """Item repository."""

    repository_type = ItemSyncRepository


# Slug book


class SlugBookAsyncService(SQLAlchemyAsyncRepositoryService[BigIntSlugBook]):
    """Book repository."""

    repository_type = SlugBookAsyncRepository
    match_fields = ["title"]

    async def to_model(self, data: ModelDictT[BigIntSlugBook], operation: str | None = None) -> BigIntSlugBook:
        data = schema_dump(data)
        if is_dict_without_field(data, "slug") and operation == "create":
            data["slug"] = await self.repository.get_available_slug(data["title"])
        if is_dict_without_field(data, "slug") and is_dict_with_field(data, "title") and operation == "update":
            data["slug"] = await self.repository.get_available_slug(data["title"])
        return await super().to_model(data, operation)


class SlugBookSyncService(SQLAlchemySyncRepositoryService[BigIntSlugBook, SlugBookSyncRepository]):
    """Book repository."""

    repository_type = SlugBookSyncRepository
    match_fields = ["title"]

    def to_model(
        self,
        data: ModelDictT[BigIntSlugBook],
        operation: str | None = None,
    ) -> BigIntSlugBook:
        data = schema_dump(data)
        if is_dict_without_field(data, "slug") and operation == "create":
            data["slug"] = self.repository.get_available_slug(data["title"])
        if is_dict_without_field(data, "slug") and is_dict_with_field(data, "title") and operation == "update":
            data["slug"] = self.repository.get_available_slug(data["title"])
        return super().to_model(data, operation)


class SlugBookAsyncMockService(SQLAlchemyAsyncRepositoryService[BigIntSlugBook, SlugBookAsyncMockRepository]):
    """Book repository."""

    repository_type = SlugBookAsyncMockRepository
    match_fields = ["title"]

    async def to_model(
        self,
        data: ModelDictT[BigIntSlugBook],
        operation: str | None = None,
    ) -> BigIntSlugBook:
        data = schema_dump(data)
        if is_dict_without_field(data, "slug") and operation == "create":
            data["slug"] = await self.repository.get_available_slug(data["title"])
        if is_dict_without_field(data, "slug") and is_dict_with_field(data, "title") and operation == "update":
            data["slug"] = await self.repository.get_available_slug(data["title"])
        return await super().to_model(data, operation)


class SlugBookSyncMockService(SQLAlchemySyncRepositoryService[BigIntSlugBook, SlugBookSyncMockRepository]):
    """Book repository."""

    repository_type = SlugBookSyncMockRepository
    match_fields = ["title"]

    def to_model(
        self,
        data: ModelDictT[BigIntSlugBook],
        operation: str | None = None,
    ) -> BigIntSlugBook:
        data = schema_dump(data)
        if is_dict_without_field(data, "slug") and operation == "create":
            data["slug"] = self.repository.get_available_slug(data["title"])
        if is_dict_without_field(data, "slug") and is_dict_with_field(data, "title") and operation == "update":
            data["slug"] = self.repository.get_available_slug(data["title"])
        return super().to_model(data, operation)
python-advanced-alchemy-1.9.3/tests/fixtures/uuid/000077500000000000000000000000001516556515500222275ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/tests/fixtures/uuid/__init__.py000066400000000000000000000000001516556515500243260ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/tests/fixtures/uuid/models.py000066400000000000000000000106401516556515500240650ustar00rootroot00000000000000"""Example domain objects for testing."""

from __future__ import annotations

import datetime
from uuid import UUID

from sqlalchemy import Column, FetchedValue, ForeignKey, String, Table, func
from sqlalchemy.orm import Mapped, declared_attr, mapped_column, relationship

from advanced_alchemy.base import (
    TableArgsType,  # pyright: ignore[reportPrivateUsage]
    UUIDAuditBase,
    UUIDBase,
    UUIDv6Base,
    UUIDv7Base,
    merge_table_arguments,
)
from advanced_alchemy.mixins import SlugKey
from advanced_alchemy.types import EncryptedString, EncryptedText, FileObject, FileObjectList, StoredObject
from advanced_alchemy.types.file_object import storages


class UUIDAuthor(UUIDAuditBase):
    """The UUIDAuthor domain object."""

    name: Mapped[str] = mapped_column(String(length=100))  # pyright: ignore
    string_field: Mapped[str] = mapped_column(String(20), default="static value", nullable=True)  # pyright: ignore
    dob: Mapped[datetime.date] = mapped_column(nullable=True)  # pyright: ignore
    books: Mapped[list[UUIDBook]] = relationship(
        lazy="selectin",
        back_populates="author",
        cascade="all, delete",
    )


class UUIDBook(UUIDBase):
    """The Book domain object."""

    title: Mapped[str] = mapped_column(String(length=250))
    author_id: Mapped[UUID] = mapped_column(ForeignKey("uuid_author.id"))
    author: Mapped[UUIDAuthor] = relationship(lazy="joined", innerjoin=True, back_populates="books")


class UUIDSlugBook(UUIDBase, SlugKey):
    """The Book domain object with a slug key."""

    title: Mapped[str] = mapped_column(String(length=250))
    author_id: Mapped[str] = mapped_column(String(length=250))

    @declared_attr.directive
    @classmethod
    def __table_args__(cls) -> TableArgsType:
        return merge_table_arguments(
            cls,
            table_args={"comment": "Slugbook"},
        )


class UUIDEventLog(UUIDAuditBase):
    """The event log domain object."""

    logged_at: Mapped[datetime.datetime] = mapped_column(default=datetime.datetime.now())  # pyright: ignore
    payload: Mapped[dict] = mapped_column(default={})  # pyright: ignore


class UUIDSecret(UUIDv7Base):
    """The secret domain model."""

    secret: Mapped[str] = mapped_column(
        EncryptedString(key="super_secret"),
    )
    long_secret: Mapped[str] = mapped_column(
        EncryptedText(key="super_secret"),
    )
    length_validated_secret: Mapped[str] = mapped_column(
        EncryptedString(key="super_secret", length=10),
        nullable=True,
    )


class UUIDModelWithFetchedValue(UUIDv6Base):
    """The ModelWithFetchedValue UUIDBase."""

    val: Mapped[int]
    updated: Mapped[datetime.datetime] = mapped_column(
        server_default=func.current_timestamp(),
        onupdate=func.current_timestamp(),
        server_onupdate=FetchedValue(),
    )


uuid_item_tag = Table(
    "uuid_item_tag",
    UUIDBase.metadata,
    Column("item_id", ForeignKey("uuid_item.id"), primary_key=True),  # pyright: ignore[reportUnknownArgumentType]
    Column("tag_id", ForeignKey("uuid_tag.id"), primary_key=True),  # pyright: ignore[reportUnknownArgumentType]
)


class UUIDItem(UUIDBase):
    name: Mapped[str] = mapped_column(String(length=50))  # pyright: ignore
    description: Mapped[str] = mapped_column(String(length=100), nullable=True)  # pyright: ignore
    tags: Mapped[list[UUIDTag]] = relationship(secondary=lambda: uuid_item_tag, back_populates="items")


class UUIDTag(UUIDAuditBase):
    """The event log domain object."""

    name: Mapped[str] = mapped_column(String(length=50))  # pyright: ignore
    items: Mapped[list[UUIDItem]] = relationship(secondary=lambda: uuid_item_tag, back_populates="tags")


class UUIDRule(UUIDAuditBase):
    """The rule domain object."""

    name: Mapped[str] = mapped_column(String(length=250))
    config: Mapped[dict] = mapped_column(default=lambda: {})  # type: ignore


class UUIDFileDocument(UUIDv7Base):
    """The file document domain model."""

    title: Mapped[str] = mapped_column(String(length=100))
    attachment: Mapped[FileObject] = mapped_column(
        StoredObject(backend="memory"),
        nullable=True,
    )
    required_file: Mapped[FileObject] = mapped_column(StoredObject(backend="memory"), nullable=True)
    required_files: Mapped[FileObjectList] = mapped_column(
        StoredObject(backend="memory", multiple=True),
        nullable=True,
    )


if not storages.is_registered("memory"):
    storages.register_backend("memory://", "memory")
python-advanced-alchemy-1.9.3/tests/fixtures/uuid/repositories.py000066400000000000000000000125661516556515500253420ustar00rootroot00000000000000"""Example domain objects for testing."""

from __future__ import annotations

from advanced_alchemy.repository import (
    SQLAlchemyAsyncRepository,
    SQLAlchemyAsyncSlugRepository,
    SQLAlchemySyncRepository,
    SQLAlchemySyncSlugRepository,
)
from advanced_alchemy.repository.memory import (
    SQLAlchemyAsyncMockRepository,
    SQLAlchemyAsyncMockSlugRepository,
    SQLAlchemySyncMockRepository,
    SQLAlchemySyncMockSlugRepository,
)
from tests.fixtures.uuid.models import (
    UUIDAuthor,
    UUIDBook,
    UUIDEventLog,
    UUIDFileDocument,
    UUIDItem,
    UUIDModelWithFetchedValue,
    UUIDRule,
    UUIDSecret,
    UUIDSlugBook,
    UUIDTag,
)


class SecretAsyncRepository(SQLAlchemyAsyncRepository[UUIDSecret]):
    """Secret repository."""

    model_type = UUIDSecret


class RuleAsyncRepository(SQLAlchemyAsyncRepository[UUIDRule]):
    """Rule repository."""

    model_type = UUIDRule


class RuleAsyncMockRepository(SQLAlchemyAsyncMockRepository[UUIDRule]):
    """Rule repository."""

    model_type = UUIDRule


class RuleSyncMockRepository(SQLAlchemySyncMockRepository[UUIDRule]):
    """Rule repository."""

    model_type = UUIDRule


class AuthorAsyncRepository(SQLAlchemyAsyncRepository[UUIDAuthor]):
    """Author repository."""

    model_type = UUIDAuthor


class AuthorAsyncMockRepository(SQLAlchemyAsyncMockRepository[UUIDAuthor]):
    model_type = UUIDAuthor


class AuthorSyncMockRepository(SQLAlchemySyncMockRepository[UUIDAuthor]):
    model_type = UUIDAuthor


class BookAsyncRepository(SQLAlchemyAsyncRepository[UUIDBook]):
    """Book repository."""

    model_type = UUIDBook


class BookAsyncMockRepository(SQLAlchemyAsyncMockRepository[UUIDBook]):
    """Book repository."""

    model_type = UUIDBook


class BookSyncMockRepository(SQLAlchemySyncMockRepository[UUIDBook]):
    """Book repository."""

    model_type = UUIDBook


class SlugBookAsyncRepository(SQLAlchemyAsyncSlugRepository[UUIDSlugBook]):
    """Book repository."""

    _uniquify_results = True
    model_type = UUIDSlugBook


class SlugBookSyncRepository(SQLAlchemySyncSlugRepository[UUIDSlugBook]):
    """Slug Book repository."""

    _uniquify_results = True
    model_type = UUIDSlugBook


class SlugBookAsyncMockRepository(SQLAlchemyAsyncMockSlugRepository[UUIDSlugBook]):
    """Book repository."""

    model_type = UUIDSlugBook


class SlugBookSyncMockRepository(SQLAlchemySyncMockSlugRepository[UUIDSlugBook]):
    """Book repository."""

    model_type = UUIDSlugBook


class EventLogAsyncRepository(SQLAlchemyAsyncRepository[UUIDEventLog]):
    """Event log repository."""

    model_type = UUIDEventLog


class ModelWithFetchedValueAsyncRepository(SQLAlchemyAsyncRepository[UUIDModelWithFetchedValue]):
    """ModelWithFetchedValue repository."""

    model_type = UUIDModelWithFetchedValue


class TagAsyncRepository(SQLAlchemyAsyncRepository[UUIDTag]):
    """Tag repository."""

    model_type = UUIDTag


class TagAsyncMockRepository(SQLAlchemyAsyncMockRepository[UUIDTag]):
    """Tag repository."""

    model_type = UUIDTag


class TagSyncMockRepository(SQLAlchemySyncMockRepository[UUIDTag]):
    """Tag repository."""

    model_type = UUIDTag


class ItemAsyncRepository(SQLAlchemyAsyncRepository[UUIDItem]):
    """Item repository."""

    model_type = UUIDItem


class ItemAsyncMockRepository(SQLAlchemyAsyncMockRepository[UUIDItem]):
    """Item repository."""

    model_type = UUIDItem


class ItemSyncMockRepository(SQLAlchemySyncMockRepository[UUIDItem]):
    """Item repository."""

    model_type = UUIDItem


class SecretAsyncMockRepository(SQLAlchemyAsyncMockRepository[UUIDSecret]):
    """Secret repository."""

    model_type = UUIDSecret


class SecretSyncMockRepository(SQLAlchemySyncMockRepository[UUIDSecret]):
    """Secret repository."""

    model_type = UUIDSecret


class AuthorSyncRepository(SQLAlchemySyncRepository[UUIDAuthor]):
    """Author repository."""

    model_type = UUIDAuthor


class BookSyncRepository(SQLAlchemySyncRepository[UUIDBook]):
    """Book repository."""

    model_type = UUIDBook


class SecretSyncRepository(SQLAlchemySyncRepository[UUIDSecret]):
    """Secret repository."""

    model_type = UUIDSecret


class EventLogSyncRepository(SQLAlchemySyncRepository[UUIDEventLog]):
    """Event log repository."""

    model_type = UUIDEventLog


class RuleSyncRepository(SQLAlchemySyncRepository[UUIDRule]):
    """Rule repository."""

    model_type = UUIDRule


class ModelWithFetchedValueSyncRepository(SQLAlchemySyncRepository[UUIDModelWithFetchedValue]):
    """ModelWithFetchedValue repository."""

    model_type = UUIDModelWithFetchedValue


class TagSyncRepository(SQLAlchemySyncRepository[UUIDTag]):
    """Tag repository."""

    model_type = UUIDTag


class ItemSyncRepository(SQLAlchemySyncRepository[UUIDItem]):
    """Item repository."""

    model_type = UUIDItem


class FileDocumentAsyncRepository(SQLAlchemyAsyncRepository[UUIDFileDocument]):
    """FileDocument repository."""

    model_type = UUIDFileDocument


class FileDocumentSyncRepository(SQLAlchemySyncRepository[UUIDFileDocument]):
    """FileDocument repository."""

    model_type = UUIDFileDocument


class FileDocumentAsyncMockRepository(SQLAlchemyAsyncMockRepository[UUIDFileDocument]):
    """FileDocument repository."""

    model_type = UUIDFileDocument


class FileDocumentSyncMockRepository(SQLAlchemySyncMockRepository[UUIDFileDocument]):
    """FileDocument repository."""

    model_type = UUIDFileDocument
python-advanced-alchemy-1.9.3/tests/fixtures/uuid/services.py000066400000000000000000000215031516556515500244250ustar00rootroot00000000000000"""Example domain objects for testing."""

from __future__ import annotations

from advanced_alchemy.service import (
    SQLAlchemyAsyncRepositoryService,
    SQLAlchemySyncRepositoryService,
)
from advanced_alchemy.service.typing import (
    ModelDictT,
    is_dict_with_field,
    is_dict_without_field,
    schema_dump,
)
from tests.fixtures.uuid.models import (
    UUIDAuthor,
    UUIDBook,
    UUIDEventLog,
    UUIDItem,
    UUIDModelWithFetchedValue,
    UUIDRule,
    UUIDSecret,
    UUIDSlugBook,
    UUIDTag,
)
from tests.fixtures.uuid.repositories import (
    AuthorAsyncMockRepository,
    AuthorAsyncRepository,
    AuthorSyncMockRepository,
    AuthorSyncRepository,
    BookAsyncMockRepository,
    BookAsyncRepository,
    BookSyncMockRepository,
    BookSyncRepository,
    EventLogAsyncRepository,
    EventLogSyncRepository,
    ItemAsyncMockRepository,
    ItemAsyncRepository,
    ItemSyncMockRepository,
    ItemSyncRepository,
    ModelWithFetchedValueAsyncRepository,
    ModelWithFetchedValueSyncRepository,
    RuleAsyncMockRepository,
    RuleAsyncRepository,
    RuleSyncMockRepository,
    RuleSyncRepository,
    SecretAsyncRepository,
    SecretSyncRepository,
    SlugBookAsyncMockRepository,
    SlugBookAsyncRepository,
    SlugBookSyncMockRepository,
    SlugBookSyncRepository,
    TagAsyncMockRepository,
    TagAsyncRepository,
    TagSyncMockRepository,
    TagSyncRepository,
)


class SecretAsyncService(SQLAlchemyAsyncRepositoryService[UUIDSecret, SecretAsyncRepository]):
    """Rule repository."""

    repository_type = SecretAsyncRepository


class RuleAsyncService(SQLAlchemyAsyncRepositoryService[UUIDRule, RuleAsyncRepository]):
    """Rule repository."""

    repository_type = RuleAsyncRepository


class RuleAsyncMockService(SQLAlchemyAsyncRepositoryService[UUIDRule, RuleAsyncMockRepository]):
    """Rule repository."""

    repository_type = RuleAsyncMockRepository


class RuleSyncMockService(SQLAlchemySyncRepositoryService[UUIDRule, RuleSyncMockRepository]):
    """Rule repository."""

    repository_type = RuleSyncMockRepository


class AuthorAsyncService(SQLAlchemyAsyncRepositoryService[UUIDAuthor, AuthorAsyncRepository]):
    """Author repository."""

    repository_type = AuthorAsyncRepository


class AuthorAsyncMockService(SQLAlchemyAsyncRepositoryService[UUIDAuthor, AuthorAsyncMockRepository]):
    """Author repository."""

    repository_type = AuthorAsyncMockRepository


class AuthorSyncMockService(SQLAlchemySyncRepositoryService[UUIDAuthor, AuthorSyncMockRepository]):
    """Author repository."""

    repository_type = AuthorSyncMockRepository


class BookAsyncService(SQLAlchemyAsyncRepositoryService[UUIDBook, BookAsyncRepository]):
    """Book repository."""

    repository_type = BookAsyncRepository


class BookAsyncMockService(SQLAlchemyAsyncRepositoryService[UUIDBook, BookAsyncMockRepository]):
    """Book repository."""

    repository_type = BookAsyncMockRepository


class BookSyncMockService(SQLAlchemySyncRepositoryService[UUIDBook, BookSyncMockRepository]):
    """Book repository."""

    repository_type = BookSyncMockRepository


class EventLogAsyncService(SQLAlchemyAsyncRepositoryService[UUIDEventLog, EventLogAsyncRepository]):
    """Event log repository."""

    repository_type = EventLogAsyncRepository


class ModelWithFetchedValueAsyncService(
    SQLAlchemyAsyncRepositoryService[UUIDModelWithFetchedValue, ModelWithFetchedValueAsyncRepository]
):
    """UUIDModelWithFetchedValue repository."""

    repository_type = ModelWithFetchedValueAsyncRepository


class TagAsyncService(SQLAlchemyAsyncRepositoryService[UUIDTag, TagAsyncRepository]):
    """Tag repository."""

    repository_type = TagAsyncRepository


class TagAsyncMockService(SQLAlchemyAsyncRepositoryService[UUIDTag, TagAsyncMockRepository]):
    """Tag repository."""

    repository_type = TagAsyncMockRepository


class TagSyncMockService(SQLAlchemySyncRepositoryService[UUIDTag, TagSyncMockRepository]):
    """Tag repository."""

    repository_type = TagSyncMockRepository


class ItemAsyncService(SQLAlchemyAsyncRepositoryService[UUIDItem, ItemAsyncRepository]):
    """Item repository."""

    repository_type = ItemAsyncRepository


class ItemAsyncMockService(SQLAlchemyAsyncRepositoryService[UUIDItem, ItemAsyncMockRepository]):
    """Item repository."""

    repository_type = ItemAsyncMockRepository


class ItemSyncMockService(SQLAlchemySyncRepositoryService[UUIDItem, ItemSyncMockRepository]):
    """Item repository."""

    repository_type = ItemSyncMockRepository


class RuleSyncService(SQLAlchemySyncRepositoryService[UUIDRule, RuleSyncRepository]):
    """Rule repository."""

    repository_type = RuleSyncRepository


class AuthorSyncService(SQLAlchemySyncRepositoryService[UUIDAuthor, AuthorSyncRepository]):
    """Author repository."""

    repository_type = AuthorSyncRepository


class BookSyncService(SQLAlchemySyncRepositoryService[UUIDBook, BookSyncRepository]):
    """Book repository."""

    repository_type = BookSyncRepository


class EventLogSyncService(SQLAlchemySyncRepositoryService[UUIDEventLog, EventLogSyncRepository]):
    """Event log repository."""

    repository_type = EventLogSyncRepository


class ModelWithFetchedValueSyncService(
    SQLAlchemySyncRepositoryService[UUIDModelWithFetchedValue, ModelWithFetchedValueSyncRepository]
):
    """UUIDModelWithFetchedValue repository."""

    repository_type = ModelWithFetchedValueSyncRepository


class TagSyncService(SQLAlchemySyncRepositoryService[UUIDTag, TagSyncRepository]):
    """Tag repository."""

    repository_type = TagSyncRepository


class ItemSyncService(SQLAlchemySyncRepositoryService[UUIDItem, ItemSyncRepository]):
    """Item repository."""

    repository_type = ItemSyncRepository


class SecretSyncService(SQLAlchemySyncRepositoryService[UUIDSecret, SecretSyncRepository]):
    """Rule repository."""

    repository_type = SecretSyncRepository


class SlugBookAsyncService(SQLAlchemyAsyncRepositoryService[UUIDSlugBook, SlugBookAsyncRepository]):
    """Book repository."""

    repository_type = SlugBookAsyncRepository
    match_fields = ["title"]

    async def to_model(
        self,
        data: ModelDictT[UUIDSlugBook],
        operation: str | None = None,
    ) -> UUIDSlugBook:
        data = schema_dump(data)
        if is_dict_without_field(data, "slug") and operation == "create":
            data["slug"] = await self.repository.get_available_slug(data["title"])
        if is_dict_without_field(data, "slug") and is_dict_with_field(data, "title") and operation == "update":
            data["slug"] = await self.repository.get_available_slug(data["title"])
        return await super().to_model(data, operation)


class SlugBookSyncService(SQLAlchemySyncRepositoryService[UUIDSlugBook, SlugBookSyncRepository]):
    """Book repository."""

    repository_type = SlugBookSyncRepository

    def to_model(
        self,
        data: ModelDictT[UUIDSlugBook],
        operation: str | None = None,
    ) -> UUIDSlugBook:
        data = schema_dump(data)
        if is_dict_without_field(data, "slug") and operation == "create":
            data["slug"] = self.repository.get_available_slug(data["title"])
        if is_dict_without_field(data, "slug") and is_dict_with_field(data, "title") and operation == "update":
            data["slug"] = self.repository.get_available_slug(data["title"])
        return super().to_model(data, operation)


class SlugBookAsyncMockService(SQLAlchemyAsyncRepositoryService[UUIDSlugBook, SlugBookAsyncMockRepository]):
    """Book repository."""

    repository_type = SlugBookAsyncMockRepository
    match_fields = ["title"]

    async def to_model(
        self,
        data: ModelDictT[UUIDSlugBook],
        operation: str | None = None,
    ) -> UUIDSlugBook:
        data = schema_dump(data)
        if is_dict_without_field(data, "slug") and operation == "create":
            data["slug"] = await self.repository.get_available_slug(data["title"])
        if is_dict_without_field(data, "slug") and is_dict_with_field(data, "title") and operation == "update":
            data["slug"] = await self.repository.get_available_slug(data["title"])
        return await super().to_model(data, operation)


class SlugBookSyncMockService(SQLAlchemySyncRepositoryService[UUIDSlugBook, SlugBookSyncMockRepository]):
    """Book repository."""

    repository_type = SlugBookSyncMockRepository
    match_fields = ["title"]

    def to_model(
        self,
        data: ModelDictT[UUIDSlugBook],
        operation: str | None = None,
    ) -> UUIDSlugBook:
        data = schema_dump(data)
        if is_dict_without_field(data, "slug") and operation == "create":
            data["slug"] = self.repository.get_available_slug(data["title"])
        if is_dict_without_field(data, "slug") and is_dict_with_field(data, "title") and operation == "update":
            data["slug"] = self.repository.get_available_slug(data["title"])
        return super().to_model(data, operation)
python-advanced-alchemy-1.9.3/tests/helpers.py000066400000000000000000000060171516556515500214300ustar00rootroot00000000000000from __future__ import annotations

import asyncio
import importlib
import inspect
import sys
from collections.abc import Awaitable
from contextlib import AbstractAsyncContextManager, AbstractContextManager
from functools import partial
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast, overload

from typing_extensions import ParamSpec

if TYPE_CHECKING:
    from types import TracebackType

T = TypeVar("T")
P = ParamSpec("P")


def purge_module(module_names: list[str], path: str | Path) -> None:
    for name in module_names:
        if name in sys.modules:
            del sys.modules[name]
    Path(importlib.util.cache_from_source(path)).unlink(missing_ok=True)  # type: ignore[arg-type]


class _ContextManagerWrapper:
    def __init__(self, cm: AbstractContextManager[T]) -> None:
        self._cm = cm

    async def __aenter__(self) -> T:  # pyright: ignore
        return self._cm.__enter__()  # type: ignore[return-value] # pyright: ignore

    async def __aexit__(
        self,
        exc_type: type[BaseException] | None,
        exc_val: BaseException | None,
        exc_tb: TracebackType | None,
    ) -> bool | None:
        return self._cm.__exit__(exc_type, exc_val, exc_tb)


@overload
async def maybe_async(obj: Awaitable[T]) -> T: ...


@overload
async def maybe_async(obj: T) -> T: ...


async def maybe_async(obj: Awaitable[T] | T) -> T:
    return cast(T, await obj) if inspect.isawaitable(obj) else cast(T, obj)  # type: ignore[redundant-cast]


def maybe_async_cm(obj: AbstractContextManager[T] | AbstractAsyncContextManager[T]) -> AbstractAsyncContextManager[T]:
    if isinstance(obj, AbstractContextManager):
        return cast(AbstractAsyncContextManager[T], _ContextManagerWrapper(obj))
    return obj


def wrap_sync(fn: Callable[P, T]) -> Callable[P, Awaitable[T]]:
    if inspect.iscoroutinefunction(fn):
        return fn

    async def wrapped(*args: P.args, **kwargs: P.kwargs) -> T:
        return await asyncio.get_running_loop().run_in_executor(None, partial(fn, *args, **kwargs))

    return wrapped


class NoValue:
    """A fake "Empty class"""


async def anext_(iterable: Any, default: Any = NoValue, *args: Any) -> Any:  # pragma: no cover
    """Return the next item from an async iterator.

    Args:
        iterable: An async iterable.
        default: An optional default value to return if the iterable is empty.
        *args: The remaining args
    Return:
        The next value of the iterable.

    Raises:
        TypeError: The iterable given is not async.

    This function will return the next value form an async iterable. If the
    iterable is empty the StopAsyncIteration will be propagated. However, if
    a default value is given as a second argument the exception is silenced and
    the default value is returned instead.
    """
    has_default = bool(not isinstance(default, NoValue))
    try:
        return await iterable.__anext__()

    except StopAsyncIteration as exc:
        if has_default:
            return default

        raise StopAsyncIteration from exc
python-advanced-alchemy-1.9.3/tests/integration/000077500000000000000000000000001516556515500217335ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/tests/integration/__init__.py000066400000000000000000000000001516556515500240320ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/tests/integration/conftest.py000066400000000000000000000773001516556515500241410ustar00rootroot00000000000000from __future__ import annotations
# ruff: noqa: I001

import logging
from collections.abc import AsyncGenerator, Generator
from typing import TYPE_CHECKING, cast
from unittest.mock import create_autospec, NonCallableMagicMock

import pytest
import pytest_asyncio
from google.cloud import spanner  # pyright: ignore
from pytest import FixtureRequest
from pytest_databases.docker.cockroachdb import CockroachDBService
from pytest_databases.docker.mssql import MSSQLService
from pytest_databases.docker.mysql import MySQLService
from pytest_databases.docker.oracle import OracleService
from pytest_databases.docker.postgres import PostgresService
from pytest_databases.docker.spanner import SpannerService
from sqlalchemy import Dialect, Engine, NullPool, create_engine, text
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import Session

# Local test helpers and fixtures

# Import all fixtures from repository_fixtures
from tests.integration.repository_fixtures import *  # noqa: F403

if TYPE_CHECKING:
    from pytest import MonkeyPatch


@pytest.fixture(scope="session", autouse=True)
def configure_safe_logging(request: pytest.FixtureRequest) -> None:
    """Configure logging to prevent I/O errors during parallel test execution.

    Both Google Cloud Spanner and SQLAlchemy try to write logs during test execution with pytest-xdist,
    but worker processes have closed file streams, causing "I/O operation on closed file" errors.
    This fixture configures a safe logging handler to suppress these errors.
    """

    # Create a safe logging handler that won't fail on closed streams
    class SafeStreamHandler(logging.StreamHandler):
        def emit(self, record: logging.LogRecord) -> None:
            try:
                super().emit(record)
            except (ValueError, OSError):
                # Suppress I/O errors from closed streams during test execution
                pass

    # Configure loggers that can cause I/O issues during parallel test execution
    problematic_loggers = [
        # Google Cloud Spanner loggers
        "google.cloud.spanner_v1.database_sessions_manager",
        "google.cloud.spanner",
        "google.cloud",
        # SQLAlchemy engine loggers
        "sqlalchemy.engine.Engine",
        "sqlalchemy.engine",
        "sqlalchemy.pool",
        # Test helpers that log cleanup operations
        "tests.integration.helpers",
        # Pytest-xdist workers
        "xdist.remote",
        "xdist",
    ]

    for logger_name in problematic_loggers:
        logger = logging.getLogger(logger_name)
        # Remove existing handlers that might cause issues
        logger.handlers.clear()
        # Add our safe handler
        logger.addHandler(SafeStreamHandler())
        logger.setLevel(logging.WARNING)  # Reduce verbosity
        logger.propagate = False  # Prevent propagation to root logger

    # Add finalizer to ensure clean shutdown
    def cleanup() -> None:
        # Flush all handlers before pytest-xdist tears down
        for logger_name in problematic_loggers:
            logger = logging.getLogger(logger_name)
            for handler in logger.handlers:
                try:
                    handler.flush()
                    handler.close()
                except Exception:
                    pass

    request.addfinalizer(cleanup)


@pytest.fixture(autouse=True)
def _patch_bases(monkeypatch: MonkeyPatch) -> None:  # pyright: ignore[reportUnusedFunction]
    """Ensure new registry state for every test.

    This prevents errors such as "Table '...' is already defined for
    this MetaData instance...
    """
    from sqlalchemy.orm import DeclarativeBase

    from advanced_alchemy import base, mixins

    class NewUUIDBase(mixins.UUIDPrimaryKey, base.CommonTableAttributes, DeclarativeBase): ...

    class NewUUIDAuditBase(
        mixins.UUIDPrimaryKey,
        base.CommonTableAttributes,
        mixins.AuditColumns,
        DeclarativeBase,
    ): ...

    class NewUUIDv6Base(mixins.UUIDPrimaryKey, base.CommonTableAttributes, DeclarativeBase): ...

    class NewUUIDv6AuditBase(
        mixins.UUIDPrimaryKey,
        base.CommonTableAttributes,
        mixins.AuditColumns,
        DeclarativeBase,
    ): ...

    class NewUUIDv7Base(mixins.UUIDPrimaryKey, base.CommonTableAttributes, DeclarativeBase): ...

    class NewUUIDv7AuditBase(
        mixins.UUIDPrimaryKey,
        base.CommonTableAttributes,
        mixins.AuditColumns,
        DeclarativeBase,
    ): ...

    class NewNanoIDBase(mixins.NanoIDPrimaryKey, base.CommonTableAttributes, DeclarativeBase): ...

    class NewNanoIDAuditBase(
        mixins.NanoIDPrimaryKey,
        base.CommonTableAttributes,
        mixins.AuditColumns,
        DeclarativeBase,
    ): ...

    class NewBigIntBase(mixins.BigIntPrimaryKey, base.CommonTableAttributes, DeclarativeBase): ...

    class NewBigIntAuditBase(
        mixins.BigIntPrimaryKey,
        base.CommonTableAttributes,
        mixins.AuditColumns,
        DeclarativeBase,
    ): ...

    monkeypatch.setattr(base, "UUIDBase", NewUUIDBase)
    monkeypatch.setattr(base, "UUIDAuditBase", NewUUIDAuditBase)
    monkeypatch.setattr(base, "UUIDv6Base", NewUUIDv6Base)
    monkeypatch.setattr(base, "UUIDv6AuditBase", NewUUIDv6AuditBase)
    monkeypatch.setattr(base, "UUIDv7Base", NewUUIDv7Base)
    monkeypatch.setattr(base, "UUIDv7AuditBase", NewUUIDv7AuditBase)
    monkeypatch.setattr(base, "NanoIDBase", NewNanoIDBase)
    monkeypatch.setattr(base, "NanoIDAuditBase", NewNanoIDAuditBase)
    monkeypatch.setattr(base, "BigIntBase", NewBigIntBase)
    monkeypatch.setattr(base, "BigIntAuditBase", NewBigIntAuditBase)


@pytest.fixture(scope="session")
def duckdb_engine(
    tmp_path_factory: pytest.TempPathFactory, request: pytest.FixtureRequest
) -> Generator[Engine, None, None]:
    # Workaround for DuckDB 1.4.0 unhashable DuckDBPyType
    # This is fixed in DuckDB 1.4.1 - can be removed once that's released
    # See: https://github.com/Mause/duckdb_engine/issues/1338
    try:
        import duckdb  # type: ignore[import-untyped]

        if hasattr(duckdb, "__version__") and duckdb.__version__.startswith("1.4.0"):
            from _duckdb import typing as duckdb_typing  # type: ignore[import-not-found]

            if hasattr(duckdb_typing, "DuckDBPyType"):
                # Test if the existing __hash__ method works
                try:
                    test_type = duckdb_typing.DuckDBPyType("INTEGER")
                    hash(test_type)
                except TypeError:
                    # Replace the broken __hash__ method with a working one
                    duckdb_typing.DuckDBPyType.__hash__ = lambda self: hash(str(self))
    except ImportError:
        pass

    worker_id = getattr(request.config, "workerinput", {}).get("workerid", "master")
    tmp_path = tmp_path_factory.mktemp(f"duckdb_{worker_id}")
    engine = create_engine(f"duckdb:///{tmp_path}/test.duck.db", poolclass=NullPool)
    try:
        yield engine
    finally:
        engine.dispose()


@pytest.fixture(scope="session")
def oracle18c_engine(oracle18c_url: str, oracle_18c_service: OracleService) -> Generator[Engine, None, None]:
    yield create_engine(oracle18c_url, poolclass=NullPool)


@pytest.fixture(scope="session")
def oracle23ai_engine(oracle23ai_url: str, oracle_23ai_service: OracleService) -> Generator[Engine, None, None]:
    yield create_engine(oracle23ai_url, poolclass=NullPool)


@pytest.fixture(scope="session")
def psycopg_engine(postgres_psycopg_url: str, postgres_service: PostgresService) -> Generator[Engine, None, None]:
    yield create_engine(postgres_psycopg_url, poolclass=NullPool)


@pytest.fixture(scope="session")
def mssql_engine(mssql_pyodbc_url: str, mssql_service: MSSQLService) -> Generator[Engine, None, None]:
    yield create_engine(mssql_pyodbc_url, poolclass=NullPool)


@pytest.fixture(scope="session")
def sqlite_engine(
    tmp_path_factory: pytest.TempPathFactory, request: pytest.FixtureRequest
) -> Generator[Engine, None, None]:
    # Include worker ID in the database name to avoid conflicts
    worker_id = getattr(request.config, "workerinput", {}).get("workerid", "master")
    tmp_path = tmp_path_factory.mktemp(f"sqlite_{worker_id}")
    db_file = tmp_path / f"test_{worker_id}.db"
    engine = create_engine(
        f"sqlite:///{db_file}",
        poolclass=NullPool,
        connect_args={
            "timeout": 30,  # Wait up to 30 seconds for locks
            "check_same_thread": False,  # Allow usage from different threads
        },
    )
    try:
        yield engine
    finally:
        engine.dispose()


@pytest.fixture(scope="session")
def spanner_engine(
    spanner_url: str, spanner_connection: spanner.Client, spanner_service: SpannerService
) -> Generator[Engine, None, None]:
    # Environment variables are still set by set_spanner_emulator in root conftest,
    # but we use the explicit URL fixture now for consistency.

    yield create_engine(spanner_url, poolclass=NullPool, connect_args={"client": spanner_connection})


@pytest.fixture(scope="session")
def cockroachdb_engine(
    cockroachdb_psycopg_url: str, cockroachdb_service: CockroachDBService
) -> Generator[Engine, None, None]:
    yield create_engine(cockroachdb_psycopg_url, poolclass=NullPool)


@pytest.fixture(scope="session")
def mock_sync_engine() -> Generator[NonCallableMagicMock, None, None]:
    mock = cast(NonCallableMagicMock, create_autospec(Engine, instance=True))
    mock.dialect = create_autospec(Dialect, instance=True)
    mock.dialect.name = "mock"
    mock.dialect.server_version_info = None
    yield mock


@pytest.fixture(
    scope="session",
    name="engine",
    params=[
        pytest.param(
            "sqlite_engine",
            marks=[
                pytest.mark.sqlite,
                pytest.mark.integration,
                pytest.mark.xdist_group("sqlite"),
            ],
        ),
        pytest.param(
            "duckdb_engine",
            marks=[
                pytest.mark.duckdb,
                pytest.mark.integration,
                pytest.mark.xdist_group("duckdb"),
            ],
        ),
        pytest.param(
            "oracle18c_engine",
            marks=[
                pytest.mark.oracledb_sync,
                pytest.mark.integration,
                pytest.mark.xdist_group("oracle18"),
            ],
        ),
        pytest.param(
            "oracle23ai_engine",
            marks=[
                pytest.mark.oracledb_sync,
                pytest.mark.integration,
                pytest.mark.xdist_group("oracle23"),
            ],
        ),
        pytest.param(
            "psycopg_engine",
            marks=[
                pytest.mark.psycopg_sync,
                pytest.mark.integration,
                pytest.mark.xdist_group("postgres"),
            ],
        ),
        pytest.param(
            "cockroachdb_engine",
            marks=[
                pytest.mark.cockroachdb_sync,
                pytest.mark.integration,
                pytest.mark.xdist_group("cockroachdb"),
            ],
        ),
        pytest.param(
            "mssql_engine",
            marks=[
                pytest.mark.mssql_sync,
                pytest.mark.integration,
                pytest.mark.xdist_group("mssql"),
            ],
        ),
        pytest.param(
            "spanner_engine",
            marks=[
                pytest.mark.spanner,
                pytest.mark.integration,
                pytest.mark.xdist_group("spanner"),
            ],
        ),
        pytest.param(
            "mock_sync_engine",
            marks=[
                pytest.mark.mock_sync,
                pytest.mark.integration,
                pytest.mark.xdist_group("mock"),
            ],
        ),
    ],
)
def engine(request: FixtureRequest) -> Engine:
    return cast(Engine, request.getfixturevalue(request.param))


@pytest.fixture()
def session(engine: Engine, request: FixtureRequest) -> Generator[Session, None, None]:
    if "mock_sync_engine" in request.fixturenames or getattr(engine.dialect, "name", "") == "mock":
        session_mock = create_autospec(Session, instance=True)
        session_mock.bind = engine
        yield session_mock
    else:
        # Use improved transaction management to avoid savepoint issues
        # Spanner uses "spanner+spanner" as dialect name, CockroachDB uses "cockroachdb"
        dialect_name = engine.dialect.name
        supports_savepoints = not any(
            dialect_name.startswith(prefix) for prefix in ("sqlite", "duckdb", "spanner", "cockroachdb")
        )

        if supports_savepoints:
            # Use savepoint-based isolation for databases that support it
            connection = engine.connect()
            transaction = connection.begin()

            try:
                # Create session bound to this connection
                session_instance = Session(bind=connection, expire_on_commit=False)

                # Create savepoint for test isolation
                savepoint = connection.begin_nested()

                try:
                    yield session_instance
                finally:
                    # Cleanup order: session operations first, then transaction management
                    try:
                        session_instance.rollback()  # Rollback any pending changes
                    except Exception:
                        pass  # Ignore rollback errors
                    finally:
                        try:
                            session_instance.close()  # Close session first
                        except Exception:
                            pass

                    # Now handle savepoint cleanup
                    try:
                        if savepoint.is_active:
                            savepoint.rollback()
                    except Exception:
                        pass  # Ignore savepoint rollback errors

            finally:
                # Rollback the main transaction and close connection
                try:
                    if transaction.is_active:
                        transaction.rollback()
                except Exception:
                    pass
                finally:
                    try:
                        connection.close()
                    except Exception:
                        pass
        else:
            # For SQLite/DuckDB: use simple transaction-based isolation without savepoints
            connection = engine.connect()
            transaction = connection.begin()

            try:
                # Create session bound to this connection
                session_instance = Session(bind=connection, expire_on_commit=False)

                try:
                    yield session_instance
                finally:
                    # Cleanup: session first, then transaction
                    try:
                        session_instance.rollback()  # Rollback any changes
                    except Exception:
                        pass
                    finally:
                        try:
                            session_instance.close()
                        except Exception:
                            pass

            finally:
                # Always rollback transaction to clean up
                try:
                    if transaction.is_active:
                        transaction.rollback()
                except Exception:
                    pass
                finally:
                    try:
                        connection.close()
                    except Exception:
                        pass


# ---------------------------------------------------------------------------
# Global, per-test cleanup to ensure data isolation between tests
# ---------------------------------------------------------------------------


@pytest.fixture(autouse=True)
def _auto_clean_sync_db(request: FixtureRequest) -> Generator[None, None, None]:
    """Universal cleanup fixture for sync engine tests.

    Handles cleanup for all test types including:
    - Repository fixture tests (uuid/bigint session-based)
    - Filter tests (movie model)
    - Legacy tests with manual cleanup

    Only runs cleanup when appropriate and avoids conflicts.
    """
    yield

    # Skip cleanup for mock engines or if no sync engine available
    if "engine" not in request.fixturenames:
        return

    try:
        engine = request.getfixturevalue("engine")
    except Exception:
        return

    # Skip cleanup for mock engines
    if getattr(engine.dialect, "name", "") == "mock":
        return

    from tests.integration import helpers as test_helpers

    try:
        # 1. Handle repository fixture tests - these use transaction-based cleanup
        if any(
            fixture in request.fixturenames
            for fixture in ["test_session_sync", "uuid_test_session_sync", "bigint_test_session_sync"]
        ):
            # These fixtures handle their own transaction-based cleanup
            # No additional cleanup needed
            return

        # 2. Handle filter tests with movie models
        if "movie_model_sync" in request.fixturenames:
            try:
                movie_model = request.getfixturevalue("movie_model_sync")
                test_helpers.clean_tables(engine, movie_model.metadata)
            except Exception as e:
                # Log but don't fail on cleanup errors
                import logging

                logging.getLogger(__name__).warning(f"Movie model cleanup failed: {e}")
            return

        # 3. Handle legacy tests with repository models
        base_model = None
        if "uuid_models" in request.fixturenames:
            try:
                uuid_models = request.getfixturevalue("uuid_models")
                base_model = uuid_models["base"]
            except Exception:
                pass
        elif "bigint_models" in request.fixturenames:
            try:
                bigint_models = request.getfixturevalue("bigint_models")
                base_model = bigint_models["base"]
            except Exception:
                pass

        if base_model:
            test_helpers.clean_tables(engine, base_model.metadata)

    except Exception as e:
        # Log but don't fail the test on cleanup errors
        import logging

        logging.getLogger(__name__).warning(f"Sync cleanup failed: {e}")


@pytest_asyncio.fixture(autouse=True, loop_scope="function")
async def _auto_clean_async_db(request: FixtureRequest) -> AsyncGenerator[None, None]:
    """Universal cleanup fixture for async engine tests.

    Handles cleanup for all async test types including:
    - Repository fixture tests (uuid/bigint session-based)
    - Filter tests (movie model)
    - Legacy tests with manual cleanup

    Only runs cleanup when appropriate and avoids conflicts.
    """
    yield

    # Skip cleanup if no async engine available
    if "async_engine" not in request.fixturenames:
        return

    try:
        async_engine = request.getfixturevalue("async_engine")
    except Exception:
        # Fixture might be torn down already
        return

    # Skip cleanup for mock engines
    if getattr(async_engine.dialect, "name", "") == "mock":
        return

    from tests.integration import helpers as test_helpers

    try:
        # 1. Handle repository fixture tests - these use transaction-based cleanup
        if any(
            fixture in request.fixturenames
            for fixture in ["test_session_async", "uuid_test_session_async", "bigint_test_session_async"]
        ):
            # These fixtures handle their own transaction-based cleanup
            # No additional cleanup needed
            return

        # 2. Handle filter tests with movie models
        if "movie_model_async" in request.fixturenames:
            try:
                movie_model = request.getfixturevalue("cached_movie_model")
                await test_helpers.async_clean_tables(async_engine, movie_model.metadata)
            except Exception as e:
                # Log but don't fail on cleanup errors
                import logging

                logging.getLogger(__name__).warning(f"Async movie model cleanup failed: {e}")
            return

        # 3. Handle legacy tests with repository models
        base_model = None
        if "uuid_models" in request.fixturenames:
            try:
                uuid_models = request.getfixturevalue("uuid_models")
                base_model = uuid_models["base"]
            except Exception:
                pass
        elif "bigint_models" in request.fixturenames:
            try:
                bigint_models = request.getfixturevalue("bigint_models")
                base_model = bigint_models["base"]
            except Exception:
                pass

        if base_model:
            await test_helpers.async_clean_tables(async_engine, base_model.metadata)

    except Exception as e:
        # Log but don't fail the test on cleanup errors
        import logging

        logging.getLogger(__name__).warning(f"Async cleanup failed: {e}")


@pytest_asyncio.fixture(scope="session", loop_scope="session")
async def aiosqlite_engine(
    tmp_path_factory: pytest.TempPathFactory, request: pytest.FixtureRequest
) -> AsyncGenerator[AsyncEngine, None]:
    # Include worker ID in the database name to avoid conflicts
    worker_id = getattr(request.config, "workerinput", {}).get("workerid", "master")
    tmp_path = tmp_path_factory.mktemp(f"aiosqlite_{worker_id}")
    db_file = tmp_path / f"test_{worker_id}.db"
    engine = create_async_engine(
        f"sqlite+aiosqlite:///{db_file}",
        poolclass=NullPool,
        connect_args={
            "timeout": 30,  # Wait up to 30 seconds for locks
            "check_same_thread": False,  # Allow usage from different threads
        },
    )
    try:
        yield engine
    finally:
        await engine.dispose()


@pytest_asyncio.fixture(scope="session", loop_scope="session")
async def asyncmy_engine(mysql_asyncmy_url: str, mysql_service: MySQLService) -> AsyncGenerator[AsyncEngine, None]:
    yield create_async_engine(mysql_asyncmy_url, poolclass=NullPool)


@pytest_asyncio.fixture(scope="session", loop_scope="session")
async def asyncpg_engine(
    postgres_asyncpg_url: str, postgres_service: PostgresService
) -> AsyncGenerator[AsyncEngine, None]:
    """AsyncPG engine fixture that ensures pgcrypto extension is created."""
    engine = create_async_engine(postgres_asyncpg_url, poolclass=NullPool)
    try:
        # Ensure pgcrypto extension is available
        async with engine.connect() as conn:
            await conn.execute(text("CREATE EXTENSION IF NOT EXISTS pgcrypto"))
            await conn.commit()  # Commit the extension creation
        yield engine
    finally:
        await engine.dispose()


@pytest_asyncio.fixture(scope="session", loop_scope="session")
async def psycopg_async_engine(
    postgres_psycopg_url: str, postgres_service: PostgresService
) -> AsyncGenerator[AsyncEngine, None]:
    yield create_async_engine(postgres_psycopg_url, poolclass=NullPool)


@pytest_asyncio.fixture(scope="session", loop_scope="session")
async def cockroachdb_async_engine(
    cockroachdb_asyncpg_url: str, cockroachdb_service: CockroachDBService
) -> AsyncGenerator[AsyncEngine, None]:
    """Cockroach DB async engine instance for end-to-end testing using asyncpg.

    Args:
        cockroachdb_asyncpg_url: Connection URL provided by the cockroachdb_asyncpg_url fixture.

    Returns:
        Async SQLAlchemy engine instance.
    """
    yield create_async_engine(cockroachdb_asyncpg_url, poolclass=NullPool)


@pytest_asyncio.fixture(scope="session", loop_scope="session")
async def mssql_async_engine(mssql_aioodbc_url: str, mssql_service: MSSQLService) -> AsyncGenerator[AsyncEngine, None]:
    """MS SQL instance for end-to-end testing using aioodbc.

    Args:
        mssql_aioodbc_url: Connection URL provided by the mssql_aioodbc_url fixture.

    Returns:
        Async SQLAlchemy engine instance.
    """
    # Add MARS_Connection=yes needed for concurrent async tests
    url_to_use = mssql_aioodbc_url
    if "MARS_Connection=yes" not in url_to_use:
        separator = "&" if "?" in url_to_use else "?"
        url_to_use += f"{separator}MARS_Connection=yes"
    yield create_async_engine(url_to_use, poolclass=NullPool)


@pytest_asyncio.fixture(scope="session", loop_scope="session")
async def oracle18c_async_engine(
    oracle18c_url: str, oracle_18c_service: OracleService
) -> AsyncGenerator[AsyncEngine, None]:
    """Oracle 18c async instance for end-to-end testing.

    Args:
        oracle18c_url: Connection URL provided by the oracle18c_url fixture.

    Returns:
        Async SQLAlchemy engine instance.
    """
    yield create_async_engine(oracle18c_url, poolclass=NullPool)


@pytest_asyncio.fixture(scope="session", loop_scope="session")
async def oracle23ai_async_engine(
    oracle23ai_url: str, oracle_23ai_service: OracleService
) -> AsyncGenerator[AsyncEngine, None]:
    """Oracle 23c async instance for end-to-end testing.

    Args:
        oracle23ai_url: Connection URL provided by the oracle23ai_url fixture.

    Returns:
        Async SQLAlchemy engine instance.
    """
    yield create_async_engine(oracle23ai_url, poolclass=NullPool)


@pytest_asyncio.fixture(scope="session", loop_scope="session")
async def mock_async_engine() -> AsyncGenerator[NonCallableMagicMock, None]:
    """Return a mocked AsyncEngine instance.

    Returns:
        Mocked Async SQLAlchemy engine instance.
    """
    mock = cast(NonCallableMagicMock, create_autospec(AsyncEngine, instance=True))
    mock.dialect = create_autospec(Dialect, instance=True)
    mock.dialect.name = "mock"
    mock.dialect.server_version_info = None
    yield mock


@pytest.fixture(
    scope="session",
    name="async_engine",
    params=[
        pytest.param(
            "aiosqlite_engine",
            marks=[
                pytest.mark.aiosqlite,
                pytest.mark.integration,
                pytest.mark.xdist_group("sqlite"),
            ],
        ),
        pytest.param(
            "asyncmy_engine",
            marks=[
                pytest.mark.asyncmy,
                pytest.mark.integration,
                pytest.mark.xdist_group("mysql"),
            ],
        ),
        pytest.param(
            "asyncpg_engine",
            marks=[
                pytest.mark.asyncpg,
                pytest.mark.integration,
                pytest.mark.xdist_group("postgres"),
            ],
        ),
        pytest.param(
            "psycopg_async_engine",
            marks=[
                pytest.mark.psycopg_async,
                pytest.mark.integration,
                pytest.mark.xdist_group("postgres"),
            ],
        ),
        pytest.param(
            "cockroachdb_async_engine",
            marks=[
                pytest.mark.cockroachdb_async,
                pytest.mark.integration,
                pytest.mark.xdist_group("cockroachdb"),
            ],
        ),
        pytest.param(
            "mssql_async_engine",
            marks=[
                pytest.mark.mssql_async,
                pytest.mark.integration,
                pytest.mark.xdist_group("mssql"),
            ],
        ),
        pytest.param(
            "oracle18c_async_engine",
            marks=[
                pytest.mark.oracledb_async,
                pytest.mark.integration,
                pytest.mark.xdist_group("oracle18"),
            ],
        ),
        pytest.param(
            "oracle23ai_async_engine",
            marks=[
                pytest.mark.oracledb_async,
                pytest.mark.integration,
                pytest.mark.xdist_group("oracle23"),
            ],
        ),
        pytest.param(
            "mock_async_engine",
            marks=[
                pytest.mark.mock_async,
                pytest.mark.integration,
                pytest.mark.xdist_group("mock"),
            ],
        ),
    ],
)
def async_engine(request: FixtureRequest) -> AsyncEngine:
    """Parametrized fixture to provide different async SQLAlchemy engines."""
    return cast(AsyncEngine, request.getfixturevalue(request.param))


@pytest_asyncio.fixture(loop_scope="function")
async def async_session(
    async_engine: AsyncEngine,
    request: FixtureRequest,
) -> AsyncGenerator[AsyncSession, None]:
    """Provides an async SQLAlchemy session for the parametrized async engine."""
    if "mock_async_engine" in request.fixturenames or getattr(async_engine.dialect, "name", "") == "mock":
        session_mock = create_autospec(AsyncSession, instance=True)
        session_mock.bind = async_engine
        yield session_mock
    else:
        # Use improved transaction management to avoid savepoint issues
        # Spanner uses "spanner+spanner" as dialect name, CockroachDB uses "cockroachdb"
        dialect_name = async_engine.dialect.name
        supports_savepoints = not any(
            dialect_name.startswith(prefix) for prefix in ("sqlite", "duckdb", "spanner", "cockroachdb")
        )

        if supports_savepoints:
            # Use savepoint-based isolation for databases that support it
            connection = await async_engine.connect()
            transaction = await connection.begin()

            try:
                # Create session bound to this connection
                session_factory = async_sessionmaker(bind=connection, expire_on_commit=False)
                session_instance = session_factory()

                # Create savepoint for test isolation
                savepoint = await connection.begin_nested()

                try:
                    yield session_instance
                finally:
                    # Cleanup order: session operations first, then transaction management
                    try:
                        await session_instance.rollback()  # Rollback any pending changes
                    except Exception:
                        pass  # Ignore rollback errors
                    finally:
                        try:
                            await session_instance.close()  # Close session first
                        except Exception:
                            pass

                    # Now handle savepoint cleanup
                    try:
                        if savepoint.is_active:
                            await savepoint.rollback()
                    except Exception:
                        pass  # Ignore savepoint rollback errors

            finally:
                # Rollback the main transaction and close connection
                try:
                    if transaction.is_active:
                        await transaction.rollback()
                except Exception:
                    pass
                finally:
                    try:
                        await connection.close()
                    except Exception:
                        pass
        else:
            # For SQLite/DuckDB: use simple transaction-based isolation without savepoints
            connection = await async_engine.connect()
            transaction = await connection.begin()

            try:
                # Create session bound to this connection
                session_factory = async_sessionmaker(bind=connection, expire_on_commit=False)
                session_instance = session_factory()

                try:
                    yield session_instance
                finally:
                    # Cleanup: session first, then transaction
                    try:
                        await session_instance.rollback()  # Rollback any changes
                    except Exception:
                        pass
                    finally:
                        try:
                            await session_instance.close()
                        except Exception:
                            pass

            finally:
                # Always rollback transaction to clean up
                try:
                    if transaction.is_active:
                        await transaction.rollback()
                except Exception:
                    pass
                finally:
                    try:
                        await connection.close()
                    except Exception:
                        pass
python-advanced-alchemy-1.9.3/tests/integration/helpers.py000066400000000000000000003326221516556515500237570ustar00rootroot00000000000000from __future__ import annotations

import asyncio
import datetime
import logging
import time
from abc import ABC, abstractmethod
from collections.abc import AsyncGenerator, Generator
from contextlib import asynccontextmanager, contextmanager
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Optional, TypeVar, Union, cast

import pytest
from sqlalchemy import Engine, MetaData, NullPool, create_engine, exc, insert, inspect, text
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine
from sqlalchemy.orm import DeclarativeBase

from advanced_alchemy.exceptions import RepositoryError

# Note: cleanup utilities are implemented within this module below.

if TYPE_CHECKING:
    from collections.abc import AsyncGenerator, Generator, Sequence

ModelT = TypeVar("ModelT", bound=DeclarativeBase)

# Module-level cache for model classes to prevent recreation
_model_class_cache: dict[str, type] = {}


class CachedModelRegistry:
    """Registry for caching test model classes with worker isolation.

    This registry creates isolated model classes per worker to prevent
    metadata conflicts during parallel test execution.
    """

    _cache: dict[tuple[str, str, str], type] = {}

    @classmethod
    def get_model(
        cls,
        pk_type: str,
        model_name: str,
        worker_id: str,
        base_module: Any,
    ) -> type:
        """Get or create a cached model class with isolated metadata.

        Args:
            pk_type: Primary key type ('uuid' or 'bigint')
            model_name: Name of the model class
            worker_id: pytest-xdist worker ID
            base_module: Module containing the model classes

        Returns:
            Cached model class with isolated metadata
        """
        cache_key = (pk_type, model_name, worker_id)

        if cache_key not in cls._cache:
            # Get the original model class
            original_model = getattr(base_module, model_name)

            # Create isolated base with new metadata
            class IsolatedBase(DeclarativeBase):
                pass

            # Create new model with worker-specific table name
            table_name = f"{original_model.__tablename__}_{worker_id}"

            # Create a new model class inheriting from the original
            class_dict = {
                "__tablename__": table_name,
                "__mapper_args__": {"concrete": True},
            }

            # Copy all mapped columns and relationships from original
            for key, value in original_model.__dict__.items():
                if not key.startswith("_") and key not in ("__tablename__", "__mapper_args__"):
                    if hasattr(value, "property"):  # It's a mapped property
                        class_dict[key] = value

            IsolatedModel = type(
                f"{model_name}_{worker_id}",
                (original_model.__class__, IsolatedBase),
                class_dict,
            )

            cls._cache[cache_key] = IsolatedModel

        return cls._cache[cache_key]

    @classmethod
    def clear_cache(cls) -> None:
        """Clear the model cache."""
        cls._cache.clear()


def get_worker_id(request: pytest.FixtureRequest) -> str:
    """Get the xdist worker ID for table isolation."""
    return str(getattr(request.config, "workerinput", {}).get("workerid", "master"))


def create_cached_model(
    cache_key: str,
    base_model: type[ModelT],
    table_suffix: str | None = None,
) -> type[ModelT]:
    """Create or retrieve a cached model class with isolated metadata.

    Args:
        cache_key: Unique key for caching the model
        base_model: The base model class to extend
        table_suffix: Optional suffix for table name (defaults to cache_key)

    Returns:
        A unique model class with isolated metadata
    """
    if cache_key not in _model_class_cache:
        # Create new base with isolated metadata
        class IsolatedBase(DeclarativeBase):
            pass

        # Create the model with unique table name
        table_name = f"{base_model.__tablename__}_{table_suffix or cache_key}"

        class IsolatedModel(base_model, IsolatedBase):  # type: ignore[misc,valid-type]
            __tablename__ = table_name
            __mapper_args__ = {"concrete": True}

        _model_class_cache[cache_key] = IsolatedModel

    return _model_class_cache[cache_key]  # type: ignore[return-value]


def create_session_fixtures(
    model_class: type[ModelT],
    table_prefix: str,
    seed_data: list[dict[str, Any]] | None = None,
) -> tuple[Any, Any, Any, Any]:
    """Generate session-scoped fixtures for any model class.

    This is the standard pattern for all integration tests to minimize DDL operations.

    Args:
        model_class: The SQLAlchemy model class
        table_prefix: Prefix for the table name (used for caching)
        seed_data: Optional seed data to insert at session start

    Returns:
        Tuple of (cached_model_fixture, sync_setup_fixture, async_setup_fixture, model_fixture)
    """

    @pytest.fixture(scope="session")
    def cached_model(request: pytest.FixtureRequest) -> type[ModelT]:
        """Create model class once per session/worker."""
        worker_id = get_worker_id(request)
        cache_key = f"{table_prefix}_{worker_id}"
        return create_cached_model(cache_key, model_class, worker_id)

    @pytest.fixture
    def sync_setup(
        cached_model: type[ModelT],
        engine: Engine,
    ) -> Generator[type[ModelT], None, None]:
        """Setup tables and seed data for sync tests."""
        # Skip for mock engines
        if getattr(engine.dialect, "name", "") != "mock":
            # Create tables once per engine type
            cached_model.metadata.create_all(engine)

            # Insert seed data if provided
            if seed_data:
                with engine.begin() as conn:
                    conn.execute(insert(cached_model.__table__), seed_data)  # type: ignore[arg-type]

        yield cached_model

        # Clean up tables at end of test run
        if getattr(engine.dialect, "name", "") != "mock":
            cached_model.metadata.drop_all(engine, checkfirst=True)

    @pytest.fixture
    async def async_setup(
        cached_model: type[ModelT],
        async_engine: AsyncEngine,
    ) -> AsyncGenerator[type[ModelT], None]:
        """Setup tables and seed data for async tests."""
        # Skip for mock engines
        if getattr(async_engine.dialect, "name", "") != "mock":
            # Create tables once per engine type
            async with async_engine.begin() as conn:
                await conn.run_sync(cached_model.metadata.create_all)

                # Insert seed data if provided
                if seed_data:
                    await conn.execute(insert(cached_model.__table__), seed_data)  # type: ignore[arg-type]

        yield cached_model

        # Clean up tables at end of test run
        if getattr(async_engine.dialect, "name", "") != "mock":
            async with async_engine.begin() as conn:
                await conn.run_sync(lambda sync_conn: cached_model.metadata.drop_all(sync_conn, checkfirst=True))

    @pytest.fixture
    def model_fixture(
        sync_setup: type[ModelT] | None = None,
        async_setup: type[ModelT] | None = None,
        engine: Engine | None = None,
        async_engine: AsyncEngine | None = None,
    ) -> Generator[type[ModelT], None, None]:
        """Per-test fixture with fast data cleanup."""
        # Determine which setup to use
        model_class = sync_setup if sync_setup is not None else async_setup
        db_engine = engine if engine is not None else async_engine

        yield model_class  # type: ignore[misc]

        # Fast data-only cleanup between tests
        if db_engine is not None and getattr(db_engine.dialect, "name", "") != "mock":
            clean_tables(db_engine, model_class.metadata)  # type: ignore[union-attr,arg-type]

    return cached_model, sync_setup, async_setup, model_fixture


class SeedDataManager:
    """Manage test seed data efficiently with bulk operations and dependency tracking."""

    def __init__(self, metadata: MetaData):
        self.metadata = metadata
        self._dependencies: dict[str, list[str]] = {}

    def track_dependencies(self, table_name: str, depends_on: list[str]) -> None:
        """Track foreign key dependencies between tables."""
        self._dependencies[table_name] = depends_on

    def get_cleanup_order(self) -> list[str]:
        """Get the correct order for cleaning tables based on dependencies."""
        # Simple topological sort
        visited = set()
        order = []

        def visit(table: str) -> None:
            if table in visited:
                return
            visited.add(table)
            for dep in self._dependencies.get(table, []):
                visit(dep)
            order.append(table)

        for table in self.metadata.tables:
            visit(table)

        return order

    def bulk_insert(
        self,
        engine: Engine,
        model: type[DeclarativeBase],
        data: list[dict[str, Any]],
    ) -> None:
        """Perform bulk insert of seed data."""
        if not data:
            return

        with engine.begin() as conn:
            conn.execute(insert(model.__table__), data)  # type: ignore[arg-type]

    async def async_bulk_insert(
        self,
        async_engine: AsyncEngine,
        model: type[DeclarativeBase],
        data: list[dict[str, Any]],
    ) -> None:
        """Perform async bulk insert of seed data."""
        if not data:
            return

        async with async_engine.begin() as conn:
            await conn.execute(insert(model.__table__), data)  # type: ignore[arg-type]


def update_raw_records(raw_authors: list[dict[str, Any]], raw_rules: list[dict[str, Any]]) -> None:
    for raw_author in raw_authors:
        raw_author["dob"] = datetime.datetime.strptime(raw_author["dob"], "%Y-%m-%d").date()
        raw_author["created_at"] = datetime.datetime.strptime(raw_author["created_at"], "%Y-%m-%dT%H:%M:%S").astimezone(
            datetime.timezone.utc,
        )
        raw_author["updated_at"] = datetime.datetime.strptime(raw_author["updated_at"], "%Y-%m-%dT%H:%M:%S").astimezone(
            datetime.timezone.utc,
        )
    for raw_rule in raw_rules:
        raw_rule["created_at"] = datetime.datetime.strptime(raw_rule["created_at"], "%Y-%m-%dT%H:%M:%S").astimezone(
            datetime.timezone.utc
        )
        raw_rule["updated_at"] = datetime.datetime.strptime(raw_rule["updated_at"], "%Y-%m-%dT%H:%M:%S").astimezone(
            datetime.timezone.utc
        )


__all__ = (
    "AsyncCockroachDBCleaner",
    "AsyncDatabaseCleaner",
    "AsyncDuckDBCleaner",
    "AsyncMSSQLCleaner",
    "AsyncMySQLCleaner",
    "AsyncOracleCleaner",
    "AsyncPostgreSQLCleaner",
    "AsyncSQLiteCleaner",
    "AsyncSpannerCleaner",
    "CleanupError",
    "CleanupStats",
    "CockroachDBCleaner",
    "DatabaseCleaner",
    "DuckDBCleaner",
    "MSSQLCleaner",
    "MySQLCleaner",
    "OracleCleaner",
    "PostgreSQLCleaner",
    "SQLiteCleaner",
    "SpannerCleaner",
    "SyncDatabaseCleaner",
    "async_clean_tables",
    "clean_tables",
    "cleanup_database",
)

logger = logging.getLogger(__name__)


@dataclass
class CleanupStats:
    """Statistics from database cleanup operation.

    Attributes:
        tables_cleaned: Number of tables that were cleaned
        duration_seconds: Total time taken for cleanup
        strategy_used: The cleanup strategy that was applied
        fallback_used: Whether fallback strategy was used
        errors_encountered: Number of errors encountered during cleanup
    """

    tables_cleaned: int = 0
    duration_seconds: float = 0.0
    strategy_used: str = ""
    fallback_used: bool = False
    errors_encountered: int = 0


class CleanupError(RepositoryError):
    """Error occurred during database cleanup.

    Args:
        *args: Variable length argument list passed to parent class.
        detail: Detailed error message.
    """


class DatabaseCleaner(ABC):
    """Abstract base class for database cleanup strategies.

    Each database engine has unique requirements for efficient data cleanup.
    This class defines the interface that all concrete cleaners must implement.

    Args:
        connection: Database connection to use for cleanup
        exclude_tables: Tables to exclude from cleanup
        include_only: Only clean these tables (if specified)
        verify_cleanup: Whether to verify tables are actually clean
        max_retries: Maximum number of retry attempts
        retry_delay: Delay between retries in seconds
    """

    def __init__(
        self,
        connection: Union[Connection, AsyncConnection],
        exclude_tables: Optional[Sequence[str]] = None,
        include_only: Optional[Sequence[str]] = None,
        verify_cleanup: bool = True,
        max_retries: int = 3,
        retry_delay: float = 0.1,
    ) -> None:
        self.connection = connection
        self.exclude_tables = set(exclude_tables or [])
        self.include_only = set(include_only or []) if include_only else None
        self.verify_cleanup = verify_cleanup
        self.max_retries = max_retries
        self.retry_delay = retry_delay
        self.stats = CleanupStats()

    @property
    @abstractmethod
    def dialect_name(self) -> str:
        """The SQLAlchemy dialect name this cleaner handles."""

    @abstractmethod
    def cleanup(self) -> CleanupStats:
        """Perform database cleanup and return statistics.

        Returns:
            CleanupStats: Statistics about the cleanup operation

        Raises:
            CleanupError: If cleanup fails after all retry attempts
        """

    @abstractmethod
    def get_table_list(self) -> Sequence[str]:
        """Get list of tables to clean, respecting include/exclude filters.

        Returns:
            Sequence[str]: List of table names to clean

        Raises:
            CleanupError: If unable to retrieve table list
        """

    @abstractmethod
    def resolve_dependencies(self, tables: Sequence[str]) -> Sequence[str]:
        """Resolve foreign key dependencies for proper cleanup order.

        Args:
            tables: List of table names to order

        Returns:
            Sequence[str]: Tables ordered by dependencies (leaves first)

        Raises:
            CleanupError: If unable to resolve dependencies
        """


class SyncDatabaseCleaner(DatabaseCleaner):
    """Synchronous database cleaner base class."""

    def __init__(
        self,
        connection: Connection,
        exclude_tables: Optional[Sequence[str]] = None,
        include_only: Optional[Sequence[str]] = None,
        verify_cleanup: bool = True,
        max_retries: int = 3,
        retry_delay: float = 0.1,
    ) -> None:
        super().__init__(connection, exclude_tables, include_only, verify_cleanup, max_retries, retry_delay)
        # Narrow the connection attribute to the concrete sync type for mypy
        self.connection: Connection = connection


class AsyncDatabaseCleaner(DatabaseCleaner):
    """Asynchronous database cleaner base class."""

    def __init__(
        self,
        connection: AsyncConnection,
        exclude_tables: Optional[Sequence[str]] = None,
        include_only: Optional[Sequence[str]] = None,
        verify_cleanup: bool = True,
        max_retries: int = 3,
        retry_delay: float = 0.1,
    ) -> None:
        super().__init__(connection, exclude_tables, include_only, verify_cleanup, max_retries, retry_delay)
        # Narrow the connection attribute to the concrete async type for mypy
        self.connection: AsyncConnection = connection

    async def cleanup(self) -> CleanupStats:  # type: ignore[override]
        """Perform async database cleanup and return statistics.

        Returns:
            CleanupStats: Statistics about the cleanup operation

        Raises:
            CleanupError: If cleanup fails after all retry attempts
        """
        return await self._perform_cleanup()

    @abstractmethod
    async def _perform_cleanup(self) -> CleanupStats:
        """Internal method to perform the actual cleanup."""

    @abstractmethod
    async def get_table_list(self) -> Sequence[str]:  # type: ignore[override]
        """Get list of tables to clean asynchronously.

        Returns:
            Sequence[str]: List of table names to clean

        Raises:
            CleanupError: If unable to retrieve table list
        """

    @abstractmethod
    async def resolve_dependencies(self, tables: Sequence[str]) -> Sequence[str]:  # type: ignore[override]
        """Resolve foreign key dependencies asynchronously.

        Args:
            tables: List of table names to order

        Returns:
            Sequence[str]: Tables ordered by dependencies (leaves first)

        Raises:
            CleanupError: If unable to resolve dependencies
        """


class PostgreSQLCleaner(SyncDatabaseCleaner):
    """PostgreSQL/CockroachDB synchronous cleaner using DELETE with sequence reset."""

    @property
    def dialect_name(self) -> str:
        return "postgresql"

    def cleanup(self) -> CleanupStats:
        """Clean PostgreSQL database using DELETE with sequence reset."""
        start_time = time.time()
        self.stats.strategy_used = "DELETE with sequence reset"

        try:
            tables = self.get_table_list()
            if not tables:
                logger.info("No tables to clean")
                return self.stats

            # Resolve dependencies for proper order
            ordered_tables = self.resolve_dependencies(tables)
            self.stats.tables_cleaned = len(ordered_tables)

            # Perform cleanup with retry logic
            for attempt in range(self.max_retries + 1):
                try:
                    self._perform_delete_cascade(ordered_tables)
                    self._reset_sequences()
                    break
                except exc.SQLAlchemyError as e:
                    self.stats.errors_encountered += 1
                    if attempt == self.max_retries:
                        logger.error(f"PostgreSQL cleanup failed after {self.max_retries} attempts: {e}")
                        self._fallback_delete(ordered_tables)
                        self.stats.fallback_used = True
                    else:
                        logger.warning(f"PostgreSQL cleanup attempt {attempt + 1} failed: {e}, retrying...")
                        time.sleep(self.retry_delay * (2**attempt))  # Exponential backoff

            if self.verify_cleanup:
                self._verify_tables_empty(ordered_tables)

        except Exception as e:
            logger.error(f"PostgreSQL cleanup failed: {e}")
            raise CleanupError(f"postgresql cleanup failed: {e}") from e
        finally:
            self.stats.duration_seconds = time.time() - start_time

        return self.stats

    def get_table_list(self) -> Sequence[str]:
        """Get list of PostgreSQL tables to clean."""
        try:
            inspector = inspect(self.connection)
            all_tables = inspector.get_table_names()

            # Apply filters
            if self.include_only:
                tables = [t for t in all_tables if t in self.include_only]
            else:
                tables = [t for t in all_tables if t not in self.exclude_tables]

            return tables
        except Exception as e:
            raise CleanupError(f"failed to get table list: {e}") from e

    def resolve_dependencies(self, tables: Sequence[str]) -> Sequence[str]:
        """Resolve PostgreSQL foreign key dependencies using topological sort."""
        try:
            # For PostgreSQL, DELETE requires proper dependency ordering
            inspector = inspect(self.connection)
            dependency_graph: dict[str, list[str]] = {}

            for table in tables:
                foreign_keys = inspector.get_foreign_keys(table)
                dependencies = [
                    fk["referred_table"]
                    for fk in foreign_keys
                    if fk["referred_table"] in tables and fk["referred_table"] != table
                ]
                dependency_graph[table] = dependencies

            # Topological sort to handle dependencies
            return self._topological_sort(dependency_graph)
        except Exception as e:
            logger.warning(f"Failed to resolve dependencies: {e}, using original order")
            return list(tables)

    def _perform_delete_cascade(self, tables: Sequence[str]) -> None:
        """Perform DELETE operations on PostgreSQL tables."""
        if not tables:
            return

        # Use DELETE statements in reverse dependency order
        for table in reversed(tables):
            sql = f'DELETE FROM "{table}"'
            logger.debug(f"Executing: {sql}")
            self.connection.execute(text(sql))
        self.connection.commit()

    def _reset_sequences(self) -> None:
        """Reset PostgreSQL sequences to start from 1."""
        # Skip sequence reset for CockroachDB as it doesn't support ALTER SEQUENCE ... RESTART
        if "cockroach" in str(self.connection.engine.url).lower():
            return

        try:
            # Get all sequences and reset them
            result = self.connection.execute(
                text("""
                SELECT schemaname, sequencename
                FROM pg_sequences
                WHERE schemaname = 'public'
            """)
            )

            for row in result:
                seq_name = f'"{row[0]}"."{row[1]}"'
                self.connection.execute(text(f"ALTER SEQUENCE {seq_name} RESTART WITH 1"))

            self.connection.commit()
        except Exception as e:
            logger.warning(f"Failed to reset sequences: {e}")

    def _fallback_delete(self, tables: Sequence[str]) -> None:
        """Fallback to DELETE statements if primary delete fails."""
        logger.info("Using DELETE fallback strategy")
        self.stats.strategy_used = "DELETE fallback"

        # Delete in reverse dependency order
        for table in reversed(tables):
            try:
                self.connection.execute(text(f'DELETE FROM "{table}"'))
                self.connection.commit()
            except Exception as e:
                logger.error(f"Failed to delete from {table}: {e}")
                self.stats.errors_encountered += 1
                # Rollback the failed transaction to prevent "current transaction is aborted" errors
                try:
                    self.connection.rollback()
                except Exception:
                    pass  # Ignore rollback errors

    def _verify_tables_empty(self, tables: Sequence[str]) -> None:
        """Verify that all tables are actually empty."""
        for table in tables:
            try:
                result = self.connection.execute(text(f'SELECT COUNT(*) FROM "{table}"'))
                count = result.scalar()
                if count and count > 0:
                    raise CleanupError(f"table {table} still contains {count} rows after cleanup")
            except CleanupError:
                raise
            except Exception as e:
                logger.warning(f"Failed to verify table {table} is empty: {e}")

    def _topological_sort(self, dependency_graph: dict[str, list[str]]) -> list[str]:
        """Perform topological sort to resolve dependency order."""
        in_degree = dict.fromkeys(dependency_graph, 0)

        # Calculate in-degrees
        for node, neighbors in dependency_graph.items():
            for neighbor in neighbors:
                if neighbor in in_degree:
                    in_degree[neighbor] += 1

        # Queue nodes with no dependencies
        queue = [node for node, degree in in_degree.items() if degree == 0]
        result = []

        while queue:
            node = queue.pop(0)
            result.append(node)

            # Reduce in-degree for neighbors
            for neighbor in dependency_graph[node]:
                if neighbor in in_degree:
                    in_degree[neighbor] -= 1
                    if in_degree[neighbor] == 0:
                        queue.append(neighbor)

        return result if len(result) == len(dependency_graph) else list(dependency_graph.keys())


class AsyncPostgreSQLCleaner(AsyncDatabaseCleaner):
    """PostgreSQL/CockroachDB asynchronous cleaner using DELETE with sequence reset."""

    @property
    def dialect_name(self) -> str:
        return "postgresql"

    async def _perform_cleanup(self) -> CleanupStats:
        """Clean PostgreSQL database asynchronously using DELETE."""
        start_time = time.time()
        self.stats.strategy_used = "DELETE with sequence reset"

        try:
            tables = await self.get_table_list()
            if not tables:
                logger.info("No tables to clean")
                return self.stats

            # Resolve dependencies for proper order
            ordered_tables = await self.resolve_dependencies(tables)
            self.stats.tables_cleaned = len(ordered_tables)

            # Perform cleanup with retry logic
            for attempt in range(self.max_retries + 1):
                try:
                    await self._perform_delete_cascade(ordered_tables)
                    await self._reset_sequences()
                    break
                except exc.SQLAlchemyError as e:
                    self.stats.errors_encountered += 1
                    if attempt == self.max_retries:
                        logger.error(f"Async PostgreSQL cleanup failed after {self.max_retries} attempts: {e}")
                        await self._fallback_delete(ordered_tables)
                        self.stats.fallback_used = True
                    else:
                        logger.warning(f"Async PostgreSQL cleanup attempt {attempt + 1} failed: {e}, retrying...")
                        await asyncio.sleep(self.retry_delay * (2**attempt))

            if self.verify_cleanup:
                await self._verify_tables_empty(ordered_tables)

        except Exception as e:
            logger.error(f"Async PostgreSQL cleanup failed: {e}")
            raise CleanupError(f"async postgresql cleanup failed: {e}") from e
        finally:
            self.stats.duration_seconds = time.time() - start_time

        return self.stats

    async def get_table_list(self) -> Sequence[str]:  # type: ignore[override]
        """Get list of PostgreSQL tables to clean asynchronously."""
        try:
            # Use async connection to get table names
            result = await self.connection.execute(
                text("""
                SELECT tablename FROM pg_tables
                WHERE schemaname = 'public'
            """)
            )
            all_tables = [row[0] for row in result]

            # Apply filters
            if self.include_only:
                tables = [t for t in all_tables if t in self.include_only]
            else:
                tables = [t for t in all_tables if t not in self.exclude_tables]

            return tables
        except Exception as e:
            raise CleanupError(f"failed to get table list: {e}") from e

    async def resolve_dependencies(self, tables: Sequence[str]) -> Sequence[str]:  # type: ignore[override]
        """Resolve PostgreSQL foreign key dependencies asynchronously."""
        try:
            # Get foreign key information
            result = await self.connection.execute(
                text("""
                SELECT
                    tc.table_name,
                    ccu.table_name AS foreign_table_name
                FROM information_schema.table_constraints tc
                JOIN information_schema.constraint_column_usage ccu
                    ON tc.constraint_name = ccu.constraint_name
                WHERE tc.constraint_type = 'FOREIGN KEY'
                    AND tc.table_schema = 'public'
            """)
            )

            dependency_graph: dict[str, list[str]] = {table: [] for table in tables}
            for row in result:
                table, foreign_table = row[0], row[1]
                if table in dependency_graph and foreign_table in tables and foreign_table != table:
                    dependency_graph[table].append(foreign_table)

            return self._topological_sort(dependency_graph)
        except Exception as e:
            logger.warning(f"Failed to resolve dependencies: {e}, using original order")
            return list(tables)

    async def _perform_delete_cascade(self, tables: Sequence[str]) -> None:
        """Perform DELETE operations on PostgreSQL tables asynchronously."""
        if not tables:
            return

        # Use DELETE statements in reverse dependency order
        for table in reversed(tables):
            sql = f'DELETE FROM "{table}"'
            logger.debug(f"Executing: {sql}")
            await self.connection.execute(text(sql))
        await self.connection.commit()

    async def _reset_sequences(self) -> None:
        """Reset PostgreSQL sequences asynchronously."""
        # Skip sequence reset for CockroachDB as it doesn't support ALTER SEQUENCE ... RESTART
        if self.dialect_name == "cockroach":
            return

        try:
            result = await self.connection.execute(
                text("""
                SELECT schemaname, sequencename
                FROM pg_sequences
                WHERE schemaname = 'public'
            """)
            )

            for row in result:
                seq_name = f'"{row[0]}"."{row[1]}"'
                await self.connection.execute(text(f"ALTER SEQUENCE {seq_name} RESTART WITH 1"))

            await self.connection.commit()
        except Exception as e:
            logger.warning(f"Failed to reset sequences: {e}")

    async def _fallback_delete(self, tables: Sequence[str]) -> None:
        """Fallback to DELETE statements if primary delete fails."""
        logger.info("Using DELETE fallback strategy")
        self.stats.strategy_used = "DELETE fallback"

        for table in reversed(tables):
            try:
                await self.connection.execute(text(f'DELETE FROM "{table}"'))
                await self.connection.commit()
            except Exception as e:
                logger.error(f"Failed to delete from {table}: {e}")
                self.stats.errors_encountered += 1
                # Rollback the failed transaction to prevent "current transaction is aborted" errors
                try:
                    await self.connection.rollback()
                except Exception:
                    pass  # Ignore rollback errors

    async def _verify_tables_empty(self, tables: Sequence[str]) -> None:
        """Verify that all tables are actually empty asynchronously."""
        for table in tables:
            try:
                # Start a new transaction for verification
                await self.connection.rollback()  # Clear any aborted transaction
                result = await self.connection.execute(text(f'SELECT COUNT(*) FROM "{table}"'))
                count = result.scalar()
                if count and count > 0:
                    raise CleanupError(f"table {table} still contains {count} rows after cleanup")
            except CleanupError:
                raise
            except Exception as e:
                logger.warning(f"Failed to verify table {table} is empty: {e}")

    def _topological_sort(self, dependency_graph: dict[str, list[str]]) -> list[str]:
        """Perform topological sort to resolve dependency order."""
        in_degree = dict.fromkeys(dependency_graph, 0)

        for node, neighbors in dependency_graph.items():
            for neighbor in neighbors:
                if neighbor in in_degree:
                    in_degree[neighbor] += 1

        queue = [node for node, degree in in_degree.items() if degree == 0]
        result = []

        while queue:
            node = queue.pop(0)
            result.append(node)

            for neighbor in dependency_graph[node]:
                if neighbor in in_degree:
                    in_degree[neighbor] -= 1
                    if in_degree[neighbor] == 0:
                        queue.append(neighbor)

        return result if len(result) == len(dependency_graph) else list(dependency_graph.keys())


class CockroachDBCleaner(PostgreSQLCleaner):
    """CockroachDB cleaner - extends PostgreSQL cleaner with CockroachDB-specific optimizations."""

    @property
    def dialect_name(self) -> str:
        return "cockroach"


class AsyncCockroachDBCleaner(AsyncPostgreSQLCleaner):
    """Async CockroachDB cleaner - extends async PostgreSQL cleaner."""

    @property
    def dialect_name(self) -> str:
        return "cockroach"


class SQLiteCleaner(SyncDatabaseCleaner):
    """SQLite synchronous cleaner using DELETE with sqlite_sequence cleanup."""

    @property
    def dialect_name(self) -> str:
        return "sqlite"

    def cleanup(self) -> CleanupStats:
        """Clean SQLite database using DELETE with sequence cleanup."""
        start_time = time.time()
        self.stats.strategy_used = "DELETE with sequence cleanup"

        try:
            tables = self.get_table_list()
            if not tables:
                logger.info("No tables to clean")
                return self.stats

            ordered_tables = self.resolve_dependencies(tables)
            self.stats.tables_cleaned = len(ordered_tables)

            # SQLite cleanup using DELETE operations
            self._perform_delete(ordered_tables)
            self._reset_autoincrement()

            if self.verify_cleanup:
                self._verify_tables_empty(ordered_tables)

        except Exception as e:
            logger.error(f"SQLite cleanup failed: {e}")
            raise CleanupError(f"sqlite cleanup failed: {e}") from e
        finally:
            self.stats.duration_seconds = time.time() - start_time

        return self.stats

    def get_table_list(self) -> Sequence[str]:
        """Get list of SQLite tables to clean."""
        try:
            result = self.connection.execute(
                text("""
                SELECT name FROM sqlite_master
                WHERE type='table' AND name NOT LIKE 'sqlite_%'
            """)
            )
            all_tables = [row[0] for row in result]

            if self.include_only:
                tables = [t for t in all_tables if t in self.include_only]
            else:
                tables = [t for t in all_tables if t not in self.exclude_tables]

            return tables
        except Exception as e:
            raise CleanupError(f"failed to get table list: {e}") from e

    def resolve_dependencies(self, tables: Sequence[str]) -> Sequence[str]:
        """Resolve SQLite foreign key dependencies."""
        try:
            dependency_graph: dict[str, list[str]] = {table: [] for table in tables}

            for table in tables:
                result = self.connection.execute(text(f"PRAGMA foreign_key_list({table})"))
                for row in result:
                    foreign_table = row[2]  # referenced table
                    if foreign_table in tables and foreign_table != table:
                        dependency_graph[table].append(foreign_table)

            return self._topological_sort(dependency_graph)
        except Exception as e:
            logger.warning(f"Failed to resolve dependencies: {e}, using original order")
            return list(tables)

    def _perform_delete(self, tables: Sequence[str]) -> None:
        """Perform DELETE operations on SQLite tables."""
        # Disable foreign key checks temporarily
        self.connection.execute(text("PRAGMA foreign_keys = OFF"))

        # Set more aggressive locking timeout for better concurrency
        self.connection.execute(text("PRAGMA busy_timeout = 30000"))  # 30 seconds

        # Try to set WAL mode, but ignore if it fails (e.g., database locked)
        try:
            self.connection.execute(text("PRAGMA journal_mode = WAL"))  # Write-Ahead Logging mode
        except Exception as e:
            logger.debug(f"Could not set WAL mode: {e}")

        try:
            # Delete in reverse dependency order
            for table in reversed(tables):
                for attempt in range(self.max_retries):
                    try:
                        # Check if we're in a transaction and commit/rollback if needed
                        if self.connection.in_transaction():
                            try:
                                self.connection.commit()
                            except Exception:
                                self.connection.rollback()

                        self.connection.execute(text(f'DELETE FROM "{table}"'))

                        # Commit immediately after each DELETE to release locks
                        if self.connection.in_transaction():
                            self.connection.commit()
                        break
                    except Exception as e:
                        if "database is locked" in str(e) and attempt < self.max_retries - 1:
                            logger.warning(f"Database locked on table {table}, retrying in {self.retry_delay}s...")
                            # Rollback any pending transaction
                            try:
                                if self.connection.in_transaction():
                                    self.connection.rollback()
                            except Exception:
                                pass
                            # Exponential backoff
                            time.sleep(self.retry_delay * (2**attempt))
                        else:
                            raise

        finally:
            # Re-enable foreign key checks
            self.connection.execute(text("PRAGMA foreign_keys = ON"))

    def _reset_autoincrement(self) -> None:
        """Reset SQLite autoincrement sequences."""
        try:
            # Clear sqlite_sequence table to reset autoincrement counters
            if self.connection.in_transaction():
                try:
                    self.connection.commit()
                except Exception:
                    self.connection.rollback()

            self.connection.execute(text("DELETE FROM sqlite_sequence"))
            if self.connection.in_transaction():
                self.connection.commit()
        except Exception as e:
            logger.warning(f"Failed to reset autoincrement: {e}")

    def _verify_tables_empty(self, tables: Sequence[str]) -> None:
        """Verify that all tables are actually empty."""
        for table in tables:
            try:
                result = self.connection.execute(text(f'SELECT COUNT(*) FROM "{table}"'))
                count = result.scalar()
                if count and count > 0:
                    raise CleanupError(f"table {table} still contains {count} rows after cleanup")
            except CleanupError:
                raise
            except Exception as e:
                logger.warning(f"Failed to verify table {table} is empty: {e}")

    def _topological_sort(self, dependency_graph: dict[str, list[str]]) -> list[str]:
        """Perform topological sort for SQLite dependencies."""
        in_degree = dict.fromkeys(dependency_graph, 0)

        for node, neighbors in dependency_graph.items():
            for neighbor in neighbors:
                if neighbor in in_degree:
                    in_degree[neighbor] += 1

        queue = [node for node, degree in in_degree.items() if degree == 0]
        result = []

        while queue:
            node = queue.pop(0)
            result.append(node)

            for neighbor in dependency_graph[node]:
                if neighbor in in_degree:
                    in_degree[neighbor] -= 1
                    if in_degree[neighbor] == 0:
                        queue.append(neighbor)

        return result if len(result) == len(dependency_graph) else list(dependency_graph.keys())


class AsyncSQLiteCleaner(AsyncDatabaseCleaner):
    """SQLite asynchronous cleaner using DELETE with sequence cleanup."""

    @property
    def dialect_name(self) -> str:
        return "sqlite"

    async def _perform_cleanup(self) -> CleanupStats:
        """Clean SQLite database asynchronously."""
        start_time = time.time()
        self.stats.strategy_used = "DELETE with sequence cleanup"

        try:
            tables = await self.get_table_list()
            if not tables:
                logger.info("No tables to clean")
                return self.stats

            ordered_tables = await self.resolve_dependencies(tables)
            self.stats.tables_cleaned = len(ordered_tables)

            await self._perform_delete(ordered_tables)
            await self._reset_autoincrement()

            if self.verify_cleanup:
                await self._verify_tables_empty(ordered_tables)

        except Exception as e:
            logger.error(f"Async SQLite cleanup failed: {e}")
            raise CleanupError(f"async sqlite cleanup failed: {e}") from e
        finally:
            self.stats.duration_seconds = time.time() - start_time

        return self.stats

    async def get_table_list(self) -> Sequence[str]:  # type: ignore[override]
        """Get list of SQLite tables to clean asynchronously."""
        try:
            result = await self.connection.execute(
                text("""
                SELECT name FROM sqlite_master
                WHERE type='table' AND name NOT LIKE 'sqlite_%'
            """)
            )
            all_tables = [row[0] for row in result]

            if self.include_only:
                tables = [t for t in all_tables if t in self.include_only]
            else:
                tables = [t for t in all_tables if t not in self.exclude_tables]

            return tables
        except Exception as e:
            raise CleanupError(f"failed to get table list: {e}") from e

    async def resolve_dependencies(self, tables: Sequence[str]) -> Sequence[str]:  # type: ignore[override]
        """Resolve SQLite foreign key dependencies asynchronously."""
        try:
            dependency_graph: dict[str, list[str]] = {table: [] for table in tables}

            for table in tables:
                result = await self.connection.execute(text(f"PRAGMA foreign_key_list({table})"))
                for row in result:
                    foreign_table = row[2]
                    if foreign_table in tables and foreign_table != table:
                        dependency_graph[table].append(foreign_table)

            return self._topological_sort(dependency_graph)
        except Exception as e:
            logger.warning(f"Failed to resolve dependencies: {e}, using original order")
            return list(tables)

    async def _perform_delete(self, tables: Sequence[str]) -> None:
        """Perform DELETE operations asynchronously."""
        await self.connection.execute(text("PRAGMA foreign_keys = OFF"))

        try:
            for table in reversed(tables):
                for attempt in range(self.max_retries):
                    try:
                        await self.connection.execute(text(f'DELETE FROM "{table}"'))
                        break
                    except Exception as e:
                        if "database is locked" in str(e) and attempt < self.max_retries - 1:
                            logger.warning(f"Database locked on table {table}, retrying in {self.retry_delay}s...")
                            await asyncio.sleep(self.retry_delay)
                        else:
                            raise

            await self.connection.commit()
        finally:
            await self.connection.execute(text("PRAGMA foreign_keys = ON"))

    async def _reset_autoincrement(self) -> None:
        """Reset SQLite autoincrement sequences asynchronously."""
        try:
            await self.connection.execute(text("DELETE FROM sqlite_sequence"))
            await self.connection.commit()
        except Exception as e:
            logger.warning(f"Failed to reset autoincrement: {e}")

    async def _verify_tables_empty(self, tables: Sequence[str]) -> None:
        """Verify that all tables are empty asynchronously."""
        for table in tables:
            try:
                result = await self.connection.execute(text(f'SELECT COUNT(*) FROM "{table}"'))
                count = result.scalar()
                if count and count > 0:
                    raise CleanupError(f"table {table} still contains {count} rows after cleanup")
            except CleanupError:
                raise
            except Exception as e:
                logger.warning(f"Failed to verify table {table} is empty: {e}")

    def _topological_sort(self, dependency_graph: dict[str, list[str]]) -> list[str]:
        """Perform topological sort for dependencies."""
        in_degree = dict.fromkeys(dependency_graph, 0)

        for node, neighbors in dependency_graph.items():
            for neighbor in neighbors:
                if neighbor in in_degree:
                    in_degree[neighbor] += 1

        queue = [node for node, degree in in_degree.items() if degree == 0]
        result = []

        while queue:
            node = queue.pop(0)
            result.append(node)

            for neighbor in dependency_graph[node]:
                if neighbor in in_degree:
                    in_degree[neighbor] -= 1
                    if in_degree[neighbor] == 0:
                        queue.append(neighbor)

        return result if len(result) == len(dependency_graph) else list(dependency_graph.keys())


class MySQLCleaner(SyncDatabaseCleaner):
    """MySQL/MariaDB synchronous cleaner using DELETE with FK bypass and AUTO_INCREMENT reset."""

    @property
    def dialect_name(self) -> str:
        return "mysql"

    def cleanup(self) -> CleanupStats:
        """Clean MySQL database using DELETE with foreign key bypass."""
        start_time = time.time()
        self.stats.strategy_used = "DELETE with FK bypass and AUTO_INCREMENT reset"

        try:
            tables = self.get_table_list()
            if not tables:
                logger.info("No tables to clean")
                return self.stats

            ordered_tables = self.resolve_dependencies(tables)
            self.stats.tables_cleaned = len(ordered_tables)

            # MySQL cleanup with retry
            for attempt in range(self.max_retries + 1):
                try:
                    self._perform_delete(ordered_tables)
                    break
                except exc.SQLAlchemyError as e:
                    self.stats.errors_encountered += 1
                    if attempt == self.max_retries:
                        logger.error(f"MySQL cleanup failed after {self.max_retries} attempts: {e}")
                        self._fallback_delete(ordered_tables)
                        self.stats.fallback_used = True
                    else:
                        logger.warning(f"MySQL cleanup attempt {attempt + 1} failed: {e}, retrying...")
                        time.sleep(self.retry_delay * (2**attempt))

            if self.verify_cleanup:
                self._verify_tables_empty(ordered_tables)

        except Exception as e:
            logger.error(f"MySQL cleanup failed: {e}")
            raise CleanupError(f"mysql cleanup failed: {e}") from e
        finally:
            self.stats.duration_seconds = time.time() - start_time

        return self.stats

    def get_table_list(self) -> Sequence[str]:
        """Get list of MySQL tables to clean."""
        try:
            result = self.connection.execute(
                text("""
                SELECT TABLE_NAME FROM information_schema.TABLES
                WHERE TABLE_SCHEMA = DATABASE() AND TABLE_TYPE = 'BASE TABLE'
            """)
            )
            all_tables = [row[0] for row in result]

            if self.include_only:
                tables = [t for t in all_tables if t in self.include_only]
            else:
                tables = [t for t in all_tables if t not in self.exclude_tables]

            return tables
        except Exception as e:
            raise CleanupError(f"failed to get table list: {e}") from e

    def resolve_dependencies(self, tables: Sequence[str]) -> Sequence[str]:
        """Resolve MySQL foreign key dependencies."""
        try:
            result = self.connection.execute(
                text("""
                SELECT
                    TABLE_NAME,
                    REFERENCED_TABLE_NAME
                FROM information_schema.KEY_COLUMN_USAGE
                WHERE TABLE_SCHEMA = DATABASE()
                    AND REFERENCED_TABLE_NAME IS NOT NULL
            """)
            )

            dependency_graph: dict[str, list[str]] = {table: [] for table in tables}
            for row in result:
                table, referenced_table = row[0], row[1]
                if table in dependency_graph and referenced_table in tables and referenced_table != table:
                    dependency_graph[table].append(referenced_table)

            return self._topological_sort(dependency_graph)
        except Exception as e:
            logger.warning(f"Failed to resolve dependencies: {e}, using original order")
            return list(tables)

    def _perform_delete(self, tables: Sequence[str]) -> None:
        """Perform DELETE operations on MySQL tables."""
        # Disable foreign key checks
        self.connection.execute(text("SET foreign_key_checks = 0"))

        try:
            for table in reversed(tables):
                self.connection.execute(text(f"DELETE FROM `{table}`"))
                # Reset AUTO_INCREMENT manually
                self.connection.execute(text(f"ALTER TABLE `{table}` AUTO_INCREMENT = 1"))

            self.connection.commit()
        finally:
            # Re-enable foreign key checks
            self.connection.execute(text("SET foreign_key_checks = 1"))

    def _fallback_delete(self, tables: Sequence[str]) -> None:
        """Fallback to DELETE statements if primary delete fails."""
        logger.info("Using DELETE fallback strategy")
        self.stats.strategy_used = "DELETE fallback"

        self.connection.execute(text("SET foreign_key_checks = 0"))

        try:
            for table in reversed(tables):
                try:
                    self.connection.execute(text(f"DELETE FROM `{table}`"))
                    # Reset AUTO_INCREMENT manually
                    self.connection.execute(text(f"ALTER TABLE `{table}` AUTO_INCREMENT = 1"))
                except Exception as e:
                    logger.error(f"Failed to delete from {table}: {e}")
                    self.stats.errors_encountered += 1

            self.connection.commit()
        finally:
            self.connection.execute(text("SET foreign_key_checks = 1"))

    def _verify_tables_empty(self, tables: Sequence[str]) -> None:
        """Verify that all tables are empty."""
        for table in tables:
            try:
                result = self.connection.execute(text(f"SELECT COUNT(*) FROM `{table}`"))
                count = result.scalar()
                if count and count > 0:
                    raise CleanupError(f"table {table} still contains {count} rows after cleanup")
            except CleanupError:
                raise
            except Exception as e:
                logger.warning(f"Failed to verify table {table} is empty: {e}")

    def _topological_sort(self, dependency_graph: dict[str, list[str]]) -> list[str]:
        """Perform topological sort for dependencies."""
        in_degree = dict.fromkeys(dependency_graph, 0)

        for node, neighbors in dependency_graph.items():
            for neighbor in neighbors:
                if neighbor in in_degree:
                    in_degree[neighbor] += 1

        queue = [node for node, degree in in_degree.items() if degree == 0]
        result = []

        while queue:
            node = queue.pop(0)
            result.append(node)

            for neighbor in dependency_graph[node]:
                if neighbor in in_degree:
                    in_degree[neighbor] -= 1
                    if in_degree[neighbor] == 0:
                        queue.append(neighbor)

        return result if len(result) == len(dependency_graph) else list(dependency_graph.keys())


class AsyncMySQLCleaner(AsyncDatabaseCleaner):
    """MySQL/MariaDB asynchronous cleaner using DELETE with FK bypass."""

    @property
    def dialect_name(self) -> str:
        return "mysql"

    async def _perform_cleanup(self) -> CleanupStats:
        """Clean MySQL database asynchronously."""
        start_time = time.time()
        self.stats.strategy_used = "DELETE with FK bypass and AUTO_INCREMENT reset"

        try:
            tables = await self.get_table_list()
            if not tables:
                logger.info("No tables to clean")
                return self.stats

            ordered_tables = await self.resolve_dependencies(tables)
            self.stats.tables_cleaned = len(ordered_tables)

            for attempt in range(self.max_retries + 1):
                try:
                    await self._perform_delete(ordered_tables)
                    break
                except exc.SQLAlchemyError as e:
                    self.stats.errors_encountered += 1
                    if attempt == self.max_retries:
                        logger.error(f"Async MySQL cleanup failed after {self.max_retries} attempts: {e}")
                        await self._fallback_delete(ordered_tables)
                        self.stats.fallback_used = True
                    else:
                        logger.warning(f"Async MySQL cleanup attempt {attempt + 1} failed: {e}, retrying...")
                        await asyncio.sleep(self.retry_delay * (2**attempt))

            if self.verify_cleanup:
                await self._verify_tables_empty(ordered_tables)

        except Exception as e:
            logger.error(f"Async MySQL cleanup failed: {e}")
            raise CleanupError(f"async mysql cleanup failed: {e}") from e
        finally:
            self.stats.duration_seconds = time.time() - start_time

        return self.stats

    async def get_table_list(self) -> Sequence[str]:  # type: ignore[override]
        """Get list of MySQL tables asynchronously."""
        try:
            result = await self.connection.execute(
                text("""
                SELECT TABLE_NAME FROM information_schema.TABLES
                WHERE TABLE_SCHEMA = DATABASE() AND TABLE_TYPE = 'BASE TABLE'
            """)
            )
            all_tables = [row[0] for row in result]

            if self.include_only:
                tables = [t for t in all_tables if t in self.include_only]
            else:
                tables = [t for t in all_tables if t not in self.exclude_tables]

            return tables
        except Exception as e:
            raise CleanupError(f"failed to get table list: {e}") from e

    async def resolve_dependencies(self, tables: Sequence[str]) -> Sequence[str]:  # type: ignore[override]
        """Resolve MySQL foreign key dependencies asynchronously."""
        try:
            result = await self.connection.execute(
                text("""
                SELECT
                    TABLE_NAME,
                    REFERENCED_TABLE_NAME
                FROM information_schema.KEY_COLUMN_USAGE
                WHERE TABLE_SCHEMA = DATABASE()
                    AND REFERENCED_TABLE_NAME IS NOT NULL
            """)
            )

            dependency_graph: dict[str, list[str]] = {table: [] for table in tables}
            for row in result:
                table, referenced_table = row[0], row[1]
                if table in dependency_graph and referenced_table in tables and referenced_table != table:
                    dependency_graph[table].append(referenced_table)

            return self._topological_sort(dependency_graph)
        except Exception as e:
            logger.warning(f"Failed to resolve dependencies: {e}, using original order")
            return list(tables)

    async def _perform_delete(self, tables: Sequence[str]) -> None:
        """Perform DELETE operations asynchronously."""
        await self.connection.execute(text("SET foreign_key_checks = 0"))

        try:
            for table in reversed(tables):
                await self.connection.execute(text(f"DELETE FROM `{table}`"))
                await self.connection.execute(text(f"ALTER TABLE `{table}` AUTO_INCREMENT = 1"))

            await self.connection.commit()
        finally:
            await self.connection.execute(text("SET foreign_key_checks = 1"))

    async def _fallback_delete(self, tables: Sequence[str]) -> None:
        """Fallback to DELETE statements if primary delete fails."""
        logger.info("Using DELETE fallback strategy")
        self.stats.strategy_used = "DELETE fallback"

        await self.connection.execute(text("SET foreign_key_checks = 0"))

        try:
            for table in reversed(tables):
                try:
                    await self.connection.execute(text(f"DELETE FROM `{table}`"))
                    await self.connection.execute(text(f"ALTER TABLE `{table}` AUTO_INCREMENT = 1"))
                except Exception as e:
                    logger.error(f"Failed to delete from {table}: {e}")
                    self.stats.errors_encountered += 1

            await self.connection.commit()
        finally:
            await self.connection.execute(text("SET foreign_key_checks = 1"))

    async def _verify_tables_empty(self, tables: Sequence[str]) -> None:
        """Verify that all tables are empty asynchronously."""
        for table in tables:
            try:
                result = await self.connection.execute(text(f"SELECT COUNT(*) FROM `{table}`"))
                count = result.scalar()
                if count and count > 0:
                    raise CleanupError(f"table {table} still contains {count} rows after cleanup")
            except CleanupError:
                raise
            except Exception as e:
                logger.warning(f"Failed to verify table {table} is empty: {e}")

    def _topological_sort(self, dependency_graph: dict[str, list[str]]) -> list[str]:
        """Perform topological sort for dependencies."""
        in_degree = dict.fromkeys(dependency_graph, 0)

        for node, neighbors in dependency_graph.items():
            for neighbor in neighbors:
                if neighbor in in_degree:
                    in_degree[neighbor] += 1

        queue = [node for node, degree in in_degree.items() if degree == 0]
        result = []

        while queue:
            node = queue.pop(0)
            result.append(node)

            for neighbor in dependency_graph[node]:
                if neighbor in in_degree:
                    in_degree[neighbor] -= 1
                    if in_degree[neighbor] == 0:
                        queue.append(neighbor)

        return result if len(result) == len(dependency_graph) else list(dependency_graph.keys())


class OracleCleaner(SyncDatabaseCleaner):
    """Oracle synchronous cleaner using DELETE with constraint management."""

    @property
    def dialect_name(self) -> str:
        return "oracle"

    def cleanup(self) -> CleanupStats:
        """Clean Oracle database using DELETE with constraint management."""
        start_time = time.time()
        self.stats.strategy_used = "DELETE with constraint management"

        try:
            tables = self.get_table_list()
            if not tables:
                logger.info("No tables to clean")
                return self.stats

            ordered_tables = self.resolve_dependencies(tables)
            self.stats.tables_cleaned = len(ordered_tables)

            for attempt in range(self.max_retries + 1):
                try:
                    self._perform_delete(ordered_tables)
                    break
                except exc.SQLAlchemyError as e:
                    self.stats.errors_encountered += 1
                    if attempt == self.max_retries:
                        logger.error(f"Oracle cleanup failed after {self.max_retries} attempts: {e}")
                        self._fallback_delete(ordered_tables)
                        self.stats.fallback_used = True
                    else:
                        logger.warning(f"Oracle cleanup attempt {attempt + 1} failed: {e}, retrying...")
                        time.sleep(self.retry_delay * (2**attempt))

            if self.verify_cleanup:
                self._verify_tables_empty(ordered_tables)

        except Exception as e:
            logger.error(f"Oracle cleanup failed: {e}")
            raise CleanupError(f"oracle cleanup failed: {e}") from e
        finally:
            self.stats.duration_seconds = time.time() - start_time

        return self.stats

    def get_table_list(self) -> Sequence[str]:
        """Get list of Oracle tables to clean."""
        try:
            result = self.connection.execute(
                text("""
                SELECT table_name FROM user_tables
                WHERE table_name NOT LIKE 'SYS_%'
            """)
            )
            all_tables = [row[0] for row in result]

            if self.include_only:
                tables = [t for t in all_tables if t in self.include_only]
            else:
                tables = [t for t in all_tables if t not in self.exclude_tables]

            return tables
        except Exception as e:
            raise CleanupError(f"failed to get table list: {e}") from e

    def resolve_dependencies(self, tables: Sequence[str]) -> Sequence[str]:
        """Resolve Oracle foreign key dependencies."""
        try:
            result = self.connection.execute(
                text("""
                SELECT
                    a.table_name,
                    c.table_name as referenced_table
                FROM user_constraints a
                JOIN user_cons_columns b ON a.constraint_name = b.constraint_name
                JOIN user_constraints c ON a.r_constraint_name = c.constraint_name
                WHERE a.constraint_type = 'R'
            """)
            )

            dependency_graph: dict[str, list[str]] = {table: [] for table in tables}
            for row in result:
                table, referenced_table = row[0], row[1]
                if table in dependency_graph and referenced_table in tables and referenced_table != table:
                    dependency_graph[table].append(referenced_table)

            return self._topological_sort(dependency_graph)
        except Exception as e:
            logger.warning(f"Failed to resolve dependencies: {e}, using original order")
            return list(tables)

    def _perform_delete(self, tables: Sequence[str]) -> None:
        """Perform DELETE operations on Oracle tables."""
        for table in reversed(tables):
            try:
                # Use DELETE operations with proper quoting for reserved words
                self.connection.execute(text(f'DELETE FROM "{table}"'))
            except Exception as e:
                logger.warning(f"Failed to delete from {table}: {e}")
                # Skip table if DELETE fails

        self.connection.commit()

    def _fallback_delete(self, tables: Sequence[str]) -> None:
        """Fallback to DELETE for Oracle tables."""
        logger.info("Using DELETE fallback strategy")
        self.stats.strategy_used = "DELETE fallback"

        for table in reversed(tables):
            try:
                self.connection.execute(text(f'DELETE FROM "{table}"'))
            except Exception as e:
                logger.error(f"Failed to delete from {table}: {e}")
                self.stats.errors_encountered += 1

        self.connection.commit()

    def _verify_tables_empty(self, tables: Sequence[str]) -> None:
        """Verify that all tables are empty."""
        for table in tables:
            try:
                result = self.connection.execute(text(f'SELECT COUNT(*) FROM "{table}"'))
                count = result.scalar()
                if count and count > 0:
                    raise CleanupError(f"table {table} still contains {count} rows after cleanup")
            except CleanupError:
                raise
            except Exception as e:
                logger.warning(f"Failed to verify table {table} is empty: {e}")

    def _topological_sort(self, dependency_graph: dict[str, list[str]]) -> list[str]:
        """Perform topological sort for dependencies."""
        in_degree = dict.fromkeys(dependency_graph, 0)

        for node, neighbors in dependency_graph.items():
            for neighbor in neighbors:
                if neighbor in in_degree:
                    in_degree[neighbor] += 1

        queue = [node for node, degree in in_degree.items() if degree == 0]
        result = []

        while queue:
            node = queue.pop(0)
            result.append(node)

            for neighbor in dependency_graph[node]:
                if neighbor in in_degree:
                    in_degree[neighbor] -= 1
                    if in_degree[neighbor] == 0:
                        queue.append(neighbor)

        return result if len(result) == len(dependency_graph) else list(dependency_graph.keys())


class AsyncOracleCleaner(AsyncDatabaseCleaner):
    """Oracle asynchronous cleaner using DELETE with constraint management."""

    @property
    def dialect_name(self) -> str:
        return "oracle"

    async def _perform_cleanup(self) -> CleanupStats:
        """Clean Oracle database asynchronously."""
        start_time = time.time()
        self.stats.strategy_used = "DELETE with constraint management"

        try:
            tables = await self.get_table_list()
            if not tables:
                logger.info("No tables to clean")
                return self.stats

            ordered_tables = await self.resolve_dependencies(tables)
            self.stats.tables_cleaned = len(ordered_tables)

            for attempt in range(self.max_retries + 1):
                try:
                    await self._perform_delete(ordered_tables)
                    break
                except exc.SQLAlchemyError as e:
                    self.stats.errors_encountered += 1
                    if attempt == self.max_retries:
                        logger.error(f"Async Oracle cleanup failed after {self.max_retries} attempts: {e}")
                        await self._fallback_delete(ordered_tables)
                        self.stats.fallback_used = True
                    else:
                        logger.warning(f"Async Oracle cleanup attempt {attempt + 1} failed: {e}, retrying...")
                        await asyncio.sleep(self.retry_delay * (2**attempt))

            if self.verify_cleanup:
                await self._verify_tables_empty(ordered_tables)

        except Exception as e:
            logger.error(f"Async Oracle cleanup failed: {e}")
            raise CleanupError(f"async oracle cleanup failed: {e}") from e
        finally:
            self.stats.duration_seconds = time.time() - start_time

        return self.stats

    async def get_table_list(self) -> Sequence[str]:  # type: ignore[override]
        """Get list of Oracle tables asynchronously."""
        try:
            result = await self.connection.execute(
                text("""
                SELECT table_name FROM user_tables
                WHERE table_name NOT LIKE 'SYS_%'
            """)
            )
            all_tables = [row[0] for row in result]

            if self.include_only:
                tables = [t for t in all_tables if t in self.include_only]
            else:
                tables = [t for t in all_tables if t not in self.exclude_tables]

            return tables
        except Exception as e:
            raise CleanupError(f"failed to get table list: {e}") from e

    async def resolve_dependencies(self, tables: Sequence[str]) -> Sequence[str]:  # type: ignore[override]
        """Resolve Oracle foreign key dependencies asynchronously."""
        try:
            result = await self.connection.execute(
                text("""
                SELECT
                    a.table_name,
                    c.table_name as referenced_table
                FROM user_constraints a
                JOIN user_cons_columns b ON a.constraint_name = b.constraint_name
                JOIN user_constraints c ON a.r_constraint_name = c.constraint_name
                WHERE a.constraint_type = 'R'
            """)
            )

            dependency_graph: dict[str, list[str]] = {table: [] for table in tables}
            for row in result:
                table, referenced_table = row[0], row[1]
                if table in dependency_graph and referenced_table in tables and referenced_table != table:
                    dependency_graph[table].append(referenced_table)

            return self._topological_sort(dependency_graph)
        except Exception as e:
            logger.warning(f"Failed to resolve dependencies: {e}, using original order")
            return list(tables)

    async def _perform_delete(self, tables: Sequence[str]) -> None:
        """Perform DELETE operations asynchronously."""
        for table in reversed(tables):
            try:
                # Use DELETE operations with proper quoting for reserved words
                await self.connection.execute(text(f'DELETE FROM "{table}"'))
            except Exception as e:
                logger.warning(f"Failed to delete from {table}: {e}")
                # Skip table if DELETE fails

        await self.connection.commit()

    async def _fallback_delete(self, tables: Sequence[str]) -> None:
        """Fallback to DELETE for Oracle tables."""
        logger.info("Using DELETE fallback strategy")
        self.stats.strategy_used = "DELETE fallback"

        for table in reversed(tables):
            try:
                await self.connection.execute(text(f'DELETE FROM "{table}"'))
            except Exception as e:
                logger.error(f"Failed to delete from {table}: {e}")
                self.stats.errors_encountered += 1

        await self.connection.commit()

    async def _verify_tables_empty(self, tables: Sequence[str]) -> None:
        """Verify that all tables are empty asynchronously."""
        for table in tables:
            try:
                result = await self.connection.execute(text(f'SELECT COUNT(*) FROM "{table}"'))
                count = result.scalar()
                if count and count > 0:
                    raise CleanupError(f"table {table} still contains {count} rows after cleanup")
            except CleanupError:
                raise
            except Exception as e:
                logger.warning(f"Failed to verify table {table} is empty: {e}")

    def _topological_sort(self, dependency_graph: dict[str, list[str]]) -> list[str]:
        """Perform topological sort for dependencies."""
        in_degree = dict.fromkeys(dependency_graph, 0)

        for node, neighbors in dependency_graph.items():
            for neighbor in neighbors:
                if neighbor in in_degree:
                    in_degree[neighbor] += 1

        queue = [node for node, degree in in_degree.items() if degree == 0]
        result = []

        while queue:
            node = queue.pop(0)
            result.append(node)

            for neighbor in dependency_graph[node]:
                if neighbor in in_degree:
                    in_degree[neighbor] -= 1
                    if in_degree[neighbor] == 0:
                        queue.append(neighbor)

        return result if len(result) == len(dependency_graph) else list(dependency_graph.keys())


class MSSQLCleaner(SyncDatabaseCleaner):
    """MS SQL Server synchronous cleaner using DELETE with IDENTITY reset."""

    @property
    def dialect_name(self) -> str:
        return "mssql"

    def cleanup(self) -> CleanupStats:
        """Clean MS SQL Server database using DELETE with IDENTITY reset."""
        start_time = time.time()
        self.stats.strategy_used = "DELETE with IDENTITY reset"

        try:
            tables = self.get_table_list()
            if not tables:
                logger.info("No tables to clean")
                return self.stats

            ordered_tables = self.resolve_dependencies(tables)
            self.stats.tables_cleaned = len(ordered_tables)

            for attempt in range(self.max_retries + 1):
                try:
                    self._perform_delete(ordered_tables)
                    break
                except exc.SQLAlchemyError as e:
                    self.stats.errors_encountered += 1
                    if attempt == self.max_retries:
                        logger.error(f"MSSQL cleanup failed after {self.max_retries} attempts: {e}")
                        self._fallback_delete(ordered_tables)
                        self.stats.fallback_used = True
                    else:
                        logger.warning(f"MSSQL cleanup attempt {attempt + 1} failed: {e}, retrying...")
                        time.sleep(self.retry_delay * (2**attempt))

            if self.verify_cleanup:
                self._verify_tables_empty(ordered_tables)

        except Exception as e:
            logger.error(f"MSSQL cleanup failed: {e}")
            raise CleanupError(f"mssql cleanup failed: {e}") from e
        finally:
            self.stats.duration_seconds = time.time() - start_time

        return self.stats

    def get_table_list(self) -> Sequence[str]:
        """Get list of MS SQL Server tables to clean."""
        try:
            result = self.connection.execute(
                text("""
                SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES
                WHERE TABLE_TYPE = 'BASE TABLE' AND TABLE_SCHEMA = 'dbo'
            """)
            )
            all_tables = [row[0] for row in result]

            if self.include_only:
                tables = [t for t in all_tables if t in self.include_only]
            else:
                tables = [t for t in all_tables if t not in self.exclude_tables]

            return tables
        except Exception as e:
            raise CleanupError(f"failed to get table list: {e}") from e

    def resolve_dependencies(self, tables: Sequence[str]) -> Sequence[str]:
        """Resolve MS SQL Server foreign key dependencies."""
        try:
            result = self.connection.execute(
                text("""
                SELECT
                    fk.TABLE_NAME,
                    fk.REFERENCED_TABLE_NAME
                FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS rc
                JOIN INFORMATION_SCHEMA.TABLE_CONSTRAINTS fk
                    ON rc.CONSTRAINT_NAME = fk.CONSTRAINT_NAME
                JOIN INFORMATION_SCHEMA.TABLE_CONSTRAINTS pk
                    ON rc.UNIQUE_CONSTRAINT_NAME = pk.CONSTRAINT_NAME
                WHERE fk.TABLE_SCHEMA = 'dbo'
            """)
            )

            dependency_graph: dict[str, list[str]] = {table: [] for table in tables}
            for row in result:
                table, referenced_table = row[0], row[1]
                if table in dependency_graph and referenced_table in tables and referenced_table != table:
                    dependency_graph[table].append(referenced_table)

            return self._topological_sort(dependency_graph)
        except Exception as e:
            logger.warning(f"Failed to resolve dependencies: {e}, using original order")
            return list(tables)

    def _perform_delete(self, tables: Sequence[str]) -> None:
        """Perform DELETE operations on MS SQL Server tables."""
        # Disable foreign key constraints
        self.connection.execute(text("EXEC sp_MSForEachTable 'ALTER TABLE ? NOCHECK CONSTRAINT ALL'"))

        try:
            for table in reversed(tables):
                self.connection.execute(text(f"DELETE FROM [{table}]"))
                # Reset IDENTITY manually
                self.connection.execute(text(f"DBCC CHECKIDENT('{table}', RESEED, 0)"))

            self.connection.commit()
        finally:
            # Re-enable foreign key constraints
            self.connection.execute(text("EXEC sp_MSForEachTable 'ALTER TABLE ? WITH CHECK CHECK CONSTRAINT ALL'"))

    def _fallback_delete(self, tables: Sequence[str]) -> None:
        """Fallback to DELETE statements if primary delete fails."""
        logger.info("Using DELETE fallback strategy")
        self.stats.strategy_used = "DELETE fallback"

        for table in reversed(tables):
            try:
                self.connection.execute(text(f"DELETE FROM [{table}]"))
                self.connection.execute(text(f"DBCC CHECKIDENT('{table}', RESEED, 0)"))
            except Exception as e:
                logger.error(f"Failed to delete from {table}: {e}")
                self.stats.errors_encountered += 1

        self.connection.commit()

    def _verify_tables_empty(self, tables: Sequence[str]) -> None:
        """Verify that all tables are empty."""
        for table in tables:
            try:
                result = self.connection.execute(text(f"SELECT COUNT(*) FROM [{table}]"))
                count = result.scalar()
                if count and count > 0:
                    raise CleanupError(f"table {table} still contains {count} rows after cleanup")
            except CleanupError:
                raise
            except Exception as e:
                logger.warning(f"Failed to verify table {table} is empty: {e}")

    def _topological_sort(self, dependency_graph: dict[str, list[str]]) -> list[str]:
        """Perform topological sort for dependencies."""
        in_degree = dict.fromkeys(dependency_graph, 0)

        for node, neighbors in dependency_graph.items():
            for neighbor in neighbors:
                if neighbor in in_degree:
                    in_degree[neighbor] += 1

        queue = [node for node, degree in in_degree.items() if degree == 0]
        result = []

        while queue:
            node = queue.pop(0)
            result.append(node)

            for neighbor in dependency_graph[node]:
                if neighbor in in_degree:
                    in_degree[neighbor] -= 1
                    if in_degree[neighbor] == 0:
                        queue.append(neighbor)

        return result if len(result) == len(dependency_graph) else list(dependency_graph.keys())


class AsyncMSSQLCleaner(AsyncDatabaseCleaner):
    """MS SQL Server asynchronous cleaner using DELETE with IDENTITY reset."""

    @property
    def dialect_name(self) -> str:
        return "mssql"

    async def _perform_cleanup(self) -> CleanupStats:
        """Clean MS SQL Server database asynchronously."""
        start_time = time.time()
        self.stats.strategy_used = "DELETE with IDENTITY reset"

        try:
            tables = await self.get_table_list()
            if not tables:
                logger.info("No tables to clean")
                return self.stats

            ordered_tables = await self.resolve_dependencies(tables)
            self.stats.tables_cleaned = len(ordered_tables)

            for attempt in range(self.max_retries + 1):
                try:
                    await self._perform_delete(ordered_tables)
                    break
                except exc.SQLAlchemyError as e:
                    self.stats.errors_encountered += 1
                    if attempt == self.max_retries:
                        logger.error(f"Async MSSQL cleanup failed after {self.max_retries} attempts: {e}")
                        await self._fallback_delete(ordered_tables)
                        self.stats.fallback_used = True
                    else:
                        logger.warning(f"Async MSSQL cleanup attempt {attempt + 1} failed: {e}, retrying...")
                        await asyncio.sleep(self.retry_delay * (2**attempt))

            if self.verify_cleanup:
                await self._verify_tables_empty(ordered_tables)

        except Exception as e:
            logger.error(f"Async MSSQL cleanup failed: {e}")
            raise CleanupError(f"async mssql cleanup failed: {e}") from e
        finally:
            self.stats.duration_seconds = time.time() - start_time

        return self.stats

    async def get_table_list(self) -> Sequence[str]:  # type: ignore[override]
        """Get list of MS SQL Server tables asynchronously."""
        try:
            result = await self.connection.execute(
                text("""
                SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES
                WHERE TABLE_TYPE = 'BASE TABLE' AND TABLE_SCHEMA = 'dbo'
            """)
            )
            all_tables = [row[0] for row in result]

            if self.include_only:
                tables = [t for t in all_tables if t in self.include_only]
            else:
                tables = [t for t in all_tables if t not in self.exclude_tables]

            return tables
        except Exception as e:
            raise CleanupError(f"failed to get table list: {e}") from e

    async def resolve_dependencies(self, tables: Sequence[str]) -> Sequence[str]:  # type: ignore[override]
        """Resolve MS SQL Server foreign key dependencies asynchronously."""
        try:
            result = await self.connection.execute(
                text("""
                SELECT
                    fk.TABLE_NAME,
                    fk.REFERENCED_TABLE_NAME
                FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS rc
                JOIN INFORMATION_SCHEMA.TABLE_CONSTRAINTS fk
                    ON rc.CONSTRAINT_NAME = fk.CONSTRAINT_NAME
                JOIN INFORMATION_SCHEMA.TABLE_CONSTRAINTS pk
                    ON rc.UNIQUE_CONSTRAINT_NAME = pk.CONSTRAINT_NAME
                WHERE fk.TABLE_SCHEMA = 'dbo'
            """)
            )

            dependency_graph: dict[str, list[str]] = {table: [] for table in tables}
            for row in result:
                table, referenced_table = row[0], row[1]
                if table in dependency_graph and referenced_table in tables and referenced_table != table:
                    dependency_graph[table].append(referenced_table)

            return self._topological_sort(dependency_graph)
        except Exception as e:
            logger.warning(f"Failed to resolve dependencies: {e}, using original order")
            return list(tables)

    async def _perform_delete(self, tables: Sequence[str]) -> None:
        """Perform DELETE operations asynchronously."""
        await self.connection.execute(text("EXEC sp_MSForEachTable 'ALTER TABLE ? NOCHECK CONSTRAINT ALL'"))

        try:
            for table in reversed(tables):
                await self.connection.execute(text(f"DELETE FROM [{table}]"))
                await self.connection.execute(text(f"DBCC CHECKIDENT('{table}', RESEED, 0)"))

            await self.connection.commit()
        finally:
            await self.connection.execute(
                text("EXEC sp_MSForEachTable 'ALTER TABLE ? WITH CHECK CHECK CONSTRAINT ALL'")
            )

    async def _fallback_delete(self, tables: Sequence[str]) -> None:
        """Fallback to DELETE statements if primary delete fails."""
        logger.info("Using DELETE fallback strategy")
        self.stats.strategy_used = "DELETE fallback"

        for table in reversed(tables):
            try:
                await self.connection.execute(text(f"DELETE FROM [{table}]"))
                await self.connection.execute(text(f"DBCC CHECKIDENT('{table}', RESEED, 0)"))
            except Exception as e:
                logger.error(f"Failed to delete from {table}: {e}")
                self.stats.errors_encountered += 1

        await self.connection.commit()

    async def _verify_tables_empty(self, tables: Sequence[str]) -> None:
        """Verify that all tables are empty asynchronously."""
        for table in tables:
            try:
                result = await self.connection.execute(text(f"SELECT COUNT(*) FROM [{table}]"))
                count = result.scalar()
                if count and count > 0:
                    raise CleanupError(f"table {table} still contains {count} rows after cleanup")
            except CleanupError:
                raise
            except Exception as e:
                logger.warning(f"Failed to verify table {table} is empty: {e}")

    def _topological_sort(self, dependency_graph: dict[str, list[str]]) -> list[str]:
        """Perform topological sort for dependencies."""
        in_degree = dict.fromkeys(dependency_graph, 0)

        for node, neighbors in dependency_graph.items():
            for neighbor in neighbors:
                if neighbor in in_degree:
                    in_degree[neighbor] += 1

        queue = [node for node, degree in in_degree.items() if degree == 0]
        result = []

        while queue:
            node = queue.pop(0)
            result.append(node)

            for neighbor in dependency_graph[node]:
                if neighbor in in_degree:
                    in_degree[neighbor] -= 1
                    if in_degree[neighbor] == 0:
                        queue.append(neighbor)

        return result if len(result) == len(dependency_graph) else list(dependency_graph.keys())


class DuckDBCleaner(SyncDatabaseCleaner):
    """DuckDB synchronous cleaner using simple DELETE."""

    @property
    def dialect_name(self) -> str:
        return "duckdb"

    def cleanup(self) -> CleanupStats:
        """Clean DuckDB database using simple DELETE."""
        start_time = time.time()
        self.stats.strategy_used = "DELETE"

        try:
            tables = self.get_table_list()
            if not tables:
                logger.info("No tables to clean")
                return self.stats

            ordered_tables = self.resolve_dependencies(tables)
            self.stats.tables_cleaned = len(ordered_tables)

            self._perform_cleanup_operations(ordered_tables)

            if self.verify_cleanup:
                self._verify_tables_empty(ordered_tables)

        except Exception as e:
            logger.error(f"DuckDB cleanup failed: {e}")
            raise CleanupError(f"duckdb cleanup failed: {e}") from e
        finally:
            self.stats.duration_seconds = time.time() - start_time

        return self.stats

    def get_table_list(self) -> Sequence[str]:
        """Get list of DuckDB tables to clean."""
        try:
            result = self.connection.execute(
                text("""
                SELECT table_name FROM information_schema.tables
                WHERE table_schema = 'main' AND table_type = 'BASE TABLE'
            """)
            )
            all_tables = [row[0] for row in result]

            if self.include_only:
                tables = [t for t in all_tables if t in self.include_only]
            else:
                tables = [t for t in all_tables if t not in self.exclude_tables]

            return tables
        except Exception as e:
            raise CleanupError(f"failed to get table list: {e}") from e

    def resolve_dependencies(self, tables: Sequence[str]) -> Sequence[str]:
        """For DuckDB, return tables in original order since dependencies are simpler."""
        return list(tables)

    def _perform_cleanup_operations(self, tables: Sequence[str]) -> None:
        """Perform cleanup operations on DuckDB tables."""
        for table in reversed(tables):
            try:
                self.connection.execute(text(f'DELETE FROM "{table}"'))
            except Exception as delete_e:
                logger.error(f"Failed to clean table {table}: {delete_e}")
                self.stats.errors_encountered += 1

        self.connection.commit()

    def _verify_tables_empty(self, tables: Sequence[str]) -> None:
        """Verify that all tables are empty."""
        for table in tables:
            try:
                result = self.connection.execute(text(f'SELECT COUNT(*) FROM "{table}"'))
                count = result.scalar()
                if count and count > 0:
                    raise CleanupError(f"table {table} still contains {count} rows after cleanup")
            except CleanupError:
                raise
            except Exception as e:
                logger.warning(f"Failed to verify table {table} is empty: {e}")


class AsyncDuckDBCleaner(AsyncDatabaseCleaner):
    """DuckDB asynchronous cleaner using simple DELETE."""

    @property
    def dialect_name(self) -> str:
        return "duckdb"

    async def _perform_cleanup(self) -> CleanupStats:
        """Clean DuckDB database asynchronously."""
        start_time = time.time()
        self.stats.strategy_used = "DELETE"

        try:
            tables = await self.get_table_list()
            if not tables:
                logger.info("No tables to clean")
                return self.stats

            ordered_tables = await self.resolve_dependencies(tables)
            self.stats.tables_cleaned = len(ordered_tables)

            await self._perform_cleanup_operations(ordered_tables)

            if self.verify_cleanup:
                await self._verify_tables_empty(ordered_tables)

        except Exception as e:
            logger.error(f"Async DuckDB cleanup failed: {e}")
            raise CleanupError(f"async duckdb cleanup failed: {e}") from e
        finally:
            self.stats.duration_seconds = time.time() - start_time

        return self.stats

    async def get_table_list(self) -> Sequence[str]:  # type: ignore[override]
        """Get list of DuckDB tables asynchronously."""
        try:
            result = await self.connection.execute(
                text("""
                SELECT table_name FROM information_schema.tables
                WHERE table_schema = 'main' AND table_type = 'BASE TABLE'
            """)
            )
            all_tables = [row[0] for row in result]

            if self.include_only:
                tables = [t for t in all_tables if t in self.include_only]
            else:
                tables = [t for t in all_tables if t not in self.exclude_tables]

            return tables
        except Exception as e:
            raise CleanupError(f"failed to get table list: {e}") from e

    async def resolve_dependencies(self, tables: Sequence[str]) -> Sequence[str]:  # type: ignore[override]
        """For DuckDB, return tables in original order."""
        return list(tables)

    async def _perform_cleanup_operations(self, tables: Sequence[str]) -> None:
        """Perform cleanup operations asynchronously."""
        for table in reversed(tables):
            try:
                await self.connection.execute(text(f'DELETE FROM "{table}"'))
            except Exception as delete_e:
                logger.error(f"Failed to clean table {table}: {delete_e}")
                self.stats.errors_encountered += 1

        await self.connection.commit()

    async def _verify_tables_empty(self, tables: Sequence[str]) -> None:
        """Verify that all tables are empty asynchronously."""
        for table in tables:
            try:
                result = await self.connection.execute(text(f'SELECT COUNT(*) FROM "{table}"'))
                count = result.scalar()
                if count and count > 0:
                    raise CleanupError(f"table {table} still contains {count} rows after cleanup")
            except CleanupError:
                raise
            except Exception as e:
                logger.warning(f"Failed to verify table {table} is empty: {e}")


class SpannerCleaner(SyncDatabaseCleaner):
    """Google Cloud Spanner synchronous cleaner using DELETE operations."""

    @property
    def dialect_name(self) -> str:
        return "spanner"

    def cleanup(self) -> CleanupStats:
        """Clean Spanner database using DELETE statements."""
        start_time = time.time()
        self.stats.strategy_used = "DELETE operations"

        try:
            tables = self.get_table_list()
            if not tables:
                logger.info("No tables to clean")
                return self.stats

            ordered_tables = self.resolve_dependencies(tables)
            self.stats.tables_cleaned = len(ordered_tables)

            self._perform_delete_operations(ordered_tables)

            if self.verify_cleanup:
                self._verify_tables_empty(ordered_tables)

        except Exception as e:
            logger.error(f"Spanner cleanup failed: {e}")
            raise CleanupError(f"spanner cleanup failed: {e}") from e
        finally:
            self.stats.duration_seconds = time.time() - start_time

        return self.stats

    def get_table_list(self) -> Sequence[str]:
        """Get list of Spanner tables to clean."""
        try:
            result = self.connection.execute(
                text("""
                SELECT table_name FROM information_schema.tables
                WHERE table_catalog = '' AND table_schema = ''
                    AND table_type = 'BASE TABLE'
            """)
            )
            all_tables = [row[0] for row in result]

            if self.include_only:
                tables = [t for t in all_tables if t in self.include_only]
            else:
                tables = [t for t in all_tables if t not in self.exclude_tables]

            return tables
        except Exception as e:
            raise CleanupError(f"failed to get table list: {e}") from e

    def resolve_dependencies(self, tables: Sequence[str]) -> Sequence[str]:
        """Resolve Spanner foreign key dependencies."""
        try:
            # Spanner foreign key information is limited, so we use a simple approach
            result = self.connection.execute(
                text("""
                SELECT
                    tc.table_name,
                    ccu.table_name AS foreign_table_name
                FROM information_schema.table_constraints tc
                JOIN information_schema.constraint_column_usage ccu
                    ON tc.constraint_name = ccu.constraint_name
                WHERE tc.constraint_type = 'FOREIGN KEY'
            """)
            )

            dependency_graph: dict[str, list[str]] = {table: [] for table in tables}
            for row in result:
                table, foreign_table = row[0], row[1]
                if table in dependency_graph and foreign_table in tables and foreign_table != table:
                    dependency_graph[table].append(foreign_table)

            return self._topological_sort(dependency_graph)
        except Exception as e:
            logger.warning(f"Failed to resolve dependencies: {e}, using original order")
            return list(tables)

    def _perform_delete_operations(self, tables: Sequence[str]) -> None:
        """Perform DELETE operations on Spanner tables."""
        # Delete in reverse dependency order
        for table in reversed(tables):
            try:
                self.connection.execute(text(f"DELETE FROM {table} WHERE TRUE"))
            except Exception as e:
                logger.error(f"Failed to delete from {table}: {e}")
                self.stats.errors_encountered += 1

        self.connection.commit()

    def _verify_tables_empty(self, tables: Sequence[str]) -> None:
        """Verify that all tables are empty."""
        for table in tables:
            try:
                result = self.connection.execute(text(f'SELECT COUNT(*) FROM "{table}"'))
                count = result.scalar()
                if count and count > 0:
                    raise CleanupError(f"table {table} still contains {count} rows after cleanup")
            except CleanupError:
                raise
            except Exception as e:
                logger.warning(f"Failed to verify table {table} is empty: {e}")

    def _topological_sort(self, dependency_graph: dict[str, list[str]]) -> list[str]:
        """Perform topological sort for dependencies."""
        in_degree = dict.fromkeys(dependency_graph, 0)

        for node, neighbors in dependency_graph.items():
            for neighbor in neighbors:
                if neighbor in in_degree:
                    in_degree[neighbor] += 1

        queue = [node for node, degree in in_degree.items() if degree == 0]
        result = []

        while queue:
            node = queue.pop(0)
            result.append(node)

            for neighbor in dependency_graph[node]:
                if neighbor in in_degree:
                    in_degree[neighbor] -= 1
                    if in_degree[neighbor] == 0:
                        queue.append(neighbor)

        return result if len(result) == len(dependency_graph) else list(dependency_graph.keys())


class AsyncSpannerCleaner(AsyncDatabaseCleaner):
    """Google Cloud Spanner asynchronous cleaner using DELETE statements."""

    @property
    def dialect_name(self) -> str:
        return "spanner"

    async def _perform_cleanup(self) -> CleanupStats:
        """Clean Spanner database asynchronously."""
        start_time = time.time()
        self.stats.strategy_used = "DELETE operations"

        try:
            tables = await self.get_table_list()
            if not tables:
                logger.info("No tables to clean")
                return self.stats

            ordered_tables = await self.resolve_dependencies(tables)
            self.stats.tables_cleaned = len(ordered_tables)

            await self._perform_delete_operations(ordered_tables)

            if self.verify_cleanup:
                await self._verify_tables_empty(ordered_tables)

        except Exception as e:
            logger.error(f"Async Spanner cleanup failed: {e}")
            raise CleanupError(f"async spanner cleanup failed: {e}") from e
        finally:
            self.stats.duration_seconds = time.time() - start_time

        return self.stats

    async def get_table_list(self) -> Sequence[str]:  # type: ignore[override]
        """Get list of Spanner tables asynchronously."""
        try:
            result = await self.connection.execute(
                text("""
                SELECT table_name FROM information_schema.tables
                WHERE table_catalog = '' AND table_schema = ''
                    AND table_type = 'BASE TABLE'
            """)
            )
            all_tables = [row[0] for row in result]

            if self.include_only:
                tables = [t for t in all_tables if t in self.include_only]
            else:
                tables = [t for t in all_tables if t not in self.exclude_tables]

            return tables
        except Exception as e:
            raise CleanupError(f"failed to get table list: {e}") from e

    async def resolve_dependencies(self, tables: Sequence[str]) -> Sequence[str]:  # type: ignore[override]
        """Resolve Spanner foreign key dependencies asynchronously."""
        try:
            result = await self.connection.execute(
                text("""
                SELECT
                    tc.table_name,
                    ccu.table_name AS foreign_table_name
                FROM information_schema.table_constraints tc
                JOIN information_schema.constraint_column_usage ccu
                    ON tc.constraint_name = ccu.constraint_name
                WHERE tc.constraint_type = 'FOREIGN KEY'
            """)
            )

            dependency_graph: dict[str, list[str]] = {table: [] for table in tables}
            for row in result:
                table, foreign_table = row[0], row[1]
                if table in dependency_graph and foreign_table in tables and foreign_table != table:
                    dependency_graph[table].append(foreign_table)

            return self._topological_sort(dependency_graph)
        except Exception as e:
            logger.warning(f"Failed to resolve dependencies: {e}, using original order")
            return list(tables)

    async def _perform_delete_operations(self, tables: Sequence[str]) -> None:
        """Perform DELETE operations asynchronously."""
        for table in reversed(tables):
            try:
                await self.connection.execute(text(f"DELETE FROM {table} WHERE TRUE"))
            except Exception as e:
                logger.error(f"Failed to delete from {table}: {e}")
                self.stats.errors_encountered += 1

        await self.connection.commit()

    async def _verify_tables_empty(self, tables: Sequence[str]) -> None:
        """Verify that all tables are empty asynchronously."""
        for table in tables:
            try:
                result = await self.connection.execute(text(f'SELECT COUNT(*) FROM "{table}"'))
                count = result.scalar()
                if count and count > 0:
                    raise CleanupError(f"table {table} still contains {count} rows after cleanup")
            except CleanupError:
                raise
            except Exception as e:
                logger.warning(f"Failed to verify table {table} is empty: {e}")

    def _topological_sort(self, dependency_graph: dict[str, list[str]]) -> list[str]:
        """Perform topological sort for dependencies."""
        in_degree = dict.fromkeys(dependency_graph, 0)

        for node, neighbors in dependency_graph.items():
            for neighbor in neighbors:
                if neighbor in in_degree:
                    in_degree[neighbor] += 1

        queue = [node for node, degree in in_degree.items() if degree == 0]
        result = []

        while queue:
            node = queue.pop(0)
            result.append(node)

            for neighbor in dependency_graph[node]:
                if neighbor in in_degree:
                    in_degree[neighbor] -= 1
                    if in_degree[neighbor] == 0:
                        queue.append(neighbor)

        return result if len(result) == len(dependency_graph) else list(dependency_graph.keys())


# Factory function to create the appropriate cleaner
def _create_cleaner(
    engine_or_connection: Union[Engine, AsyncEngine, Connection, AsyncConnection],
    exclude_tables: Optional[Sequence[str]] = None,
    include_only: Optional[Sequence[str]] = None,
    verify_cleanup: bool = True,
    max_retries: int = 3,
    retry_delay: float = 0.1,
) -> Union[DatabaseCleaner, AsyncDatabaseCleaner]:
    """Create appropriate database cleaner based on engine dialect and type.

    Args:
        engine_or_connection: Database engine or connection
        exclude_tables: Tables to exclude from cleanup
        include_only: Only clean these tables if specified
        verify_cleanup: Whether to verify tables are actually clean
        max_retries: Maximum number of retry attempts
        retry_delay: Delay between retries in seconds

    Returns:
        Appropriate database cleaner instance

    Raises:
        CleanupError: If no suitable cleaner is found for the dialect
    """
    # Determine if this is async or sync
    is_async = isinstance(engine_or_connection, (AsyncEngine, AsyncConnection))

    # Get connection if we have an engine
    if isinstance(engine_or_connection, (Engine, AsyncEngine)):
        dialect_name = engine_or_connection.dialect.name
        connection = engine_or_connection  # type: ignore[assignment]
    else:
        dialect_name = engine_or_connection.dialect.name
        connection = engine_or_connection  # type: ignore[assignment]

    # Handle Spanner which reports as "spanner+spanner"
    if "spanner" in dialect_name:
        dialect_name = "spanner"

    # Handle CockroachDB which reports as postgresql
    elif dialect_name == "postgresql":
        # Check if it's actually CockroachDB by looking for server version info.
        # Some engines expose a server_version_info on the dialect that includes
        # a string with "cockroach" when connected to CockroachDB.
        try:
            if hasattr(connection, "dialect") and hasattr(connection.dialect, "server_version_info"):
                server_version = getattr(connection.dialect, "server_version_info", None)
                if server_version and "cockroach" in str(server_version).lower():
                    dialect_name = "cockroach"
        except Exception:
            # If we can't determine, assume PostgreSQL
            pass

    # Map dialect names to cleaner classes
    sync_cleaners = {
        "postgresql": PostgreSQLCleaner,
        "cockroach": CockroachDBCleaner,
        "cockroachdb": CockroachDBCleaner,  # Also accept 'cockroachdb' dialect name
        "sqlite": SQLiteCleaner,
        "mysql": MySQLCleaner,
        "oracle": OracleCleaner,
        "mssql": MSSQLCleaner,
        "duckdb": DuckDBCleaner,
        "spanner": SpannerCleaner,
    }

    async_cleaners = {
        "postgresql": AsyncPostgreSQLCleaner,
        "cockroach": AsyncCockroachDBCleaner,
        "cockroachdb": AsyncCockroachDBCleaner,  # Also accept 'cockroachdb' dialect name
        "sqlite": AsyncSQLiteCleaner,
        "mysql": AsyncMySQLCleaner,
        "oracle": AsyncOracleCleaner,
        "mssql": AsyncMSSQLCleaner,
        "duckdb": AsyncDuckDBCleaner,
        "spanner": AsyncSpannerCleaner,
    }

    cleaners = async_cleaners if is_async else sync_cleaners

    if dialect_name not in cleaners:
        supported = list(cleaners.keys())
        raise CleanupError(f"unsupported database dialect: {dialect_name}, supported: {supported}")

    cleaner_class = cleaners[dialect_name]

    # Create connection if needed
    if isinstance(connection, (Engine, AsyncEngine)):
        if is_async:
            # For async engines, we'll need to connect within an async context
            raise CleanupError("async engines must be used with async context manager")
        connection = connection.connect()  # type: ignore[union-attr,assignment]

    return cleaner_class(  # type: ignore[abstract]
        connection=connection,  # type: ignore[arg-type]
        exclude_tables=exclude_tables,
        include_only=include_only,
        verify_cleanup=verify_cleanup,
        max_retries=max_retries,
        retry_delay=retry_delay,
    )


@contextmanager
def cleanup_database(
    engine: Engine,
    exclude_tables: Optional[Sequence[str]] = None,
    include_only: Optional[Sequence[str]] = None,
    verify_cleanup: bool = True,
    max_retries: int = 3,
    retry_delay: float = 0.1,
) -> Generator[DatabaseCleaner, None, None]:
    """Context manager for synchronous database cleanup.

    Creates and yields an appropriate database cleaner for the engine dialect.
    Automatically manages connection lifecycle.

    Args:
        engine: Synchronous SQLAlchemy engine
        exclude_tables: Tables to exclude from cleanup
        include_only: Only clean these tables if specified
        verify_cleanup: Whether to verify tables are actually clean
        max_retries: Maximum number of retry attempts
        retry_delay: Delay between retries in seconds

    Yields:
        DatabaseCleaner: Configured cleaner for the database dialect

    Example:
        >>> with cleanup_database(engine) as cleaner:
        ...     stats = cleaner.cleanup()
        ...     print(f"Cleaned {stats.tables_cleaned} tables")
    """
    connection = engine.connect()
    try:
        cleaner = _create_cleaner(
            connection,
            exclude_tables=exclude_tables,
            include_only=include_only,
            verify_cleanup=verify_cleanup,
            max_retries=max_retries,
            retry_delay=retry_delay,
        )
        yield cleaner  # type: ignore[misc]
    finally:
        connection.close()


@asynccontextmanager
async def cleanup_database_async(
    engine: AsyncEngine,
    exclude_tables: Optional[Sequence[str]] = None,
    include_only: Optional[Sequence[str]] = None,
    verify_cleanup: bool = True,
    max_retries: int = 3,
    retry_delay: float = 0.1,
) -> AsyncGenerator[AsyncDatabaseCleaner, None]:
    """Async context manager for asynchronous database cleanup.

    Creates and yields an appropriate async database cleaner for the engine dialect.
    Automatically manages connection lifecycle.

    Args:
        engine: Asynchronous SQLAlchemy engine
        exclude_tables: Tables to exclude from cleanup
        include_only: Only clean these tables if specified
        verify_cleanup: Whether to verify tables are actually clean
        max_retries: Maximum number of retry attempts
        retry_delay: Delay between retries in seconds

    Yields:
        AsyncDatabaseCleaner: Configured async cleaner for the database dialect

    Example:
        >>> async with cleanup_database_async(async_engine) as cleaner:
        ...     stats = await cleaner.cleanup()
        ...     print(f"Cleaned {stats.tables_cleaned} tables")
    """
    connection = await engine.connect()
    try:
        cleaner = _create_cleaner(
            connection,
            exclude_tables=exclude_tables,
            include_only=include_only,
            verify_cleanup=verify_cleanup,
            max_retries=max_retries,
            retry_delay=retry_delay,
        )
        yield cast(AsyncDatabaseCleaner, cleaner)
    finally:
        await connection.close()


# Simple static utility functions for easy cleanup without context managers
def clean_tables(engine: Engine, metadata: MetaData) -> None:
    """Clean all tables in the metadata using appropriate strategy for the database.

    This is a convenience function that automatically selects the right cleaner
    based on the database dialect and performs cleanup without needing a context manager.

    Args:
        engine: SQLAlchemy engine
        metadata: Metadata containing tables to clean
    """
    # For SQLite, use a new connection to avoid transaction conflicts
    if engine.dialect.name == "sqlite":
        # Create a new engine with a fresh connection for cleanup
        cleanup_engine = create_engine(
            engine.url,
            poolclass=NullPool,  # Force new connections
            pool_pre_ping=True,
            connect_args={
                "timeout": 30,  # Increase timeout for locked databases
                "check_same_thread": False,  # Allow connections from different threads
                "isolation_level": None,  # Use autocommit mode
            },
        )
        try:
            with cleanup_database(cleanup_engine) as cleaner:
                tables_to_clean = [table.name for table in metadata.sorted_tables]
                cleaner.include_only = set(tables_to_clean) if tables_to_clean else None
                cleaner.cleanup()
        finally:
            cleanup_engine.dispose()
    else:
        with cleanup_database(engine) as cleaner:
            # Get table names from metadata
            tables_to_clean = [table.name for table in metadata.sorted_tables]
            cleaner.include_only = set(tables_to_clean) if tables_to_clean else None
            cleaner.cleanup()


async def async_clean_tables(engine: AsyncEngine, metadata: MetaData) -> None:
    """Async clean all tables in the metadata using appropriate strategy.

    This is a convenience function that automatically selects the right cleaner
    based on the database dialect and performs cleanup without needing a context manager.

    Args:
        engine: Async SQLAlchemy engine
        metadata: Metadata containing tables to clean
    """
    async with cleanup_database_async(engine) as cleaner:
        # Get table names from metadata
        tables_to_clean = [table.name for table in metadata.sorted_tables]
        cleaner.include_only = set(tables_to_clean) if tables_to_clean else None
        await cleaner.cleanup()
python-advanced-alchemy-1.9.3/tests/integration/repository_fixtures.py000066400000000000000000001257231516556515500264670ustar00rootroot00000000000000"""Comprehensive fixture system for session-based testing with data isolation.

This module provides a two-tier fixture architecture that separates DDL operations
from DML operations, ensuring proper test isolation and preventing metadata conflicts.
"""

import datetime
from collections.abc import AsyncGenerator, Generator
from typing import TYPE_CHECKING, Any, Optional, Union
from uuid import UUID

import pytest
import pytest_asyncio

# Import at module level for SQLAlchemy annotation resolution
from sqlalchemy import Column, Engine, FetchedValue, ForeignKey, MetaData, String, Table, delete, insert, text
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
from sqlalchemy.orm import Mapped, Session, mapped_column, relationship

# Import types for annotations
from advanced_alchemy.types.json import JsonB
from tests.integration.helpers import get_worker_id

if TYPE_CHECKING:
    from pytest import FixtureRequest


def create_dynamic_models(base_type: str = "uuid", worker_id: str = "master") -> dict[str, type]:
    """Create model classes using the current patched base classes.

    This function must be called during test execution after _patch_bases
    has run to ensure we use the correct registry.

    Args:
        base_type: Primary key type ("uuid" or "bigint")
        worker_id: Worker ID to ensure unique class names across parallel test workers
    """
    # Create unique suffix for class names to avoid registry conflicts
    from advanced_alchemy import base
    from advanced_alchemy.types import EncryptedString, EncryptedText

    if base_type == "uuid":
        # Use the patched UUID base classes - all should use the same registry
        # So we'll use the same UUIDAuditBase for all models to ensure same registry
        BaseClass = base.UUIDAuditBase
        SimpleBaseClass = base.UUIDAuditBase  # Changed from UUIDBase
        SecretBaseClass = base.UUIDAuditBase  # Changed from UUIDv7Base
        FetchedValueBaseClass = base.UUIDAuditBase  # Changed from UUIDv6Base

        # Define UUID models using patched bases with unique class names

        class IntegrationUUIDAuthor(BaseClass):  # type: ignore[valid-type,misc]
            """The Author domain object."""

            __tablename__ = f"uuid_author_{worker_id}"
            __mapper_args__ = {"polymorphic_identity": f"uuid_author_{worker_id}"}

            name: Mapped[str] = mapped_column(String(length=100))
            string_field: Mapped[Optional[str]] = mapped_column(String(20), default="static value", nullable=True)
            dob: Mapped[Optional[datetime.date]] = mapped_column(nullable=True)

        class IntegrationUUIDBook(SimpleBaseClass):  # type: ignore[valid-type,misc]
            """The Book domain object."""

            __tablename__ = f"uuid_book_{worker_id}"
            __mapper_args__ = {"polymorphic_identity": f"uuid_book_{worker_id}"}

            title: Mapped[str] = mapped_column(String(length=250))
            author_id: Mapped[UUID] = mapped_column(ForeignKey(f"uuid_author_{worker_id}.id"))

        # Define relationships after both classes exist to avoid forward references
        IntegrationUUIDAuthor.books = relationship(
            IntegrationUUIDBook,
            lazy="selectin",
            back_populates="author",
            cascade="all, delete",
        )
        IntegrationUUIDBook.author = relationship(
            IntegrationUUIDAuthor, lazy="joined", innerjoin=True, back_populates="books"
        )

        class IntegrationUUIDRule(BaseClass):  # type: ignore[valid-type,misc]
            """The rule domain object."""

            __tablename__ = f"uuid_rule_{worker_id}"
            __mapper_args__ = {"polymorphic_identity": f"uuid_rule_{worker_id}"}

            name: Mapped[str] = mapped_column(String(length=250))
            config: Mapped[dict] = mapped_column(JsonB, default=lambda: {})

        class IntegrationUUIDSecret(SecretBaseClass):  # type: ignore[valid-type,misc]
            """The secret domain object."""

            __tablename__ = f"uuid_secret_{worker_id}"
            __mapper_args__ = {"polymorphic_identity": f"uuid_secret_{worker_id}"}

            secret: Mapped[str] = mapped_column(EncryptedString(key="test_secret_key"))
            long_secret: Mapped[Optional[str]] = mapped_column(EncryptedText, nullable=True)

        class IntegrationUUIDSlugBook(BaseClass):  # type: ignore[valid-type,misc]
            """The SlugBook domain object."""

            __tablename__ = f"uuid_slug_book_{worker_id}"
            __mapper_args__ = {"polymorphic_identity": f"uuid_slug_book_{worker_id}"}

            title: Mapped[str] = mapped_column(String(length=250))
            slug: Mapped[str] = mapped_column(String(100), unique=True)

        class IntegrationUUIDItem(BaseClass):  # type: ignore[valid-type,misc]
            """The Item domain object."""

            __tablename__ = f"uuid_item_{worker_id}"
            __mapper_args__ = {"polymorphic_identity": f"uuid_item_{worker_id}"}

            name: Mapped[str] = mapped_column(String(length=50))

        class IntegrationUUIDTag(BaseClass):  # type: ignore[valid-type,misc]
            """The Tag domain object."""

            __tablename__ = f"uuid_tag_{worker_id}"
            __mapper_args__ = {"polymorphic_identity": f"uuid_tag_{worker_id}"}

            name: Mapped[str] = mapped_column(String(length=50), unique=True)

        # Define association table for many-to-many relationship
        uuid_item_tag_table = Table(
            f"uuid_item_tag_{worker_id}",
            BaseClass.metadata,
            Column("item_id", ForeignKey(f"uuid_item_{worker_id}.id"), primary_key=True),
            Column("tag_id", ForeignKey(f"uuid_tag_{worker_id}.id"), primary_key=True),
        )

        # Define many-to-many relationships after classes and table exist
        IntegrationUUIDItem.tags = relationship(
            IntegrationUUIDTag, secondary=uuid_item_tag_table, back_populates="items"
        )
        IntegrationUUIDTag.items = relationship(
            IntegrationUUIDItem, secondary=uuid_item_tag_table, back_populates="tags"
        )

        class IntegrationUUIDModelWithFetchedValue(FetchedValueBaseClass):  # type: ignore[valid-type,misc]
            """Model with fetched value."""

            __tablename__ = f"uuid_model_with_fetched_value_{worker_id}"
            __mapper_args__ = {"polymorphic_identity": f"uuid_model_with_fetched_value_{worker_id}"}

            name: Mapped[str] = mapped_column(String(length=50))
            # Use a simple default instead of random() to avoid MSSQL compatibility issues
            val: Mapped[int] = mapped_column(FetchedValue(), server_default=text("1"))

        class IntegrationUUIDFileDocument(BaseClass):  # type: ignore[valid-type,misc]
            """FileDocument with JsonB storage for cross-database compatibility."""

            __tablename__ = f"uuid_file_document_{worker_id}"
            __mapper_args__ = {"polymorphic_identity": f"uuid_file_document_{worker_id}"}

            name: Mapped[str] = mapped_column(String(length=50))
            # Use JsonB for better database compatibility instead of BLOB storage
            file_data: Mapped[Optional[dict]] = mapped_column(JsonB, nullable=True)
            files_data: Mapped[Optional[dict]] = mapped_column(JsonB, nullable=True)
            file_metadata: Mapped[Optional[dict]] = mapped_column(JsonB, nullable=True)

        class IntegrationUUIDUserRole(base.BasicAttributes, BaseClass.registry.generate_base()):  # type: ignore[misc,name-defined]
            """Model with composite primary key for testing composite PK support.

            Inherits BasicAttributes to get to_dict() method for upsert operations.
            """

            __tablename__ = f"uuid_user_role_{worker_id}"

            user_id: Mapped[int] = mapped_column(primary_key=True)
            role_id: Mapped[int] = mapped_column(primary_key=True)
            assigned_at: Mapped[datetime.datetime] = mapped_column(
                default=lambda: datetime.datetime.now(datetime.timezone.utc)
            )
            is_active: Mapped[bool] = mapped_column(default=True)

    else:  # bigint
        # Use the patched BigInt base classes - all should use the same registry
        BaseClass = base.BigIntAuditBase  # type: ignore[assignment]
        SimpleBaseClass = base.BigIntAuditBase  # type: ignore[assignment]
        SecretBaseClass = base.BigIntAuditBase  # type: ignore[assignment]
        FetchedValueBaseClass = base.BigIntAuditBase  # type: ignore[assignment]

        # Define BigInt models using patched bases with unique class names
        class BigIntAuthor(BaseClass):  # type: ignore[valid-type,misc]
            """The Author domain object."""

            __tablename__ = f"bigint_author_{worker_id}"
            __mapper_args__ = {"polymorphic_identity": f"bigint_author_{worker_id}"}

            name: Mapped[str] = mapped_column(String(length=100))
            string_field: Mapped[Optional[str]] = mapped_column(String(20), default="static value", nullable=True)
            dob: Mapped[Optional[datetime.date]] = mapped_column(nullable=True)

        class BigIntBook(SimpleBaseClass):  # type: ignore[valid-type,misc]
            """The Book domain object."""

            __tablename__ = f"bigint_book_{worker_id}"
            __mapper_args__ = {"polymorphic_identity": f"bigint_book_{worker_id}"}

            title: Mapped[str] = mapped_column(String(length=250))
            author_id: Mapped[int] = mapped_column(ForeignKey(f"bigint_author_{worker_id}.id"))

        # Define relationships after both classes exist to avoid forward references
        BigIntAuthor.books = relationship(
            BigIntBook,
            lazy="selectin",
            back_populates="author",
            cascade="all, delete",
        )
        BigIntBook.author = relationship(BigIntAuthor, lazy="joined", innerjoin=True, back_populates="books")

        class BigIntRule(BaseClass):  # type: ignore[valid-type,misc]
            """The rule domain object."""

            __tablename__ = f"bigint_rule_{worker_id}"
            __mapper_args__ = {"polymorphic_identity": f"bigint_rule_{worker_id}"}

            name: Mapped[str] = mapped_column(String(length=250))
            config: Mapped[dict] = mapped_column(JsonB, default=lambda: {})

        class BigIntSecret(SecretBaseClass):  # type: ignore[valid-type,misc]
            """The secret domain object."""

            __tablename__ = f"bigint_secret_{worker_id}"
            __mapper_args__ = {"polymorphic_identity": f"bigint_secret_{worker_id}"}

            secret: Mapped[str] = mapped_column(EncryptedString(key="test_secret_key"))
            long_secret: Mapped[Optional[str]] = mapped_column(EncryptedText, nullable=True)

        class BigIntSlugBook(BaseClass):  # type: ignore[valid-type,misc]
            """The SlugBook domain object."""

            __tablename__ = f"bigint_slug_book_{worker_id}"
            __mapper_args__ = {"polymorphic_identity": f"bigint_slug_book_{worker_id}"}

            title: Mapped[str] = mapped_column(String(length=250))
            slug: Mapped[str] = mapped_column(String(100), unique=True)

        class BigIntItem(BaseClass):  # type: ignore[valid-type,misc]
            """The Item domain object."""

            __tablename__ = f"bigint_item_{worker_id}"
            __mapper_args__ = {"polymorphic_identity": f"bigint_item_{worker_id}"}

            name: Mapped[str] = mapped_column(String(length=50))

        class BigIntTag(BaseClass):  # type: ignore[valid-type,misc]
            """The Tag domain object."""

            __tablename__ = f"bigint_tag_{worker_id}"
            __mapper_args__ = {"polymorphic_identity": f"bigint_tag_{worker_id}"}

            name: Mapped[str] = mapped_column(String(length=50), unique=True)

        # Define association table for many-to-many relationship
        bigint_item_tag_table = Table(
            f"bigint_item_tag_{worker_id}",
            BaseClass.metadata,
            Column("item_id", ForeignKey(f"bigint_item_{worker_id}.id"), primary_key=True),
            Column("tag_id", ForeignKey(f"bigint_tag_{worker_id}.id"), primary_key=True),
        )

        # Define many-to-many relationships after classes and table exist
        BigIntItem.tags = relationship(BigIntTag, secondary=bigint_item_tag_table, back_populates="items")
        BigIntTag.items = relationship(BigIntItem, secondary=bigint_item_tag_table, back_populates="tags")

        class BigIntModelWithFetchedValue(FetchedValueBaseClass):  # type: ignore[valid-type,misc]
            """Model with fetched value."""

            __tablename__ = f"bigint_model_with_fetched_value_{worker_id}"
            __mapper_args__ = {"polymorphic_identity": f"bigint_model_with_fetched_value_{worker_id}"}

            name: Mapped[str] = mapped_column(String(length=50))
            # Use a simple default instead of random() to avoid MSSQL compatibility issues
            val: Mapped[int] = mapped_column(FetchedValue(), server_default=text("1"))

        class BigIntFileDocument(BaseClass):  # type: ignore[valid-type,misc]
            """FileDocument with JsonB storage for cross-database compatibility."""

            __tablename__ = f"bigint_file_document_{worker_id}"
            __mapper_args__ = {"polymorphic_identity": f"bigint_file_document_{worker_id}"}

            name: Mapped[str] = mapped_column(String(length=50))
            # Use JsonB for better database compatibility instead of BLOB storage
            file_data: Mapped[Optional[dict]] = mapped_column(JsonB, nullable=True)
            files_data: Mapped[Optional[dict]] = mapped_column(JsonB, nullable=True)
            file_metadata: Mapped[Optional[dict]] = mapped_column(JsonB, nullable=True)

        class BigIntUserRole(base.BasicAttributes, BaseClass.registry.generate_base()):  # type: ignore[misc,name-defined]
            """Model with composite primary key for testing composite PK support.

            Inherits BasicAttributes to get to_dict() method for upsert operations.
            """

            __tablename__ = f"bigint_user_role_{worker_id}"

            user_id: Mapped[int] = mapped_column(primary_key=True)
            role_id: Mapped[int] = mapped_column(primary_key=True)
            assigned_at: Mapped[datetime.datetime] = mapped_column(
                default=lambda: datetime.datetime.now(datetime.timezone.utc)
            )
            is_active: Mapped[bool] = mapped_column(default=True)

    # Return all models
    if base_type == "uuid":
        return {
            "base": IntegrationUUIDAuthor,  # Use Author as base since it has the metadata
            "author": IntegrationUUIDAuthor,
            "book": IntegrationUUIDBook,
            "rule": IntegrationUUIDRule,
            "secret": IntegrationUUIDSecret,
            "slug_book": IntegrationUUIDSlugBook,
            "item": IntegrationUUIDItem,
            "tag": IntegrationUUIDTag,
            "model_with_fetched_value": IntegrationUUIDModelWithFetchedValue,
            "file_document": IntegrationUUIDFileDocument,
            "user_role": IntegrationUUIDUserRole,
        }
    # bigint
    return {
        "base": BigIntAuthor,  # Use Author as base since it has the metadata
        "author": BigIntAuthor,
        "book": BigIntBook,
        "rule": BigIntRule,
        "secret": BigIntSecret,
        "slug_book": BigIntSlugBook,
        "item": BigIntItem,
        "tag": BigIntTag,
        "model_with_fetched_value": BigIntModelWithFetchedValue,
        "file_document": BigIntFileDocument,
        "user_role": BigIntUserRole,
    }


class TestDataManager:
    """Manages test data seeding and cleanup for both sync and async sessions."""

    @staticmethod
    def get_seed_data(pk_type: str) -> dict[str, list[dict[str, Any]]]:
        """Get base seed data for the given PK type."""
        if pk_type == "uuid":
            return {
                "authors": [
                    {
                        "id": UUID("97108ac1-ffcb-411d-8b1e-d9183399f63b"),
                        "name": "Agatha Christie",
                        "dob": datetime.date(1890, 9, 15),
                        "created_at": datetime.datetime(2023, 3, 1, tzinfo=datetime.timezone.utc),
                        "updated_at": datetime.datetime(2023, 5, 11, tzinfo=datetime.timezone.utc),
                    },
                    {
                        "id": UUID("5ef29f3c-3560-4d15-ba6b-a2e5c721e4d2"),
                        "name": "Leo Tolstoy",
                        "dob": datetime.date(1828, 9, 9),
                        "created_at": datetime.datetime(2023, 5, 2, tzinfo=datetime.timezone.utc),
                        "updated_at": datetime.datetime(2023, 5, 15, tzinfo=datetime.timezone.utc),
                    },
                ],
                "rules": [
                    {
                        "id": UUID("f34545b9-663c-4fce-915d-dd1ae9cea42a"),
                        "name": "Initial loading rule.",
                        "config": {"url": "https://example.org", "setting_123": 1},
                        "created_at": datetime.datetime(2023, 1, 1, tzinfo=datetime.timezone.utc),
                        "updated_at": datetime.datetime(2023, 2, 1, tzinfo=datetime.timezone.utc),
                    },
                    {
                        "id": UUID("f34545b9-663c-4fce-915d-dd1ae9cea34b"),
                        "name": "Secondary loading rule.",
                        "config": {"url": "https://example.org", "bar": "foo", "setting_123": 4},
                        "created_at": datetime.datetime(2023, 2, 1, tzinfo=datetime.timezone.utc),
                        "updated_at": datetime.datetime(2023, 2, 1, tzinfo=datetime.timezone.utc),
                    },
                ],
                "secrets": [
                    {
                        "id": UUID("f34545b9-663c-4fce-915d-dd1ae9cea42a"),
                        "secret": "I'm a secret!",
                        "long_secret": "It's clobbering time.",
                    },
                ],
                "user_roles": [
                    {
                        "user_id": 1,
                        "role_id": 10,
                        "assigned_at": datetime.datetime(2023, 1, 1, tzinfo=datetime.timezone.utc),
                        "is_active": True,
                    },
                    {
                        "user_id": 1,
                        "role_id": 20,
                        "assigned_at": datetime.datetime(2023, 2, 1, tzinfo=datetime.timezone.utc),
                        "is_active": True,
                    },
                    {
                        "user_id": 2,
                        "role_id": 10,
                        "assigned_at": datetime.datetime(2023, 3, 1, tzinfo=datetime.timezone.utc),
                        "is_active": False,
                    },
                ],
            }
        # bigint
        return {
            "authors": [
                {
                    "id": 2023,
                    "name": "Agatha Christie",
                    "dob": datetime.date(1890, 9, 15),
                    "created_at": datetime.datetime(2023, 3, 1, tzinfo=datetime.timezone.utc),
                    "updated_at": datetime.datetime(2023, 5, 11, tzinfo=datetime.timezone.utc),
                },
                {
                    "id": 2024,
                    "name": "Leo Tolstoy",
                    "dob": datetime.date(1828, 9, 9),
                    "created_at": datetime.datetime(2023, 5, 2, tzinfo=datetime.timezone.utc),
                    "updated_at": datetime.datetime(2023, 5, 15, tzinfo=datetime.timezone.utc),
                },
            ],
            "rules": [
                {
                    "id": 2025,
                    "name": "Initial loading rule.",
                    "config": {"url": "https://example.org", "setting_123": 1},
                    "created_at": datetime.datetime(2023, 1, 1, tzinfo=datetime.timezone.utc),
                    "updated_at": datetime.datetime(2023, 2, 1, tzinfo=datetime.timezone.utc),
                },
                {
                    "id": 2026,
                    "name": "Secondary loading rule.",
                    "config": {"url": "https://example.org", "bar": "foo", "setting_123": 4},
                    "created_at": datetime.datetime(2023, 2, 1, tzinfo=datetime.timezone.utc),
                    "updated_at": datetime.datetime(2023, 2, 1, tzinfo=datetime.timezone.utc),
                },
            ],
            "secrets": [
                {
                    "id": 2025,
                    "secret": "I'm a secret!",
                    "long_secret": "It's clobbering time.",
                },
            ],
            "user_roles": [
                {
                    "user_id": 1,
                    "role_id": 10,
                    "assigned_at": datetime.datetime(2023, 1, 1, tzinfo=datetime.timezone.utc),
                    "is_active": True,
                },
                {
                    "user_id": 1,
                    "role_id": 20,
                    "assigned_at": datetime.datetime(2023, 2, 1, tzinfo=datetime.timezone.utc),
                    "is_active": True,
                },
                {
                    "user_id": 2,
                    "role_id": 10,
                    "assigned_at": datetime.datetime(2023, 3, 1, tzinfo=datetime.timezone.utc),
                    "is_active": False,
                },
            ],
        }

    @classmethod
    def clean_and_seed_sync(cls, session: Session, models: dict[str, type], pk_type: str) -> None:
        """Clean all data and insert fresh seed data for sync session."""
        metadata = models["base"].metadata
        seed_data = cls.get_seed_data(pk_type)

        # Clean all tables in reverse dependency order
        for table in reversed(metadata.sorted_tables):
            try:
                session.execute(delete(table))
            except Exception:
                # Ignore deletion errors for non-existent data
                pass

        # Insert fresh seed data
        if "author" in models:
            session.execute(insert(models["author"]), seed_data["authors"])
        if "rule" in models:
            session.execute(insert(models["rule"]), seed_data["rules"])
        if "secret" in models:
            session.execute(insert(models["secret"]), seed_data["secrets"])
        if "user_role" in models:
            session.execute(insert(models["user_role"]), seed_data["user_roles"])

        session.flush()  # Ensure data is written but don't commit yet

    @classmethod
    async def clean_and_seed_async(cls, session: AsyncSession, models: dict[str, type], pk_type: str) -> None:
        """Clean all data and insert fresh seed data for async session."""
        metadata = models["base"].metadata
        seed_data = cls.get_seed_data(pk_type)

        # Clean all tables in reverse dependency order
        for table in reversed(metadata.sorted_tables):
            try:
                await session.execute(delete(table))
            except Exception:
                # Ignore deletion errors for non-existent data
                pass

        # Insert fresh seed data
        if "author" in models:
            await session.execute(insert(models["author"]), seed_data["authors"])
        if "rule" in models:
            await session.execute(insert(models["rule"]), seed_data["rules"])
        if "secret" in models:
            await session.execute(insert(models["secret"]), seed_data["secrets"])
        if "user_role" in models:
            await session.execute(insert(models["user_role"]), seed_data["user_roles"])

        await session.flush()  # Ensure data is written but don't commit yet


class SchemaManager:
    """Manages schema creation and destruction for test databases."""

    _created_schemas: dict[str, bool] = {}

    @classmethod
    def ensure_schema_sync(cls, engine: Engine, metadata: MetaData, schema_key: str) -> None:
        """Ensure schema exists for sync engine."""
        if schema_key not in cls._created_schemas:
            metadata.create_all(engine, checkfirst=True)
            cls._created_schemas[schema_key] = True

    @classmethod
    async def ensure_schema_async(cls, engine: AsyncEngine, metadata: MetaData, schema_key: str) -> None:
        """Ensure schema exists for async engine."""
        if schema_key not in cls._created_schemas:
            async with engine.begin() as conn:
                await conn.run_sync(metadata.create_all, checkfirst=True)
            cls._created_schemas[schema_key] = True


# ============================================================================
# Model Registry and Caching System
# ============================================================================

# Module-level caches for model classes
_uuid_model_cache: dict[str, dict[str, type]] = {}
_bigint_model_cache: dict[str, dict[str, type]] = {}


class RepositoryModelRegistry:
    """Registry for cached repository test models with worker isolation."""

    @classmethod
    def get_uuid_models(cls, worker_id: str) -> dict[str, type]:
        """Get all UUID-based models for a worker."""
        cache_key = f"uuid_{worker_id}"
        if cache_key not in _uuid_model_cache:
            # Create models using the patched base classes
            _uuid_model_cache[cache_key] = create_dynamic_models("uuid", worker_id)

        return _uuid_model_cache[cache_key]

    @classmethod
    def get_bigint_models(cls, worker_id: str) -> dict[str, type]:
        """Get all BigInt-based models for a worker."""
        cache_key = f"bigint_{worker_id}"
        if cache_key not in _bigint_model_cache:
            # Create models using the patched base classes
            _bigint_model_cache[cache_key] = create_dynamic_models("bigint", worker_id)

        return _bigint_model_cache[cache_key]


# ============================================================================
# DBA Fixtures - Session Scoped DDL Management
# ============================================================================


@pytest.fixture(scope="session")
def uuid_models_dba(request: "FixtureRequest") -> "dict[str, type]":
    """Session-scoped UUID models for DDL operations."""
    worker_id = get_worker_id(request)
    return RepositoryModelRegistry.get_uuid_models(worker_id)


@pytest.fixture(scope="session")
def bigint_models_dba(request: "FixtureRequest") -> dict[str, type]:
    """Session-scoped BigInt models for DDL operations."""
    worker_id = get_worker_id(request)
    return RepositoryModelRegistry.get_bigint_models(worker_id)


@pytest.fixture(scope="session")
def uuid_schema_sync(engine: Engine, uuid_models_dba: dict[str, type], request: "FixtureRequest") -> None:
    """Ensure UUID schema exists for sync engine."""
    if getattr(engine.dialect, "name", "") != "mock":
        worker_id = get_worker_id(request)
        schema_key = f"uuid_sync_{engine.dialect.name}_{worker_id}"
        SchemaManager.ensure_schema_sync(engine, uuid_models_dba["base"].metadata, schema_key)


@pytest.fixture(scope="session")
def bigint_schema_sync(engine: Engine, bigint_models_dba: dict[str, type], request: "FixtureRequest") -> None:
    """Ensure BigInt schema exists for sync engine."""
    # Skip for engines that don't support BigInt PKs well
    if engine.dialect.name.startswith(("spanner", "cockroach", "mock")):
        pytest.skip(f"{engine.dialect.name} doesn't support bigint PKs well")

    worker_id = get_worker_id(request)
    schema_key = f"bigint_sync_{engine.dialect.name}_{worker_id}"
    SchemaManager.ensure_schema_sync(engine, bigint_models_dba["base"].metadata, schema_key)


@pytest_asyncio.fixture(scope="session", loop_scope="session")
async def uuid_schema_async(
    async_engine: AsyncEngine, uuid_models_dba: dict[str, type], request: "FixtureRequest"
) -> None:
    """Ensure UUID schema exists for async engine."""
    if getattr(async_engine.dialect, "name", "") != "mock":
        worker_id = get_worker_id(request)
        schema_key = f"uuid_async_{async_engine.dialect.name}_{worker_id}"
        await SchemaManager.ensure_schema_async(async_engine, uuid_models_dba["base"].metadata, schema_key)


@pytest_asyncio.fixture(scope="session", loop_scope="session")
async def bigint_schema_async(
    async_engine: AsyncEngine, bigint_models_dba: dict[str, type], request: "FixtureRequest"
) -> None:
    """Ensure BigInt schema exists for async engine."""
    # Skip for engines that don't support BigInt PKs well
    if async_engine.dialect.name.startswith(("spanner", "cockroach", "mock")):
        pytest.skip(f"{async_engine.dialect.name} doesn't support bigint PKs well")

    worker_id = get_worker_id(request)
    schema_key = f"bigint_async_{async_engine.dialect.name}_{worker_id}"
    await SchemaManager.ensure_schema_async(async_engine, bigint_models_dba["base"].metadata, schema_key)


# ============================================================================
# Per-Test Fixtures - Function Scoped DML Management with Transaction Isolation
# ============================================================================


def supports_savepoints(engine: "Union[Engine, AsyncEngine]") -> bool:
    """Check if the database engine supports savepoints reliably."""
    dialect_name = engine.dialect.name.lower()
    # SQLite and DuckDB don't support nested savepoints reliably
    # Spanner doesn't support savepoints in our test scenario
    return dialect_name not in ("sqlite", "duckdb", "spanner")


@pytest.fixture
def uuid_test_session_sync(
    engine: Engine,
    uuid_models_dba: dict[str, type],
    uuid_schema_sync: None,
    request: "FixtureRequest",
) -> Generator[tuple[Session, dict[str, type]], None, None]:
    """Per-test sync session with UUID data isolation using transactions."""
    if getattr(engine.dialect, "name", "") == "mock":
        # Mock engine handling
        from unittest.mock import create_autospec

        session_mock = create_autospec(Session, instance=True)
        session_mock.bind = engine
        yield session_mock, uuid_models_dba
        return

    # Real database session with transaction isolation
    connection = engine.connect()
    transaction = connection.begin()

    try:
        # Create session bound to this connection
        session = Session(bind=connection, expire_on_commit=False)

        savepoint = None
        if supports_savepoints(engine):
            # Create savepoint for test isolation (PostgreSQL, MySQL, etc.)
            savepoint = connection.begin_nested()

        try:
            # Just yield clean session - no automatic seeding

            yield session, uuid_models_dba

        finally:
            # Cleanup in proper order
            try:
                session.rollback()
            except Exception:
                pass

            try:
                session.close()
            except Exception:
                pass

            if savepoint and savepoint.is_active:
                try:
                    savepoint.rollback()
                except Exception:
                    pass

    finally:
        # Rollback the main transaction and close connection
        try:
            if transaction.is_active:
                transaction.rollback()
        except Exception:
            pass

        try:
            connection.close()
        except Exception:
            pass


@pytest.fixture
def bigint_test_session_sync(
    engine: Engine,
    bigint_models_dba: dict[str, type],
    bigint_schema_sync: None,
    request: "FixtureRequest",
) -> Generator[tuple[Session, dict[str, type]], None, None]:
    """Per-test sync session with BigInt data isolation using transactions."""
    if getattr(engine.dialect, "name", "") == "mock":
        pytest.skip("Mock engines don't support BigInt operations")

    # Real database session with transaction isolation
    connection = engine.connect()
    transaction = connection.begin()

    try:
        # Create session bound to this connection
        session = Session(bind=connection, expire_on_commit=False)

        savepoint = None
        if supports_savepoints(engine):
            # Create savepoint for test isolation (PostgreSQL, MySQL, etc.)
            savepoint = connection.begin_nested()

        try:
            # Just yield clean session - no automatic seeding

            yield session, bigint_models_dba

        finally:
            # Cleanup in proper order
            try:
                session.rollback()
            except Exception:
                pass

            try:
                session.close()
            except Exception:
                pass

            if savepoint and savepoint.is_active:
                try:
                    savepoint.rollback()
                except Exception:
                    pass

    finally:
        # Rollback the main transaction and close connection
        try:
            if transaction.is_active:
                transaction.rollback()
        except Exception:
            pass

        try:
            connection.close()
        except Exception:
            pass


@pytest_asyncio.fixture(loop_scope="function")
async def uuid_test_session_async(
    async_engine: AsyncEngine,
    uuid_models_dba: dict[str, type],
    uuid_schema_async: None,
    request: "FixtureRequest",
) -> AsyncGenerator[tuple[AsyncSession, dict[str, type]], None]:
    """Per-test async session with UUID data isolation using transactions."""
    if getattr(async_engine.dialect, "name", "") == "mock":
        # Mock engine handling
        from unittest.mock import create_autospec

        session_mock = create_autospec(AsyncSession, instance=True)
        session_mock.bind = async_engine
        yield session_mock, uuid_models_dba
        return

    # Real database session with transaction isolation
    connection = await async_engine.connect()
    transaction = await connection.begin()

    try:
        # Create session bound to this connection
        from sqlalchemy.ext.asyncio import async_sessionmaker

        session_factory = async_sessionmaker(bind=connection, expire_on_commit=False)
        session = session_factory()

        savepoint = None
        if supports_savepoints(async_engine):
            # Create savepoint for test isolation (PostgreSQL, MySQL, etc.)
            savepoint = await connection.begin_nested()

        try:
            # Just yield clean session - no automatic seeding

            yield session, uuid_models_dba

        finally:
            # Cleanup in proper order
            try:
                await session.rollback()
            except Exception:
                pass

            try:
                await session.close()
            except Exception:
                pass

            if savepoint and savepoint.is_active:
                try:
                    await savepoint.rollback()
                except Exception:
                    pass

    finally:
        # Rollback the main transaction and close connection
        try:
            if transaction.is_active:
                await transaction.rollback()
        except Exception:
            pass

        try:
            await connection.close()
        except Exception:
            pass


@pytest_asyncio.fixture(loop_scope="function")
async def bigint_test_session_async(
    async_engine: AsyncEngine,
    bigint_models_dba: dict[str, type],
    bigint_schema_async: None,
    request: "FixtureRequest",
) -> AsyncGenerator[tuple[AsyncSession, dict[str, type]], None]:
    """Per-test async session with BigInt data isolation using transactions."""
    if getattr(async_engine.dialect, "name", "") == "mock":
        pytest.skip("Mock engines don't support BigInt operations")

    # Real database session with transaction isolation
    connection = await async_engine.connect()
    transaction = await connection.begin()

    try:
        # Create session bound to this connection
        from sqlalchemy.ext.asyncio import async_sessionmaker

        session_factory = async_sessionmaker(bind=connection, expire_on_commit=False)
        session = session_factory()

        savepoint = None
        if supports_savepoints(async_engine):
            # Create savepoint for test isolation (PostgreSQL, MySQL, etc.)
            savepoint = await connection.begin_nested()

        try:
            # Just yield clean session - no automatic seeding

            yield session, bigint_models_dba

        finally:
            # Cleanup in proper order
            try:
                await session.rollback()
            except Exception:
                pass

            try:
                await session.close()
            except Exception:
                pass

            if savepoint and savepoint.is_active:
                try:
                    await savepoint.rollback()
                except Exception:
                    pass

    finally:
        # Rollback the main transaction and close connection
        try:
            if transaction.is_active:
                await transaction.rollback()
        except Exception:
            pass

        try:
            await connection.close()
        except Exception:
            pass


# ============================================================================
# Unified Fixtures for Test Consumption
# ============================================================================


@pytest.fixture(params=["uuid", "bigint"])
def repository_pk_type(request: "FixtureRequest") -> str:
    """Determine which primary key type to use for repository tests."""
    pk_type = str(request.param)

    # Skip BigInt tests for engines that don't support them well
    if pk_type == "bigint":
        fixture_names = request.fixturenames
        if any("cockroach" in name or "spanner" in name for name in fixture_names):
            pytest.skip(f"BigInt primary keys not supported for {pk_type}")

    return pk_type


@pytest.fixture
def test_session_sync(
    repository_pk_type: str,
    uuid_test_session_sync: tuple[Session, dict[str, type]],
    bigint_test_session_sync: tuple[Session, dict[str, type]],
) -> tuple[Session, dict[str, type]]:
    """Get the appropriate session and models for sync tests based on PK type."""
    if repository_pk_type == "uuid":
        return uuid_test_session_sync
    return bigint_test_session_sync


@pytest_asyncio.fixture(loop_scope="function")
async def test_session_async(
    repository_pk_type: str,
    uuid_test_session_async: tuple[AsyncSession, dict[str, type]],
    bigint_test_session_async: tuple[AsyncSession, dict[str, type]],
) -> tuple[AsyncSession, dict[str, type]]:
    """Get the appropriate session and models for async tests based on PK type."""
    if repository_pk_type == "uuid":
        return uuid_test_session_async
    return bigint_test_session_async


# ============================================================================
# Legacy Compatibility Fixtures
# ============================================================================


# Keep these for backward compatibility during migration
@pytest.fixture(scope="session")
def uuid_models(request: "FixtureRequest") -> dict[str, type]:
    """Get all UUID models for the current worker."""
    worker_id = get_worker_id(request)
    return RepositoryModelRegistry.get_uuid_models(worker_id)


@pytest.fixture(scope="session")
def bigint_models(request: "FixtureRequest") -> dict[str, type]:
    """Get all BigInt models for the current worker."""
    worker_id = get_worker_id(request)
    return RepositoryModelRegistry.get_bigint_models(worker_id)


# Individual model fixtures for backward compatibility
@pytest.fixture(scope="session")
def uuid_author_model(uuid_models: dict[str, type]) -> type:
    """Get UUID Author model."""
    return uuid_models["author"]


@pytest.fixture(scope="session")
def uuid_book_model(uuid_models: dict[str, type]) -> type:
    """Get UUID Book model."""
    return uuid_models["book"]


@pytest.fixture(scope="session")
def uuid_rule_model(uuid_models: dict[str, type]) -> type:
    """Get UUID Rule model."""
    return uuid_models["rule"]


@pytest.fixture(scope="session")
def uuid_secret_model(uuid_models: dict[str, type]) -> type:
    """Get UUID Secret model."""
    return uuid_models["secret"]


@pytest.fixture(scope="session")
def uuid_slug_book_model(uuid_models: dict[str, type]) -> type:
    """Get UUID SlugBook model."""
    return uuid_models["slug_book"]


@pytest.fixture(scope="session")
def bigint_author_model(bigint_models: dict[str, type]) -> type:
    """Get BigInt Author model."""
    return bigint_models["author"]


@pytest.fixture(scope="session")
def bigint_book_model(bigint_models: dict[str, type]) -> type:
    """Get BigInt Book model."""
    return bigint_models["book"]


@pytest.fixture(scope="session")
def bigint_rule_model(bigint_models: dict[str, type]) -> type:
    """Get BigInt Rule model."""
    return bigint_models["rule"]


@pytest.fixture(scope="session")
def bigint_secret_model(bigint_models: dict[str, type]) -> type:
    """Get BigInt Secret model."""
    return bigint_models["secret"]


@pytest.fixture(scope="session")
def bigint_slug_book_model(bigint_models: dict[str, type]) -> type:
    """Get BigInt SlugBook model."""
    return bigint_models["slug_book"]


# ============================================================================
# Data Seeding Helpers
# ============================================================================


async def seed_test_data_async(session: AsyncSession, models: "dict[str, type]", pk_type: str) -> None:
    """Simple helper to seed test data when needed - call this in tests that need data."""
    seed_data = TestDataManager.get_seed_data(pk_type)

    # Insert fresh seed data
    if "author" in models:
        await session.execute(insert(models["author"]), seed_data["authors"])
    if "rule" in models:
        await session.execute(insert(models["rule"]), seed_data["rules"])
    if "secret" in models:
        await session.execute(insert(models["secret"]), seed_data["secrets"])
    if "user_role" in models:
        await session.execute(insert(models["user_role"]), seed_data["user_roles"])
    await session.flush()  # Ensure data is written but don't commit yet


def seed_test_data_sync(session: Session, models: "dict[str, type]", pk_type: str) -> None:
    """Simple helper to seed test data when needed - call this in tests that need data."""
    seed_data = TestDataManager.get_seed_data(pk_type)

    # Insert fresh seed data
    if "author" in models:
        session.execute(insert(models["author"]), seed_data["authors"])
    if "rule" in models:
        session.execute(insert(models["rule"]), seed_data["rules"])
    if "secret" in models:
        session.execute(insert(models["secret"]), seed_data["secrets"])
    if "user_role" in models:
        session.execute(insert(models["user_role"]), seed_data["user_roles"])
    session.flush()  # Ensure data is written but don't commit yet


@pytest_asyncio.fixture(loop_scope="function")
async def seeded_test_session_async(
    test_session_async: "tuple[AsyncSession, dict[str, type]]", repository_pk_type: str
) -> "tuple[AsyncSession, dict[str, type]]":
    """Auto-seeded async session for tests that need data."""
    session, models = test_session_async
    await seed_test_data_async(session, models, repository_pk_type)
    return session, models


@pytest.fixture
def seeded_test_session_sync(
    test_session_sync: "tuple[Session, dict[str, type]]", repository_pk_type: str
) -> "tuple[Session, dict[str, type]]":
    """Auto-seeded sync session for tests that need data."""
    session, models = test_session_sync
    seed_test_data_sync(session, models, repository_pk_type)
    return session, models
python-advanced-alchemy-1.9.3/tests/integration/test_alembic_cli_integration.py000066400000000000000000000445471516556515500302100ustar00rootroot00000000000000"""Integration tests for Alembic CLI commands.

Tests the CLI commands against real databases to verify they interact
correctly with Alembic migration operations. These tests focus on verifying
commands execute without errors and perform their intended operations.
"""

from __future__ import annotations

import os
from collections.abc import Generator
from pathlib import Path
from typing import TYPE_CHECKING, cast
from unittest.mock import patch

import pytest
from alembic.util.exc import CommandError
from pytest import FixtureRequest
from pytest_lazy_fixtures import lf
from sqlalchemy import Engine
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker
from sqlalchemy.orm import sessionmaker

from advanced_alchemy import base
from advanced_alchemy.alembic import commands
from advanced_alchemy.extensions.litestar import SQLAlchemyAsyncConfig, SQLAlchemySyncConfig

if TYPE_CHECKING:
    from _pytest.monkeypatch import MonkeyPatch


pytestmark = [
    pytest.mark.integration,
    pytest.mark.xdist_group("alembic_cli_integration"),
]


@pytest.fixture(
    scope="session",
    params=[
        pytest.param(
            "sqlite_engine",
            marks=[
                pytest.mark.sqlite,
                pytest.mark.integration,
            ],
        ),
        pytest.param(
            "psycopg_engine",
            marks=[
                pytest.mark.psycopg_sync,
                pytest.mark.integration,
                pytest.mark.xdist_group("postgres"),
            ],
        ),
    ],
)
def sync_test_config(request: FixtureRequest) -> Generator[SQLAlchemySyncConfig, None, None]:
    """Create sync SQLAlchemy config for testing."""
    engine = cast(Engine, request.getfixturevalue(request.param))
    orm_registry = base.create_registry()
    yield SQLAlchemySyncConfig(
        engine_instance=engine,
        session_maker=sessionmaker(bind=engine, expire_on_commit=False),
        metadata=orm_registry.metadata,
    )


@pytest.fixture(
    scope="session",
    params=[
        pytest.param(
            "aiosqlite_engine",
            marks=[
                pytest.mark.aiosqlite,
                pytest.mark.integration,
            ],
        ),
        pytest.param(
            "asyncpg_engine",
            marks=[
                pytest.mark.asyncpg,
                pytest.mark.integration,
                pytest.mark.xdist_group("postgres"),
            ],
        ),
    ],
)
def async_test_config(request: FixtureRequest) -> Generator[SQLAlchemyAsyncConfig, None, None]:
    """Create async SQLAlchemy config for testing."""
    async_engine = cast(AsyncEngine, request.getfixturevalue(request.param))
    orm_registry = base.create_registry()
    yield SQLAlchemyAsyncConfig(
        engine_instance=async_engine,
        session_maker=async_sessionmaker(bind=async_engine, expire_on_commit=False),
        metadata=orm_registry.metadata,
    )


@pytest.fixture(
    scope="session",
    params=[lf("sync_test_config"), lf("async_test_config")],
    ids=["sync", "async"],
)
def test_config(request: FixtureRequest) -> Generator[SQLAlchemySyncConfig | SQLAlchemyAsyncConfig, None, None]:
    """Return config for current session."""
    if isinstance(request.param, SQLAlchemyAsyncConfig):
        request.getfixturevalue("async_test_config")
    else:
        request.getfixturevalue("sync_test_config")
    yield request.param  # type: ignore[no-any-return]


@pytest.fixture()
def alembic_cmds(
    test_config: SQLAlchemySyncConfig | SQLAlchemyAsyncConfig,
) -> Generator[commands.AlembicCommands, None, None]:
    """Create AlembicCommands instance."""
    cmds = commands.AlembicCommands(
        sqlalchemy_config=test_config,
    )
    yield cmds
    # Clean up: Drop the alembic_version table after each test to prevent
    # revision ID conflicts between tests using session-scoped databases
    try:
        from sqlalchemy import text
        from sqlalchemy.ext.asyncio import AsyncEngine

        engine = test_config.get_engine()
        table_name = test_config.alembic_config.version_table_name

        if isinstance(engine, AsyncEngine):
            import asyncio

            async def _cleanup() -> None:
                async with engine.begin() as conn:
                    await conn.execute(text(f"DROP TABLE IF EXISTS {table_name}"))

            asyncio.run(_cleanup())
        else:
            with engine.begin() as conn:
                conn.execute(text(f"DROP TABLE IF EXISTS {table_name}"))
    except Exception:
        # Ignore cleanup errors - table may not exist
        pass


@pytest.fixture()
def migration_dir(monkeypatch: MonkeyPatch, tmp_path: Path) -> Generator[Path, None, None]:
    """Create temporary migration directory."""
    project_dir = tmp_path / "test_project"
    project_dir.mkdir(exist_ok=True)
    monkeypatch.chdir(project_dir)
    yield project_dir


@pytest.fixture()
def initialized_migrations(alembic_cmds: commands.AlembicCommands, migration_dir: Path) -> Generator[Path, None, None]:
    """Initialize Alembic migrations directory."""
    migrations_path = migration_dir / "migrations"
    alembic_cmds.init(directory=str(migrations_path))
    yield migrations_path


@pytest.fixture()
def sample_migration(
    alembic_cmds: commands.AlembicCommands, initialized_migrations: Path
) -> Generator[str, None, None]:
    """Create a sample migration revision."""
    # Generate empty migration (no autogenerate to avoid model dependencies)
    alembic_cmds.revision(message="test migration", autogenerate=False)

    # Find the created revision file
    versions_dir = initialized_migrations / "versions"
    revision_files = list(versions_dir.glob("*.py"))
    assert len(revision_files) > 0, "migration revision should be created"

    # Extract revision ID from filename (format: {rev}_{message}.py)
    revision_id = revision_files[0].stem.split("_")[0]
    yield revision_id


def test_check_with_pending_migrations(alembic_cmds: commands.AlembicCommands, sample_migration: str) -> None:
    """Test check command with pending migrations."""
    # Check should run without crashing
    # It may or may not raise CommandError depending on database state
    try:
        alembic_cmds.check()
    except CommandError:
        # Expected - pending migrations detected
        pass


def test_check_with_no_migrations(alembic_cmds: commands.AlembicCommands, initialized_migrations: Path) -> None:
    """Test check command with no migrations present."""
    # With no migrations, check may succeed or raise error
    try:
        alembic_cmds.check()
    except CommandError:
        # Expected - no migrations to check
        pass


def test_ensure_version_executes(alembic_cmds: commands.AlembicCommands, initialized_migrations: Path) -> None:
    """Test ensure-version command executes without error."""
    # Command should execute without raising exception
    alembic_cmds.ensure_version(sql=False)


def test_ensure_version_sql_output(alembic_cmds: commands.AlembicCommands, initialized_migrations: Path) -> None:
    """Test ensure-version with --sql flag generates SQL."""
    # Calling with sql=True should not raise error
    alembic_cmds.ensure_version(sql=True)


def test_stamp_executes(alembic_cmds: commands.AlembicCommands, sample_migration: str) -> None:
    """Test stamp command executes."""
    # Ensure version table first
    alembic_cmds.ensure_version(sql=False)
    # Stamp should execute without error
    alembic_cmds.stamp(revision=sample_migration, sql=False, tag=None, purge=False)


def test_stamp_with_sql_flag(alembic_cmds: commands.AlembicCommands, sample_migration: str) -> None:
    """Test stamp --sql generates SQL output."""
    # Stamp with sql flag should generate SQL without applying
    alembic_cmds.stamp(revision="head", sql=True, tag=None, purge=False)


def test_stamp_with_tag(alembic_cmds: commands.AlembicCommands, sample_migration: str) -> None:
    """Test stamp with custom tag."""
    # Ensure version table
    alembic_cmds.ensure_version(sql=False)
    # Stamp with tag should execute
    alembic_cmds.stamp(revision="head", sql=False, tag="release-1.0", purge=False)


def test_stamp_with_purge(alembic_cmds: commands.AlembicCommands, sample_migration: str) -> None:
    """Test stamp --purge option."""
    # Ensure version table
    alembic_cmds.ensure_version(sql=False)
    # Stamp with purge
    alembic_cmds.stamp(revision="head", sql=False, tag=None, purge=True)


def test_heads_executes(alembic_cmds: commands.AlembicCommands, sample_migration: str) -> None:
    """Test heads command executes."""
    alembic_cmds.heads(verbose=False, resolve_dependencies=False)


def test_heads_verbose_mode(alembic_cmds: commands.AlembicCommands, sample_migration: str) -> None:
    """Test heads command with verbose flag."""
    alembic_cmds.heads(verbose=True, resolve_dependencies=False)


def test_heads_with_resolve_dependencies(alembic_cmds: commands.AlembicCommands, sample_migration: str) -> None:
    """Test heads command with dependency resolution."""
    alembic_cmds.heads(verbose=False, resolve_dependencies=True)


def test_heads_no_migrations(alembic_cmds: commands.AlembicCommands, initialized_migrations: Path) -> None:
    """Test heads command with no migrations."""
    alembic_cmds.heads(verbose=False, resolve_dependencies=False)


def test_history_shows_migrations(alembic_cmds: commands.AlembicCommands, sample_migration: str) -> None:
    """Test history command lists migrations."""
    alembic_cmds.history(rev_range=None, verbose=False, indicate_current=False)


def test_history_verbose_mode(alembic_cmds: commands.AlembicCommands, sample_migration: str) -> None:
    """Test history command with verbose output."""
    alembic_cmds.history(rev_range=None, verbose=True, indicate_current=False)


def test_history_with_rev_range(alembic_cmds: commands.AlembicCommands, sample_migration: str) -> None:
    """Test history command with revision range."""
    alembic_cmds.history(rev_range="base:head", verbose=False, indicate_current=False)


def test_history_indicate_current(alembic_cmds: commands.AlembicCommands, sample_migration: str) -> None:
    """Test history command with current revision indicator."""
    # Ensure version table and stamp
    alembic_cmds.ensure_version(sql=False)
    alembic_cmds.stamp(revision=sample_migration, sql=False, tag=None, purge=False)
    # Show history with current indicator
    alembic_cmds.history(rev_range=None, verbose=False, indicate_current=True)


def test_history_no_migrations(alembic_cmds: commands.AlembicCommands, initialized_migrations: Path) -> None:
    """Test history command with no migrations."""
    alembic_cmds.history(rev_range=None, verbose=False, indicate_current=False)


def test_show_head_revision(alembic_cmds: commands.AlembicCommands, sample_migration: str) -> None:
    """Test show command displays head revision."""
    alembic_cmds.show(rev="head")


def test_show_specific_revision(alembic_cmds: commands.AlembicCommands, sample_migration: str) -> None:
    """Test show command with specific revision ID."""
    alembic_cmds.show(rev=sample_migration)


def test_show_base_revision(alembic_cmds: commands.AlembicCommands, sample_migration: str) -> None:
    """Test show command with base revision."""
    alembic_cmds.show(rev="base")


def test_show_invalid_revision(alembic_cmds: commands.AlembicCommands, initialized_migrations: Path) -> None:
    """Test show command with invalid revision raises error."""
    with pytest.raises(CommandError):
        alembic_cmds.show(rev="nonexistent_revision_12345")


def test_branches_executes(alembic_cmds: commands.AlembicCommands, initialized_migrations: Path) -> None:
    """Test branches command executes."""
    alembic_cmds.branches(verbose=False)


def test_branches_verbose_mode(alembic_cmds: commands.AlembicCommands, initialized_migrations: Path) -> None:
    """Test branches command with verbose output."""
    alembic_cmds.branches(verbose=True)


def test_branches_with_multiple_heads(alembic_cmds: commands.AlembicCommands, initialized_migrations: Path) -> None:
    """Test branches command with branched migrations."""
    # Create branched migrations
    alembic_cmds.revision(message="main_branch", autogenerate=False, head="base", branch_label="main")
    alembic_cmds.revision(message="feature_branch", autogenerate=False, head="base", branch_label="feature")
    # Show branches
    alembic_cmds.branches(verbose=False)


def test_merge_creates_revision(alembic_cmds: commands.AlembicCommands, initialized_migrations: Path) -> None:
    """Test merge command creates merge revision."""
    # Create two branches
    alembic_cmds.revision(message="branch_a", autogenerate=False, head="base", branch_label="branch_a")
    alembic_cmds.revision(message="branch_b", autogenerate=False, head="base", branch_label="branch_b")
    # Merge
    result = alembic_cmds.merge(
        revisions="heads",
        message="merge branches",
        branch_label=None,
        rev_id=None,
    )
    assert result is not None


def test_merge_with_custom_message(alembic_cmds: commands.AlembicCommands, initialized_migrations: Path) -> None:
    """Test merge command with custom message."""
    alembic_cmds.revision(message="feature_1", autogenerate=False, head="base", branch_label="feature_1")
    alembic_cmds.revision(message="feature_2", autogenerate=False, head="base", branch_label="feature_2")
    result = alembic_cmds.merge(
        revisions="heads",
        message="custom merge message",
        branch_label=None,
        rev_id=None,
    )
    assert result is not None


def test_merge_with_branch_label(alembic_cmds: commands.AlembicCommands, initialized_migrations: Path) -> None:
    """Test merge command with branch label."""
    alembic_cmds.revision(message="dev_1", autogenerate=False, head="base", branch_label="dev_1")
    alembic_cmds.revision(message="dev_2", autogenerate=False, head="base", branch_label="dev_2")
    result = alembic_cmds.merge(
        revisions="heads",
        message="merge development branches",
        branch_label="merged_dev",
        rev_id=None,
    )
    assert result is not None


def test_merge_with_custom_rev_id(alembic_cmds: commands.AlembicCommands, initialized_migrations: Path) -> None:
    """Test merge command with custom revision ID."""
    alembic_cmds.revision(message="work_1", autogenerate=False, head="base", branch_label="work_1")
    alembic_cmds.revision(message="work_2", autogenerate=False, head="base", branch_label="work_2")
    custom_id = "custom_merge_001"
    result = alembic_cmds.merge(
        revisions="heads",
        message="merge with custom id",
        branch_label=None,
        rev_id=custom_id,
    )
    assert result is not None
    assert result.revision == custom_id


def test_merge_heads_shortcut(alembic_cmds: commands.AlembicCommands, initialized_migrations: Path) -> None:
    """Test merge command with 'heads' shortcut."""
    alembic_cmds.revision(message="head_1", autogenerate=False, head="base", branch_label="head_1")
    alembic_cmds.revision(message="head_2", autogenerate=False, head="base", branch_label="head_2")
    result = alembic_cmds.merge(
        revisions="heads",
        message="merge all heads",
        branch_label=None,
        rev_id=None,
    )
    assert result is not None


def test_edit_with_editor(alembic_cmds: commands.AlembicCommands, sample_migration: str) -> None:
    """Test edit command with editor set."""
    # Use 'true' as a safe test editor (exits successfully without interaction)
    with patch.dict(os.environ, {"EDITOR": "true"}):
        alembic_cmds.edit(revision=sample_migration)


def test_edit_head_revision(alembic_cmds: commands.AlembicCommands, sample_migration: str) -> None:
    """Test edit command with 'head' revision."""
    with patch.dict(os.environ, {"EDITOR": "true"}):
        alembic_cmds.edit(revision="head")


def test_edit_without_editor(alembic_cmds: commands.AlembicCommands, sample_migration: str) -> None:
    """Test edit command without $EDITOR set."""
    # Remove EDITOR from environment
    env = {k: v for k, v in os.environ.items() if k != "EDITOR"}
    with patch.dict(os.environ, env, clear=True):
        # Should raise error or use fallback editor
        try:
            alembic_cmds.edit(revision=sample_migration)
        except (CommandError, OSError, KeyError):
            # Expected - no editor configured
            pass


def test_edit_invalid_revision(alembic_cmds: commands.AlembicCommands, initialized_migrations: Path) -> None:
    """Test edit command with invalid revision."""
    with patch.dict(os.environ, {"EDITOR": "true"}):
        with pytest.raises(CommandError):
            alembic_cmds.edit(revision="invalid_revision_xyz")


def test_list_templates_executes(alembic_cmds: commands.AlembicCommands, initialized_migrations: Path) -> None:
    """Test list-templates command executes."""
    alembic_cmds.list_templates()


def test_current_executes(alembic_cmds: commands.AlembicCommands, initialized_migrations: Path) -> None:
    """Test current command executes."""
    # Ensure version table exists
    alembic_cmds.ensure_version(sql=False)
    alembic_cmds.current(verbose=False)


def test_current_verbose_mode(alembic_cmds: commands.AlembicCommands, sample_migration: str) -> None:
    """Test current command with verbose output."""
    alembic_cmds.ensure_version(sql=False)
    alembic_cmds.stamp(revision=sample_migration, sql=False, tag=None, purge=False)
    alembic_cmds.current(verbose=True)


def test_workflow_check_heads_history(alembic_cmds: commands.AlembicCommands, sample_migration: str) -> None:
    """Test workflow of check, heads, and history commands."""
    # Check for pending migrations
    try:
        alembic_cmds.check()
    except CommandError:
        pass
    # Show heads
    alembic_cmds.heads(verbose=False, resolve_dependencies=False)
    # Show history
    alembic_cmds.history(rev_range=None, verbose=False, indicate_current=False)


def test_workflow_branch_and_merge(alembic_cmds: commands.AlembicCommands, initialized_migrations: Path) -> None:
    """Test workflow of creating branches and merging."""
    # Create branches
    alembic_cmds.revision(message="branch_x", autogenerate=False, head="base", branch_label="branch_x")
    alembic_cmds.revision(message="branch_y", autogenerate=False, head="base", branch_label="branch_y")
    # Show branches
    alembic_cmds.branches(verbose=False)
    # Merge
    alembic_cmds.merge(
        revisions="heads",
        message="merged branches",
        branch_label=None,
        rev_id=None,
    )
    # Show history after merge
    alembic_cmds.history(rev_range=None, verbose=False, indicate_current=False)
python-advanced-alchemy-1.9.3/tests/integration/test_alembic_commands.py000066400000000000000000000255141516556515500266300ustar00rootroot00000000000000from __future__ import annotations

from collections.abc import Generator
from pathlib import Path
from typing import cast
from uuid import UUID

import pytest
from _pytest.monkeypatch import MonkeyPatch
from alembic.util.exc import CommandError
from pytest import CaptureFixture, FixtureRequest
from pytest_lazy_fixtures import lf
from sqlalchemy import Engine, ForeignKey, String
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker
from sqlalchemy.orm import Mapped, mapped_column, relationship, sessionmaker

from advanced_alchemy import base
from advanced_alchemy.alembic import commands
from advanced_alchemy.alembic.utils import drop_all, dump_tables
from advanced_alchemy.extensions.litestar import SQLAlchemyAsyncConfig, SQLAlchemySyncConfig
from tests.fixtures.uuid import models as models_uuid
from tests.helpers import maybe_async

AuthorModel = type[models_uuid.UUIDAuthor]
RuleModel = type[models_uuid.UUIDRule]
ModelWithFetchedValue = type[models_uuid.UUIDModelWithFetchedValue]
ItemModel = type[models_uuid.UUIDItem]
TagModel = type[models_uuid.UUIDTag]

pytestmark = [
    pytest.mark.integration,
    pytest.mark.xdist_group("alembic_commands"),
]


@pytest.fixture(
    scope="session",
    params=[
        pytest.param(
            "sqlite_engine",
            marks=[
                pytest.mark.sqlite,
                pytest.mark.integration,
            ],
        ),
        pytest.param(
            "duckdb_engine",
            marks=[
                pytest.mark.duckdb,
                pytest.mark.integration,
                pytest.mark.xdist_group("duckdb"),
            ],
        ),
        pytest.param(
            "oracle18c_engine",
            marks=[
                pytest.mark.oracledb_sync,
                pytest.mark.integration,
                pytest.mark.xdist_group("oracle18"),
            ],
        ),
        pytest.param(
            "oracle23ai_engine",
            marks=[
                pytest.mark.oracledb_sync,
                pytest.mark.integration,
                pytest.mark.xdist_group("oracle23"),
            ],
        ),
        pytest.param(
            "psycopg_engine",
            marks=[
                pytest.mark.psycopg_sync,
                pytest.mark.integration,
                pytest.mark.xdist_group("postgres"),
            ],
        ),
        pytest.param(
            "spanner_engine",
            marks=[
                pytest.mark.spanner,
                pytest.mark.integration,
                pytest.mark.xdist_group("spanner"),
            ],
        ),
        pytest.param(
            "mssql_engine",
            marks=[
                pytest.mark.mssql_sync,
                pytest.mark.integration,
                pytest.mark.xdist_group("mssql"),
            ],
        ),
        pytest.param(
            "cockroachdb_engine",
            marks=[
                pytest.mark.cockroachdb_sync,
                pytest.mark.integration,
                pytest.mark.xdist_group("cockroachdb"),
            ],
        ),
    ],
)
def sync_sqlalchemy_config(request: FixtureRequest) -> Generator[SQLAlchemySyncConfig, None, None]:
    engine = cast(Engine, request.getfixturevalue(request.param))
    orm_registry = base.create_registry()
    yield SQLAlchemySyncConfig(
        engine_instance=engine,
        session_maker=sessionmaker(bind=engine, expire_on_commit=False),
        metadata=orm_registry.metadata,
    )


@pytest.fixture(
    scope="session",
    params=[
        pytest.param(
            "aiosqlite_engine",
            marks=[
                pytest.mark.aiosqlite,
                pytest.mark.integration,
            ],
        ),
        pytest.param(
            "asyncmy_engine",
            marks=[
                pytest.mark.asyncmy,
                pytest.mark.integration,
                pytest.mark.xdist_group("mysql"),
            ],
        ),
        pytest.param(
            "asyncpg_engine",
            marks=[
                pytest.mark.asyncpg,
                pytest.mark.integration,
                pytest.mark.xdist_group("postgres"),
            ],
        ),
        pytest.param(
            "psycopg_async_engine",
            marks=[
                pytest.mark.psycopg_async,
                pytest.mark.integration,
                pytest.mark.xdist_group("postgres"),
            ],
        ),
        pytest.param(
            "cockroachdb_async_engine",
            marks=[
                pytest.mark.cockroachdb_async,
                pytest.mark.integration,
                pytest.mark.xdist_group("cockroachdb"),
            ],
        ),
        pytest.param(
            "oracle18c_async_engine",
            marks=[
                pytest.mark.oracledb_async,
                pytest.mark.integration,
                pytest.mark.xdist_group("oracle18"),
            ],
        ),
        pytest.param(
            "oracle23ai_async_engine",
            marks=[
                pytest.mark.oracledb_async,
                pytest.mark.integration,
                pytest.mark.xdist_group("oracle23"),
            ],
        ),
        pytest.param(
            "mssql_async_engine",
            marks=[
                pytest.mark.mssql_async,
                pytest.mark.integration,
                pytest.mark.xdist_group("mssql"),
            ],
        ),
    ],
)
def async_sqlalchemy_config(
    request: FixtureRequest,
) -> Generator[SQLAlchemyAsyncConfig, None, None]:
    async_engine = cast(AsyncEngine, request.getfixturevalue(request.param))
    orm_registry = base.create_registry()
    yield SQLAlchemyAsyncConfig(
        engine_instance=async_engine,
        session_maker=async_sessionmaker(bind=async_engine, expire_on_commit=False),
        metadata=orm_registry.metadata,
    )


@pytest.fixture(
    scope="session",
    params=[lf("sync_sqlalchemy_config"), lf("async_sqlalchemy_config")],
    ids=["sync", "async"],
)
def any_config(request: FixtureRequest) -> Generator[SQLAlchemySyncConfig | SQLAlchemyAsyncConfig, None, None]:
    """Return a session for the current session"""
    if isinstance(request.param, SQLAlchemyAsyncConfig):
        request.getfixturevalue("async_sqlalchemy_config")
    else:
        request.getfixturevalue("sync_sqlalchemy_config")
    yield request.param  # type: ignore[no-any-return]


@pytest.fixture()
def alembic_commands(
    any_config: SQLAlchemySyncConfig | SQLAlchemyAsyncConfig,
) -> Generator[commands.AlembicCommands, None, None]:
    yield commands.AlembicCommands(
        sqlalchemy_config=any_config,
    )


@pytest.fixture()
def tmp_project_dir(monkeypatch: MonkeyPatch, tmp_path: Path) -> Generator[Path, None, None]:
    path = tmp_path / "project_dir"
    path.mkdir(exist_ok=True)
    monkeypatch.chdir(path)
    yield path


async def test_alembic_init(alembic_commands: commands.AlembicCommands, tmp_project_dir: Path) -> None:
    from advanced_alchemy.utils.sync_tools import async_

    alembic_commands.init(directory=f"{tmp_project_dir}/migrations/")
    expected_dirs = [f"{tmp_project_dir}/migrations/", f"{tmp_project_dir}/migrations/versions"]
    expected_files = [f"{tmp_project_dir}/migrations/env.py", f"{tmp_project_dir}/migrations/script.py.mako"]
    for dir in expected_dirs:
        assert await async_(Path(dir).is_dir)()
    for file in expected_files:
        assert await async_(Path(file).is_file)()


async def test_alembic_init_already(alembic_commands: commands.AlembicCommands, tmp_project_dir: Path) -> None:
    from advanced_alchemy.utils.sync_tools import async_

    alembic_commands.init(directory=f"{tmp_project_dir}/migrations/")
    expected_dirs = [f"{tmp_project_dir}/migrations/", f"{tmp_project_dir}/migrations/versions"]
    expected_files = [f"{tmp_project_dir}/migrations/env.py", f"{tmp_project_dir}/migrations/script.py.mako"]
    for dir in expected_dirs:
        assert await async_(Path(dir).is_dir)()
    for file in expected_files:
        assert await async_(Path(file).is_file)()
    with pytest.raises(CommandError):
        alembic_commands.init(directory=f"{tmp_project_dir}/migrations/")


@pytest.mark.xdist_group("alembic_drop_all")  # Isolate destructive test to prevent race conditions
async def test_drop_all(
    alembic_commands: commands.AlembicCommands,
    any_config: SQLAlchemySyncConfig | SQLAlchemyAsyncConfig,
    capfd: CaptureFixture[str],
) -> None:
    from examples.litestar.litestar_repo_only import app

    await maybe_async(any_config.create_all_metadata(app))
    if isinstance(any_config, SQLAlchemySyncConfig):
        assert any_config.metadata
        any_config.metadata.create_all(any_config.get_engine())
    else:
        async with any_config.get_engine().begin() as conn:
            assert any_config.metadata
            await conn.run_sync(any_config.metadata.create_all)

    await drop_all(
        alembic_commands.config.engine,
        alembic_commands.config.version_table_name,
        base.metadata_registry.get(alembic_commands.config.bind_key),
    )
    result = capfd.readouterr()
    assert "Successfully dropped all objects" in result.out


async def test_dump_tables(
    any_config: SQLAlchemySyncConfig | SQLAlchemyAsyncConfig,
    capfd: CaptureFixture[str],
    tmp_project_dir: Path,
) -> None:
    from sqlalchemy.orm import DeclarativeBase

    from advanced_alchemy import base, mixins

    class _UUIDAuditBase(base.CommonTableAttributes, mixins.UUIDPrimaryKey, DeclarativeBase):
        registry = base.create_registry()

    class TestAuthorModel(_UUIDAuditBase):
        name: Mapped[str] = mapped_column(String(10))

    class TestBookModel(_UUIDAuditBase):
        title: Mapped[str] = mapped_column(String(10))
        author_id: Mapped[UUID] = mapped_column(ForeignKey("test_author_model.id"))

    TestBookModel.author = relationship(TestAuthorModel, lazy="joined", innerjoin=True, viewonly=True)
    TestAuthorModel.books = relationship(TestBookModel, back_populates="author", lazy="noload", uselist=True)

    if isinstance(any_config, SQLAlchemySyncConfig):
        TestBookModel.metadata.create_all(any_config.get_engine())
    else:
        async with any_config.get_engine().begin() as conn:
            await conn.run_sync(TestBookModel.metadata.create_all)

    await dump_tables(
        tmp_project_dir,
        any_config.get_session(),
        [TestAuthorModel, TestBookModel],
    )
    result = capfd.readouterr()
    assert "Dumping table 'test_author_model'" in result.out
    assert "Dumping table 'test_book_model" in result.out


"""
async def test_alembic_revision(alembic_commands: commands.AlembicCommands, tmp_project_dir: Path) -> None:
    alembic_commands.init(directory=f"{tmp_project_dir}/migrations/")
    alembic_commands.revision(message="test", autogenerate=True)


async def test_alembic_upgrade(alembic_commands: commands.AlembicCommands, tmp_project_dir: Path) -> None:
    alembic_commands.init(directory=f"{tmp_project_dir}/migrations/")
    alembic_commands.revision(message="test", autogenerate=True)
    alembic_commands.upgrade(revision="head")
"""
python-advanced-alchemy-1.9.3/tests/integration/test_association_proxy.py000066400000000000000000000147561516556515500271360ustar00rootroot00000000000000from __future__ import annotations

from typing import TYPE_CHECKING

import pytest
from sqlalchemy import Column, Engine, ForeignKey, String, Table, select
from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import Mapped, Session, mapped_column, relationship, sessionmaker

if TYPE_CHECKING:
    from pytest import MonkeyPatch

pytestmark = [
    pytest.mark.integration,
    pytest.mark.xdist_group("association_proxy"),
]


@pytest.mark.xdist_group("loader")
def test_ap_sync(monkeypatch: MonkeyPatch, engine: Engine) -> None:
    # Skip DuckDB and mock engines
    dialect_name = getattr(engine.dialect, "name", "")
    if dialect_name == "duckdb":
        pytest.skip("DuckDB doesn't support CASCADE in foreign key constraints")
    if dialect_name == "mock":
        pytest.skip("Mock engines don't properly support multi-row inserts with RETURNING")

    from sqlalchemy.orm import DeclarativeBase

    from advanced_alchemy import base, mixins

    orm_registry = base.create_registry()

    class NewUUIDBase(mixins.UUIDPrimaryKey, base.CommonTableAttributes, DeclarativeBase):
        registry = orm_registry

    monkeypatch.setattr(base, "UUIDBase", NewUUIDBase)

    product_tag_table = Table(
        "product_tag_sync",
        orm_registry.metadata,
        Column("product_id", ForeignKey("product_sync.id", ondelete="CASCADE"), primary_key=True),  # pyright: ignore[reportUnknownArgumentType]
        Column("tag_id", ForeignKey("tag_sync.id", ondelete="CASCADE"), primary_key=True),  # pyright: ignore[reportUnknownArgumentType]
    )

    class Tag(NewUUIDBase):
        __tablename__ = "tag_sync"
        name: Mapped[str] = mapped_column(String(100), index=True)
        products: Mapped[list[Product]] = relationship(
            secondary=lambda: product_tag_table,
            back_populates="product_tags",
            cascade="all, delete",
            passive_deletes=True,
            lazy="noload",
        )

    class Product(NewUUIDBase):
        __tablename__ = "product_sync"
        name: Mapped[str] = mapped_column(String(50))  # pyright: ignore
        product_tags: Mapped[list[Tag]] = relationship(
            secondary=lambda: product_tag_table,
            back_populates="products",
            cascade="all, delete",
            passive_deletes=True,
            lazy="joined",
        )
        tags: AssociationProxy[list[str]] = association_proxy(
            "product_tags",
            "name",
            creator=lambda name: Tag(name=name),  # pyright: ignore[reportUnknownArgumentType,reportUnknownLambdaType]
        )

    session_factory: sessionmaker[Session] = sessionmaker(engine, expire_on_commit=False)

    with engine.begin() as conn:
        Product.metadata.create_all(conn)

    with session_factory() as db_session:
        product_1 = Product(name="Product 1", tags=["a new tag", "second tag"])
        db_session.add(product_1)

        tags = db_session.execute(select(Tag)).unique().fetchall()
        assert len(tags) == 2

        product_2 = Product(name="Product 2", tags=["third tag"])
        db_session.add(product_2)
        tags = db_session.execute(select(Tag)).unique().fetchall()
        assert len(tags) == 3

        product_2.tags = []
        db_session.add(product_2)

        product_2_validate = db_session.execute(select(Product).where(Product.name == "Product 2")).unique().fetchone()
        assert product_2_validate
        tags_2 = db_session.execute(select(Tag)).unique().fetchall()
        assert len(product_2_validate[0].product_tags) == 0
        assert len(tags_2) == 3
        # add more assertions


@pytest.mark.xdist_group("loader")
async def test_ap_async(monkeypatch: MonkeyPatch, async_engine: AsyncEngine) -> None:
    # Skip DuckDB and mock engines
    dialect_name = getattr(async_engine.dialect, "name", "")
    if dialect_name == "duckdb":
        pytest.skip("DuckDB doesn't support CASCADE in foreign key constraints")
    if dialect_name == "mock":
        pytest.skip("Mock engines don't properly support multi-row inserts with RETURNING")

    from sqlalchemy.orm import DeclarativeBase

    from advanced_alchemy import base, mixins

    orm_registry = base.create_registry()

    class NewUUIDBase(mixins.UUIDPrimaryKey, base.CommonTableAttributes, DeclarativeBase):
        registry = orm_registry

    monkeypatch.setattr(base, "UUIDBase", NewUUIDBase)

    product_tag_table = Table(
        "product_tag_async",
        orm_registry.metadata,
        Column("product_id", ForeignKey("product_async.id", ondelete="CASCADE"), primary_key=True),  # pyright: ignore[reportUnknownArgumentType]
        Column("tag_id", ForeignKey("tag_async.id", ondelete="CASCADE"), primary_key=True),  # pyright: ignore[reportUnknownArgumentType]
    )

    class Tag(NewUUIDBase):
        __tablename__ = "tag_async"
        name: Mapped[str] = mapped_column(String(100), index=True)
        products: Mapped[list[Product]] = relationship(
            secondary=lambda: product_tag_table,
            back_populates="product_tags",
            cascade="all, delete",
            passive_deletes=True,
            lazy="noload",
        )

    class Product(NewUUIDBase):
        __tablename__ = "product_async"
        name: Mapped[str] = mapped_column(String(50))  # pyright: ignore
        product_tags: Mapped[list[Tag]] = relationship(
            secondary=lambda: product_tag_table,
            back_populates="products",
            cascade="all, delete",
            passive_deletes=True,
            lazy="joined",
        )
        tags: AssociationProxy[list[str]] = association_proxy(
            "product_tags",
            "name",
            creator=lambda name: Tag(name=name),  # pyright: ignore[reportUnknownArgumentType,reportUnknownLambdaType]
        )

    session_factory: async_sessionmaker[AsyncSession] = async_sessionmaker(async_engine, expire_on_commit=False)

    async with async_engine.begin() as conn:
        await conn.run_sync(Tag.metadata.create_all)

    async with session_factory() as db_session:
        product_1 = Product(name="Product 1 async", tags=["a new tag", "second tag"])
        db_session.add(product_1)

        tags = await db_session.execute(select(Tag))
        assert len(tags.unique().fetchall()) == 2

        product_2 = Product(name="Product 2 async", tags=["third tag"])
        db_session.add(product_2)
        tags = await db_session.execute(select(Tag))
        assert len(tags.unique().fetchall()) == 3

        # add more assertions
python-advanced-alchemy-1.9.3/tests/integration/test_attrs_service.py000066400000000000000000000520171516556515500262260ustar00rootroot00000000000000"""Integration tests for attrs support in Advanced Alchemy services."""

from __future__ import annotations

from pathlib import Path
from typing import Any, Optional, cast

import pytest
from sqlalchemy import Engine, String, select
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column

from advanced_alchemy import base, mixins
from advanced_alchemy.repository import (
    SQLAlchemyAsyncRepository,
    SQLAlchemySyncRepository,
)
from advanced_alchemy.service import SQLAlchemyAsyncQueryService, SQLAlchemySyncQueryService
from advanced_alchemy.service._async import SQLAlchemyAsyncRepositoryService
from advanced_alchemy.service._sync import SQLAlchemySyncRepositoryService
from advanced_alchemy.service.typing import (
    ATTRS_INSTALLED,
    is_attrs_instance,
    is_attrs_instance_with_field,
    is_attrs_instance_without_field,
)

pytestmark = [
    pytest.mark.integration,
    pytest.mark.skipif(not ATTRS_INSTALLED, reason="attrs not installed"),
    pytest.mark.xdist_group("attrs_service"),
]

here = Path(__file__).parent
fixture_path = here.parent.parent / "examples"
attrs_registry = base.create_registry()


@pytest.fixture()
def attrs_test_tables(engine: Engine) -> None:
    """Create attrs test tables for sync engines."""
    if getattr(engine.dialect, "name", "") != "mock":
        attrs_registry.metadata.create_all(engine)


@pytest.fixture()
async def attrs_test_tables_async(async_engine: AsyncEngine) -> None:
    """Create attrs test tables for async engines."""
    if getattr(async_engine.dialect, "name", "") != "mock":
        async with async_engine.begin() as conn:
            await conn.run_sync(attrs_registry.metadata.create_all)


if ATTRS_INSTALLED:
    from attrs import define, field

    @define
    class PersonAttrs:
        """attrs class for testing."""

        name: str
        age: int
        email: Optional[str] = None

    @define
    class PersonWithDefaults:
        """attrs class with field defaults."""

        name: str
        age: int = field(default=18)
        is_active: bool = field(default=True)
        tags: list[str] = field(factory=list)

    @define
    class StateAttrs:
        """attrs class matching US State structure."""

        abbreviation: str
        name: str

    @define
    class StateQueryAttrs:
        """attrs class for query results."""

        state_abbreviation: str
        state_name: str


class UUIDBase(mixins.UUIDPrimaryKey, base.CommonTableAttributes, DeclarativeBase):
    """Base for all SQLAlchemy declarative models with UUID primary keys."""

    registry = attrs_registry


class Person(UUIDBase):
    """Person model for testing attrs integration."""

    __tablename__ = "person"
    name: Mapped[str] = mapped_column(String(255))
    age: Mapped[int]
    email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)


class USState(UUIDBase):
    """US State model for testing."""

    __tablename__ = "us_state_lookup"
    abbreviation: Mapped[str] = mapped_column(String(10))
    name: Mapped[str] = mapped_column(String(255))


class PersonSyncRepository(SQLAlchemySyncRepository[Person]):
    """Person repository."""

    model_type = Person


class PersonSyncService(SQLAlchemySyncRepositoryService[Person, PersonSyncRepository]):
    """Person service."""

    repository_type = PersonSyncRepository


class PersonAsyncRepository(SQLAlchemyAsyncRepository[Person]):
    """Person async repository."""

    model_type = Person


class PersonAsyncService(SQLAlchemyAsyncRepositoryService[Person, PersonAsyncRepository]):
    """Person async service."""

    repository_type = PersonAsyncRepository


class USStateSyncRepository(SQLAlchemySyncRepository[USState]):
    """US State repository."""

    model_type = USState


class USStateSyncService(SQLAlchemySyncRepositoryService[USState, USStateSyncRepository]):
    """US State service."""

    repository_type = USStateSyncRepository


class USStateAsyncRepository(SQLAlchemyAsyncRepository[USState]):
    """US State async repository."""

    model_type = USState


class USStateAsyncService(SQLAlchemyAsyncRepositoryService[USState, USStateAsyncRepository]):
    """US State async service."""

    repository_type = USStateAsyncRepository


class StateQuery(base.SQLQuery):
    """Custom query for testing attrs conversion."""

    __table__ = select(  # type: ignore[misc]
        USState.abbreviation.label("state_abbreviation"),
        USState.name.label("state_name"),
    ).alias("state_lookup")
    __mapper_args__ = {
        "primary_key": [USState.abbreviation],
    }
    state_abbreviation: str  # type: ignore[misc]
    state_name: str  # type: ignore[misc]


@pytest.mark.skipif(not ATTRS_INSTALLED, reason="attrs not installed")
@pytest.mark.xdist_group("attrs")
def test_sync_attrs_service_basic_operations(engine: Engine, attrs_test_tables: None) -> None:
    """Test basic service operations with attrs classes."""
    # Skip mock engines as they don't support auto-generated primary keys
    if getattr(engine.dialect, "name", "") == "mock":
        pytest.skip("Mock engines don't support auto-generated primary keys")

    with Session(engine) as session:
        service = PersonSyncService(session=session)

        # Test create with dict data first (which works with existing services)
        person_data = {"name": "John Doe", "age": 30, "email": "john@example.com"}
        created_person = service.create(person_data)

        assert created_person.name == "John Doe"
        assert created_person.age == 30
        assert created_person.email == "john@example.com"

        # Test create many with dict data
        people_data = [
            {"name": "Jane Smith", "age": 25, "email": "jane@example.com"},
            {"name": "Bob Wilson", "age": 35, "email": "bob@example.com"},
        ]
        created_people = service.create_many(people_data)
        assert len(created_people) == 2

        # Test to_schema conversion to attrs - this is the main integration point
        person_attrs = service.to_schema(created_person, schema_type=PersonAttrs)
        assert isinstance(person_attrs, PersonAttrs)
        assert is_attrs_instance(person_attrs)
        assert is_attrs_instance_with_field(person_attrs, "name")
        assert is_attrs_instance_with_field(person_attrs, "age")
        assert is_attrs_instance_with_field(person_attrs, "email")
        assert not is_attrs_instance_without_field(person_attrs, "name")

        # Test list conversion to attrs
        all_people = service.list()
        people_paginated = service.to_schema(all_people, schema_type=PersonAttrs)
        assert len(people_paginated.items) == 3
        assert all(isinstance(person, PersonAttrs) for person in people_paginated.items)


@pytest.mark.skipif(not ATTRS_INSTALLED, reason="attrs not installed")
@pytest.mark.xdist_group("attrs")
def test_sync_attrs_with_defaults(engine: Engine, attrs_test_tables: None) -> None:
    """Test attrs classes with default values."""
    # Skip mock engines as they don't support auto-generated primary keys
    if getattr(engine.dialect, "name", "") == "mock":
        pytest.skip("Mock engines don't support auto-generated primary keys")

    with Session(engine) as session:
        service = PersonSyncService(session=session)

        # Create with dict data and default values
        person_data = {"name": "Default Person", "age": 18}
        created_person = service.create(person_data)

        assert created_person.name == "Default Person"
        assert created_person.age == 18

        # Convert to attrs with defaults - this tests the attrs schema conversion
        person_attrs = service.to_schema(created_person, schema_type=PersonWithDefaults)
        assert isinstance(person_attrs, PersonWithDefaults)
        assert person_attrs.name == "Default Person"
        assert person_attrs.age == 18
        assert person_attrs.is_active is True  # default value from attrs class
        assert person_attrs.tags == []  # default factory from attrs class


@pytest.mark.skipif(not ATTRS_INSTALLED, reason="attrs not installed")
@pytest.mark.xdist_group("attrs")
def test_sync_query_service_with_attrs(engine: Engine, attrs_test_tables: None) -> None:
    """Test query service with attrs schema conversion."""
    # Skip mock engines as they don't support auto-generated primary keys
    if getattr(engine.dialect, "name", "") == "mock":
        pytest.skip("Mock engines don't support auto-generated primary keys")

    with Session(engine) as session:
        state_service = USStateSyncService(session=session)
        query_service = SQLAlchemySyncQueryService(session=session)

        # Create test data with dict data
        states_data = [
            {"abbreviation": "CA", "name": "California"},
            {"abbreviation": "TX", "name": "Texas"},
            {"abbreviation": "NY", "name": "New York"},
        ]
        state_service.create_many(states_data)

        # Query and convert to attrs
        query_results, count = query_service.repository.list_and_count(statement=select(StateQuery))
        assert count >= 3

        # Test single item conversion
        single_result = query_service.to_schema(
            data=query_results[0],
            schema_type=StateQueryAttrs,
        )
        assert isinstance(single_result, StateQueryAttrs)
        assert is_attrs_instance(single_result)
        assert hasattr(single_result, "state_abbreviation")
        assert hasattr(single_result, "state_name")

        # Test paginated conversion
        paginated_results = query_service.to_schema(
            data=query_results,
            total=count,
            schema_type=StateQueryAttrs,
        )
        assert len(paginated_results.items) >= 3
        assert all(isinstance(item, StateQueryAttrs) for item in paginated_results.items)
        assert all(is_attrs_instance(item) for item in paginated_results.items)


@pytest.mark.skipif(not ATTRS_INSTALLED, reason="attrs not installed")
@pytest.mark.xdist_group("attrs")
async def test_async_attrs_service_basic_operations(async_engine: AsyncEngine, attrs_test_tables_async: None) -> None:
    """Test async service operations with attrs classes."""
    # Skip mock engines as they don't support auto-generated primary keys
    if getattr(async_engine.dialect, "name", "") == "mock":
        pytest.skip("Mock engines don't support auto-generated primary keys")

    async with AsyncSession(async_engine) as session:
        service = PersonAsyncService(session=session)

        # Test create with dict data
        person_data = {"name": "Async John", "age": 28, "email": "async.john@example.com"}
        created_person = await service.create(person_data)

        assert created_person.name == "Async John"
        assert created_person.age == 28
        assert created_person.email == "async.john@example.com"

        # Test create many with dict data
        people_data = [
            {"name": "Async Jane", "age": 26, "email": "async.jane@example.com"},
            {"name": "Async Bob", "age": 32, "email": "async.bob@example.com"},
        ]
        created_people = await service.create_many(people_data)
        assert len(created_people) == 2

        # Test to_schema conversion to attrs
        person_attrs = service.to_schema(created_person, schema_type=PersonAttrs)
        assert isinstance(person_attrs, PersonAttrs)
        assert is_attrs_instance(person_attrs)
        # Type cast to help pyright understand the specific attrs type
        person_attrs = cast(PersonAttrs, person_attrs)
        assert person_attrs.name == "Async John"
        assert person_attrs.age == 28

        # Test list conversion to attrs
        all_people = await service.list()
        people_paginated = service.to_schema(all_people, schema_type=PersonAttrs)
        assert len(people_paginated.items) == 3
        assert all(isinstance(person, PersonAttrs) for person in people_paginated.items)


@pytest.mark.skipif(not ATTRS_INSTALLED, reason="attrs not installed")
@pytest.mark.xdist_group("attrs")
async def test_async_query_service_with_attrs(async_engine: AsyncEngine, attrs_test_tables_async: None) -> None:
    """Test async query service with attrs schema conversion."""
    # Skip mock engines as they don't support auto-generated primary keys
    if getattr(async_engine.dialect, "name", "") == "mock":
        pytest.skip("Mock engines don't support auto-generated primary keys")

    async with AsyncSession(async_engine) as session:
        state_service = USStateAsyncService(session=session)
        query_service = SQLAlchemyAsyncQueryService(session=session)

        # Create test data with dict data
        states_data = [
            {"abbreviation": "FL", "name": "Florida"},
            {"abbreviation": "WA", "name": "Washington"},
            {"abbreviation": "OR", "name": "Oregon"},
        ]
        await state_service.create_many(states_data)

        # Query and convert to attrs
        query_results, count = await query_service.repository.list_and_count(statement=select(StateQuery))
        assert count >= 3

        # Test single item conversion
        single_result = query_service.to_schema(
            data=query_results[0],
            schema_type=StateQueryAttrs,
        )
        assert isinstance(single_result, StateQueryAttrs)
        assert is_attrs_instance(single_result)

        # Test paginated conversion
        paginated_results = query_service.to_schema(
            data=query_results,
            total=count,
            schema_type=StateQueryAttrs,
        )
        assert len(paginated_results.items) >= 3
        assert all(isinstance(item, StateQueryAttrs) for item in paginated_results.items)


@pytest.mark.skipif(not ATTRS_INSTALLED, reason="attrs not installed")
@pytest.mark.xdist_group("attrs")
def test_attrs_error_handling(engine: Engine, attrs_test_tables: None) -> None:
    """Test error handling with attrs integration."""
    # Skip mock engines as they don't support auto-generated primary keys
    if getattr(engine.dialect, "name", "") == "mock":
        pytest.skip("Mock engines don't support auto-generated primary keys")

    with Session(engine) as session:
        service = PersonSyncService(session=session)

        # Test with invalid attrs-like class (should work with duck typing)
        class FakeAttrsClass:
            def __init__(self, name: str, age: int) -> None:
                self.name = name
                self.age = age

        fake_data = FakeAttrsClass(name="Fake", age=25)

        # This should still work because the service will use __dict__ fallback
        created_person = service.create(fake_data)  # type: ignore[arg-type]
        assert created_person.name == "Fake"
        assert created_person.age == 25


@pytest.mark.skipif(not ATTRS_INSTALLED, reason="attrs not installed")
@pytest.mark.xdist_group("attrs")
def test_attrs_mixed_with_other_schemas(engine: Engine, attrs_test_tables: None) -> None:
    """Test attrs alongside other schema types."""
    # Skip mock engines as they don't support auto-generated primary keys
    if getattr(engine.dialect, "name", "") == "mock":
        pytest.skip("Mock engines don't support auto-generated primary keys")

    with Session(engine) as session:
        service = PersonSyncService(session=session)

        # Create with attrs
        attrs_person = PersonAttrs(name="Attrs Person", age=30)
        created_attrs = service.create(attrs_person)  # type: ignore[arg-type]

        # Create with dict
        dict_person = {"name": "Dict Person", "age": 25, "email": "dict@example.com"}
        created_dict = service.create(dict_person)

        # Both should work
        assert created_attrs.name == "Attrs Person"
        assert created_dict.name == "Dict Person"

        # Convert both to attrs
        attrs_result = service.to_schema(created_attrs, schema_type=PersonAttrs)
        dict_to_attrs_result = service.to_schema(created_dict, schema_type=PersonAttrs)

        assert isinstance(attrs_result, PersonAttrs)
        assert isinstance(dict_to_attrs_result, PersonAttrs)
        assert is_attrs_instance(attrs_result)
        assert is_attrs_instance(dict_to_attrs_result)


@pytest.mark.skipif(not ATTRS_INSTALLED, reason="attrs not installed")
@pytest.mark.xdist_group("attrs")
def test_attrs_partial_update_with_nothing_values(engine: Engine, attrs_test_tables: None) -> None:
    """Test attrs partial updates with NOTHING values (GitHub Issue #535)."""
    # Skip mock engines as they don't support auto-generated primary keys
    if getattr(engine.dialect, "name", "") == "mock":
        pytest.skip("Mock engines don't support auto-generated primary keys")

    try:
        from attrs import NOTHING, define, field
    except ImportError:
        pytest.skip("attrs not installed")

    @define
    class PersonPartialUpdate:
        """attrs class for partial updates with NOTHING sentinel values."""

        # For partial updates, use Any to avoid type issues with NOTHING
        # Use factory to properly make fields optional with NOTHING defaults
        name: Any = field(factory=lambda: NOTHING)
        age: Any = field(factory=lambda: NOTHING)
        email: Any = field(factory=lambda: NOTHING)

    with Session(engine) as session:
        service = PersonSyncService(session=session)

        # Create initial person
        initial_data = {"name": "Initial Name", "age": 30, "email": "initial@example.com"}
        created_person = service.create(initial_data)
        person_id = created_person.id

        # Test partial update with NOTHING values (only update name)
        # This was broken in v1.5.0+ and should work now
        partial_update = PersonPartialUpdate(name="Updated Name")  # age and email default to NOTHING

        # This should not raise IntegrityError and should only update the name field
        updated_person = service.update(partial_update, item_id=person_id)  # type: ignore[arg-type]

        # Verify the update worked correctly
        assert updated_person.name == "Updated Name"  # This should be updated
        assert updated_person.age == 30  # This should remain unchanged
        assert updated_person.email == "initial@example.com"  # This should remain unchanged
        assert updated_person.id == person_id  # ID should be same

        # Test another partial update (only update age and email)
        partial_update2 = PersonPartialUpdate(name=NOTHING, age=35, email="updated@example.com")
        updated_person2 = service.update(partial_update2, item_id=person_id)  # type: ignore[arg-type]

        # Verify this update
        assert updated_person2.name == "Updated Name"  # Should remain from previous update
        assert updated_person2.age == 35  # This should be updated
        assert updated_person2.email == "updated@example.com"  # This should be updated


@pytest.mark.skipif(not ATTRS_INSTALLED, reason="attrs not installed")
@pytest.mark.xdist_group("attrs")
def test_attrs_update_many_with_nothing_values(engine: Engine, attrs_test_tables: None) -> None:
    """Test attrs update_many with NOTHING values for partial updates."""
    # Skip mock engines as they don't support auto-generated primary keys
    if getattr(engine.dialect, "name", "") == "mock":
        pytest.skip("Mock engines don't support auto-generated primary keys")

    try:
        from attrs import NOTHING, define, field
    except ImportError:
        pytest.skip("attrs not installed")

    @define
    class PersonBulkUpdate:
        """attrs class for bulk partial updates."""

        # For partial updates, use Any to avoid type issues with NOTHING
        # Use factory to properly make fields optional with NOTHING defaults
        id: Any = field(factory=lambda: NOTHING)  # Still need ID for updates
        name: Any = field(factory=lambda: NOTHING)
        age: Any = field(factory=lambda: NOTHING)
        email: Any = field(factory=lambda: NOTHING)

    with Session(engine) as session:
        service = PersonSyncService(session=session)

        # Create two people
        person1 = service.create({"name": "Person 1", "age": 25, "email": "person1@example.com"})
        person2 = service.create({"name": "Person 2", "age": 30, "email": "person2@example.com"})

        # Bulk update with NOTHING values (partial updates)
        bulk_updates = [
            PersonBulkUpdate(id=person1.id, name="Updated Person 1"),  # age and email default to NOTHING
            PersonBulkUpdate(id=person2.id, age=35, email="updated2@example.com"),  # name defaults to NOTHING
        ]

        # This should work correctly with the attrs NOTHING filtering
        updated_people = service.update_many(bulk_updates)  # type: ignore[arg-type]

        # Verify updates
        assert len(updated_people) == 2

        person1_updated = next(p for p in updated_people if p.id == person1.id)
        person2_updated = next(p for p in updated_people if p.id == person2.id)

        # Person 1: only name should be updated
        assert person1_updated.name == "Updated Person 1"
        assert person1_updated.age == 25  # unchanged
        assert person1_updated.email == "person1@example.com"  # unchanged

        # Person 2: age and email should be updated
        assert person2_updated.name == "Person 2"  # unchanged
        assert person2_updated.age == 35
        assert person2_updated.email == "updated2@example.com"
python-advanced-alchemy-1.9.3/tests/integration/test_bigint_identity.py000066400000000000000000000160601516556515500265340ustar00rootroot00000000000000from __future__ import annotations

from typing import TYPE_CHECKING

import pytest
from sqlalchemy import Column, Engine, ForeignKey, String, Table, select
from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import Mapped, Session, mapped_column, relationship, sessionmaker

if TYPE_CHECKING:
    from pytest import MonkeyPatch

pytestmark = [
    pytest.mark.integration,
    pytest.mark.xdist_group("bigint_identity"),
]


@pytest.mark.xdist_group("loader")
def test_ap_sync(monkeypatch: MonkeyPatch, engine: Engine) -> None:
    # Skip problematic engines
    dialect_name = getattr(engine.dialect, "name", "")
    if dialect_name == "duckdb":
        pytest.skip("DuckDB doesn't support BIGSERIAL type")
    if dialect_name == "mock":
        pytest.skip("Mock engines don't properly support multi-row inserts with RETURNING")
    if "oracle" in dialect_name:
        pytest.skip("Oracle has issues with BIGSERIAL syntax")
    if dialect_name.startswith(("spanner", "cockroach")):
        pytest.skip(f"{dialect_name} doesn't support Identity/BigInt primary keys")

    from sqlalchemy.orm import DeclarativeBase

    from advanced_alchemy import base, mixins

    orm_registry = base.create_registry()

    class NewIdentityBase(mixins.IdentityPrimaryKey, base.CommonTableAttributes, DeclarativeBase):
        registry = orm_registry

    monkeypatch.setattr(base, "IdentityBase", NewIdentityBase)

    product_tag_table = Table(
        "product_tag_identity_sync",
        orm_registry.metadata,
        Column("product_id", ForeignKey("product_identity_sync.id", ondelete="CASCADE"), primary_key=True),  # pyright: ignore[reportUnknownArgumentType]
        Column("tag_id", ForeignKey("tag_identity_sync.id", ondelete="CASCADE"), primary_key=True),  # pyright: ignore[reportUnknownArgumentType]
    )

    class Tag(NewIdentityBase):
        __tablename__ = "tag_identity_sync"
        name: Mapped[str] = mapped_column(String(100), index=True)
        products: Mapped[list[Product]] = relationship(
            secondary=lambda: product_tag_table,
            back_populates="product_tags",
            cascade="all, delete",
            passive_deletes=True,
            lazy="noload",
        )

    class Product(NewIdentityBase):
        __tablename__ = "product_identity_sync"
        name: Mapped[str] = mapped_column(String(length=50))  # pyright: ignore
        product_tags: Mapped[list[Tag]] = relationship(
            secondary=lambda: product_tag_table,
            back_populates="products",
            cascade="all, delete",
            passive_deletes=True,
            lazy="joined",
        )
        tags: AssociationProxy[list[str]] = association_proxy(
            "product_tags",
            "name",
            creator=lambda name: Tag(name=name),  # pyright: ignore[reportUnknownArgumentType,reportUnknownLambdaType]
        )

    session_factory: sessionmaker[Session] = sessionmaker(engine, expire_on_commit=False)

    with engine.begin() as conn:
        Product.metadata.create_all(conn)

    with session_factory() as db_session:
        product_1 = Product(name="Product 1", tags=["a new tag", "second tag"])
        db_session.add(product_1)

        tags = db_session.execute(select(Tag)).unique().fetchall()
        assert len(tags) == 2

        product_2 = Product(name="Product 2", tags=["third tag"])
        db_session.add(product_2)
        tags = db_session.execute(select(Tag)).unique().fetchall()
        assert len(tags) == 3

        product_2.tags = []
        db_session.add(product_2)

        product_2_validate = db_session.execute(select(Product).where(Product.name == "Product 2")).unique().fetchone()
        assert product_2_validate
        tags_2 = db_session.execute(select(Tag)).unique().fetchall()
        assert len(product_2_validate[0].product_tags) == 0
        assert len(tags_2) == 3
        # add more assertions


@pytest.mark.xdist_group("loader")
async def test_ap_async(monkeypatch: MonkeyPatch, async_engine: AsyncEngine) -> None:
    # Skip problematic engines
    dialect_name = getattr(async_engine.dialect, "name", "")
    if dialect_name == "duckdb":
        pytest.skip("DuckDB doesn't support BIGSERIAL type")
    if dialect_name == "mock":
        pytest.skip("Mock engines don't properly support multi-row inserts with RETURNING")
    if "oracle" in dialect_name:
        pytest.skip("Oracle has issues with BIGSERIAL syntax")
    if dialect_name.startswith(("spanner", "cockroach")):
        pytest.skip(f"{dialect_name} doesn't support Identity/BigInt primary keys")

    from sqlalchemy.orm import DeclarativeBase

    from advanced_alchemy import base, mixins

    orm_registry = base.create_registry()

    class NewIdentityBase(mixins.IdentityPrimaryKey, base.CommonTableAttributes, DeclarativeBase):
        registry = orm_registry

    monkeypatch.setattr(base, "IdentityBase", NewIdentityBase)

    product_tag_table = Table(
        "product_tag_identity_async",
        orm_registry.metadata,
        Column("product_id", ForeignKey("product_identity_async.id", ondelete="CASCADE"), primary_key=True),  # pyright: ignore[reportUnknownArgumentType]
        Column("tag_id", ForeignKey("tag_identity_async.id", ondelete="CASCADE"), primary_key=True),  # pyright: ignore[reportUnknownArgumentType]
    )

    class Tag(NewIdentityBase):
        __tablename__ = "tag_identity_async"
        name: Mapped[str] = mapped_column(String(100), index=True)
        products: Mapped[list[Product]] = relationship(
            secondary=lambda: product_tag_table,
            back_populates="product_tags",
            cascade="all, delete",
            passive_deletes=True,
            lazy="noload",
        )

    class Product(NewIdentityBase):
        __tablename__ = "product_identity_async"
        name: Mapped[str] = mapped_column(String(length=50))  # pyright: ignore
        product_tags: Mapped[list[Tag]] = relationship(
            secondary=lambda: product_tag_table,
            back_populates="products",
            cascade="all, delete",
            passive_deletes=True,
            lazy="joined",
        )
        tags: AssociationProxy[list[str]] = association_proxy(
            "product_tags",
            "name",
            creator=lambda name: Tag(name=name),  # pyright: ignore[reportUnknownArgumentType,reportUnknownLambdaType]
        )

    session_factory: async_sessionmaker[AsyncSession] = async_sessionmaker(async_engine, expire_on_commit=False)

    async with async_engine.begin() as conn:
        await conn.run_sync(Tag.metadata.create_all)

    async with session_factory() as db_session:
        product_1 = Product(name="Product 1 async", tags=["a new tag", "second tag"])
        db_session.add(product_1)

        tags = await db_session.execute(select(Tag))
        assert len(tags.unique().fetchall()) == 2

        product_2 = Product(name="Product 2 async", tags=["third tag"])
        db_session.add(product_2)
        tags = await db_session.execute(select(Tag))
        assert len(tags.unique().fetchall()) == 3

        # add more assertions
python-advanced-alchemy-1.9.3/tests/integration/test_cache_bind_group.py000066400000000000000000001034541516556515500266260ustar00rootroot00000000000000"""Integration tests for repository caching with bind_group support.

These tests verify that cache keys properly include bind_group for multi-master
database configurations, preventing data leaks between database shards.
"""

import asyncio
from collections.abc import Generator
from typing import TYPE_CHECKING, Any, Optional, cast

import pytest
from sqlalchemy import Engine, String, event
from sqlalchemy.ext.asyncio import AsyncEngine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column

from advanced_alchemy.base import UUIDBase
from advanced_alchemy.cache import setup_cache_listeners
from advanced_alchemy.cache.config import CacheConfig
from advanced_alchemy.cache.manager import DOGPILE_CACHE_INSTALLED, CacheManager
from advanced_alchemy.repository import SQLAlchemyAsyncRepository, SQLAlchemySyncRepository

if TYPE_CHECKING:
    pass

pytestmark = [
    pytest.mark.integration,
    pytest.mark.xdist_group("cache"),
]


@pytest.fixture(scope="module", autouse=True)
def _setup_cache_listeners() -> Generator[None, None, None]:
    """Set up global cache listeners for all tests in this module."""
    setup_cache_listeners()
    yield


# Module-level cache for model and counter for unique names
_model_cache: dict[str, type] = {}
_class_counter = 0


def get_bind_group_author_model(engine_dialect_name: str, worker_id: str) -> type[DeclarativeBase]:
    """Create appropriate model for bind_group tests."""
    global _class_counter
    cache_key = f"bind_group_author_{worker_id}_{engine_dialect_name}"

    if cache_key not in _model_cache:

        class TestBase(DeclarativeBase):
            pass

        _class_counter += 1
        unique_suffix = f"{_class_counter}_{worker_id}_{engine_dialect_name}"

        class_name = f"BindGroupAuthor_{unique_suffix}"

        BindGroupAuthor = type(
            class_name,
            (UUIDBase, TestBase),
            {
                "__tablename__": f"test_bind_group_authors_{worker_id}_{engine_dialect_name}",
                "__mapper_args__": {"concrete": True},
                "__module__": __name__,
                "name": mapped_column(String(length=100)),
                "bio": mapped_column(String(length=500), nullable=True),
                "__annotations__": {"name": Mapped[str], "bio": Mapped[Optional[str]]},
            },
        )

        _model_cache[cache_key] = BindGroupAuthor

    return _model_cache[cache_key]


def get_worker_id(request: pytest.FixtureRequest) -> str:
    """Get worker ID for pytest-xdist or 'master' for single process."""
    workerinput = getattr(request.config, "workerinput", None)
    if isinstance(workerinput, dict):
        return cast("str", workerinput.get("workerid", "master"))
    return "master"


@pytest.fixture
def memory_cache_manager() -> CacheManager:
    """Create a CacheManager with memory backend for testing."""
    config = CacheConfig(
        backend="dogpile.cache.memory",
        expiration_time=300,
        key_prefix="test_bind_group:",
    )
    return CacheManager(config)


# ============================================================================
# Cache Invalidation Tracker Tests
# ============================================================================


@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed")
def test_cache_invalidation_tracker_add_invalidation_with_bind_group(memory_cache_manager: CacheManager) -> None:
    """Test CacheInvalidationTracker stores bind_group with invalidations."""
    from advanced_alchemy._listeners import CacheInvalidationTracker

    tracker = CacheInvalidationTracker(memory_cache_manager)

    # Add invalidations with different bind_groups
    tracker.add_invalidation("model_a", "id1", bind_group=None)
    tracker.add_invalidation("model_a", "id2", bind_group="shard_a")
    tracker.add_invalidation("model_b", "id3", bind_group="shard_b")

    # Verify pending invalidations stored correctly
    assert len(tracker._pending_invalidations) == 3
    assert ("model_a", "id1", None) in tracker._pending_invalidations
    assert ("model_a", "id2", "shard_a") in tracker._pending_invalidations
    assert ("model_b", "id3", "shard_b") in tracker._pending_invalidations

    # Verify model bumps queued
    assert "model_a" in tracker._pending_model_bumps
    assert "model_b" in tracker._pending_model_bumps


@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed")
def test_cache_invalidation_tracker_rollback_clears_pending(memory_cache_manager: CacheManager) -> None:
    """Test CacheInvalidationTracker rollback clears all pending invalidations."""
    from advanced_alchemy._listeners import CacheInvalidationTracker

    tracker = CacheInvalidationTracker(memory_cache_manager)

    tracker.add_invalidation("model", "id1", bind_group="shard_a")
    tracker.add_invalidation("model", "id2", bind_group="shard_b")

    # Verify pending invalidations exist
    assert len(tracker._pending_invalidations) == 2
    assert len(tracker._pending_model_bumps) == 1

    # Rollback should clear everything
    tracker.rollback()

    assert len(tracker._pending_invalidations) == 0
    assert len(tracker._pending_model_bumps) == 0


# ============================================================================
# Async Repository Tests with bind_group
# ============================================================================


@pytest.mark.asyncio
@pytest.mark.aiosqlite
@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed")
async def test_async_repository_get_with_bind_group_uses_separate_cache(
    aiosqlite_engine: AsyncEngine,
    memory_cache_manager: CacheManager,
    request: pytest.FixtureRequest,
) -> None:
    """Test async repository get() with bind_group uses separate cache entries."""
    from sqlalchemy.ext.asyncio import AsyncSession as AS
    from sqlalchemy.ext.asyncio import async_sessionmaker

    worker_id = get_worker_id(request)
    BindGroupAuthor = get_bind_group_author_model("aiosqlite_bind", worker_id)

    async with aiosqlite_engine.begin() as conn:
        await conn.run_sync(BindGroupAuthor.metadata.create_all)

    try:
        async_session_factory = async_sessionmaker(aiosqlite_engine, class_=AS, expire_on_commit=False)
        async with async_session_factory() as session:

            class BindGroupAuthorRepository(SQLAlchemyAsyncRepository[Any]):
                model_type = BindGroupAuthor

            repo = BindGroupAuthorRepository(session=session, cache_manager=memory_cache_manager, auto_expunge=True)

            # Create an author
            author = BindGroupAuthor(name="Test Author", bio="Bio")
            await repo.add(author)
            await session.commit()

            author_id = author.id
            table_name = BindGroupAuthor.__tablename__

            # Get without bind_group - should cache to default key
            author1 = await repo.get(author_id)
            assert author1.name == "Test Author"

            # Verify default cache was populated
            cached_default = memory_cache_manager.get_entity_sync(
                table_name, author_id, BindGroupAuthor, bind_group=None
            )
            assert cached_default is not None

            # Get with bind_group="shard_a" - should cache to shard_a key
            author2 = await repo.get(author_id, bind_group="shard_a")
            assert author2.name == "Test Author"

            # Verify shard_a cache was populated
            cached_shard_a = memory_cache_manager.get_entity_sync(
                table_name, author_id, BindGroupAuthor, bind_group="shard_a"
            )
            assert cached_shard_a is not None

            # Invalidate only shard_a cache
            memory_cache_manager.invalidate_entity_sync(table_name, author_id, bind_group="shard_a")

            # Verify default cache still exists
            cached_default_after = memory_cache_manager.get_entity_sync(
                table_name, author_id, BindGroupAuthor, bind_group=None
            )
            assert cached_default_after is not None, "Default cache should still exist"

            # Verify shard_a cache was invalidated
            cached_shard_a_after = memory_cache_manager.get_entity_sync(
                table_name, author_id, BindGroupAuthor, bind_group="shard_a"
            )
            assert cached_shard_a_after is None, "shard_a cache should be invalidated"

    finally:
        async with aiosqlite_engine.begin() as conn:
            await conn.run_sync(BindGroupAuthor.metadata.drop_all)


@pytest.mark.asyncio
@pytest.mark.aiosqlite
@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed")
async def test_async_repository_singleflight_key_includes_bind_group(
    aiosqlite_engine: AsyncEngine,
    memory_cache_manager: CacheManager,
    request: pytest.FixtureRequest,
) -> None:
    """Test that singleflight deduplication includes bind_group in key."""
    from sqlalchemy.ext.asyncio import AsyncSession as AS
    from sqlalchemy.ext.asyncio import async_sessionmaker

    worker_id = get_worker_id(request)
    BindGroupAuthor = get_bind_group_author_model("aiosqlite_sf", worker_id)

    async with aiosqlite_engine.begin() as conn:
        await conn.run_sync(BindGroupAuthor.metadata.create_all)

    query_count = 0

    def before_cursor_execute(_conn: object, _cursor: object, statement: str, *_: object) -> None:
        nonlocal query_count
        if statement.lstrip().upper().startswith("SELECT"):
            query_count += 1

    event.listen(aiosqlite_engine.sync_engine, "before_cursor_execute", before_cursor_execute)

    try:
        async_session_factory = async_sessionmaker(aiosqlite_engine, class_=AS, expire_on_commit=False)
        async with async_session_factory() as session:

            class BindGroupAuthorRepository(SQLAlchemyAsyncRepository[Any]):
                model_type = BindGroupAuthor

            repo = BindGroupAuthorRepository(session=session, cache_manager=memory_cache_manager, auto_expunge=True)

            author = BindGroupAuthor(name="SF Author")
            await repo.add(author)
            await session.commit()

            author_id = author.id
            table_name = BindGroupAuthor.__tablename__

            # Invalidate cache to force misses
            memory_cache_manager.invalidate_entity_sync(table_name, author_id, bind_group=None)
            memory_cache_manager.invalidate_entity_sync(table_name, author_id, bind_group="shard_a")

            # Concurrent gets without bind_group should coalesce
            query_count = 0
            results_default = await asyncio.gather(*[repo.get(author_id) for _ in range(5)])
            assert all(r.id == author_id for r in results_default)
            queries_default = query_count

            # Concurrent gets with bind_group="shard_a" should coalesce separately
            query_count = 0
            results_shard_a = await asyncio.gather(*[repo.get(author_id, bind_group="shard_a") for _ in range(5)])
            assert all(r.id == author_id for r in results_shard_a)
            queries_shard_a = query_count

            # Both should have coalesced to 1 query each
            assert queries_default == 1, "Default queries should coalesce"
            assert queries_shard_a == 1, "shard_a queries should coalesce"

    finally:
        event.remove(aiosqlite_engine.sync_engine, "before_cursor_execute", before_cursor_execute)
        async with aiosqlite_engine.begin() as conn:
            await conn.run_sync(BindGroupAuthor.metadata.drop_all)


@pytest.mark.asyncio
@pytest.mark.aiosqlite
@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed")
async def test_async_repository_get_bypasses_cache_when_disabled(
    aiosqlite_engine: AsyncEngine,
    memory_cache_manager: CacheManager,
    request: pytest.FixtureRequest,
) -> None:
    """Test async repository get() with use_cache=False bypasses cache regardless of bind_group."""
    from sqlalchemy.ext.asyncio import AsyncSession as AS
    from sqlalchemy.ext.asyncio import async_sessionmaker

    worker_id = get_worker_id(request)
    BindGroupAuthor = get_bind_group_author_model("aiosqlite_bypass", worker_id)

    async with aiosqlite_engine.begin() as conn:
        await conn.run_sync(BindGroupAuthor.metadata.create_all)

    try:
        async_session_factory = async_sessionmaker(aiosqlite_engine, class_=AS, expire_on_commit=False)
        async with async_session_factory() as session:

            class BindGroupAuthorRepository(SQLAlchemyAsyncRepository[Any]):
                model_type = BindGroupAuthor

            repo = BindGroupAuthorRepository(session=session, cache_manager=memory_cache_manager, auto_expunge=True)

            author = BindGroupAuthor(name="No Cache Author")
            await repo.add(author)
            await session.commit()

            author_id = author.id
            table_name = BindGroupAuthor.__tablename__

            # Get with cache disabled and bind_group
            await repo.get(author_id, use_cache=False, bind_group="shard_a")

            # Cache should not be populated
            cached = memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group="shard_a")
            assert cached is None, "Cache should not be populated when use_cache=False"

    finally:
        async with aiosqlite_engine.begin() as conn:
            await conn.run_sync(BindGroupAuthor.metadata.drop_all)


# ============================================================================
# Sync Repository Tests with bind_group
# ============================================================================


@pytest.mark.sqlite
@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed")
def test_sync_repository_get_with_bind_group_uses_separate_cache(
    sqlite_engine: Engine,
    memory_cache_manager: CacheManager,
    request: pytest.FixtureRequest,
) -> None:
    """Test sync repository get() with bind_group uses separate cache entries."""
    from sqlalchemy.orm import sessionmaker

    worker_id = get_worker_id(request)
    BindGroupAuthor = get_bind_group_author_model("sqlite_bind", worker_id)

    BindGroupAuthor.metadata.create_all(sqlite_engine)

    try:
        session_factory = sessionmaker(sqlite_engine)
        with session_factory() as session:

            class BindGroupAuthorRepository(SQLAlchemySyncRepository[Any]):
                model_type = BindGroupAuthor

            repo = BindGroupAuthorRepository(session=session, cache_manager=memory_cache_manager, auto_expunge=True)

            # Create an author
            author = BindGroupAuthor(name="Test Author", bio="Bio")
            repo.add(author)
            session.commit()

            author_id = author.id
            table_name = BindGroupAuthor.__tablename__

            # Get without bind_group
            author1 = repo.get(author_id)
            assert author1.name == "Test Author"

            # Verify default cache was populated
            cached_default = memory_cache_manager.get_entity_sync(
                table_name, author_id, BindGroupAuthor, bind_group=None
            )
            assert cached_default is not None

            # Get with bind_group="shard_a"
            author2 = repo.get(author_id, bind_group="shard_a")
            assert author2.name == "Test Author"

            # Verify shard_a cache was populated
            cached_shard_a = memory_cache_manager.get_entity_sync(
                table_name, author_id, BindGroupAuthor, bind_group="shard_a"
            )
            assert cached_shard_a is not None

            # Invalidate only shard_a
            memory_cache_manager.invalidate_entity_sync(table_name, author_id, bind_group="shard_a")

            # Verify default still cached, shard_a invalidated
            assert (
                memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group=None)
                is not None
            )
            assert (
                memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group="shard_a")
                is None
            )

    finally:
        BindGroupAuthor.metadata.drop_all(sqlite_engine)


@pytest.mark.sqlite
@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed")
def test_sync_repository_get_bypasses_cache_when_disabled(
    sqlite_engine: Engine,
    memory_cache_manager: CacheManager,
    request: pytest.FixtureRequest,
) -> None:
    """Test sync repository get() with use_cache=False bypasses cache regardless of bind_group."""
    from sqlalchemy.orm import sessionmaker

    worker_id = get_worker_id(request)
    BindGroupAuthor = get_bind_group_author_model("sqlite_bypass", worker_id)

    BindGroupAuthor.metadata.create_all(sqlite_engine)

    try:
        session_factory = sessionmaker(sqlite_engine)
        with session_factory() as session:

            class BindGroupAuthorRepository(SQLAlchemySyncRepository[Any]):
                model_type = BindGroupAuthor

            repo = BindGroupAuthorRepository(session=session, cache_manager=memory_cache_manager, auto_expunge=True)

            author = BindGroupAuthor(name="No Cache")
            repo.add(author)
            session.commit()

            author_id = author.id
            table_name = BindGroupAuthor.__tablename__

            # Get with cache disabled and bind_group
            repo.get(author_id, use_cache=False, bind_group="shard_a")

            # Cache should not be populated
            cached = memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group="shard_a")
            assert cached is None, "Cache should not be populated when use_cache=False"

    finally:
        BindGroupAuthor.metadata.drop_all(sqlite_engine)


# ============================================================================
# Repository default bind_group Tests
# ============================================================================


@pytest.mark.asyncio
@pytest.mark.aiosqlite
@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed")
async def test_async_repository_uses_default_bind_group_for_cache(
    aiosqlite_engine: AsyncEngine,
    memory_cache_manager: CacheManager,
    request: pytest.FixtureRequest,
) -> None:
    """Test that repository uses default bind_group from constructor for cache keys."""
    from sqlalchemy.ext.asyncio import AsyncSession as AS
    from sqlalchemy.ext.asyncio import async_sessionmaker

    worker_id = get_worker_id(request)
    BindGroupAuthor = get_bind_group_author_model("aiosqlite_default", worker_id)

    async with aiosqlite_engine.begin() as conn:
        await conn.run_sync(BindGroupAuthor.metadata.create_all)

    try:
        async_session_factory = async_sessionmaker(aiosqlite_engine, class_=AS, expire_on_commit=False)
        async with async_session_factory() as session:

            class BindGroupAuthorRepository(SQLAlchemyAsyncRepository[Any]):
                model_type = BindGroupAuthor

            # Create repo with default bind_group via constructor parameter
            repo = BindGroupAuthorRepository(
                session=session,
                cache_manager=memory_cache_manager,
                auto_expunge=True,
                bind_group="default_shard",
            )

            author = BindGroupAuthor(name="Default Shard Author")
            await repo.add(author)
            await session.commit()

            author_id = author.id
            table_name = BindGroupAuthor.__tablename__

            # Get without explicit bind_group - should use default from constructor
            await repo.get(author_id)

            # Verify cache was populated for default_shard bind_group
            cached = memory_cache_manager.get_entity_sync(
                table_name, author_id, BindGroupAuthor, bind_group="default_shard"
            )
            assert cached is not None, "Cache should use default bind_group from constructor"

            # Verify no cache for None bind_group
            cached_none = memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group=None)
            assert cached_none is None, "No cache should exist for None bind_group"

    finally:
        async with aiosqlite_engine.begin() as conn:
            await conn.run_sync(BindGroupAuthor.metadata.drop_all)


@pytest.mark.asyncio
@pytest.mark.aiosqlite
@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed")
async def test_async_repository_explicit_bind_group_overrides_default(
    aiosqlite_engine: AsyncEngine,
    memory_cache_manager: CacheManager,
    request: pytest.FixtureRequest,
) -> None:
    """Test that explicit bind_group parameter overrides repository default."""
    from sqlalchemy.ext.asyncio import AsyncSession as AS
    from sqlalchemy.ext.asyncio import async_sessionmaker

    worker_id = get_worker_id(request)
    BindGroupAuthor = get_bind_group_author_model("aiosqlite_override", worker_id)

    async with aiosqlite_engine.begin() as conn:
        await conn.run_sync(BindGroupAuthor.metadata.create_all)

    try:
        async_session_factory = async_sessionmaker(aiosqlite_engine, class_=AS, expire_on_commit=False)
        async with async_session_factory() as session:

            class BindGroupAuthorRepository(SQLAlchemyAsyncRepository[Any]):
                model_type = BindGroupAuthor

            # Create repo with default bind_group via constructor
            repo = BindGroupAuthorRepository(
                session=session,
                cache_manager=memory_cache_manager,
                auto_expunge=True,
                bind_group="default_shard",
            )

            author = BindGroupAuthor(name="Override Test")
            await repo.add(author)
            await session.commit()

            author_id = author.id
            table_name = BindGroupAuthor.__tablename__

            # Get with explicit bind_group override
            await repo.get(author_id, bind_group="override_shard")

            # Verify cache was populated for override_shard, not default_shard
            cached_override = memory_cache_manager.get_entity_sync(
                table_name, author_id, BindGroupAuthor, bind_group="override_shard"
            )
            cached_default = memory_cache_manager.get_entity_sync(
                table_name, author_id, BindGroupAuthor, bind_group="default_shard"
            )

            assert cached_override is not None, "Cache should be for override_shard"
            assert cached_default is None, "No cache for default_shard when overridden"

    finally:
        async with aiosqlite_engine.begin() as conn:
            await conn.run_sync(BindGroupAuthor.metadata.drop_all)


# ============================================================================
# Cache Manager Direct Tests (using model instances)
# ============================================================================


@pytest.mark.asyncio
@pytest.mark.aiosqlite
@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed")
async def test_cache_manager_entity_methods_with_bind_group(
    aiosqlite_engine: AsyncEngine,
    memory_cache_manager: CacheManager,
    request: pytest.FixtureRequest,
) -> None:
    """Test CacheManager entity methods correctly handle bind_group in cache keys."""
    from sqlalchemy.ext.asyncio import AsyncSession as AS
    from sqlalchemy.ext.asyncio import async_sessionmaker

    worker_id = get_worker_id(request)
    BindGroupAuthor = get_bind_group_author_model("aiosqlite_cm", worker_id)

    async with aiosqlite_engine.begin() as conn:
        await conn.run_sync(BindGroupAuthor.metadata.create_all)

    try:
        async_session_factory = async_sessionmaker(aiosqlite_engine, class_=AS, expire_on_commit=False)
        async with async_session_factory() as session:
            # Create a model instance
            author = BindGroupAuthor(name="Cache Manager Test")
            session.add(author)
            await session.commit()

            author_id = author.id
            table_name = BindGroupAuthor.__tablename__

            # Test set_entity_sync with different bind_groups
            memory_cache_manager.set_entity_sync(table_name, author_id, author, bind_group=None)

            # Modify for shard_a (simulate different data in shard)
            author.name = "Shard A Data"
            memory_cache_manager.set_entity_sync(table_name, author_id, author, bind_group="shard_a")

            # Modify for shard_b
            author.name = "Shard B Data"
            memory_cache_manager.set_entity_sync(table_name, author_id, author, bind_group="shard_b")

            # Test get_entity_sync returns correct cached entity per bind_group
            cached_default = memory_cache_manager.get_entity_sync(
                table_name, author_id, BindGroupAuthor, bind_group=None
            )
            cached_shard_a = memory_cache_manager.get_entity_sync(
                table_name, author_id, BindGroupAuthor, bind_group="shard_a"
            )
            cached_shard_b = memory_cache_manager.get_entity_sync(
                table_name, author_id, BindGroupAuthor, bind_group="shard_b"
            )

            assert cached_default is not None
            assert cached_shard_a is not None
            assert cached_shard_b is not None

            # Names should reflect what was cached at the time
            assert cached_default.name == "Cache Manager Test"
            assert cached_shard_a.name == "Shard A Data"
            assert cached_shard_b.name == "Shard B Data"

            # Test invalidate_entity_sync only affects specific bind_group
            memory_cache_manager.invalidate_entity_sync(table_name, author_id, bind_group="shard_a")

            # Verify only shard_a was invalidated
            assert (
                memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group=None)
                is not None
            )
            assert (
                memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group="shard_a")
                is None
            )
            assert (
                memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group="shard_b")
                is not None
            )

    finally:
        async with aiosqlite_engine.begin() as conn:
            await conn.run_sync(BindGroupAuthor.metadata.drop_all)


@pytest.mark.asyncio
@pytest.mark.aiosqlite
@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed")
async def test_cache_manager_async_entity_methods_with_bind_group(
    aiosqlite_engine: AsyncEngine,
    memory_cache_manager: CacheManager,
    request: pytest.FixtureRequest,
) -> None:
    """Test CacheManager async entity methods correctly handle bind_group in cache keys."""
    from sqlalchemy.ext.asyncio import AsyncSession as AS
    from sqlalchemy.ext.asyncio import async_sessionmaker

    worker_id = get_worker_id(request)
    BindGroupAuthor = get_bind_group_author_model("aiosqlite_cm_async", worker_id)

    async with aiosqlite_engine.begin() as conn:
        await conn.run_sync(BindGroupAuthor.metadata.create_all)

    try:
        async_session_factory = async_sessionmaker(aiosqlite_engine, class_=AS, expire_on_commit=False)
        async with async_session_factory() as session:
            author = BindGroupAuthor(name="Async Cache Test")
            session.add(author)
            await session.commit()

            author_id = author.id
            table_name = BindGroupAuthor.__tablename__

            # Test async set/get with bind_groups
            await memory_cache_manager.set_entity_async(table_name, author_id, author, bind_group=None)

            author.name = "Shard A Async"
            await memory_cache_manager.set_entity_async(table_name, author_id, author, bind_group="shard_a")

            # Verify both cached correctly
            cached_default = await memory_cache_manager.get_entity_async(
                table_name, author_id, BindGroupAuthor, bind_group=None
            )
            cached_shard_a = await memory_cache_manager.get_entity_async(
                table_name, author_id, BindGroupAuthor, bind_group="shard_a"
            )

            assert cached_default is not None
            assert cached_shard_a is not None
            assert cached_default.name == "Async Cache Test"
            assert cached_shard_a.name == "Shard A Async"

            # Test async invalidation
            await memory_cache_manager.invalidate_entity_async(table_name, author_id, bind_group="shard_a")

            # Verify only shard_a was invalidated
            assert (
                await memory_cache_manager.get_entity_async(table_name, author_id, BindGroupAuthor, bind_group=None)
                is not None
            )
            assert (
                await memory_cache_manager.get_entity_async(
                    table_name, author_id, BindGroupAuthor, bind_group="shard_a"
                )
                is None
            )

    finally:
        async with aiosqlite_engine.begin() as conn:
            await conn.run_sync(BindGroupAuthor.metadata.drop_all)


# ============================================================================
# Cache Invalidation Tracker with Commit Tests
# ============================================================================


@pytest.mark.asyncio
@pytest.mark.aiosqlite
@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed")
async def test_cache_invalidation_tracker_commit_with_bind_group(
    aiosqlite_engine: AsyncEngine,
    memory_cache_manager: CacheManager,
    request: pytest.FixtureRequest,
) -> None:
    """Test CacheInvalidationTracker commit properly invalidates with bind_group."""
    from sqlalchemy.ext.asyncio import AsyncSession as AS
    from sqlalchemy.ext.asyncio import async_sessionmaker

    from advanced_alchemy._listeners import CacheInvalidationTracker

    worker_id = get_worker_id(request)
    BindGroupAuthor = get_bind_group_author_model("aiosqlite_tracker", worker_id)

    async with aiosqlite_engine.begin() as conn:
        await conn.run_sync(BindGroupAuthor.metadata.create_all)

    try:
        async_session_factory = async_sessionmaker(aiosqlite_engine, class_=AS, expire_on_commit=False)
        async with async_session_factory() as session:
            author = BindGroupAuthor(name="Tracker Test")
            session.add(author)
            await session.commit()

            author_id = author.id
            table_name = BindGroupAuthor.__tablename__

            # Pre-populate cache for different bind_groups
            memory_cache_manager.set_entity_sync(table_name, author_id, author, bind_group=None)
            memory_cache_manager.set_entity_sync(table_name, author_id, author, bind_group="shard_a")
            memory_cache_manager.set_entity_sync(table_name, author_id, author, bind_group="shard_b")

            # Create tracker and add invalidation for shard_a only
            tracker = CacheInvalidationTracker(memory_cache_manager)
            tracker.add_invalidation(table_name, author_id, bind_group="shard_a")

            # Commit the tracker (sync)
            tracker.commit()

            # Verify only shard_a was invalidated
            assert (
                memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group=None)
                is not None
            )
            assert (
                memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group="shard_a")
                is None
            )
            assert (
                memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group="shard_b")
                is not None
            )

    finally:
        async with aiosqlite_engine.begin() as conn:
            await conn.run_sync(BindGroupAuthor.metadata.drop_all)


@pytest.mark.asyncio
@pytest.mark.aiosqlite
@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed")
async def test_cache_invalidation_tracker_async_commit_with_bind_group(
    aiosqlite_engine: AsyncEngine,
    memory_cache_manager: CacheManager,
    request: pytest.FixtureRequest,
) -> None:
    """Test CacheInvalidationTracker async commit properly invalidates with bind_group."""
    from sqlalchemy.ext.asyncio import AsyncSession as AS
    from sqlalchemy.ext.asyncio import async_sessionmaker

    from advanced_alchemy._listeners import CacheInvalidationTracker

    worker_id = get_worker_id(request)
    BindGroupAuthor = get_bind_group_author_model("aiosqlite_tracker_async", worker_id)

    async with aiosqlite_engine.begin() as conn:
        await conn.run_sync(BindGroupAuthor.metadata.create_all)

    try:
        async_session_factory = async_sessionmaker(aiosqlite_engine, class_=AS, expire_on_commit=False)
        async with async_session_factory() as session:
            author = BindGroupAuthor(name="Async Tracker Test")
            session.add(author)
            await session.commit()

            author_id = author.id
            table_name = BindGroupAuthor.__tablename__

            # Pre-populate cache
            await memory_cache_manager.set_entity_async(table_name, author_id, author, bind_group=None)
            await memory_cache_manager.set_entity_async(table_name, author_id, author, bind_group="shard_a")

            # Create tracker and add invalidation
            tracker = CacheInvalidationTracker(memory_cache_manager)
            tracker.add_invalidation(table_name, author_id, bind_group="shard_a")

            # Async commit
            await tracker.commit_async()

            # Verify only shard_a was invalidated
            assert (
                await memory_cache_manager.get_entity_async(table_name, author_id, BindGroupAuthor, bind_group=None)
                is not None
            )
            assert (
                await memory_cache_manager.get_entity_async(
                    table_name, author_id, BindGroupAuthor, bind_group="shard_a"
                )
                is None
            )

    finally:
        async with aiosqlite_engine.begin() as conn:
            await conn.run_sync(BindGroupAuthor.metadata.drop_all)
python-advanced-alchemy-1.9.3/tests/integration/test_cache_repository.py000066400000000000000000000470441516556515500267170ustar00rootroot00000000000000"""Integration tests for repository caching with dogpile.cache."""

from __future__ import annotations

import asyncio
from collections.abc import Generator
from typing import TYPE_CHECKING, Any, Optional, cast

import pytest
from sqlalchemy import Engine, String, event
from sqlalchemy.ext.asyncio import AsyncEngine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column

from advanced_alchemy.base import UUIDBase
from advanced_alchemy.cache import setup_cache_listeners
from advanced_alchemy.cache.config import CacheConfig
from advanced_alchemy.cache.manager import DOGPILE_CACHE_INSTALLED, CacheManager
from advanced_alchemy.repository import SQLAlchemyAsyncRepository, SQLAlchemySyncRepository

if TYPE_CHECKING:
    pass

pytestmark = [
    pytest.mark.integration,
    pytest.mark.xdist_group("cache"),
]


@pytest.fixture(scope="module", autouse=True)
def _setup_cache_listeners() -> Generator[None, None, None]:
    """Set up global cache listeners for all tests in this module."""
    setup_cache_listeners()
    yield


# Module-level cache for model and counter for unique names
_model_cache: dict[str, type] = {}
_class_counter = 0


def get_cached_author_model(engine_dialect_name: str, worker_id: str) -> type[DeclarativeBase]:
    """Create appropriate CachedAuthor model based on engine dialect."""
    global _class_counter
    cache_key = f"cached_author_{worker_id}_{engine_dialect_name}"

    if cache_key not in _model_cache:

        class TestBase(DeclarativeBase):
            pass

        _class_counter += 1
        unique_suffix = f"{_class_counter}_{worker_id}_{engine_dialect_name}"

        class_name = f"CachedAuthor_{unique_suffix}"

        CachedAuthor = type(
            class_name,
            (UUIDBase, TestBase),
            {
                "__tablename__": f"test_cached_authors_{worker_id}_{engine_dialect_name}",
                "__mapper_args__": {"concrete": True},
                "__module__": __name__,
                "name": mapped_column(String(length=100)),
                "bio": mapped_column(String(length=500), nullable=True),
                "__annotations__": {"name": Mapped[str], "bio": Mapped[Optional[str]]},
            },
        )

        _model_cache[cache_key] = CachedAuthor

    return _model_cache[cache_key]


def get_worker_id(request: pytest.FixtureRequest) -> str:
    """Get worker ID for pytest-xdist or 'master' for single process."""
    workerinput = getattr(request.config, "workerinput", None)
    if isinstance(workerinput, dict):
        return cast("str", workerinput.get("workerid", "master"))
    return "master"


@pytest.fixture
def memory_cache_manager() -> CacheManager:
    """Create a CacheManager with memory backend for testing."""
    config = CacheConfig(
        backend="dogpile.cache.memory",
        expiration_time=300,
        key_prefix="test:",
    )
    return CacheManager(config)


@pytest.fixture
def disabled_cache_manager() -> CacheManager:
    """Create a CacheManager with caching disabled."""
    config = CacheConfig(enabled=False)
    return CacheManager(config)


# Async repository tests


@pytest.mark.asyncio
@pytest.mark.aiosqlite
@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed")
async def test_async_repository_get_uses_cache(
    aiosqlite_engine: AsyncEngine,
    memory_cache_manager: CacheManager,
    request: pytest.FixtureRequest,
) -> None:
    """Test async repository get() uses cache on second call."""
    from sqlalchemy.ext.asyncio import AsyncSession as AS
    from sqlalchemy.ext.asyncio import async_sessionmaker

    worker_id = get_worker_id(request)
    CachedAuthor = get_cached_author_model("aiosqlite", worker_id)

    # Create tables
    async with aiosqlite_engine.begin() as conn:
        await conn.run_sync(CachedAuthor.metadata.create_all)

    try:
        async_session_factory = async_sessionmaker(aiosqlite_engine, class_=AS, expire_on_commit=False)
        async with async_session_factory() as session:

            class CachedAuthorRepository(SQLAlchemyAsyncRepository[Any]):
                model_type = CachedAuthor

            repo = CachedAuthorRepository(session=session, cache_manager=memory_cache_manager, auto_expunge=True)

            # Create an author
            author = CachedAuthor(name="John Doe", bio="Author bio")
            await repo.add(author)
            await session.commit()

            author_id = author.id

            # First get - should hit database and populate cache
            author1 = await repo.get(author_id)
            assert author1.name == "John Doe"

            # Verify cache was populated
            table_name = CachedAuthor.__tablename__
            cached = memory_cache_manager.get_entity_sync(table_name, author_id, CachedAuthor)
            assert cached is not None
            assert cached.name == "John Doe"

            # Get again - should use cache
            author2 = await repo.get(author_id)
            assert author2.name == "John Doe"

    finally:
        async with aiosqlite_engine.begin() as conn:
            await conn.run_sync(CachedAuthor.metadata.drop_all)


@pytest.mark.asyncio
@pytest.mark.aiosqlite
@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed")
async def test_async_repository_get_use_cache_false_bypasses_cache(
    aiosqlite_engine: AsyncEngine,
    memory_cache_manager: CacheManager,
    request: pytest.FixtureRequest,
) -> None:
    """Test async repository get() with use_cache=False bypasses cache."""
    from sqlalchemy.ext.asyncio import AsyncSession as AS
    from sqlalchemy.ext.asyncio import async_sessionmaker

    worker_id = get_worker_id(request)
    CachedAuthor = get_cached_author_model("aiosqlite_bypass", worker_id)

    async with aiosqlite_engine.begin() as conn:
        await conn.run_sync(CachedAuthor.metadata.create_all)

    try:
        async_session_factory = async_sessionmaker(aiosqlite_engine, class_=AS, expire_on_commit=False)
        async with async_session_factory() as session:

            class CachedAuthorRepository(SQLAlchemyAsyncRepository[Any]):
                model_type = CachedAuthor

            repo = CachedAuthorRepository(session=session, cache_manager=memory_cache_manager, auto_expunge=True)

            # Create an author
            author = CachedAuthor(name="Jane Doe")
            await repo.add(author)
            await session.commit()

            author_id = author.id

            # Get with cache disabled
            author1 = await repo.get(author_id, use_cache=False)
            assert author1.name == "Jane Doe"

            # Cache should be empty since we used use_cache=False
            table_name = CachedAuthor.__tablename__
            cached = memory_cache_manager.get_entity_sync(table_name, author_id, CachedAuthor)
            assert cached is None

    finally:
        async with aiosqlite_engine.begin() as conn:
            await conn.run_sync(CachedAuthor.metadata.drop_all)


@pytest.mark.asyncio
@pytest.mark.aiosqlite
async def test_async_repository_without_cache_manager_works(
    aiosqlite_engine: AsyncEngine,
    request: pytest.FixtureRequest,
) -> None:
    """Test async repository works without cache_manager (cache disabled)."""
    from sqlalchemy.ext.asyncio import AsyncSession as AS
    from sqlalchemy.ext.asyncio import async_sessionmaker

    worker_id = get_worker_id(request)
    CachedAuthor = get_cached_author_model("aiosqlite_nocache", worker_id)

    async with aiosqlite_engine.begin() as conn:
        await conn.run_sync(CachedAuthor.metadata.create_all)

    try:
        async_session_factory = async_sessionmaker(aiosqlite_engine, class_=AS, expire_on_commit=False)
        async with async_session_factory() as session:

            class CachedAuthorRepository(SQLAlchemyAsyncRepository[Any]):
                model_type = CachedAuthor

            repo = CachedAuthorRepository(session=session)  # No cache_manager

            # Should work normally
            author = CachedAuthor(name="No Cache")
            await repo.add(author)
            await session.commit()

            retrieved = await repo.get(author.id)
            assert retrieved.name == "No Cache"

    finally:
        async with aiosqlite_engine.begin() as conn:
            await conn.run_sync(CachedAuthor.metadata.drop_all)


@pytest.mark.asyncio
@pytest.mark.aiosqlite
async def test_async_repository_cache_disabled_config(
    aiosqlite_engine: AsyncEngine,
    disabled_cache_manager: CacheManager,
    request: pytest.FixtureRequest,
) -> None:
    """Test async repository with disabled cache config."""
    from sqlalchemy.ext.asyncio import AsyncSession as AS
    from sqlalchemy.ext.asyncio import async_sessionmaker

    worker_id = get_worker_id(request)
    CachedAuthor = get_cached_author_model("aiosqlite_disabled", worker_id)

    async with aiosqlite_engine.begin() as conn:
        await conn.run_sync(CachedAuthor.metadata.create_all)

    try:
        async_session_factory = async_sessionmaker(aiosqlite_engine, class_=AS, expire_on_commit=False)
        async with async_session_factory() as session:

            class CachedAuthorRepository(SQLAlchemyAsyncRepository[Any]):
                model_type = CachedAuthor

            repo = CachedAuthorRepository(session=session, cache_manager=disabled_cache_manager, auto_expunge=True)

            # Should work but not cache anything
            author = CachedAuthor(name="Test")
            await repo.add(author)
            await session.commit()

            retrieved = await repo.get(author.id)
            assert retrieved.name == "Test"

    finally:
        async with aiosqlite_engine.begin() as conn:
            await conn.run_sync(CachedAuthor.metadata.drop_all)


@pytest.mark.asyncio
@pytest.mark.aiosqlite
@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed")
async def test_async_repository_list_uses_cache_and_invalidates_on_commit(
    aiosqlite_engine: AsyncEngine,
    memory_cache_manager: CacheManager,
    request: pytest.FixtureRequest,
) -> None:
    """Test async repository list() caching and version-token invalidation."""
    from sqlalchemy.ext.asyncio import AsyncSession as AS
    from sqlalchemy.ext.asyncio import async_sessionmaker

    worker_id = get_worker_id(request)
    CachedAuthor = get_cached_author_model("aiosqlite_list", worker_id)

    async with aiosqlite_engine.begin() as conn:
        await conn.run_sync(CachedAuthor.metadata.create_all)

    query_count = 0

    def before_cursor_execute(_conn: object, _cursor: object, statement: str, *_: object) -> None:
        nonlocal query_count
        if statement.lstrip().upper().startswith("SELECT"):
            query_count += 1

    event.listen(aiosqlite_engine.sync_engine, "before_cursor_execute", before_cursor_execute)

    try:
        async_session_factory = async_sessionmaker(aiosqlite_engine, class_=AS, expire_on_commit=False)
        async with async_session_factory() as session:

            class CachedAuthorRepository(SQLAlchemyAsyncRepository[Any]):
                model_type = CachedAuthor

            repo = CachedAuthorRepository(session=session, cache_manager=memory_cache_manager, auto_expunge=True)

            author = CachedAuthor(name="List Test", bio="Bio")
            await repo.add(author)
            await session.commit()

            # Ensure we start from a known version token
            model_name = CachedAuthor.__tablename__
            version_before = memory_cache_manager.get_model_version_sync(model_name)

            query_count = 0
            authors_1 = await repo.list()
            assert len(authors_1) == 1
            assert query_count > 0

            query_count = 0
            authors_2 = await repo.list()
            assert len(authors_2) == 1
            assert query_count == 0

            # Mutate + commit should bump model version token (invalidating list caches)
            author = await repo.get(author.id, use_cache=False)
            author.bio = "Updated"
            await repo.update(author)
            await session.commit()

            # Wait for eventual async invalidation tasks to complete
            import advanced_alchemy._listeners as listeners

            if listeners._active_cache_operations:
                await asyncio.gather(*list(listeners._active_cache_operations))

            version_after = memory_cache_manager.get_model_version_sync(model_name)
            assert version_after != version_before

            query_count = 0
            authors_3 = await repo.list()
            assert len(authors_3) == 1
            assert query_count > 0

    finally:
        event.remove(aiosqlite_engine.sync_engine, "before_cursor_execute", before_cursor_execute)
        async with aiosqlite_engine.begin() as conn:
            await conn.run_sync(CachedAuthor.metadata.drop_all)


@pytest.mark.asyncio
@pytest.mark.aiosqlite
@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed")
async def test_async_repository_list_and_count_uses_cache(
    aiosqlite_engine: AsyncEngine,
    memory_cache_manager: CacheManager,
    request: pytest.FixtureRequest,
) -> None:
    """Test async repository list_and_count() caching."""
    from sqlalchemy.ext.asyncio import AsyncSession as AS
    from sqlalchemy.ext.asyncio import async_sessionmaker

    worker_id = get_worker_id(request)
    CachedAuthor = get_cached_author_model("aiosqlite_list_and_count", worker_id)

    async with aiosqlite_engine.begin() as conn:
        await conn.run_sync(CachedAuthor.metadata.create_all)

    query_count = 0

    def before_cursor_execute(_conn: object, _cursor: object, statement: str, *_: object) -> None:
        nonlocal query_count
        if statement.lstrip().upper().startswith("SELECT"):
            query_count += 1

    event.listen(aiosqlite_engine.sync_engine, "before_cursor_execute", before_cursor_execute)

    try:
        async_session_factory = async_sessionmaker(aiosqlite_engine, class_=AS, expire_on_commit=False)
        async with async_session_factory() as session:

            class CachedAuthorRepository(SQLAlchemyAsyncRepository[Any]):
                model_type = CachedAuthor

            repo = CachedAuthorRepository(session=session, cache_manager=memory_cache_manager, auto_expunge=True)

            await repo.add(CachedAuthor(name="A1"))
            await repo.add(CachedAuthor(name="A2"))
            await session.commit()

            query_count = 0
            items_1, count_1 = await repo.list_and_count()
            assert count_1 == 2
            assert len(items_1) == 2
            assert query_count > 0

            query_count = 0
            items_2, count_2 = await repo.list_and_count()
            assert count_2 == 2
            assert len(items_2) == 2
            assert query_count == 0

    finally:
        event.remove(aiosqlite_engine.sync_engine, "before_cursor_execute", before_cursor_execute)
        async with aiosqlite_engine.begin() as conn:
            await conn.run_sync(CachedAuthor.metadata.drop_all)


@pytest.mark.asyncio
@pytest.mark.aiosqlite
@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed")
async def test_async_repository_get_singleflight_coalesces_concurrent_misses(
    aiosqlite_engine: AsyncEngine,
    memory_cache_manager: CacheManager,
    request: pytest.FixtureRequest,
) -> None:
    """Test per-process async singleflight reduces stampedes on cache miss."""
    from sqlalchemy.ext.asyncio import AsyncSession as AS
    from sqlalchemy.ext.asyncio import async_sessionmaker

    worker_id = get_worker_id(request)
    CachedAuthor = get_cached_author_model("aiosqlite_singleflight", worker_id)

    async with aiosqlite_engine.begin() as conn:
        await conn.run_sync(CachedAuthor.metadata.create_all)

    query_count = 0

    def before_cursor_execute(_conn: object, _cursor: object, statement: str, *_: object) -> None:
        nonlocal query_count
        if statement.lstrip().upper().startswith("SELECT"):
            query_count += 1

    event.listen(aiosqlite_engine.sync_engine, "before_cursor_execute", before_cursor_execute)

    try:
        async_session_factory = async_sessionmaker(aiosqlite_engine, class_=AS, expire_on_commit=False)
        async with async_session_factory() as session:

            class CachedAuthorRepository(SQLAlchemyAsyncRepository[Any]):
                model_type = CachedAuthor

            repo = CachedAuthorRepository(session=session, cache_manager=memory_cache_manager, auto_expunge=True)

            author = CachedAuthor(name="SF")
            await repo.add(author)
            await session.commit()

            author_id = author.id
            model_name = CachedAuthor.__tablename__

            # Force cache miss for this entity
            memory_cache_manager.invalidate_entity_sync(model_name, author_id)

            query_count = 0
            results = await asyncio.gather(*[repo.get(author_id) for _ in range(10)])
            assert all(r.id == author_id for r in results)
            assert query_count == 1

    finally:
        event.remove(aiosqlite_engine.sync_engine, "before_cursor_execute", before_cursor_execute)
        async with aiosqlite_engine.begin() as conn:
            await conn.run_sync(CachedAuthor.metadata.drop_all)


# Sync repository tests


@pytest.mark.sqlite
@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed")
def test_sync_repository_get_uses_cache(
    sqlite_engine: Engine,
    memory_cache_manager: CacheManager,
    request: pytest.FixtureRequest,
) -> None:
    """Test sync repository get() uses cache on second call."""
    from sqlalchemy.orm import sessionmaker

    worker_id = get_worker_id(request)
    CachedAuthor = get_cached_author_model("sqlite", worker_id)

    CachedAuthor.metadata.create_all(sqlite_engine)

    try:
        session_factory = sessionmaker(sqlite_engine)
        with session_factory() as session:

            class CachedAuthorRepository(SQLAlchemySyncRepository[Any]):
                model_type = CachedAuthor

            repo = CachedAuthorRepository(session=session, cache_manager=memory_cache_manager, auto_expunge=True)

            # Create an author
            author = CachedAuthor(name="John Doe", bio="Author bio")
            repo.add(author)
            session.commit()

            author_id = author.id

            # First get - should hit database and populate cache
            author1 = repo.get(author_id)
            assert author1.name == "John Doe"

            # Verify cache was populated
            table_name = CachedAuthor.__tablename__
            cached = memory_cache_manager.get_entity_sync(table_name, author_id, CachedAuthor)
            assert cached is not None
            assert cached.name == "John Doe"

    finally:
        CachedAuthor.metadata.drop_all(sqlite_engine)


@pytest.mark.sqlite
def test_sync_repository_without_cache_manager_works(
    sqlite_engine: Engine,
    request: pytest.FixtureRequest,
) -> None:
    """Test sync repository works without cache_manager."""
    from sqlalchemy.orm import sessionmaker

    worker_id = get_worker_id(request)
    CachedAuthor = get_cached_author_model("sqlite_nocache", worker_id)

    CachedAuthor.metadata.create_all(sqlite_engine)

    try:
        session_factory = sessionmaker(sqlite_engine)
        with session_factory() as session:

            class CachedAuthorRepository(SQLAlchemySyncRepository[Any]):
                model_type = CachedAuthor

            repo = CachedAuthorRepository(session=session)  # No cache_manager

            author = CachedAuthor(name="No Cache Sync")
            repo.add(author)
            session.commit()

            retrieved = repo.get(author.id)
            assert retrieved.name == "No Cache Sync"

    finally:
        CachedAuthor.metadata.drop_all(sqlite_engine)
python-advanced-alchemy-1.9.3/tests/integration/test_extensions/000077500000000000000000000000001516556515500251715ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/tests/integration/test_extensions/__init__.py000066400000000000000000000000571516556515500273040ustar00rootroot00000000000000"""Integration tests for extension modules."""
python-advanced-alchemy-1.9.3/tests/integration/test_extensions/test_fastapi/000077500000000000000000000000001516556515500276575ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/tests/integration/test_extensions/test_fastapi/__init__.py000066400000000000000000000000531516556515500317660ustar00rootroot00000000000000"""FastAPI extension integration tests."""
test_asyncpg_lifecycle.py000066400000000000000000000073441516556515500347040ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/tests/integration/test_extensions/test_fastapi"""Integration tests for asyncpg session lifecycle in FastAPI extension."""

import warnings
from typing import Annotated

import pytest
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import String
from sqlalchemy.ext.asyncio import AsyncEngine
from sqlalchemy.orm import Mapped, mapped_column

from advanced_alchemy.base import UUIDBase
from advanced_alchemy.extensions.fastapi import AdvancedAlchemy, SQLAlchemyAsyncConfig
from advanced_alchemy.repository import SQLAlchemyAsyncRepository
from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService


class LifecycleWidget(UUIDBase):
    """Model for asyncpg lifecycle tests."""

    __tablename__ = "fastapi_asyncpg_lifecycle_widget"

    name: Mapped[str] = mapped_column(String(length=50))


class LifecycleWidgetRepository(SQLAlchemyAsyncRepository[LifecycleWidget]):
    """Async repository for lifecycle widgets."""

    model_type = LifecycleWidget


class LifecycleWidgetService(SQLAlchemyAsyncRepositoryService[LifecycleWidget, LifecycleWidgetRepository]):
    """Async service for lifecycle widgets."""

    repository_type = LifecycleWidgetRepository


@pytest.mark.asyncpg
@pytest.mark.integration
def test_no_gc_warning_on_service_create(asyncpg_engine: AsyncEngine) -> None:
    """Verify generator-managed services do not leave GC warnings."""
    config = SQLAlchemyAsyncConfig(engine_instance=asyncpg_engine)
    app = FastAPI()
    alchemy = AdvancedAlchemy(config=config, app=app)

    @app.get("/")
    async def handler(
        service: Annotated[LifecycleWidgetService, Depends(alchemy.provide_service(LifecycleWidgetService))],
    ) -> dict[str, str]:
        return {"status": "ok"}

    with warnings.catch_warnings(record=True) as caught:
        warnings.simplefilter("always")
        with TestClient(app=app) as client:
            response = client.get("/")
            assert response.status_code == 200

    assert not any("non-checked-in connection" in str(warning.message) for warning in caught)


@pytest.mark.asyncpg
@pytest.mark.integration
def test_connection_returned_to_pool(asyncpg_engine: AsyncEngine) -> None:
    """Verify asyncpg connections are returned after generator cleanup."""
    config = SQLAlchemyAsyncConfig(engine_instance=asyncpg_engine)
    app = FastAPI()
    alchemy = AdvancedAlchemy(config=config, app=app)

    @app.get("/")
    async def handler(
        service: Annotated[LifecycleWidgetService, Depends(alchemy.provide_service(LifecycleWidgetService))],
    ) -> dict[str, str]:
        return {"status": "ok"}

    with TestClient(app=app) as client:
        response = client.get("/")
        assert response.status_code == 200

    pool = getattr(asyncpg_engine, "pool", None)
    if pool is None or not hasattr(pool, "checkedout"):
        pytest.skip("Pool does not expose checkedout metrics")
    assert pool.checkedout() == 0


@pytest.mark.asyncpg
@pytest.mark.integration
def test_pool_not_exhausted_under_load(asyncpg_engine: AsyncEngine) -> None:
    """Verify multiple requests do not exhaust the asyncpg pool."""
    config = SQLAlchemyAsyncConfig(engine_instance=asyncpg_engine)
    app = FastAPI()
    alchemy = AdvancedAlchemy(config=config, app=app)

    @app.get("/")
    async def handler(
        service: Annotated[LifecycleWidgetService, Depends(alchemy.provide_service(LifecycleWidgetService))],
    ) -> dict[str, str]:
        return {"status": "ok"}

    with TestClient(app=app) as client:
        for _ in range(5):
            response = client.get("/")
            assert response.status_code == 200

    pool = getattr(asyncpg_engine, "pool", None)
    if pool is None or not hasattr(pool, "checkedout"):
        pytest.skip("Pool does not expose checkedout metrics")
    assert pool.checkedout() == 0
python-advanced-alchemy-1.9.3/tests/integration/test_extensions/test_litestar/000077500000000000000000000000001516556515500300575ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/tests/integration/test_extensions/test_litestar/__init__.py000066400000000000000000000000001516556515500321560ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/tests/integration/test_extensions/test_litestar/test_session.py000066400000000000000000000506061516556515500331620ustar00rootroot00000000000000"""Integration tests for Litestar session backend extensions.

These tests run against actual database instances to verify that session backends
work correctly across all supported database backends.
"""

import asyncio
import datetime
import uuid
from collections.abc import AsyncGenerator, Generator
from functools import partial
from typing import Optional
from unittest.mock import Mock

import pytest
from litestar import Litestar, Request, get, post
from litestar.middleware.session import SessionMiddleware
from litestar.middleware.session.server_side import ServerSideSessionConfig
from litestar.stores.base import Store
from litestar.testing import AsyncTestClient
from sqlalchemy import Engine, select
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
from sqlalchemy.orm import DeclarativeBase, Session

from advanced_alchemy.extensions.litestar.plugins.init.config.asyncio import SQLAlchemyAsyncConfig
from advanced_alchemy.extensions.litestar.plugins.init.config.sync import SQLAlchemySyncConfig
from advanced_alchemy.extensions.litestar.session import (
    SessionModelMixin,
    SQLAlchemyAsyncSessionBackend,
    SQLAlchemySyncSessionBackend,
)
from tests.integration.helpers import cleanup_database, cleanup_database_async

pytestmark = [
    pytest.mark.integration,
    pytest.mark.xdist_group("litestar_session"),  # Isolate session tests to prevent interference
]


# Module-level cache for model classes to prevent recreation
_session_model_cache: "dict[str, type]" = {}


@pytest.fixture(scope="session")
def session_model_class(request: pytest.FixtureRequest) -> "type[SessionModelMixin]":
    """Create session model class once per session/worker.

    This fixture creates a unique model class per pytest session or xdist worker
    to prevent metadata conflicts while allowing table reuse across tests.
    """
    # Get worker ID for xdist parallel execution
    worker_id = getattr(request.config, "workerinput", {}).get("workerid", "master")
    cache_key = f"session_{worker_id}"

    if cache_key not in _session_model_cache:

        class TestSessionBase(DeclarativeBase):
            pass

        class IntegrationTestSessionModel(SessionModelMixin, TestSessionBase):
            """Test session model for integration tests."""

            __tablename__ = f"integration_test_sessions_{worker_id}"

        _session_model_cache[cache_key] = IntegrationTestSessionModel

    return _session_model_cache[cache_key]


@pytest.fixture
def session_tables_setup(
    engine: Engine, session_model_class: "type[SessionModelMixin]"
) -> "Generator[type[SessionModelMixin], None, None]":
    """Create session tables for each test run but reuse model classes.

    Tables are created per database engine type but model classes are cached
    to prevent recreation. Fast data cleanup is used between individual tests.
    """
    # Skip for Spanner - doesn't support UNIQUE constraints directly
    # Skip for MSSQL - doesn't support random() function used in session backends
    dialect_name = getattr(engine.dialect, "name", "")
    if dialect_name == "spanner+spanner":
        pytest.skip("Spanner doesn't support direct UNIQUE constraints creation")

    # Skip table creation for mock engines
    if dialect_name != "mock":
        session_model_class.metadata.create_all(engine)

    yield session_model_class

    # Clean up tables at end of test run for this engine
    if getattr(engine.dialect, "name", "") != "mock":
        try:
            with cleanup_database(engine) as cleaner:
                # Get table names from metadata
                tables_to_clean = [table.name for table in session_model_class.metadata.sorted_tables]
                cleaner.include_only = set(tables_to_clean) if tables_to_clean else None
                cleaner.cleanup()
        except Exception:
            # Ignore cleanup errors - they don't affect test results
            pass


@pytest.fixture
async def async_session_tables_setup(
    async_engine: AsyncEngine, session_model_class: "type[SessionModelMixin]"
) -> "AsyncGenerator[type[SessionModelMixin], None]":
    """Create async session tables for each test run but reuse model classes.

    Tables are created per database engine type but model classes are cached
    to prevent recreation. Fast data cleanup is used between individual tests.
    """
    # Skip for Spanner - doesn't support UNIQUE constraints directly
    # Skip for MSSQL - doesn't support random() function used in session backends
    dialect_name = getattr(async_engine.dialect, "name", "")
    if dialect_name == "spanner+spanner":
        pytest.skip("Spanner doesn't support direct UNIQUE constraints creation")

    # Skip table creation for mock engines
    if dialect_name != "mock":
        async with async_engine.begin() as conn:
            await conn.run_sync(session_model_class.metadata.create_all)

    yield session_model_class

    # Clean up tables at end of test run for this engine
    if getattr(async_engine.dialect, "name", "") != "mock":
        try:
            async with cleanup_database_async(async_engine) as cleaner:
                # Get table names from metadata
                tables_to_clean = [table.name for table in session_model_class.metadata.sorted_tables]
                cleaner.include_only = set(tables_to_clean) if tables_to_clean else None
                await cleaner.cleanup()
        except Exception:
            # Ignore cleanup errors - they don't affect test results
            pass


@pytest.fixture
def test_session_model(session_tables_setup: "type[SessionModelMixin]") -> "type[SessionModelMixin]":
    """Per-test fixture - no cleanup needed with session-scoped engines.

    Session-scoped engines are incompatible with per-test cleanup.
    Tables are cleaned up only at session end via session_tables_setup.
    """
    return session_tables_setup


@pytest.fixture
async def async_test_session_model(async_session_tables_setup: "type[SessionModelMixin]") -> "type[SessionModelMixin]":
    """Per-test async fixture - no cleanup needed with session-scoped engines.

    Session-scoped engines are incompatible with per-test cleanup.
    Tables are cleaned up only at session end via async_session_tables_setup.
    """
    return async_session_tables_setup


@pytest.fixture
def mock_store() -> Store:
    """Create a mock store for testing."""
    return Mock(spec=Store)


# Session backend fixtures
@pytest.fixture
def sync_session_config(engine: Engine) -> SQLAlchemySyncConfig:
    """Create sync config with test engine."""
    return SQLAlchemySyncConfig(
        engine_instance=engine,
        session_dependency_key="db_session",
    )


@pytest.fixture
async def async_session_config(async_engine: AsyncEngine) -> SQLAlchemyAsyncConfig:
    """Create async config with test engine."""
    return SQLAlchemyAsyncConfig(
        engine_instance=async_engine,
        session_dependency_key="db_session",
    )


@pytest.fixture
def sync_session_backend(
    sync_session_config: SQLAlchemySyncConfig, test_session_model: "type[SessionModelMixin]"
) -> SQLAlchemySyncSessionBackend:
    """Create sync session backend."""
    return SQLAlchemySyncSessionBackend(
        config=ServerSideSessionConfig(max_age=3600),
        alchemy_config=sync_session_config,
        model=test_session_model,
    )


@pytest.fixture
async def async_session_backend(
    async_session_config: SQLAlchemyAsyncConfig, async_test_session_model: "type[SessionModelMixin]"
) -> SQLAlchemyAsyncSessionBackend:
    """Create async session backend."""
    return SQLAlchemyAsyncSessionBackend(
        config=ServerSideSessionConfig(max_age=3600),
        alchemy_config=async_session_config,
        model=async_test_session_model,
    )


# Legacy database setup fixtures - now no-ops since tables are session-scoped
@pytest.fixture
def setup_sync_database() -> "Generator[None, None, None]":
    """Legacy fixture - tables are now session-scoped, no setup needed."""
    yield


@pytest.fixture
async def setup_async_database() -> "AsyncGenerator[None, None]":
    """Legacy fixture - tables are now session-scoped, no setup needed."""
    yield


def _handle_database_encoding(data: Optional[bytes], expected: bytes, dialect_name: str) -> None:
    """Handle database-specific encoding issues."""
    if dialect_name.startswith("spanner") and data != expected:
        import base64

        # Spanner base64 encodes binary data
        if data:
            try:
                decoded_data = base64.b64decode(data)
                assert decoded_data == expected, f"Expected {expected!r}, got decoded {decoded_data!r} from {data!r}"
            except Exception:
                assert data == expected, f"Spanner: Expected {expected!r}, got {data!r}"
        return

    assert data == expected, f"Expected {expected!r}, got {data!r}"


# Session Backend Tests
async def test_async_session_backend_complete_lifecycle(
    async_session_backend: SQLAlchemyAsyncSessionBackend,
    async_session: AsyncSession,
    mock_store: Store,
    setup_async_database: None,
) -> None:
    """Test complete session lifecycle: create, retrieve, update, delete."""

    # Skip mock engines - integration tests should test real databases
    engine_instance = async_session_backend.alchemy.engine_instance
    if engine_instance is not None and getattr(engine_instance.dialect, "name", "") == "mock":
        pytest.skip("Mock engine cannot test real database operations")

    # Skip Spanner - doesn't support direct UNIQUE constraints
    if async_session.bind is not None and getattr(async_session.bind.dialect, "name", "") == "spanner+spanner":
        pytest.skip("Spanner doesn't support direct UNIQUE constraints creation. Create UNIQUE indexes instead.")

    session_id = str(uuid.uuid4())
    original_data = b"test_data_123"
    updated_data = b"updated_data_456"

    dialect_name = getattr(async_session.bind.dialect, "name", "")

    # Create session
    await async_session_backend.set(session_id, original_data, mock_store)

    # Retrieve session
    retrieved_data = await async_session_backend.get(session_id, mock_store)
    _handle_database_encoding(retrieved_data, original_data, dialect_name)

    # Update session
    await async_session_backend.set(session_id, updated_data, mock_store)

    # Verify update
    retrieved_data = await async_session_backend.get(session_id, mock_store)
    _handle_database_encoding(retrieved_data, updated_data, dialect_name)

    # Delete session
    await async_session_backend.delete(session_id, mock_store)

    # Verify deletion
    retrieved_data = await async_session_backend.get(session_id, mock_store)
    assert retrieved_data is None


async def test_sync_session_backend_complete_lifecycle(
    sync_session_backend: SQLAlchemySyncSessionBackend,
    session: Session,
    mock_store: Store,
    setup_sync_database: None,
) -> None:
    """Test complete session lifecycle with sync backend."""
    session_id = str(uuid.uuid4())
    original_data = b"sync_test_data"
    updated_data = b"sync_updated_data"

    # Skip mock engines
    if session.bind is not None and getattr(session.bind.dialect, "name", "") == "mock":
        pytest.skip("Mock engine cannot test real database operations")

    # Skip Spanner - doesn't support direct UNIQUE constraints
    if session.bind is not None and getattr(session.bind.dialect, "name", "") == "spanner+spanner":
        pytest.skip("Spanner doesn't support direct UNIQUE constraints creation. Create UNIQUE indexes instead.")

    dialect_name = getattr(session.bind.dialect, "name", "") if session.bind is not None else ""

    # Create session
    await sync_session_backend.set(session_id, original_data, mock_store)

    # Retrieve session
    retrieved_data = await sync_session_backend.get(session_id, mock_store)
    _handle_database_encoding(retrieved_data, original_data, dialect_name)

    # Update session
    await sync_session_backend.set(session_id, updated_data, mock_store)

    # Verify update
    retrieved_data = await sync_session_backend.get(session_id, mock_store)
    _handle_database_encoding(retrieved_data, updated_data, dialect_name)

    # Delete session
    await sync_session_backend.delete(session_id, mock_store)

    # Verify deletion
    retrieved_data = await sync_session_backend.get(session_id, mock_store)
    assert retrieved_data is None


async def test_async_session_backend_expiration(
    async_engine: AsyncEngine,
    async_test_session_model: "type[SessionModelMixin]",
    mock_store: Store,
    setup_async_database: None,
) -> None:
    """Test session expiration handling."""
    # Skip mock engines
    if getattr(async_engine.dialect, "name", "") == "mock":
        pytest.skip("Mock engine cannot test real database operations")

    # Create config with very short expiration
    config = SQLAlchemyAsyncConfig(
        engine_instance=async_engine,
        session_dependency_key="db_session",
    )

    backend = SQLAlchemyAsyncSessionBackend(
        config=ServerSideSessionConfig(max_age=1),  # 1 second
        alchemy_config=config,
        model=async_test_session_model,
    )

    session_id = str(uuid.uuid4())
    data = b"expires_soon"

    # Create session
    await backend.set(session_id, data, mock_store)

    # Verify it exists
    retrieved_data = await backend.get(session_id, mock_store)
    dialect_name = getattr(async_engine.dialect, "name", "")

    # Oracle MERGE statements may not be immediately visible due to transaction isolation
    # The backend uses MERGE statements which require explicit commit for visibility
    if dialect_name == "oracle" and retrieved_data is None:
        # For Oracle, briefly wait and retry since MERGE may need transaction settle time
        await asyncio.sleep(0.2)
        retrieved_data = await backend.get(session_id, mock_store)

        # If still None, Oracle MERGE timing issue - this is a known limitation
        if retrieved_data is None:
            pytest.skip("Oracle MERGE statement visibility issue in test environment")

    _handle_database_encoding(retrieved_data, data, dialect_name)

    # Wait for expiration
    await asyncio.sleep(2)

    # Should return None and delete expired session
    assert await backend.get(session_id, mock_store) is None


async def test_async_session_backend_delete_all(
    async_session_backend: SQLAlchemyAsyncSessionBackend,
    mock_store: Store,
    setup_async_database: None,
) -> None:
    """Test deletion of all sessions."""
    # Skip mock engines
    engine_instance = async_session_backend.alchemy.engine_instance
    if engine_instance is not None and getattr(engine_instance.dialect, "name", "") == "mock":
        pytest.skip("Mock engine cannot test real database operations")

    # Create multiple sessions
    session_ids = [str(uuid.uuid4()) for _ in range(5)]
    for sid in session_ids:
        await async_session_backend.set(sid, b"data", mock_store)

    # Delete all
    await async_session_backend.delete_all(mock_store)

    # Verify all deleted
    for sid in session_ids:
        assert await async_session_backend.get(sid, mock_store) is None


async def test_async_session_backend_delete_expired(
    async_session_backend: SQLAlchemyAsyncSessionBackend,
    async_session: AsyncSession,
    mock_store: Store,
    setup_async_database: None,
) -> None:
    """Test bulk deletion of expired sessions."""
    # Skip mock engines
    if getattr(async_session.bind.dialect, "name", "") == "mock":
        pytest.skip("Mock engine cannot test real database operations")

    now = datetime.datetime.now(datetime.timezone.utc)
    test_session_model = async_session_backend.model

    # Create mix of expired and active sessions
    expired_ids = [str(uuid.uuid4()) for _ in range(3)]
    active_ids = [str(uuid.uuid4()) for _ in range(2)]

    # Insert expired sessions directly
    async with async_session_backend.alchemy.get_session() as db_session:
        for sid in expired_ids:
            session_obj = test_session_model(
                session_id=sid,
                data=b"expired",
                expires_at=now - datetime.timedelta(hours=1),
            )
            db_session.add(session_obj)
        await db_session.commit()

    # Create active sessions through backend
    for sid in active_ids:
        await async_session_backend.set(sid, b"active", mock_store)

    # Delete expired
    await async_session_backend.delete_expired()

    # Verify only active sessions remain
    async with async_session_backend.alchemy.get_session() as db_session:
        result = await db_session.execute(select(test_session_model.session_id))
        remaining_ids = {row[0] for row in result}
        assert remaining_ids == set(active_ids)


# Litestar Integration Tests
async def test_async_session_middleware_integration(
    async_engine: AsyncEngine,
    async_test_session_model: "type[SessionModelMixin]",
    setup_async_database: None,
) -> None:
    """Test async session backend with Litestar middleware."""
    # Skip mock engines
    if getattr(async_engine.dialect, "name", "") == "mock":
        pytest.skip("Mock engine cannot test real database operations")

    config = SQLAlchemyAsyncConfig(
        engine_instance=async_engine,
        session_dependency_key="db_session",
    )

    backend = SQLAlchemyAsyncSessionBackend(
        config=ServerSideSessionConfig(max_age=3600, key="test-session"),
        alchemy_config=config,
        model=async_test_session_model,
    )

    @get("/set")
    async def set_session(request: Request) -> "dict[str, str]":
        request.session["user_id"] = "123"
        request.session["username"] = "testuser"
        return {"status": "session set"}

    @get("/get")
    async def get_session(request: Request) -> "dict[str, Optional[str]]":
        return {
            "user_id": request.session.get("user_id"),
            "username": request.session.get("username"),
        }

    @post("/clear")
    async def clear_session(request: Request) -> "dict[str, str]":
        request.clear_session()
        return {"status": "session cleared"}

    app = Litestar(
        route_handlers=[set_session, get_session, clear_session],
        middleware=[partial(SessionMiddleware, backend=backend)],
    )

    async with AsyncTestClient(app=app) as client:
        # Set session data
        response = await client.get("/set")
        assert response.status_code == 200
        assert response.json() == {"status": "session set"}

        # Get session data
        response = await client.get("/get")
        assert response.status_code == 200
        assert response.json() == {"user_id": "123", "username": "testuser"}

        # Clear session
        response = await client.post("/clear")
        assert response.status_code == 201
        assert response.json() == {"status": "session cleared"}

        # Verify cleared
        response = await client.get("/get")
        assert response.status_code == 200
        assert response.json() == {"user_id": None, "username": None}


async def test_sync_session_middleware_integration(
    engine: Engine,
    test_session_model: "type[SessionModelMixin]",
    setup_sync_database: None,
) -> None:
    """Test sync session backend with Litestar middleware."""
    # Skip mock engines
    if getattr(engine.dialect, "name", "") == "mock":
        pytest.skip("Mock engine cannot test real database operations")

    config = SQLAlchemySyncConfig(
        engine_instance=engine,
        session_dependency_key="db_session",
    )

    backend = SQLAlchemySyncSessionBackend(
        config=ServerSideSessionConfig(max_age=3600, key="test-session"),
        alchemy_config=config,
        model=test_session_model,
    )

    @get("/set", sync_to_thread=False)
    def set_session(request: Request) -> "dict[str, int]":
        counter = request.session.get("counter", 0) + 1
        request.session["counter"] = counter
        return {"counter": counter}

    @get("/get", sync_to_thread=False)
    def get_session(request: Request) -> "dict[str, Optional[int]]":
        return {"counter": request.session.get("counter")}

    app = Litestar(
        route_handlers=[set_session, get_session],
        middleware=[partial(SessionMiddleware, backend=backend)],
    )

    async with AsyncTestClient(app=app) as client:
        # Initial set
        response = await client.get("/set")
        assert response.status_code == 200
        assert response.json() == {"counter": 1}

        # Increment counter
        response = await client.get("/set")
        assert response.status_code == 200
        assert response.json() == {"counter": 2}

        # Get current value
        response = await client.get("/get")
        assert response.status_code == 200
        assert response.json() == {"counter": 2}
python-advanced-alchemy-1.9.3/tests/integration/test_extensions/test_litestar/test_store.py000066400000000000000000000402401516556515500326240ustar00rootroot00000000000000"""Integration tests for Litestar store extensions.

These tests run against actual database instances to verify that store implementations
work correctly across all supported database backends.
"""

from __future__ import annotations

from collections.abc import AsyncGenerator, Generator
from typing import TYPE_CHECKING

import pytest
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase, sessionmaker

from advanced_alchemy.extensions.litestar.plugins.init.config.asyncio import SQLAlchemyAsyncConfig
from advanced_alchemy.extensions.litestar.plugins.init.config.sync import SQLAlchemySyncConfig
from advanced_alchemy.extensions.litestar.store import SQLAlchemyStore, StoreModelMixin
from tests.integration.helpers import async_clean_tables, clean_tables

if TYPE_CHECKING:
    from sqlalchemy import Engine

pytestmark = [
    pytest.mark.integration,
    pytest.mark.xdist_group("litestar_store"),
]


# Module-level cache for model classes to prevent recreation
_store_model_cache: dict[str, type] = {}


@pytest.fixture(scope="session")
def store_model_class(request: pytest.FixtureRequest) -> type[StoreModelMixin]:
    """Create store model class once per session/worker.

    This fixture creates a unique model class per pytest session or xdist worker
    to prevent metadata conflicts while allowing table reuse across tests.
    """
    # Get worker ID for xdist parallel execution
    worker_id = getattr(request.config, "workerinput", {}).get("workerid", "master")
    cache_key = f"store_{worker_id}"

    if cache_key not in _store_model_cache:

        class TestStoreBase(DeclarativeBase):
            pass

        class IntegrationTestStoreModel(StoreModelMixin, TestStoreBase):
            """Test store model for integration tests."""

            __tablename__ = f"integration_test_store_{worker_id}"

        _store_model_cache[cache_key] = IntegrationTestStoreModel

    return _store_model_cache[cache_key]


@pytest.fixture
def store_tables_setup(
    engine: Engine, store_model_class: type[StoreModelMixin]
) -> Generator[type[StoreModelMixin], None, None]:
    """Create store tables for each test run but reuse model classes.

    Tables are created per database engine type but model classes are cached
    to prevent recreation. Fast data cleanup is used between individual tests.
    """
    # Skip for Spanner and CockroachDB - table conflicts with BigInt models
    # Skip for MSSQL - doesn't support random() function used in store backends
    dialect_name = getattr(engine.dialect, "name", "")
    if dialect_name == "spanner+spanner":
        pytest.skip("Spanner doesn't support direct UNIQUE constraints creation")
    if dialect_name.startswith("cockroach"):
        pytest.skip("CockroachDB has table conflicts with BigInt models")

    # Skip table creation for mock engines
    if dialect_name != "mock":
        store_model_class.metadata.create_all(engine)

    yield store_model_class

    # Clean up tables at end of test run for this engine
    if getattr(engine.dialect, "name", "") != "mock":
        store_model_class.metadata.drop_all(engine, checkfirst=True)


@pytest.fixture
async def async_store_tables_setup(
    async_engine: AsyncEngine, store_model_class: type[StoreModelMixin]
) -> AsyncGenerator[type[StoreModelMixin], None]:
    """Create async store tables for each test run but reuse model classes.

    Tables are created per database engine type but model classes are cached
    to prevent recreation. Fast data cleanup is used between individual tests.
    """
    # Skip for Spanner and CockroachDB - table conflicts with BigInt models
    # Skip for MSSQL - doesn't support random() function used in store backends
    dialect_name = getattr(async_engine.dialect, "name", "")
    if dialect_name == "spanner+spanner":
        pytest.skip("Spanner doesn't support direct UNIQUE constraints creation")
    if dialect_name.startswith("cockroach"):
        pytest.skip("CockroachDB has table conflicts with BigInt models")

    # Skip table creation for mock engines
    if dialect_name != "mock":
        async with async_engine.begin() as conn:
            await conn.run_sync(store_model_class.metadata.create_all)

    yield store_model_class

    # Clean up tables at end of test run for this engine
    if getattr(async_engine.dialect, "name", "") != "mock":
        async with async_engine.begin() as conn:
            await conn.run_sync(lambda sync_conn: store_model_class.metadata.drop_all(sync_conn, checkfirst=True))


@pytest.fixture
def test_store_model(
    store_tables_setup: type[StoreModelMixin], engine: Engine
) -> Generator[type[StoreModelMixin], None, None]:
    """Per-test fixture with fast data cleanup.

    This fixture provides the store model class and ensures data cleanup
    between tests without recreating tables.
    """
    model_class = store_tables_setup
    yield model_class

    # Fast data-only cleanup between tests
    if getattr(engine.dialect, "name", "") != "mock":
        clean_tables(engine, model_class.metadata)


@pytest.fixture
async def async_test_store_model(
    async_store_tables_setup: type[StoreModelMixin], async_engine: AsyncEngine
) -> AsyncGenerator[type[StoreModelMixin], None]:
    """Per-test async fixture with fast data cleanup.

    This fixture provides the store model class and ensures data cleanup
    between tests without recreating tables.
    """
    model_class = async_store_tables_setup
    yield model_class

    # Fast data-only cleanup between tests
    if getattr(async_engine.dialect, "name", "") != "mock":
        await async_clean_tables(async_engine, model_class.metadata)


# Store fixtures
@pytest.fixture
def sync_store_config(engine: Engine) -> SQLAlchemySyncConfig:
    """Create sync config with test engine."""
    return SQLAlchemySyncConfig(
        engine_instance=engine,
        session_dependency_key="db_session",
    )


@pytest.fixture
async def async_store_config(async_engine: AsyncEngine) -> SQLAlchemyAsyncConfig:
    """Create async config with test engine."""
    return SQLAlchemyAsyncConfig(
        engine_instance=async_engine,
        session_dependency_key="db_session",
    )


@pytest.fixture
def sync_store(sync_store_config: SQLAlchemySyncConfig, test_store_model: type[StoreModelMixin]) -> SQLAlchemyStore:
    """Create sync store."""
    return SQLAlchemyStore(config=sync_store_config, model=test_store_model, namespace="test")


@pytest.fixture
def async_store(
    async_store_config: SQLAlchemyAsyncConfig, async_test_store_model: type[StoreModelMixin]
) -> SQLAlchemyStore:
    """Create async store."""
    return SQLAlchemyStore(config=async_store_config, model=async_test_store_model, namespace="test")


# Legacy database setup fixtures - now no-ops since tables are session-scoped
@pytest.fixture
def setup_sync_database() -> Generator[None, None, None]:
    """Legacy fixture - tables are now session-scoped, no setup needed."""
    yield


@pytest.fixture
async def setup_async_database() -> AsyncGenerator[None, None]:
    """Legacy fixture - tables are now session-scoped, no setup needed."""
    yield


# Store Tests
async def test_async_store_complete_lifecycle(
    async_store: SQLAlchemyStore,
    setup_async_database: None,
) -> None:
    """Test complete store lifecycle: set, get, update, delete."""

    # Skip mock engines - integration tests should test real databases
    engine_instance = async_store._config.engine_instance
    if engine_instance is not None and getattr(engine_instance.dialect, "name", "") == "mock":
        pytest.skip("Mock engine cannot test real database operations")

    key = "test_key"
    original_value = "test_value"
    updated_value = "updated_value"
    expires_in = 3600

    # Set value
    await async_store.set(key, original_value, expires_in=expires_in)

    # Get value
    result = await async_store.get(key)
    assert result == original_value.encode()

    # Update value
    await async_store.set(key, updated_value, expires_in=expires_in)

    # Verify update
    result = await async_store.get(key)
    assert result == updated_value.encode()

    # Check expiration time
    expires_time = await async_store.expires_in(key)
    assert expires_time is not None
    assert expires_time > 3500  # Should be close to 3600 seconds

    # Delete value
    await async_store.delete(key)

    # Verify deletion
    result = await async_store.get(key)
    assert result is None


async def test_sync_store_complete_lifecycle(
    sync_store: SQLAlchemyStore,
    setup_sync_database: None,
) -> None:
    """Test complete store lifecycle with sync store."""

    # Skip mock engines - integration tests should test real databases
    engine_instance = sync_store._config.engine_instance
    if engine_instance is not None and getattr(engine_instance.dialect, "name", "") == "mock":
        pytest.skip("Mock engine cannot test real database operations")

    key = "sync_key"
    original_value = "sync_value"
    updated_value = "sync_updated"
    expires_in = 3600

    # Set value
    await sync_store.set(key, original_value, expires_in=expires_in)

    # Get value
    result = await sync_store.get(key)
    assert result == original_value.encode()

    # Update value
    await sync_store.set(key, updated_value, expires_in=expires_in)

    # Verify update
    result = await sync_store.get(key)
    assert result == updated_value.encode()

    # Check expiration time
    expires_time = await sync_store.expires_in(key)
    assert expires_time is not None
    assert expires_time > 3500  # Should be close to 3600 seconds

    # Delete value
    await sync_store.delete(key)

    # Verify deletion
    result = await sync_store.get(key)
    assert result is None


async def test_async_store_delete_all(
    async_store: SQLAlchemyStore,
    setup_async_database: None,
) -> None:
    """Test deletion of all store entries."""

    # Skip mock engines - integration tests should test real databases
    engine_instance = async_store._config.engine_instance
    if engine_instance is not None and getattr(engine_instance.dialect, "name", "") == "mock":
        pytest.skip("Mock engine cannot test real database operations")

    # Set multiple values
    keys = ["key1", "key2", "key3"]
    for key in keys:
        await async_store.set(key, f"value_{key}", expires_in=3600)

    # Verify they exist
    for key in keys:
        result = await async_store.get(key)
        assert result == f"value_{key}".encode()

    # Delete all
    await async_store.delete_all()

    # Verify all deleted
    for key in keys:
        result = await async_store.get(key)
        assert result is None


async def test_sync_store_delete_all(
    sync_store: SQLAlchemyStore,
    setup_sync_database: None,
) -> None:
    """Test deletion of all store entries with sync store."""

    # Skip mock engines - integration tests should test real databases
    engine_instance = sync_store._config.engine_instance
    if engine_instance is not None and getattr(engine_instance.dialect, "name", "") == "mock":
        pytest.skip("Mock engine cannot test real database operations")

    # Set multiple values
    keys = ["sync_key1", "sync_key2", "sync_key3"]
    for key in keys:
        await sync_store.set(key, f"sync_value_{key}", expires_in=3600)

    # Verify they exist
    for key in keys:
        result = await sync_store.get(key)
        assert result == f"sync_value_{key}".encode()

    # Delete all
    await sync_store.delete_all()

    # Verify all deleted
    for key in keys:
        result = await sync_store.get(key)
        assert result is None


async def test_store_with_namespace(
    async_store: SQLAlchemyStore,
    setup_async_database: None,
) -> None:
    """Test store namespace functionality."""

    # Skip mock engines - integration tests should test real databases
    engine_instance = async_store._config.engine_instance
    if engine_instance is not None and getattr(engine_instance.dialect, "name", "") == "mock":
        pytest.skip("Mock engine cannot test real database operations")

    # Create namespaced store
    namespaced_store = async_store.with_namespace("sub")
    assert namespaced_store.namespace == "test_sub"

    # Set value in original store
    await async_store.set("key", "original", expires_in=3600)

    # Set value in namespaced store
    await namespaced_store.set("key", "namespaced", expires_in=3600)

    # Verify both values exist independently
    original_result = await async_store.get("key")
    namespaced_result = await namespaced_store.get("key")

    assert original_result == b"original"
    assert namespaced_result == b"namespaced"


async def test_store_exists_functionality(
    async_store: SQLAlchemyStore,
    setup_async_database: None,
) -> None:
    """Test store exists functionality."""

    # Skip mock engines - integration tests should test real databases
    engine_instance = async_store._config.engine_instance
    if engine_instance is not None and getattr(engine_instance.dialect, "name", "") == "mock":
        pytest.skip("Mock engine cannot test real database operations")

    key = "exists_test"
    value = "test_exists_value"

    # Key should not exist initially
    assert await async_store.exists(key) is False

    # Set value
    await async_store.set(key, value, expires_in=3600)

    # Key should exist now
    assert await async_store.exists(key) is True

    # Delete key
    await async_store.delete(key)

    # Key should not exist anymore
    assert await async_store.exists(key) is False


async def test_store_database_upsert_integration(
    async_store: SQLAlchemyStore,
    setup_async_database: None,
) -> None:
    """Test that store correctly uses upsert operations internally."""

    # Skip mock engines - integration tests should test real databases
    engine_instance = async_store._config.engine_instance
    if engine_instance is not None and getattr(engine_instance.dialect, "name", "") == "mock":
        pytest.skip("Mock engine cannot test real database operations")

    key = "upsert_test_key"
    value1 = "initial_value"
    value2 = "updated_value"
    expires_in = 3600

    # First set - should insert
    await async_store.set(key, value1, expires_in=expires_in)

    # Verify insert
    result = await async_store.get(key)
    assert result == value1.encode()

    # Second set - should update using upsert
    await async_store.set(key, value2, expires_in=expires_in)

    # Verify update
    result = await async_store.get(key)
    assert result == value2.encode()

    # Verify only one record exists in the store
    engine = async_store._config.engine_instance
    model = async_store._model

    if isinstance(engine, AsyncEngine):
        # Async engine
        async_session_factory = async_sessionmaker(bind=engine)
        async with async_session_factory() as session:
            count_result = await session.execute(
                select(func.count()).select_from(model).where(model.key == key, model.namespace == "test")
            )
            count = count_result.scalar()
            assert count == 1
    else:
        # Sync engine
        session_factory = sessionmaker(bind=engine)
        with session_factory() as session:
            count = session.scalar(
                select(func.count()).select_from(model).where(model.key == key, model.namespace == "test")
            )
            assert count == 1


async def test_store_renew_functionality(
    async_store: SQLAlchemyStore,
    setup_async_database: None,
) -> None:
    """Test store renew functionality."""

    # Skip mock engines - integration tests should test real databases
    engine_instance = async_store._config.engine_instance
    if engine_instance is not None and getattr(engine_instance.dialect, "name", "") == "mock":
        pytest.skip("Mock engine cannot test real database operations")

    key = "renew_test"
    value = "test_renew_value"
    initial_expires_in = 3600
    renew_for = 7200

    # Set value with initial expiration
    await async_store.set(key, value, expires_in=initial_expires_in)

    # Get value with renewal
    result = await async_store.get(key, renew_for=renew_for)
    assert result == value.encode()

    # Check that expiration was extended
    expires_time = await async_store.expires_in(key)
    assert expires_time is not None
    assert expires_time > 6000  # Should be close to 7200 seconds (renewed time)
python-advanced-alchemy-1.9.3/tests/integration/test_file_object.py000066400000000000000000002074411516556515500256210ustar00rootroot00000000000000import logging
import sys
from collections.abc import AsyncGenerator, Generator
from contextlib import suppress
from pathlib import Path
from typing import Optional

import pytest
import pytest_asyncio
from minio import Minio  # type: ignore[import-untyped]
from pytest_databases.docker.minio import MinioService
from sqlalchemy import Engine, String, event
from sqlalchemy.exc import InvalidRequestError
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column

from advanced_alchemy._listeners import set_async_context, setup_file_object_listeners
from advanced_alchemy.base import create_registry
from advanced_alchemy.exceptions import ImproperConfigurationError
from advanced_alchemy.types.file_object import (
    FileObject,
    FileObjectList,
    StoredObject,
)
from advanced_alchemy.types.file_object.backends.fsspec import FSSpecBackend
from advanced_alchemy.types.file_object.backends.obstore import ObstoreBackend
from advanced_alchemy.types.file_object.registry import StorageRegistry, storages
from advanced_alchemy.types.mutables import MutableList

# Setup logger
logger = logging.getLogger(__name__)

pytestmark = [
    pytest.mark.integration,
    pytest.mark.xdist_group("file_object"),
]


def remove_listeners() -> None:
    """Remove file object listeners safely to prevent test interactions."""
    from sqlalchemy.event import contains

    from advanced_alchemy._listeners import FileObjectListener

    # Only try to remove listeners if they're actually registered
    if contains(Session, "before_flush", FileObjectListener.before_flush):
        with suppress(InvalidRequestError):
            event.remove(Session, "before_flush", FileObjectListener.before_flush)

    if contains(Session, "after_commit", FileObjectListener.after_commit):
        with suppress(InvalidRequestError):
            event.remove(Session, "after_commit", FileObjectListener.after_commit)

    if contains(Session, "after_rollback", FileObjectListener.after_rollback):
        with suppress(InvalidRequestError):
            event.remove(Session, "after_rollback", FileObjectListener.after_rollback)

    # Reset async context flag to ensure clean state
    set_async_context(False)


# --- Fixtures ---
orm_registry = create_registry()


# --- SQLAlchemy Model Definition ---
class Base(DeclarativeBase):
    metadata = orm_registry.metadata


class Document(Base):
    __tablename__ = "documents"
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(String(50))
    # Single file storage
    attachment: Mapped[Optional[FileObject]] = mapped_column(
        StoredObject(backend="local_test_store"),  # Use StoredObject wrapper
        nullable=True,
    )
    # Multiple file storage
    images: Mapped[Optional[FileObjectList]] = mapped_column(
        StoredObject(backend="local_test_store", multiple=True),  # Use StoredObject wrapper
        nullable=True,
    )


@pytest.fixture(scope="session")
def storage_registry(tmp_path_factory: pytest.TempPathFactory) -> "StorageRegistry":
    """Clears and returns the global storage registry for the module.

    Returns:
        StorageRegistry: The global storage registry.
    """
    from obstore.store import LocalStore, MemoryStore

    if not storages.is_registered("memory"):
        storages.register_backend(ObstoreBackend(fs=MemoryStore(), key="memory"))
    if storages.is_registered("local_test_store"):
        storages.unregister_backend("local_test_store")

    # Create the storage directory using tmp_path_factory for session scope
    storage_dir = tmp_path_factory.mktemp("file_object_test_storage")

    storages.register_backend(
        ObstoreBackend(
            fs=LocalStore(prefix=str(storage_dir)),  # pyright: ignore
            key="local_test_store",
        )
    )
    return storages


@pytest.fixture(scope="function")
def sync_db_engine(engine: Engine) -> Generator[Engine, None, None]:
    """Provides a sync engine with file object listener execution options."""
    dialect_name = getattr(engine.dialect, "name", "")

    # Skip engines that don't support file object operations properly
    if dialect_name == "mock":
        pytest.skip("Mock engines don't support file object operations")
    elif dialect_name == "duckdb":
        pytest.skip("DuckDB doesn't support SERIAL type")
    elif dialect_name == "mssql":
        pytest.skip("MSSQL has issues with file object operations")
    elif dialect_name.startswith("spanner"):
        pytest.skip("Spanner has issues with file object operations")
    elif dialect_name.startswith("oracle"):
        pytest.skip("Oracle requires explicit ID values, not auto-generated")
    elif dialect_name == "mysql":
        pytest.skip("MySQL has issues with file object operations")

    # Set the execution option for file object listener
    engine = engine.execution_options(enable_file_object_listener=True)
    Base.metadata.create_all(engine)

    try:
        yield engine
    finally:
        # Clean up metadata
        Base.metadata.drop_all(engine, checkfirst=True)


@pytest.fixture(scope="function")
def async_db_engine(request: pytest.FixtureRequest) -> Generator[AsyncEngine, None, None]:
    """Provides an async engine with file object listener execution options."""
    # Try to get async_engine, fall back to specific engine fixtures
    if "async_engine" in request.fixturenames:
        async_engine = request.getfixturevalue("async_engine")
    elif "aiosqlite_engine" in request.fixturenames:
        async_engine = request.getfixturevalue("aiosqlite_engine")
    elif "asyncpg_engine" in request.fixturenames:
        async_engine = request.getfixturevalue("asyncpg_engine")
    else:
        pytest.skip("No async engine fixture available")

    dialect_name = getattr(async_engine.dialect, "name", "")

    # Skip engines that don't support file object operations properly
    if dialect_name == "mock":
        pytest.skip("Mock engines don't support file object operations")
    elif dialect_name == "duckdb":
        pytest.skip("DuckDB doesn't support SERIAL type")
    elif dialect_name == "mssql":
        pytest.skip("MSSQL has issues with file object operations")
    elif dialect_name.startswith("spanner"):
        pytest.skip("Spanner has issues with file object operations")
    elif dialect_name.startswith("oracle"):
        pytest.skip("Oracle requires explicit ID values, not auto-generated")
    elif dialect_name in ("mysql", "asyncmy"):
        pytest.skip("MySQL has issues with file object operations")

    # Set the execution option for file object listener
    engine = async_engine.execution_options(enable_file_object_listener=True)

    async def _create() -> None:
        async with engine.begin() as conn:
            await conn.run_sync(Base.metadata.create_all)

    import asyncio as _asyncio

    _asyncio.run(_create())

    try:
        yield engine
    finally:
        # Clean up metadata
        async def _drop() -> None:
            async with engine.begin() as conn:
                await conn.run_sync(Base.metadata.drop_all)

        import asyncio as _asyncio

        _asyncio.run(_drop())


@pytest.fixture(scope="function")
def session(sync_db_engine: Engine, storage_registry: "StorageRegistry") -> Generator[Session, None, None]:
    """Provides a SQLAlchemy session scoped to the test session."""
    with Session(sync_db_engine) as db_session:
        yield db_session


@pytest_asyncio.fixture(scope="function")
async def async_session(
    async_db_engine: AsyncEngine, storage_registry: "StorageRegistry"
) -> AsyncGenerator[AsyncSession, None]:
    """Provides a SQLAlchemy session scoped to the test session."""
    # Create session with flag for listener to identify async operations
    set_async_context(True)

    async_session_factory = async_sessionmaker(
        async_db_engine,
        expire_on_commit=False,
    )

    async with async_session_factory() as db_session:
        db_session.info["enable_file_object_listener"] = True
        logger.debug(f"Created async session: {id(db_session)}, with info: {db_session.info}")
        yield db_session

    # Reset async context flag
    set_async_context(False)


@pytest.mark.xdist_group("file_object")
@pytest.mark.xfail(
    sys.version_info < (3, 10),
    reason="s3fs endpoint_url parameter incompatible with Python 3.9",
)
async def test_fsspec_s3_basic_operations_async(
    storage_registry: StorageRegistry,
    minio_client: "Minio",
    minio_service: "MinioService",
    minio_default_bucket_name: str,
) -> None:
    """Test basic save, get_content, delete via backend and FileObject with prefix."""
    remove_listeners()
    try:
        import s3fs
    except ImportError:
        pytest.skip("s3fs not installed")

    assert minio_client.bucket_exists(minio_default_bucket_name)
    _ = minio_client

    # Create s3fs filesystem instance without bucket info
    fs = s3fs.S3FileSystem(
        anon=False,
        key=minio_service.access_key,
        secret=minio_service.secret_key,
        endpoint_url=f"http://{minio_service.endpoint}",
        client_kwargs={
            "verify": False,
            "use_ssl": False,
        },
    )

    # Initialize backend with prefix
    backend = FSSpecBackend(
        key="s3_test_store",
        fs=fs,
        prefix=minio_default_bucket_name,
    )

    test_content = b"Hello Storage!"
    # Use relative path, prefix handles the bucket
    file_path = "test_basic_s3_async.txt"

    # Create initial FileObject with relative path
    obj = FileObject(backend=backend, filename="test_basic_s3_async.txt", to_filename=file_path)

    # Save using backend
    updated_obj = await backend.save_object_async(obj, test_content)

    # Assert FileObject updated
    assert updated_obj is obj  # Should update in-place
    assert obj.path == file_path  # Path should remain relative
    assert obj.filename == "test_basic_s3_async.txt"
    assert obj.etag is not None
    assert obj.size == len(test_content)
    assert obj.backend is backend
    assert obj.protocol == "s3"  # Based on s3fs filesystem

    # Retrieve content via FileObject method (uses relative obj.path, backend adds prefix)
    retrieved_content = await obj.get_content_async()
    assert retrieved_content == test_content

    # Test sign_async method
    url_async = await obj.sign_async(expires_in=3600)
    assert isinstance(url_async, str)
    assert url_async.startswith("http")

    # Test for_upload parameter
    with pytest.raises(
        NotImplementedError,
        match=r"Generating signed URLs for upload is generally not supported by fsspec's generic sign method.",
    ):
        _ = await obj.sign_async(for_upload=True)
    # Delete via FileObject method (uses relative obj.path, backend adds prefix)
    await obj.delete_async()

    # Verify deletion using relative path with backend (backend adds prefix)
    with pytest.raises(FileNotFoundError):
        await backend.get_content_async(file_path)


@pytest.mark.xdist_group("file_object")
@pytest.mark.xfail(
    sys.version_info < (3, 10),
    reason="s3fs endpoint_url parameter incompatible with Python 3.9",
)
def test_fsspec_s3_basic_operations_sync(
    storage_registry: StorageRegistry,
    minio_client: "Minio",
    minio_service: "MinioService",
    minio_default_bucket_name: str,
) -> None:
    """Test basic save, get_content, delete via backend and FileObject with prefix."""
    remove_listeners()
    try:
        import s3fs
    except ImportError:
        pytest.skip("s3fs not installed")

    assert minio_client.bucket_exists(minio_default_bucket_name)
    _ = minio_client

    # Create s3fs filesystem instance without bucket info
    fs = s3fs.S3FileSystem(
        anon=False,
        key=minio_service.access_key,
        secret=minio_service.secret_key,
        endpoint_url=f"http://{minio_service.endpoint}",
        client_kwargs={
            "verify": False,
            "use_ssl": False,
        },
        asynchronous=False,
        loop=None,
    )

    # Initialize backend with prefix
    backend = FSSpecBackend(
        key="s3_test_store",
        fs=fs,
        prefix=minio_default_bucket_name,
    )

    test_content = b"Hello Storage!"
    # Use relative path, prefix handles the bucket
    file_path = "test_basic_s3_sync.txt"

    # Create initial FileObject with relative path
    obj = FileObject(backend=backend, filename="test_basic_s3_sync.txt", to_filename=file_path)

    # Save using backend
    updated_obj = backend.save_object(obj, test_content)

    # Assert FileObject updated
    assert updated_obj is obj  # Should update in-place
    assert obj.path == file_path  # Path should remain relative
    assert obj.filename == "test_basic_s3_sync.txt"
    assert obj.etag is not None
    assert obj.size == len(test_content)
    assert obj.backend is backend
    assert obj.protocol == "s3"  # Based on s3fs filesystem

    # Retrieve content via FileObject method (uses relative obj.path, backend adds prefix)
    retrieved_content = obj.get_content()
    assert retrieved_content == test_content

    # Test sign_async method
    url_async = obj.sign(expires_in=3600)
    assert isinstance(url_async, str)
    assert url_async.startswith("http")

    # Test for_upload parameter
    with pytest.raises(
        NotImplementedError,
        match=r"Generating signed URLs for upload is generally not supported by fsspec's generic sign method.",
    ):
        _ = obj.sign(for_upload=True)
    # Delete via FileObject method (uses relative obj.path, backend adds prefix)
    obj.delete()

    # Verify deletion using relative path with backend (backend adds prefix)
    with pytest.raises(FileNotFoundError):
        backend.get_content(file_path)


@pytest.mark.xdist_group("file_object")
async def test_obstore_s3_basic_operations_async(
    storage_registry: StorageRegistry,
    minio_client: "Minio",
    minio_service: "MinioService",
    minio_default_bucket_name: str,
) -> None:
    """Test basic save, get_content, delete via backend and FileObject."""
    remove_listeners()
    assert minio_client.bucket_exists(minio_default_bucket_name)
    _ = minio_client
    backend = ObstoreBackend(
        key="s3_test_store",
        fs=f"s3://{minio_default_bucket_name}/",
        aws_endpoint=f"http://{minio_service.endpoint}/",
        aws_access_key_id=minio_service.access_key,
        aws_secret_access_key=minio_service.secret_key,
        aws_virtual_hosted_style_request=False,
        client_options={"allow_http": True},
    )

    test_content = b"Hello Storage!"
    file_path = "test_basic_s3_async.txt"  # Relative path for the backend

    # Create initial FileObject
    obj = FileObject(backend=backend, filename="test_basic_s3_async.txt", to_filename=file_path)

    # Save using backend
    updated_obj = await backend.save_object_async(obj, test_content)

    # Assert FileObject updated
    assert updated_obj is obj  # Should update in-place
    assert obj.path == file_path
    assert obj.filename == "test_basic_s3_async.txt"
    assert obj.etag is not None
    assert obj.size == len(test_content)
    assert obj.backend is backend
    assert obj.protocol == "s3"  # Based on LocalFileSystem

    # Retrieve content via FileObject method
    retrieved_content = await obj.get_content_async()
    assert retrieved_content == test_content

    # Test sign method
    url = obj.sign(expires_in=3600)
    assert isinstance(url, str)
    assert url.startswith("http")

    # Test sign_async method
    url_async = await obj.sign_async(expires_in=3600)
    assert isinstance(url_async, str)
    assert url_async.startswith("http")

    url_for_upload_async = await obj.sign_async(for_upload=True)
    assert isinstance(url_for_upload_async, str)
    assert url_for_upload_async.startswith("http")

    # Delete via FileObject method
    await obj.delete_async()

    # Verify deletion (expect FileNotFoundError or similar from backend)
    with pytest.raises(FileNotFoundError):
        await backend.get_content_async(file_path)


@pytest.mark.xdist_group("file_object")
def test_obstore_s3_basic_operations_sync(
    storage_registry: StorageRegistry,
    minio_client: "Minio",
    minio_service: "MinioService",
    minio_default_bucket_name: str,
) -> None:
    """Test basic save, get_content, delete via backend and FileObject."""
    remove_listeners()
    assert minio_client.bucket_exists(minio_default_bucket_name)
    _ = minio_client
    backend = ObstoreBackend(
        key="s3_test_store",
        fs=f"s3://{minio_default_bucket_name}/",
        aws_endpoint=f"http://{minio_service.endpoint}/",
        aws_access_key_id=minio_service.access_key,
        aws_secret_access_key=minio_service.secret_key,
        aws_virtual_hosted_style_request=False,
        client_options={"allow_http": True},
    )

    test_content = b"Hello Storage!"
    file_path = "test_basic_s3_sync.txt"  # Relative path for the backend

    # Create initial FileObject
    obj = FileObject(backend=backend, filename="test_basic_s3_sync.txt", to_filename=file_path)

    # Save using backend
    updated_obj = backend.save_object(obj, test_content)

    # Assert FileObject updated
    assert updated_obj is obj  # Should update in-place
    assert obj.path == file_path
    assert obj.filename == "test_basic_s3_sync.txt"
    assert obj.etag is not None
    assert obj.size == len(test_content)
    assert obj.backend is backend
    assert obj.protocol == "s3"  # Based on LocalFileSystem

    # Retrieve content via FileObject method
    retrieved_content = obj.get_content()
    assert retrieved_content == test_content

    # Test sign method
    url = obj.sign(expires_in=3600)
    assert isinstance(url, str)
    assert url.startswith("http")

    # Test sign_async method
    url_async = obj.sign(expires_in=3600)
    assert isinstance(url_async, str)
    assert url_async.startswith("http")

    # Test for_upload parameter
    url_for_upload = obj.sign(for_upload=True)
    assert isinstance(url_for_upload, str)
    assert url_for_upload.startswith("http")

    # Delete via FileObject method
    obj.delete()

    # Verify deletion (expect FileNotFoundError or similar from backend)
    with pytest.raises(FileNotFoundError):
        backend.get_content(file_path)


@pytest.mark.xdist_group("file_object")
async def test_obstore_basic_operations_async(storage_registry: StorageRegistry) -> None:
    """Test basic save, get_content, delete via backend and FileObject."""
    remove_listeners()
    backend = storage_registry.get_backend("local_test_store")
    test_content = b"Hello Storage!"
    file_path = "test_basic_async.txt"  # Relative path for the backend

    # Create initial FileObject
    obj = FileObject(backend=backend, filename="test_basic_async.txt", to_filename=file_path)

    # Save using backend
    updated_obj = await backend.save_object_async(obj, test_content)

    # Assert FileObject updated
    assert updated_obj is obj  # Should update in-place
    assert obj.path == file_path
    assert obj.filename == "test_basic_async.txt"
    assert obj.etag is not None
    assert obj.size == len(test_content)
    assert obj.backend is backend
    assert obj.protocol == "file"  # Based on LocalFileSystem

    # Retrieve content via FileObject method
    retrieved_content = await obj.get_content_async()
    assert retrieved_content == test_content

    # Delete via FileObject method
    await obj.delete_async()

    # Verify deletion (expect FileNotFoundError or similar from backend)
    with pytest.raises(FileNotFoundError):
        await backend.get_content_async(file_path)


@pytest.mark.xdist_group("file_object")
def test_obstore_basic_operations_sync(storage_registry: StorageRegistry) -> None:
    """Test basic save, get_content, delete via backend and FileObject."""
    remove_listeners()
    backend = storage_registry.get_backend("local_test_store")
    test_content = b"Hello Storage!"
    file_path = "test_basic_sync.txt"  # Relative path for the backend

    # Create initial FileObject
    obj = FileObject(backend=backend, filename="test_basic_sync.txt", to_filename=file_path)

    # Save using backend
    updated_obj = backend.save_object(obj, test_content)

    # Assert FileObject updated
    assert updated_obj is obj  # Should update in-place
    assert obj.path == file_path
    assert obj.filename == "test_basic_sync.txt"
    assert obj.etag is not None
    assert obj.size == len(test_content)
    assert obj.backend is backend
    assert obj.protocol == "file"  # Based on LocalFileSystem

    # Retrieve content via FileObject method
    retrieved_content = obj.get_content()
    assert retrieved_content == test_content

    # Delete via FileObject method
    obj.delete()

    # Verify deletion (expect FileNotFoundError or similar from backend)
    with pytest.raises(FileNotFoundError):
        backend.get_content(file_path)


@pytest.mark.xdist_group("file_object")
async def test_obstore_single_file_async_no_listener(
    async_session: AsyncSession, storage_registry: StorageRegistry
) -> None:
    """Test saving and loading a model with a single StoredObject."""
    remove_listeners()
    file_content = b"SQLAlchemy Integration Test"
    doc_name = "Integration Doc"
    file_path = "sqlalchemy_single_async.bin"

    # 1. Prepare FileObject and save via backend
    initial_obj = FileObject(
        backend="local_test_store",
        filename="report.bin",
        to_filename=file_path,
        content_type="application/octet-stream",
    )
    updated_obj = await initial_obj.save_async(data=file_content)

    # 2. Create and save model instance
    doc = Document(name=doc_name, attachment=updated_obj)
    async_session.add(doc)
    await async_session.commit()
    await async_session.refresh(doc)

    assert doc.id is not None
    assert doc.attachment is not None
    assert isinstance(doc.attachment, FileObject)
    assert doc.attachment.filename == "sqlalchemy_single_async.bin"
    assert doc.attachment.path == file_path
    assert doc.attachment.size == len(file_content) or doc.attachment.size is None
    assert doc.attachment.content_type == "application/octet-stream"
    assert doc.attachment.backend.key == "local_test_store"

    # 3. Retrieve content via loaded FileObject
    loaded_content = await doc.attachment.get_content_async()
    assert loaded_content == file_content


@pytest.mark.xdist_group("file_object")
async def test_obstore_multiple_files_async_no_listener(
    async_session: AsyncSession, storage_registry: StorageRegistry
) -> None:
    """Test saving and loading a model with multiple StoredObjects."""
    remove_listeners()
    backend = storage_registry.get_backend("local_test_store")
    img1_content = b"img_data_1"
    img2_content = b"img_data_2"
    doc_name = "Multi Image Doc"
    img1_path = "img1.jpg"
    img2_path = "img2.png"

    # 1. Prepare FileObjects and save via backend
    obj1 = FileObject(backend=backend, filename="image1.jpg", to_filename=img1_path, content_type="image/jpeg")
    obj1_updated = await obj1.save_async(img1_content)

    obj2 = FileObject(backend=backend, filename="image2.png", to_filename=img2_path, content_type="image/png")
    obj2_updated = await obj2.save_async(img2_content)

    # 2. Create and save model instance with MutableList
    img_list = MutableList[FileObject]([obj1_updated, obj2_updated])
    doc = Document(name=doc_name, images=img_list)
    async_session.add(doc)
    await async_session.commit()
    await async_session.refresh(doc)

    assert doc.id is not None
    assert doc.images is not None
    assert isinstance(doc.images, MutableList)
    assert len(doc.images) == 2

    # Verify loaded objects
    loaded_obj1 = doc.images[0]
    loaded_obj2 = doc.images[1]
    assert isinstance(loaded_obj1, FileObject)
    assert loaded_obj1.filename == "img1.jpg"
    assert loaded_obj1.path == img1_path
    assert loaded_obj1.size == len(img1_content) or loaded_obj1.size is None
    assert loaded_obj1.backend and loaded_obj1.backend.driver == backend.driver

    assert isinstance(loaded_obj2, FileObject)
    assert loaded_obj2.filename == "img2.png"
    assert loaded_obj2.path == img2_path
    assert loaded_obj2.size == len(img2_content) or loaded_obj2.size is None
    assert loaded_obj2.backend and loaded_obj2.backend.driver == backend.driver

    # Verify content
    assert await loaded_obj1.get_content_async() == img1_content
    assert await loaded_obj2.get_content_async() == img2_content


@pytest.mark.xdist_group("file_object")
async def test_obstore_update_async_with_listener(
    async_session: AsyncSession, storage_registry: StorageRegistry
) -> None:
    """Test listener deletes old file when attribute is updated and session committed."""
    # Set async context flag to enable async operations in the listener
    set_async_context(True)

    setup_file_object_listeners()
    backend = storage_registry.get_backend("local_test_store")
    old_content = b"Old file content"
    new_content = b"New file content"
    old_path = "old_file_async.txt"
    new_path = "new_file_async.txt"

    # Save initial file and model
    old_obj = FileObject(backend=backend, filename="old_file_async.txt", to_filename=old_path, content=old_content)
    # Make sure file is saved to the backend
    old_obj = await old_obj.save_async()

    doc = Document(name="DocToUpdate", attachment=old_obj)
    async_session.add(doc)
    await async_session.commit()
    await async_session.refresh(doc)

    # Verify old file exists
    assert await backend.get_content_async(old_path) == old_content

    # Prepare new file
    new_obj = FileObject(backend=backend, filename="new_file_async.txt", to_filename=new_path, content=new_content)

    # Update the document with the new file
    doc.attachment = new_obj
    async_session.add(doc)
    await async_session.commit()
    await async_session.refresh(doc)

    # Verify new file exists and attachment updated
    assert await backend.get_content_async(new_path) == new_content
    assert doc.attachment is not None and doc.attachment.path == new_path  # pyright: ignore

    # Verify the listener deleted the old file
    with pytest.raises(FileNotFoundError):
        await backend.get_content_async(old_path)


@pytest.mark.xdist_group("file_object")
async def test_obstore_delete_async_on_update_clear_with_listener(
    async_session: AsyncSession, storage_registry: StorageRegistry
) -> None:
    """Test listener deletes file when attribute is cleared.

    Note that AsyncSession in SQLAlchemy 2.0 has limitations with event listeners.
    We will manually handle cleanup of files to ensure proper functionality.
    """
    # Set async context flag to enable async operations in the listener
    set_async_context(True)

    setup_file_object_listeners()
    backend = storage_registry.get_backend("local_test_store")
    old_content = b"File to clear"
    old_path = "clear_me_async.log"

    # Save initial file and model
    old_obj = FileObject(backend=backend, filename="clear_me_async.log", to_filename=old_path, content=old_content)
    old_obj = await old_obj.save_async()  # Make sure it's saved to the backend

    doc = Document(name="DocToClear", attachment=old_obj)
    async_session.add(doc)
    await async_session.commit()
    await async_session.refresh(doc)

    # Verify old file exists
    assert await backend.get_content_async(old_path) == old_content

    # Clear the attachment
    doc.attachment = None
    async_session.add(doc)
    await async_session.commit()
    await async_session.refresh(doc)

    # Verify attachment is None
    assert doc.attachment is None

    # Verify the listener deleted the file
    with pytest.raises(FileNotFoundError):
        await backend.get_content_async(old_path)


@pytest.mark.xdist_group("file_object")
async def test_obstore_delete_async_multiple_removed_with_listener(
    async_session: AsyncSession, storage_registry: StorageRegistry
) -> None:
    """Test listener deletes files removed from a multiple list.

    Note that AsyncSession in SQLAlchemy 2.0 has limitations with event listeners.
    MutableList tracking doesn't work properly with AsyncSession, so we use direct
    assignment for updates instead of mutating the list in-place.
    """
    # Set async context flag to enable async operations in the listener
    set_async_context(True)

    setup_file_object_listeners()
    backend = storage_registry.get_backend("local_test_store")
    content1 = b"img1"
    content2 = b"img2"
    path1 = "img1_list_async.jpg"
    path2 = "img2_list_async.png"

    # Create file objects and save them
    obj1 = FileObject(backend=backend, filename="img1_list_async.jpg", to_filename=path1, content=content1)
    obj1 = await obj1.save_async()

    obj2 = FileObject(backend=backend, filename="img2_list_async.png", to_filename=path2, content=content2)
    obj2 = await obj2.save_async()

    # Create and save model with both images
    img_list = MutableList[FileObject]([obj1, obj2])
    doc = Document(name="ImagesDoc", images=img_list)
    async_session.add(doc)
    await async_session.commit()
    await async_session.refresh(doc)

    # Verify files exist
    assert await backend.get_content_async(path1) == content1
    assert await backend.get_content_async(path2) == content2

    # Verify images are loaded
    assert doc.images is not None
    assert len(doc.images) == 2

    # With AsyncSession, mutations to MutableList may not be tracked correctly.
    # Instead of mutating the list in place, we'll create a new list with only obj2
    doc.images = MutableList[FileObject]([obj2])

    async_session.add(doc)
    await async_session.commit()
    await async_session.refresh(doc)

    # Verify only one image remains
    assert doc.images is not None
    assert len(doc.images or []) == 1
    assert doc.images[0].path == path2  # pyright: ignore

    # Verify first file is deleted and second still exists
    with pytest.raises(FileNotFoundError):
        await backend.get_content_async(path1)
    assert await backend.get_content_async(path2) == content2


@pytest.mark.xdist_group("file_object")
async def test_file_object_invalid_init(storage_registry: StorageRegistry) -> None:
    """Test FileObject initialization with invalid parameters."""
    backend = storage_registry.get_backend("local_test_store")
    test_content = b"Test content"
    test_path = Path("test.txt")

    # Test both content and source_path provided
    with pytest.raises(ValueError, match="Cannot provide both 'source_content' and 'source_path'"):
        FileObject(
            backend=backend,
            filename="test.txt",
            content=test_content,
            source_path=test_path,
        )


@pytest.mark.xdist_group("file_object")
async def test_file_object_metadata_management(storage_registry: StorageRegistry) -> None:
    """Test FileObject metadata handling."""
    backend = storage_registry.get_backend("local_test_store")
    initial_metadata = {"category": "test", "tags": ["sample"]}
    additional_metadata = {"priority": "high", "tags": ["important"]}

    # Create FileObject with initial metadata
    obj = FileObject(
        backend=backend,
        filename="test.txt",
        metadata=initial_metadata,
    )
    assert obj.metadata == initial_metadata

    # Update metadata
    obj.update_metadata(additional_metadata)
    expected_metadata = {
        "category": "test",
        "tags": ["important"],  # New tags override old ones
        "priority": "high",
    }
    assert obj.metadata == expected_metadata


@pytest.mark.xdist_group("file_object")
async def test_file_object_to_dict(storage_registry: StorageRegistry) -> None:
    """Test FileObject to_dict method."""
    backend = storage_registry.get_backend("local_test_store")
    obj = FileObject(
        backend=backend,
        filename="test.txt",
        content_type="text/plain",
        size=100,
        last_modified=1234567890.0,
        checksum="abc123",
        etag="xyz789",
        version_id="v1",
        metadata={"category": "test"},
    )

    # Convert to dict
    obj_dict = obj.to_dict()
    assert obj_dict == {
        "filename": "test.txt",
        "content_type": "text/plain",
        "size": 100,
        "last_modified": 1234567890.0,
        "checksum": "abc123",
        "etag": "xyz789",
        "version_id": "v1",
        "metadata": {"category": "test"},
        "backend": "local_test_store",
    }


@pytest.mark.xdist_group("file_object")
async def test_obstore_local_sign_urls(storage_registry: StorageRegistry) -> None:
    """Test FileObject sign and sign_async methods."""
    backend = storage_registry.get_backend("local_test_store")
    test_content = b"Test content for signing"
    file_path = "test_sign.txt"

    # Create and save file
    obj = FileObject(backend=backend, filename="test.txt", to_filename=file_path)
    await obj.save_async(data=test_content)

    # Test sign method
    with pytest.raises(NotImplementedError, match=r"Error signing path test_sign.txt"):
        _ = obj.sign(expires_in=3600)

    # Test sign_async method
    with pytest.raises(NotImplementedError, match=r"Error signing path test_sign.txt"):
        _ = await obj.sign_async(expires_in=3600)

    with pytest.raises(
        NotImplementedError,
        match=r"Error signing path test_sign.txt",
    ):
        _ = obj.sign(for_upload=True)

    with pytest.raises(
        NotImplementedError,
        match=r"Error signing path test_sign.txt",
    ):
        _ = await obj.sign_async(for_upload=True)


@pytest.mark.xdist_group("file_object")
async def test_file_object_save_with_different_data_types(storage_registry: StorageRegistry) -> None:
    """Test FileObject save with different data types."""
    backend = storage_registry.get_backend("local_test_store")
    test_content = b"Test content"
    file_path = "test_data_types.txt"

    # Test with bytes
    obj1 = FileObject(backend=backend, filename="test1.txt", to_filename=file_path, content=test_content)
    obj1.save()
    assert await obj1.get_content_async() == test_content

    # Test with Path
    import tempfile

    with tempfile.NamedTemporaryFile(mode="wb", delete=False) as f:
        f.write(test_content)
        temp_path = Path(f.name)

    obj2 = FileObject(backend=backend, filename="test2.txt", to_filename=file_path)
    await obj2.save_async(data=temp_path)
    assert await obj2.get_content_async() == test_content
    assert obj2.get_content() == test_content
    # Cleanup
    from advanced_alchemy.utils.sync_tools import async_

    await async_(temp_path.unlink)()


@pytest.mark.xdist_group("file_object")
async def test_file_object_pending_data_property(storage_registry: StorageRegistry) -> None:
    """Test FileObject has_pending_data property."""
    backend = storage_registry.get_backend("local_test_store")
    test_content = b"Test content"
    test_path = Path("test.txt")

    # Test with content
    obj1 = FileObject(backend=backend, filename="test1.txt", content=test_content)
    assert obj1.has_pending_data

    # Test with source_path
    obj2 = FileObject(backend=backend, filename="test2.txt", source_path=test_path)
    assert obj2.has_pending_data

    # Test without pending data
    obj3 = FileObject(backend=backend, filename="test3.txt")
    assert not obj3.has_pending_data


@pytest.mark.xdist_group("file_object")
async def test_file_object_delete_methods(storage_registry: StorageRegistry) -> None:
    """Test FileObject delete and delete_async methods."""
    backend = storage_registry.get_backend("local_test_store")
    test_content = b"Test content to delete"
    file_path = "test_delete.txt"

    # Create and save file
    obj = FileObject(backend=backend, filename="test.txt", to_filename=file_path)
    await obj.save_async(data=test_content)

    # Verify file exists
    assert await backend.get_content_async(file_path) == test_content

    # Test delete_async
    await obj.delete_async()
    with pytest.raises(FileNotFoundError):
        await backend.get_content_async(file_path)

    # Create and save file again
    await obj.save_async(data=test_content)
    assert await backend.get_content_async(file_path) == test_content

    # Test delete
    obj.delete()
    with pytest.raises(FileNotFoundError):
        await backend.get_content_async(file_path)


@pytest.mark.xdist_group("file_object")
async def test_obstore_backend_storage_registry_management(storage_registry: StorageRegistry) -> None:
    """Test StorageRegistry management methods."""
    from obstore.store import MemoryStore

    from advanced_alchemy.types.file_object.backends.obstore import ObstoreBackend

    # Test registered_backends
    initial_backends = storage_registry.registered_backends()
    assert "local_test_store" in initial_backends
    assert "memory" in initial_backends

    # Test unregister_backend
    storage_registry.unregister_backend("local_test_store")
    assert "local_test_store" not in storage_registry.registered_backends()
    with pytest.raises(ImproperConfigurationError):
        storage_registry.get_backend("local_test_store")

    # Test clear_backends
    storage_registry.clear_backends()
    assert not storage_registry.registered_backends()

    # Test set_default_backend
    storage_registry.set_default_backend("advanced_alchemy.types.file_object.backends.obstore.ObstoreBackend")
    assert storage_registry.default_backend == "advanced_alchemy.types.file_object.backends.obstore.ObstoreBackend"

    # Test register_backend with string value
    storage_registry.register_backend("memory://", key="test_backend")
    assert "test_backend" in storage_registry.registered_backends()
    assert isinstance(storage_registry.get_backend("test_backend"), ObstoreBackend)

    # Test register_backend with StorageBackend instance
    test_backend = ObstoreBackend(fs=MemoryStore(), key="test_backend2")
    storage_registry.register_backend(test_backend)
    assert "test_backend2" in storage_registry.registered_backends()
    assert storage_registry.get_backend("test_backend2") is test_backend

    # Test error cases
    with pytest.raises(ImproperConfigurationError, match="key is required when registering a string value"):
        storage_registry.register_backend("memory://")  # type: ignore[arg-type]

    with pytest.raises(ImproperConfigurationError, match="key is not allowed when registering a StorageBackend"):
        storage_registry.register_backend(test_backend, key="invalid_key")  # type: ignore[arg-type]

    # Restore the original backends for session-scoped fixture compatibility
    import tempfile

    from obstore.store import LocalStore, MemoryStore

    # Re-register the memory backend
    if not storage_registry.is_registered("memory"):
        storage_registry.register_backend(ObstoreBackend(fs=MemoryStore(), key="memory"))

    # Re-register the local_test_store backend
    if not storage_registry.is_registered("local_test_store"):
        storage_dir = tempfile.mkdtemp(prefix="file_object_test_storage_")
        storage_registry.register_backend(
            ObstoreBackend(
                fs=LocalStore(prefix=storage_dir),
                key="local_test_store",
            )
        )


@pytest.mark.xdist_group("file_object")
async def test_obstore_backend_storage_registry_error_handling(storage_registry: StorageRegistry) -> None:
    """Test StorageRegistry error handling."""
    # Test get_backend with non-existent key
    with pytest.raises(ImproperConfigurationError, match='No storage backend registered with key "nonexistent"'):
        storage_registry.get_backend("nonexistent")

    # Test unregister_backend with non-existent key
    storage_registry.unregister_backend("nonexistent")  # Should not raise an error

    # Test set_default_backend with invalid backend
    storage_registry.set_default_backend("invalid.module.path.Backend")


@pytest.mark.xdist_group("file_object")
async def test_fsspec_backend_basic_operations(storage_registry: StorageRegistry) -> None:
    """Test basic operations with FSSpec backend."""
    try:
        import fsspec
    except ImportError:
        pytest.skip("fsspec not installed")

    # Create a local filesystem backend
    fs = fsspec.filesystem("file")
    backend = FSSpecBackend(fs=fs, key="fsspec_test")
    test_content = b"Test content"
    file_path = "test_fsspec.txt"

    # Test save and get content
    obj = FileObject(backend=backend, filename="test.txt", to_filename=file_path)
    await obj.save_async(data=test_content)
    assert await obj.get_content_async() == test_content

    # Test delete
    await obj.delete_async()
    with pytest.raises(FileNotFoundError):
        await obj.get_content_async()


@pytest.mark.xdist_group("file_object")
async def test_fsspec_backend_protocols(storage_registry: StorageRegistry) -> None:
    """Test FSSpec backend with different protocols."""
    try:
        import fsspec
    except ImportError:
        pytest.skip("fsspec not installed")

    # Test local filesystem
    fs_local = fsspec.filesystem("file")
    backend_local = FSSpecBackend(fs=fs_local, key="fsspec_local")
    assert backend_local.protocol == "file"

    # Test memory filesystem
    fs_memory = fsspec.filesystem("memory")
    backend_memory = FSSpecBackend(fs=fs_memory, key="fsspec_memory")
    assert backend_memory.protocol == "memory"

    # Test with protocol string
    backend_from_string = FSSpecBackend(fs="file", key="fsspec_string")
    assert backend_from_string.protocol == "file"


@pytest.mark.xdist_group("file_object")
async def test_fsspec_backend_content_types(storage_registry: StorageRegistry) -> None:
    """Test FSSpec backend with different content types."""
    try:
        import fsspec
    except ImportError:
        pytest.skip("fsspec not installed")

    fs = fsspec.filesystem("memory")
    backend = FSSpecBackend(fs=fs, key="fsspec_content")
    file_path = "test_content.txt"

    # Test with bytes
    content_bytes = b"Test bytes"
    obj_bytes = FileObject(backend=backend, filename="test_bytes.txt", to_filename=file_path)
    await obj_bytes.save_async(data=content_bytes)
    assert await obj_bytes.get_content_async() == content_bytes

    # Test with string
    content_str = "Test string"
    obj_str = FileObject(backend=backend, filename="test_str.txt", to_filename=file_path)
    await obj_str.save_async(data=content_str.encode("utf-8"))
    assert await obj_str.get_content_async() == content_str.encode("utf-8")


@pytest.mark.xdist_group("file_object")
async def test_fsspec_backend_multipart_upload(storage_registry: StorageRegistry) -> None:
    """Test FSSpec backend multipart upload."""
    try:
        import fsspec
    except ImportError:
        pytest.skip("fsspec not installed")

    fs = fsspec.filesystem("memory")
    backend = FSSpecBackend(fs=fs, key="fsspec_multipart")
    file_path = "test_multipart.txt"

    # Create large content for multipart upload
    large_content = b"x" * (5 * 1024 * 1024 + 1)  # 5MB + 1 byte
    obj = FileObject(backend=backend, filename="test.txt", to_filename=file_path)

    # Test with multipart upload
    await obj.save_async(
        data=large_content,
        use_multipart=True,
        chunk_size=1024 * 1024,  # 1MB chunks
        max_concurrency=4,
    )
    assert await obj.get_content_async() == large_content


@pytest.mark.xdist_group("file_object")
async def test_fsspec_backend_sign_urls(storage_registry: StorageRegistry, tmp_path: Path) -> None:
    """Test FSSpec backend URL signing."""
    try:
        import fsspec
    except ImportError:
        pytest.skip("fsspec not installed")

    fs = fsspec.filesystem("file")
    backend = FSSpecBackend(fs=fs, key="fsspec_sign", prefix=str(tmp_path))
    file_path = "test_sign.txt"

    # Create and save test file
    test_content = b"Test content for signing"
    obj = FileObject(backend=backend, filename="test.txt", to_filename=file_path)
    await obj.save_async(data=test_content)

    # Test sign method
    with pytest.raises(NotImplementedError, match="Signing URLs not supported by file backend"):
        _ = obj.sign(expires_in=3600)

    # Test sign_async method
    with pytest.raises(NotImplementedError, match="Signing URLs not supported by file backend"):
        _ = await obj.sign_async(expires_in=3600)

    # Test for_upload parameter
    with pytest.raises(
        NotImplementedError,
        match=r"Generating signed URLs for upload is generally not supported by fsspec's generic sign method.",
    ):
        _ = obj.sign(for_upload=True)


@pytest.mark.xdist_group("file_object")
def test_file_object_sync_save_and_get_content(storage_registry: StorageRegistry) -> None:
    """Test FileObject synchronous save and get_content methods."""
    backend = storage_registry.get_backend("local_test_store")
    test_content = b"Test synchronous content"
    file_path = "test_sync_save.txt"

    # Create FileObject with content
    obj = FileObject(backend=backend, filename="test_sync.txt", to_filename=file_path, content=test_content)

    # Test synchronous save method
    updated_obj = obj.save()

    # Verify save worked correctly
    assert updated_obj is obj  # Should update in-place
    assert obj.path == file_path
    assert obj.size == len(test_content) or obj.size is None

    # Test synchronous get_content method
    retrieved_content = obj.get_content()
    assert retrieved_content == test_content

    # Clean up
    obj.delete()


@pytest.mark.xdist_group("file_object")
def test_file_object_save_with_source_path(storage_registry: StorageRegistry, tmp_path: Path) -> None:
    """Test FileObject save with source_path."""
    backend = storage_registry.get_backend("local_test_store")
    test_content = b"Test content from file"
    file_path = "test_source_path.txt"

    # Create a temporary file
    source_file = tmp_path / "source.txt"
    source_file.write_bytes(test_content)

    # Create FileObject with source_path
    obj = FileObject(backend=backend, filename="test_source.txt", to_filename=file_path, source_path=source_file)

    # Test save method with source_path
    obj.save()

    # Verify save worked correctly
    retrieved_content = obj.get_content()
    assert retrieved_content == test_content

    # Clean up
    obj.delete()


@pytest.mark.xdist_group("file_object")
def test_file_object_equality_and_hash(storage_registry: StorageRegistry) -> None:
    """Test FileObject __eq__ and __hash__ methods."""
    backend = storage_registry.get_backend("local_test_store")

    # Create two identical FileObjects
    obj1 = FileObject(backend=backend, filename="test.txt", to_filename="same_path.txt")
    obj2 = FileObject(backend=backend, filename="different.txt", to_filename="same_path.txt")

    # They should be equal because they have the same path and backend
    assert obj1 == obj2
    assert hash(obj1) == hash(obj2)

    # Create a different FileObject
    obj3 = FileObject(backend=backend, filename="test.txt", to_filename="different_path.txt")

    # They should not be equal because they have different paths
    assert obj1 != obj3
    assert hash(obj1) != hash(obj3)

    # Compare with a non-FileObject
    assert obj1 != "not a file object"


@pytest.mark.xdist_group("file_object")
def test_file_object_property_setters(storage_registry: StorageRegistry) -> None:
    """Test FileObject property setters."""
    backend = storage_registry.get_backend("local_test_store")

    obj = FileObject(backend=backend, filename="test.txt")

    # Test size property
    obj.size = 100
    assert obj.size == 100

    # Test last_modified property
    timestamp = 1234567890.0
    obj.last_modified = timestamp
    assert obj.last_modified == timestamp

    # Test checksum property
    obj.checksum = "abc123"
    assert obj.checksum == "abc123"

    # Test etag property
    obj.etag = "etag123"
    assert obj.etag == "etag123"

    # Test version_id property
    obj.version_id = "v1"
    assert obj.version_id == "v1"

    # Test metadata property
    new_metadata = {"key": "value"}
    obj.metadata = new_metadata
    assert obj.metadata == new_metadata


@pytest.mark.xdist_group("file_object")
def test_file_object_repr(storage_registry: StorageRegistry) -> None:
    """Test FileObject __repr__ method."""
    backend = storage_registry.get_backend("local_test_store")

    # Create a FileObject with all attributes set
    obj = FileObject(
        backend=backend,
        filename="test.txt",
        size=100,
        content_type="text/plain",
        last_modified=1234567890.0,
        etag="etag123",
        version_id="v1",
    )

    # Test __repr__ method
    repr_str = repr(obj)
    assert "FileObject" in repr_str
    assert "filename=test.txt" in repr_str
    assert "backend=local_test_store" in repr_str
    assert "size=100" in repr_str
    assert "content_type=text/plain" in repr_str
    assert "etag=etag123" in repr_str
    assert "last_modified=1234567890.0" in repr_str
    assert "version_id=v1" in repr_str


@pytest.mark.xdist_group("file_object")
def test_file_object_content_type_guessing(storage_registry: StorageRegistry) -> None:
    """Test content_type guessing from filename."""
    backend = storage_registry.get_backend("local_test_store")

    # Test common file types
    file_types = {
        "test.txt": "text/plain",
        "image.jpg": "image/jpeg",
        "doc.pdf": "application/pdf",
        "data.json": "application/json",
        "unknown": "application/octet-stream",
    }

    for filename, expected_type in file_types.items():
        obj = FileObject(backend=backend, filename=filename)
        assert obj.content_type == expected_type


@pytest.mark.xdist_group("file_object")
def test_file_object_save_no_data(storage_registry: StorageRegistry) -> None:
    """Test save method with no data."""
    backend = storage_registry.get_backend("local_test_store")

    # Create a FileObject with no content or source_path
    obj = FileObject(backend=backend, filename="test.txt")

    # Saving with no data should raise a TypeError
    with pytest.raises(TypeError, match=r"No data provided and no pending content/path found to save."):
        obj.save()


@pytest.mark.xdist_group("file_object")
async def test_file_object_save_async_no_data(storage_registry: StorageRegistry) -> None:
    """Test save_async method with no data."""
    backend = storage_registry.get_backend("local_test_store")

    # Create a FileObject with no content or source_path
    obj = FileObject(backend=backend, filename="test.txt")

    # Saving with no data should raise a TypeError
    with pytest.raises(TypeError, match=r"No data provided and no pending content/path found to save."):
        await obj.save_async()


@pytest.mark.xdist_group("file_object")
def test_obstore_backend_sqlalchemy_single_file_persist_sync(
    session: Session, storage_registry: StorageRegistry
) -> None:
    """Test saving and loading a model with a single StoredObject using synchronous SQLAlchemy session."""
    remove_listeners()
    file_content = b"SQLAlchemy Sync Integration Test"
    doc_name = "Sync Integration Doc"
    file_path = "sqlalchemy_single_sync.bin"

    # 1. Prepare FileObject and save via backend
    initial_obj = FileObject(
        backend="local_test_store",
        filename="report.bin",
        to_filename=file_path,
        content_type="application/octet-stream",
    )
    updated_obj = initial_obj.save(data=file_content)

    # 2. Create and save model instance
    doc = Document(name=doc_name, attachment=updated_obj)
    session.add(doc)
    session.commit()
    session.refresh(doc)

    assert doc.id is not None
    assert doc.attachment is not None
    assert isinstance(doc.attachment, FileObject)
    assert doc.attachment.filename == "sqlalchemy_single_sync.bin"
    assert doc.attachment.path == file_path
    assert doc.attachment.size == len(file_content) or doc.attachment.size is None
    assert doc.attachment.content_type == "application/octet-stream"
    assert doc.attachment.backend.key == "local_test_store"

    # 3. Retrieve content via loaded FileObject
    loaded_content = doc.attachment.get_content()
    assert loaded_content == file_content


@pytest.mark.xdist_group("file_object")
async def test_obstore_backend_listener_sqlalchemy_single_file_persist_async(
    async_session: AsyncSession, storage_registry: StorageRegistry
) -> None:
    """Test saving and loading a model with a single StoredObject using synchronous SQLAlchemy session."""
    setup_file_object_listeners()
    set_async_context(True)
    file_content = b"SQLAlchemy Async Integration Test"
    doc_name = "Sync Integration Doc"
    file_path = "sqlalchemy_single_async.bin"

    # 1. Prepare FileObject and save via backend
    initial_obj = FileObject(
        backend="local_test_store",
        filename="report.bin",
        to_filename=file_path,
        content_type="application/octet-stream",
        content=file_content,
    )
    # 2. Create and save model instance
    doc = Document(name=doc_name, attachment=initial_obj)
    async_session.add(doc)
    await async_session.commit()
    await async_session.refresh(doc)

    assert doc.id is not None
    assert doc.attachment is not None
    assert isinstance(doc.attachment, FileObject)
    assert doc.attachment.filename == "sqlalchemy_single_async.bin"
    assert doc.attachment.path == file_path
    assert doc.attachment.size == len(file_content) or doc.attachment.size is None
    assert doc.attachment.content_type == "application/octet-stream"
    assert doc.attachment.backend.key == "local_test_store"

    # 3. Retrieve content via loaded FileObject
    loaded_content = doc.attachment.get_content()
    assert loaded_content == file_content


@pytest.mark.xdist_group("file_object")
def test_obstore_backend_sqlalchemy_multiple_files_persist_sync(
    session: Session, storage_registry: StorageRegistry
) -> None:
    """Test saving and loading a model with multiple StoredObjects using synchronous SQLAlchemy session."""
    remove_listeners()
    backend = storage_registry.get_backend("local_test_store")
    img1_content = b"img_data_1_sync"
    img2_content = b"img_data_2_sync"
    doc_name = "Multi Image Doc Sync"
    img1_path = "img1_list_sync.jpg"
    img2_path = "img2_list_sync.png"

    # 1. Prepare FileObjects and save via backend
    obj1 = FileObject(
        backend=backend, filename="image1_list_sync.jpg", to_filename=img1_path, content_type="image/jpeg"
    )
    obj1_updated = obj1.save(img1_content)

    obj2 = FileObject(backend=backend, filename="image2_list_sync.png", to_filename=img2_path, content_type="image/png")
    obj2_updated = obj2.save(img2_content)

    # 2. Create and save model instance with MutableList
    img_list = MutableList[FileObject]([obj1_updated, obj2_updated])
    doc = Document(name=doc_name, images=img_list)
    session.add(doc)
    session.commit()
    session.refresh(doc)

    assert doc.id is not None
    assert doc.images is not None
    assert isinstance(doc.images, MutableList)
    assert len(doc.images) == 2

    # Verify loaded objects
    loaded_obj1 = doc.images[0]
    loaded_obj2 = doc.images[1]
    assert isinstance(loaded_obj1, FileObject)
    assert loaded_obj1.filename == "img1_list_sync.jpg"
    assert loaded_obj1.path == img1_path
    assert loaded_obj1.size == len(img1_content) or loaded_obj1.size is None
    assert loaded_obj1.backend and loaded_obj1.backend.driver == backend.driver

    assert isinstance(loaded_obj2, FileObject)
    assert loaded_obj2.filename == "img2_list_sync.png"
    assert loaded_obj2.path == img2_path
    assert loaded_obj2.size == len(img2_content) or loaded_obj2.size is None
    assert loaded_obj2.backend and loaded_obj2.backend.driver == backend.driver

    # Verify content
    assert loaded_obj1.get_content() == img1_content
    assert loaded_obj2.get_content() == img2_content


@pytest.mark.xdist_group("file_object")
def test_obstore_backend_listener_delete_on_update_clear_sync(
    session: Session, storage_registry: StorageRegistry
) -> None:
    """Test listener deletes old file when attribute is cleared using synchronous SQLAlchemy session."""
    setup_file_object_listeners()
    backend = storage_registry.get_backend("local_test_store")
    old_content = b"File to clear sync"
    old_path = "clear_me_sync.log"

    # Save initial file and model
    old_obj = FileObject(backend=backend, filename="clear.log", to_filename=old_path, content=old_content)
    doc = Document(name="DocToClearSync", attachment=old_obj)
    session.add(doc)
    session.commit()
    session.refresh(doc)

    # Verify old file exists
    assert backend.get_content(old_path) == old_content

    # Clear the attachment
    doc.attachment = None
    session.add(doc)
    session.commit()
    session.refresh(doc)

    # Verify attachment is None
    assert doc.attachment is None

    # Verify the listener deleted the file from storage
    with pytest.raises(FileNotFoundError):
        backend.get_content(old_path)


@pytest.mark.flaky(reruns=5)
@pytest.mark.xdist_group("file_object")
async def test_obstore_backend_listener_delete_on_update_clear_async(
    async_session: AsyncSession, storage_registry: StorageRegistry
) -> None:
    """Test listener deletes old file when attribute is cleared using asynchronous SQLAlchemy session."""
    setup_file_object_listeners()
    set_async_context(True)
    backend = storage_registry.get_backend("local_test_store")
    old_content = b"File to clear sync"
    old_path = "clear_me_sync.log"

    # Save initial file and model
    old_obj = FileObject(backend=backend, filename="clear.log", to_filename=old_path, content=old_content)
    doc = Document(name="DocToClearSync", attachment=old_obj)
    async_session.add(doc)
    await async_session.commit()
    await async_session.refresh(doc)

    # Verify old file exists
    assert await backend.get_content_async(old_path) == old_content

    # Clear the attachment
    doc.attachment = None
    async_session.add(doc)
    await async_session.commit()
    await async_session.refresh(doc)

    # Verify attachment is None
    assert doc.attachment is None

    # Verify the listener deleted the file from storage
    with pytest.raises(FileNotFoundError):
        await backend.get_content_async(old_path)


@pytest.mark.flaky(reruns=5)
@pytest.mark.xdist_group("file_object")
def test_obstore_backend_listener_update_file_object_sync(session: Session, storage_registry: StorageRegistry) -> None:
    """Test listener deletes old file when attribute is updated and session committed using synchronous SQLAlchemy session."""
    setup_file_object_listeners()
    backend = storage_registry.get_backend("local_test_store")
    old_content = b"Old file content sync"
    new_content = b"New file content sync"
    old_path = "old_file_sync_update.txt"
    new_path = "new_file_sync_update.txt"

    # Save initial file and model
    old_obj = FileObject(
        backend=backend, filename="old_file_sync_update.txt", to_filename=old_path, content=old_content
    )
    doc = Document(name="DocToUpdateSync", attachment=old_obj)
    session.add(doc)
    session.commit()
    session.refresh(doc)

    # Verify old file exists
    assert backend.get_content(old_path) == old_content

    # Update the document's attachment (inline creation)
    new_obj = FileObject(
        backend=backend, filename="new_file_sync_update.txt", to_filename=new_path, content=new_content
    )
    doc.attachment = new_obj
    session.add(doc)  # Add again as it's modified
    session.commit()  # Listener should save new_obj and queue deletion of old_obj
    session.refresh(doc)

    # Verify new file exists and attachment updated
    assert backend.get_content(new_path) == new_content
    assert doc.attachment is not None and doc.attachment.path == new_path  # pyright: ignore

    # Verify the listener deleted the old file from storage
    with pytest.raises(FileNotFoundError):
        backend.get_content(old_path)


@pytest.mark.flaky(reruns=5)
@pytest.mark.xdist_group("file_object")
async def test_obstore_backend_listener_update_file_object_async(
    async_session: AsyncSession, storage_registry: StorageRegistry
) -> None:
    """Test listener deletes old file when attribute is updated and session committed using asynchronous SQLAlchemy session."""
    setup_file_object_listeners()
    backend = storage_registry.get_backend("local_test_store")
    old_content = b"Old file content sync"
    new_content = b"New file content sync"
    old_path = "old_file_async_update.txt"
    new_path = "new_file_async_update.txt"

    # Save initial file and model
    old_obj = FileObject(
        backend=backend, filename="old_file_async_update.txt", to_filename=old_path, content=old_content
    )
    doc = Document(name="DocToUpdateSync", attachment=old_obj)
    async_session.add(doc)
    await async_session.commit()
    await async_session.refresh(doc)

    # Verify old file exists
    assert backend.get_content(old_path) == old_content

    # Update the document's attachment (inline creation)
    new_obj = FileObject(
        backend=backend, filename="new_file_async_update.txt", to_filename=new_path, content=new_content
    )
    doc.attachment = new_obj
    async_session.add(doc)  # Add again as it's modified
    await async_session.commit()  # Listener should save new_obj and queue deletion of old_obj
    await async_session.refresh(doc)

    assert backend.get_content(new_path) == new_content
    assert doc.attachment is not None and doc.attachment.path == new_path  # pyright: ignore

    # Verify the listener deleted the old file from storage
    with pytest.raises(FileNotFoundError):
        backend.get_content(old_path)


@pytest.mark.flaky(reruns=5)
@pytest.mark.xdist_group("file_object")
def test_obstore_backend_listener_delete_multiple_removed_sync(
    session: Session, storage_registry: StorageRegistry
) -> None:
    """Test listener deletes files removed from a multiple list using synchronous SQLAlchemy session."""
    set_async_context(False)
    setup_file_object_listeners()
    backend = storage_registry.get_backend("local_test_store")
    content1 = b"img1_sync_multi"
    content2 = b"img2_sync_multi"
    path1 = "multi_del_1_sync.dat"
    path2 = "multi_del_2_sync.dat"

    # Save files
    obj1 = FileObject(backend=backend, filename=path1, content=content1)
    obj2 = FileObject(backend=backend, filename=path2, content=content2)

    # Create model with initial list
    doc = Document(name="MultiDeleteSyncTest", images=[obj1, obj2])
    session.add(doc)
    session.commit()
    session.refresh(doc)

    # Verify all files exist
    assert backend.get_content(path1) == content1
    assert backend.get_content(path2) == content2

    # Remove items from the list (triggers MutableList tracking)
    assert doc.images is not None
    current_images = list(doc.images)  # Create standard list copy
    removed_item = current_images.pop(1)  # Mutate copy
    assert removed_item.path == obj2.path
    del current_images[0]  # Mutate copy
    assert len(current_images) == 0
    doc.images = MutableList(current_images)  # Wrap in MutableList before reassignment

    session.add(doc)
    # Commit the session to trigger listener
    session.commit()
    session.refresh(doc)
    assert doc.images == []
    # Verify the listener deleted the files
    with pytest.raises(FileNotFoundError):
        backend.get_content(path1)
    with pytest.raises(FileNotFoundError):
        backend.get_content(path2)


@pytest.mark.flaky(reruns=5)
@pytest.mark.xdist_group("file_object")
async def test_obstore_backend_listener_delete_multiple_removed_async(
    async_session: AsyncSession, storage_registry: StorageRegistry
) -> None:
    """Test listener deletes files removed from a multiple list using asynchronous SQLAlchemy session."""
    set_async_context(True)
    setup_file_object_listeners()
    backend = storage_registry.get_backend("local_test_store")
    content1 = b"img1_async_multi"
    content2 = b"img2_async_multi"
    path1 = "multi_del_1_async.dat"
    path2 = "multi_del_2_async.dat"

    # Save files
    obj1 = FileObject(backend=backend, filename=path1, content=content1)
    obj2 = FileObject(backend=backend, filename=path2, content=content2)

    # Create model with initial list
    doc = Document(name="MultiDeleteAsyncTest", images=[obj1, obj2])
    async_session.add(doc)
    await async_session.commit()
    await async_session.refresh(doc)

    # Verify all files exist
    assert await backend.get_content_async(path1) == content1
    assert await backend.get_content_async(path2) == content2

    # Remove items from the list (triggers MutableList tracking)
    assert doc.images is not None
    current_images = list(doc.images)  # Create standard list copy
    removed_item = current_images.pop(1)  # Mutate copy
    assert removed_item.path == obj2.path
    del current_images[0]  # Mutate copy
    assert len(current_images) == 0
    doc.images = MutableList(current_images)  # Wrap in MutableList before reassignment

    # Commit the session to trigger listener
    await async_session.commit()
    await async_session.refresh(doc)

    # Verify the listener deleted the files
    with pytest.raises(FileNotFoundError):
        await backend.get_content_async(path1)
    with pytest.raises(FileNotFoundError):
        await backend.get_content_async(path2)


@pytest.mark.xdist_group("file_object")
async def test_obstore_content_type_and_metadata_passing(storage_registry: StorageRegistry) -> None:
    """Test that content_type and custom metadata are properly passed to obstore backend."""
    remove_listeners()
    backend = storage_registry.get_backend("memory")  # Use memory store for faster testing

    test_content = b"Hello Storage with metadata!"
    file_path = "test_metadata.json"

    # Create FileObject with specific content_type and custom metadata
    custom_metadata = {
        "Cache-Control": "no-cache",
        "Content-Disposition": "attachment; filename=test.json",
        "x-custom-field": "custom-value",
    }

    obj = FileObject(backend=backend, filename=file_path, content_type="application/json", metadata=custom_metadata)

    # Save the object
    updated_obj = await backend.save_object_async(obj, test_content)

    # Verify the content_type was set correctly
    assert updated_obj.content_type == "application/json"

    # Verify custom metadata was preserved
    assert updated_obj.metadata == custom_metadata

    # Note: MemoryStore doesn't persist custom attributes like Content-Type, but real storage
    # backends (S3, GCS, etc.) will. The important thing is that our code correctly passes
    # the attributes parameter to obstore's put method. The FileObject metadata preservation
    # above confirms our fix works.

    # Test the same with sync method
    file_path_sync = "test_metadata_sync.json"
    obj_sync = FileObject(
        backend=backend, filename=file_path_sync, content_type="application/json", metadata=custom_metadata
    )

    updated_obj_sync = backend.save_object(obj_sync, test_content)

    assert updated_obj_sync.content_type == "application/json"
    assert updated_obj_sync.metadata == custom_metadata


@pytest.mark.xdist_group("file_object")
async def test_obstore_nested_metadata_serialization(storage_registry: StorageRegistry) -> None:
    """Test that nested metadata (lists, dicts) doesn't crash obstore's put().

    Regression test for https://github.com/jolt-org/advanced-alchemy/issues/676.
    Obstore attributes must be strings; non-string values are JSON-serialized.
    """
    remove_listeners()
    backend = storage_registry.get_backend("memory")

    nested_metadata = {
        "tags": ["sample", "test"],
        "nested": {"key": "value", "count": 42},
        "flat_string": "just-a-string",
        "number": 123,
    }

    # Async path
    obj = FileObject(backend=backend, filename="nested_async.txt", metadata=nested_metadata)
    updated_obj = await backend.save_object_async(obj, b"async content")
    assert updated_obj.metadata == nested_metadata

    # Sync path
    obj_sync = FileObject(backend=backend, filename="nested_sync.txt", metadata=nested_metadata)
    updated_obj_sync = backend.save_object(obj_sync, b"sync content")
    assert updated_obj_sync.metadata == nested_metadata


@pytest.mark.xdist_group("file_object")
async def test_obstore_content_type_guessing(storage_registry: StorageRegistry) -> None:
    """Test that content_type is properly guessed when not explicitly set."""
    remove_listeners()
    backend = storage_registry.get_backend("memory")

    test_content = b"Hello HTML!"
    file_path = "test.html"

    # Create FileObject without explicit content_type
    obj = FileObject(backend=backend, filename=file_path)

    # The content_type should be guessed from the filename
    assert obj.content_type == "text/html"

    # Save the object
    updated_obj = await backend.save_object_async(obj, test_content)

    # Verify the guessed content_type is preserved
    assert updated_obj.content_type == "text/html"
python-advanced-alchemy-1.9.3/tests/integration/test_filters.py000066400000000000000000001553441516556515500250300ustar00rootroot00000000000000from collections.abc import AsyncGenerator, Generator
from datetime import datetime, timezone
from typing import Optional

import pytest
from pytest import FixtureRequest
from sqlalchemy import Engine, String, func, select
from sqlalchemy.ext.asyncio import AsyncEngine
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column

from advanced_alchemy.base import BigIntBase, UUIDAuditBase
from advanced_alchemy.filters import (
    BeforeAfter,
    CollectionFilter,
    ComparisonFilter,
    ExistsFilter,
    FilterGroup,
    LimitOffset,
    MultiFilter,
    NotExistsFilter,
    NotInCollectionFilter,
    NotNullFilter,
    NullFilter,
    OnBeforeAfter,
    OrderBy,
    SearchFilter,
    and_,
    or_,
)
from tests.integration.helpers import get_worker_id

pytestmark = [
    pytest.mark.integration,
    pytest.mark.xdist_group("filters"),
]


# Module-level cache for Movie model and counter for unique names
_movie_model_cache: dict[str, type] = {}
_movie_class_counter = 0


def get_movie_model_for_engine(engine_dialect_name: str, worker_id: str) -> type[DeclarativeBase]:
    """Create appropriate Movie model based on engine dialect."""
    global _movie_class_counter
    cache_key = f"movie_{worker_id}_{engine_dialect_name}"

    if cache_key not in _movie_model_cache:
        # Create unique base class with its own metadata for each engine
        class TestBase(DeclarativeBase):
            pass

        # Use UUID base for CockroachDB and Spanner, BigInt for others
        base_class = UUIDAuditBase if engine_dialect_name.startswith(("cockroach", "spanner")) else BigIntBase

        # Create class with globally unique name to avoid SQLAlchemy registry conflicts
        _movie_class_counter += 1
        unique_suffix = f"{_movie_class_counter}_{worker_id}_{engine_dialect_name}"

        # Create the class with unique name from the start to avoid registry conflicts
        class_name = f"Movie_{unique_suffix}"

        Movie = type(
            class_name,
            (base_class, TestBase),
            {
                "__tablename__": f"test_movies_{worker_id}_{engine_dialect_name}",
                "__mapper_args__": {"concrete": True},
                "__module__": __name__,  # Set proper module
                "title": mapped_column(String(length=100)),
                "release_date": mapped_column(),
                "genre": mapped_column(String(length=50)),
                "director": mapped_column(String(length=100), nullable=True),
                "__annotations__": {
                    "title": Mapped[str],
                    "release_date": Mapped[datetime],
                    "genre": Mapped[str],
                    "director": Mapped[Optional[str]],
                },
            },
        )  # type: ignore[valid-type,misc]

        _movie_model_cache[cache_key] = Movie

    return _movie_model_cache[cache_key]


@pytest.fixture(scope="session")
def cached_movie_model(request: FixtureRequest) -> type[DeclarativeBase]:
    """Create Movie model once per session/worker - placeholder."""
    # This will be replaced by movie_model_sync/async fixtures
    return None  # type: ignore[return-value]


@pytest.fixture
def movie_model_sync(
    engine: Engine,
    request: FixtureRequest,
) -> Generator[type[DeclarativeBase], None, None]:
    """Setup movie table for sync engines."""
    worker_id = get_worker_id(request)
    engine_dialect_name = getattr(engine.dialect, "name", "mock")

    # Skip Spanner, CockroachDB, and MSSQL due to database-specific issues
    if engine_dialect_name.startswith(("spanner", "cockroach", "mssql")):
        pytest.skip(f"Filter tests are not supported on {engine_dialect_name}")

    # Get the appropriate model for this engine type
    movie_model = get_movie_model_for_engine(engine_dialect_name, worker_id)

    # Skip for mock engines
    if engine_dialect_name != "mock":
        # Create table once per engine type
        movie_model.metadata.create_all(engine)

    yield movie_model

    # Cleanup is handled by _auto_clean_sync_db fixture


@pytest.fixture
async def movie_model_async(
    cached_movie_model: type[DeclarativeBase],
    async_engine: AsyncEngine,
) -> AsyncGenerator[type[DeclarativeBase], None]:
    """Setup movie table for async engines."""
    engine_dialect_name = getattr(async_engine.dialect, "name", "mock")

    # Skip Spanner, CockroachDB, and MSSQL due to database-specific issues
    if engine_dialect_name.startswith(("spanner", "cockroach", "mssql")):
        pytest.skip(f"Filter tests are not supported on {engine_dialect_name}")

    # Skip for mock engines
    if engine_dialect_name != "mock":
        # Create table once per engine type
        async with async_engine.begin() as conn:
            await conn.run_sync(cached_movie_model.metadata.create_all)

    yield cached_movie_model

    # Cleanup is handled by _auto_clean_async_db fixture


def setup_movie_data(session: Session, movie_model: type[DeclarativeBase]) -> None:
    """Add test data to the session."""
    dialect_name = getattr(session.bind.dialect, "name", "")
    if dialect_name == "mock":
        # For mock engines, configure the mock to return expected data
        mock_movies = [
            type(
                "Movie",
                (),
                {"title": "The Matrix", "release_date": datetime(1999, 3, 31, tzinfo=timezone.utc), "genre": "Action"},
            ),
            type(
                "Movie",
                (),
                {"title": "The Hangover", "release_date": datetime(2009, 6, 1, tzinfo=timezone.utc), "genre": "Comedy"},
            ),
            type(
                "Movie",
                (),
                {
                    "title": "Shawshank Redemption",
                    "release_date": datetime(1994, 10, 14, tzinfo=timezone.utc),
                    "genre": "Drama",
                },
            ),
        ]
        session.execute.return_value.scalars.return_value.all.return_value = mock_movies
        return

    Movie = movie_model

    # CockroachDB and Spanner require UUID primary keys to be provided
    dialect_name = getattr(session.bind.dialect, "name", "")
    movie_data = [
        {
            "title": "The Matrix",
            "release_date": datetime(1999, 3, 31, tzinfo=timezone.utc),
            "genre": "Action",
            "director": "Wachowskis",
        },
        {
            "title": "The Hangover",
            "release_date": datetime(2009, 6, 1, tzinfo=timezone.utc),
            "genre": "Comedy",
            "director": None,  # NULL director for testing NullFilter
        },
        {
            "title": "Shawshank Redemption",
            "release_date": datetime(1994, 10, 14, tzinfo=timezone.utc),
            "genre": "Drama",
            "director": "Frank Darabont",
        },
    ]

    if dialect_name.startswith(("cockroach", "spanner")):
        # For UUID-based models, generate IDs
        from advanced_alchemy.base import UUIDAuditBase

        if issubclass(Movie, UUIDAuditBase):
            import uuid

            for data in movie_data:
                data["id"] = str(uuid.uuid4())

    movies = [Movie(**data) for data in movie_data]
    session.add_all(movies)
    session.commit()


def test_before_after_filter(session: Session, movie_model_sync: type[DeclarativeBase]) -> None:
    Movie = movie_model_sync

    # Skip mock engines
    if getattr(session.bind.dialect, "name", "") == "mock":
        pytest.skip("Mock engines not supported for filter tests")

    # Clean any existing data first, then setup fresh data
    if getattr(session.bind.dialect, "name", "") != "mock":
        session.execute(Movie.__table__.delete())
        session.commit()
    setup_movie_data(session, Movie)

    before_after_filter = BeforeAfter(
        field_name="release_date", before=datetime(1999, 3, 31, tzinfo=timezone.utc), after=None
    )
    statement = before_after_filter.append_to_statement(select(Movie), Movie)
    results = session.execute(statement).scalars().all()
    assert len(results) == 1


def test_on_before_after_filter(session: Session, movie_model_sync: type[DeclarativeBase]) -> None:
    Movie = movie_model_sync

    # Skip mock engines
    if getattr(session.bind.dialect, "name", "") == "mock":
        pytest.skip("Mock engines not supported for filter tests")

    # Clean any existing data first, then setup fresh data
    if getattr(session.bind.dialect, "name", "") != "mock":
        session.execute(Movie.__table__.delete())
        session.commit()
    setup_movie_data(session, Movie)

    on_before_after_filter = OnBeforeAfter(
        field_name="release_date", on_or_before=None, on_or_after=datetime(1999, 3, 31, tzinfo=timezone.utc)
    )
    statement = on_before_after_filter.append_to_statement(select(Movie), Movie)
    results = session.execute(statement).scalars().all()
    assert len(results) == 2


def test_collection_filter(session: Session, movie_model_sync: type[DeclarativeBase]) -> None:
    Movie = movie_model_sync

    # Skip mock engines
    if getattr(session.bind.dialect, "name", "") == "mock":
        pytest.skip("Mock engines not supported for filter tests")

    # Clean any existing data first, then setup fresh data
    if getattr(session.bind.dialect, "name", "") != "mock":
        session.execute(Movie.__table__.delete())
        session.commit()
    setup_movie_data(session, Movie)
    collection_filter = CollectionFilter(field_name="title", values=["The Matrix", "Shawshank Redemption"])
    statement = collection_filter.append_to_statement(select(Movie), Movie)
    results = session.execute(statement).scalars().all()
    assert len(results) == 2


def test_not_in_collection_filter(session: Session, movie_model_sync: type[DeclarativeBase]) -> None:
    Movie = movie_model_sync

    # Skip mock engines
    if getattr(session.bind.dialect, "name", "") == "mock":
        pytest.skip("Mock engines not supported for filter tests")

    # Clean any existing data first, then setup fresh data
    if getattr(session.bind.dialect, "name", "") != "mock":
        session.execute(Movie.__table__.delete())
        session.commit()
    setup_movie_data(session, Movie)
    not_in_collection_filter = NotInCollectionFilter(field_name="title", values=["The Hangover"])
    statement = not_in_collection_filter.append_to_statement(select(Movie), Movie)
    results = session.execute(statement).scalars().all()
    assert len(results) == 2


def test_exists_filter(session: Session, movie_model_sync: type[DeclarativeBase]) -> None:
    Movie = movie_model_sync

    # Skip mock engines
    if getattr(session.bind.dialect, "name", "") == "mock":
        pytest.skip("Mock engines not supported for filter tests")

    # Skip Spanner Emulator - EXISTS filters have constraints in emulator
    if getattr(session.bind.dialect, "name", "") == "spanner+spanner":
        pytest.skip("Spanner Emulator has constraints with EXISTS filters")

    # Clean any existing data first, then setup fresh data
    if getattr(session.bind.dialect, "name", "") != "mock":
        session.execute(Movie.__table__.delete())
        session.commit()
    setup_movie_data(session, Movie)
    # Test EXISTS with a condition that is true for at least one row
    # Should return all rows because the subquery finds a match
    exists_filter_1 = ExistsFilter(values=[Movie.genre == "Action"])
    # For correlated subquery: Should return only rows where the condition is true
    statement = exists_filter_1.append_to_statement(select(Movie), Movie)
    results = session.execute(statement).scalars().all()
    assert len(results) == 1

    # Test EXISTS with multiple conditions using AND (default) that are true for different rows
    # The combination (Action AND Drama) is never true for a single row, so subquery is empty
    exists_filter_2 = ExistsFilter(values=[Movie.genre == "Action", Movie.genre == "Drama"])
    # For correlated subquery: Should return only rows where BOTH conditions are true (none)
    statement = exists_filter_2.append_to_statement(select(Movie), Movie)
    results = session.execute(statement).scalars().all()
    assert len(results) == 0

    # Test EXISTS with a condition that is never true
    # Should return no rows because the subquery is empty
    exists_filter_3 = ExistsFilter(values=[Movie.genre == "SciFi"])
    # For correlated subquery: Should return only rows where the condition is true (none)
    statement = exists_filter_3.append_to_statement(select(Movie), Movie)
    results = session.execute(statement).scalars().all()
    assert len(results) == 0


def test_exists_filter_operators(session: Session, movie_model_sync: type[DeclarativeBase]) -> None:
    Movie = movie_model_sync

    # Skip mock engines
    if getattr(session.bind.dialect, "name", "") == "mock":
        pytest.skip("Mock engines not supported for filter tests")

    # Skip Spanner Emulator - EXISTS filters have constraints in emulator
    if getattr(session.bind.dialect, "name", "") == "spanner+spanner":
        pytest.skip("Spanner Emulator has constraints with EXISTS filters")

    # Clean any existing data first, then setup fresh data
    if getattr(session.bind.dialect, "name", "") != "mock":
        session.execute(Movie.__table__.delete())
        session.commit()
    setup_movie_data(session, Movie)
    # Test EXISTS with OR operator - condition is true
    exists_filter_or = ExistsFilter(values=[Movie.genre == "Action", Movie.genre == "SciFi"], operator="or")
    # For correlated subquery: Should return rows where EITHER condition is true (only Action movie)
    statement = exists_filter_or.append_to_statement(select(Movie), Movie)
    results = session.execute(statement).scalars().all()
    assert len(results) == 1

    exists_filter_or_2 = ExistsFilter(values=[Movie.genre == "Action", Movie.genre == "Drama"], operator="or")
    # For correlated subquery: Should return rows where EITHER condition is true (only Action movie)
    statement = exists_filter_or_2.append_to_statement(select(Movie), Movie)
    results = session.execute(statement).scalars().all()
    assert len(results) == 2

    # Test EXISTS with AND operator - conditions never true simultaneously
    exists_filter_and = ExistsFilter(
        values=[Movie.title.startswith("The Matrix"), Movie.title.startswith("Shawshank")], operator="and"
    )
    # For correlated subquery: Should return rows where BOTH conditions are true (none)
    statement = exists_filter_and.append_to_statement(select(Movie), Movie)
    results = session.execute(statement).scalars().all()
    assert len(results) == 0


def test_not_exists_filter(session: Session, movie_model_sync: type[DeclarativeBase]) -> None:
    Movie = movie_model_sync

    # Skip mock engines
    if getattr(session.bind.dialect, "name", "") == "mock":
        pytest.skip("Mock engines not supported for filter tests")

    # Skip Spanner Emulator - EXISTS filters have constraints in emulator
    if getattr(session.bind.dialect, "name", "") == "spanner+spanner":
        pytest.skip("Spanner Emulator has constraints with EXISTS filters")

    # Clean any existing data first, then setup fresh data
    if getattr(session.bind.dialect, "name", "") != "mock":
        session.execute(Movie.__table__.delete())
        session.commit()
    setup_movie_data(session, Movie)
    # Test NOT EXISTS with a condition that is true for at least one row
    # Should return no rows because the subquery finds a match
    not_exists_filter_true = NotExistsFilter(values=[Movie.title.like("%Hangover%")])
    # For correlated subquery: Should return rows where condition is FALSE (Matrix, Shawshank)
    statement = not_exists_filter_true.append_to_statement(select(Movie), Movie)
    results = session.execute(statement).scalars().all()
    assert len(results) == 2

    # Test NOT EXISTS with a condition that is never true
    # Should return all rows because the subquery is empty
    not_exists_filter_false = NotExistsFilter(values=[Movie.title == "NonExistentMovie"])
    # For correlated subquery: Should return rows where condition is FALSE (all movies)
    statement = not_exists_filter_false.append_to_statement(select(Movie), Movie)
    results = session.execute(statement).scalars().all()
    assert len(results) == 3


def test_not_exists_filter_operators(session: Session, movie_model_sync: type[DeclarativeBase]) -> None:
    Movie = movie_model_sync

    # Skip mock engines
    if getattr(session.bind.dialect, "name", "") == "mock":
        pytest.skip("Mock engines not supported for filter tests")

    # Skip Spanner Emulator - EXISTS filters have constraints in emulator
    if getattr(session.bind.dialect, "name", "") == "spanner+spanner":
        pytest.skip("Spanner Emulator has constraints with EXISTS filters")

    # Clean any existing data first, then setup fresh data
    if getattr(session.bind.dialect, "name", "") != "mock":
        session.execute(Movie.__table__.delete())
        session.commit()
    setup_movie_data(session, Movie)
    # Test NOT EXISTS with OR operator - Should return rows where NEITHER condition is true
    not_exists_filter_or = NotExistsFilter(values=[Movie.genre == "Comedy", Movie.genre == "SciFi"], operator="or")
    # For correlated subquery: Should return rows where NEITHER condition is true (Action, Drama)
    statement = not_exists_filter_or.append_to_statement(select(Movie), Movie)
    results = session.execute(statement).scalars().all()
    assert len(results) == 2

    # Test NOT EXISTS with AND operator - Should return rows where NOT BOTH conditions are true
    not_exists_filter_and = NotExistsFilter(
        values=[Movie.title.startswith("The Matrix"), Movie.title.startswith("Shawshank")], operator="and"
    )
    # For correlated subquery: Should return rows where NOT BOTH conditions are true (all)
    statement = not_exists_filter_and.append_to_statement(select(Movie), Movie)
    results = session.execute(statement).scalars().all()
    assert len(results) == 3


def test_limit_offset_filter(session: Session, movie_model_sync: type[DeclarativeBase]) -> None:
    Movie = movie_model_sync

    # Skip mock engines
    if getattr(session.bind.dialect, "name", "") == "mock":
        pytest.skip("Mock engines not supported for filter tests")

    # Clean any existing data first, then setup fresh data
    if getattr(session.bind.dialect, "name", "") != "mock":
        session.execute(Movie.__table__.delete())
        session.commit()
    setup_movie_data(session, Movie)
    limit_offset_filter = LimitOffset(limit=2, offset=1)
    # Add ORDER BY for MSSQL compatibility (required when using OFFSET)
    statement = select(Movie).order_by(Movie.id)
    statement = limit_offset_filter.append_to_statement(statement, Movie)
    results = session.execute(statement).scalars().all()
    assert len(results) == 2


def test_order_by_filter(session: Session, movie_model_sync: type[DeclarativeBase]) -> None:
    Movie = movie_model_sync

    # Skip mock engines
    if getattr(session.bind.dialect, "name", "") == "mock":
        pytest.skip("Mock engines not supported for filter tests")

    # Clean any existing data first, then setup fresh data
    if getattr(session.bind.dialect, "name", "") != "mock":
        session.execute(Movie.__table__.delete())
        session.commit()
    setup_movie_data(session, Movie)
    order_by_filter = OrderBy(field_name="release_date", sort_order="asc")
    statement = order_by_filter.append_to_statement(select(Movie), Movie)
    results = session.execute(statement).scalars().all()
    assert results[0].title == "Shawshank Redemption"
    order_by_filter = OrderBy(field_name="release_date", sort_order="desc")
    statement = order_by_filter.append_to_statement(select(Movie), Movie)
    results = session.execute(statement).scalars().all()
    assert results[0].title == "The Hangover"


def test_order_by_with_func_random(session: Session, movie_model_sync: type[DeclarativeBase]) -> None:
    """Test OrderBy filter with func.random() expression."""
    Movie = movie_model_sync

    # Skip mock engines
    dialect_name = getattr(session.bind.dialect, "name", "")
    if dialect_name == "mock":
        pytest.skip("Mock engines not supported for filter tests")

    # Skip Oracle - uses dbms_random.value() instead of random()
    if dialect_name.startswith("oracle"):
        pytest.skip("Oracle uses dbms_random.value() instead of random()")

    # Clean any existing data first, then setup fresh data
    if dialect_name != "mock":
        session.execute(Movie.__table__.delete())
        session.commit()
    setup_movie_data(session, Movie)

    # Test func.random() - should not raise type error
    order_by_filter = OrderBy(field_name=func.random())
    statement = order_by_filter.append_to_statement(select(Movie), Movie)
    results = session.execute(statement).scalars().all()
    # Should return all movies, order is random
    assert len(results) == 3


def test_order_by_with_func_lower(session: Session, movie_model_sync: type[DeclarativeBase]) -> None:
    """Test OrderBy filter with func.lower() for case-insensitive sorting."""
    Movie = movie_model_sync

    # Skip mock engines
    if getattr(session.bind.dialect, "name", "") == "mock":
        pytest.skip("Mock engines not supported for filter tests")

    # Clean any existing data first, then setup fresh data
    if getattr(session.bind.dialect, "name", "") != "mock":
        session.execute(Movie.__table__.delete())
        session.commit()
    setup_movie_data(session, Movie)

    # Test func.lower() for case-insensitive alphabetical sorting
    order_by_filter = OrderBy(field_name=func.lower(Movie.title), sort_order="asc")
    statement = order_by_filter.append_to_statement(select(Movie), Movie)
    results = session.execute(statement).scalars().all()
    # Should be sorted alphabetically: Shawshank, The Hangover, The Matrix
    assert results[0].title == "Shawshank Redemption"
    assert results[1].title == "The Hangover"
    assert results[2].title == "The Matrix"


def test_order_by_with_instrumented_attribute(session: Session, movie_model_sync: type[DeclarativeBase]) -> None:
    """Test OrderBy filter with InstrumentedAttribute (Model.field)."""
    Movie = movie_model_sync

    # Skip mock engines
    if getattr(session.bind.dialect, "name", "") == "mock":
        pytest.skip("Mock engines not supported for filter tests")

    # Clean any existing data first, then setup fresh data
    if getattr(session.bind.dialect, "name", "") != "mock":
        session.execute(Movie.__table__.delete())
        session.commit()
    setup_movie_data(session, Movie)

    # Test with InstrumentedAttribute (backward compatibility)
    order_by_filter = OrderBy(field_name=Movie.release_date, sort_order="asc")
    statement = order_by_filter.append_to_statement(select(Movie), Movie)
    results = session.execute(statement).scalars().all()
    assert results[0].title == "Shawshank Redemption"


def test_search_filter(session: Session, movie_model_sync: type[DeclarativeBase]) -> None:
    Movie = movie_model_sync

    # Skip mock engines
    if getattr(session.bind.dialect, "name", "") == "mock":
        pytest.skip("Mock engines not supported for filter tests")

    # Clean any existing data first, then setup fresh data
    if getattr(session.bind.dialect, "name", "") != "mock":
        session.execute(Movie.__table__.delete())
        session.commit()
    setup_movie_data(session, Movie)
    search_filter = SearchFilter(field_name="title", value="Hangover")
    statement = search_filter.append_to_statement(select(Movie), Movie)
    results = session.execute(statement).scalars().all()
    assert len(results) == 1


def test_filter_group_logical_operators(session: Session, movie_model_sync: type[DeclarativeBase]) -> None:
    Movie = movie_model_sync

    # Skip mock engines
    if getattr(session.bind.dialect, "name", "") == "mock":
        pytest.skip("Mock engines not supported for filter tests")

    # Clean any existing data first, then setup fresh data
    if getattr(session.bind.dialect, "name", "") != "mock":
        session.execute(Movie.__table__.delete())
        session.commit()
    setup_movie_data(session, Movie)
    # Test AND operator
    before_2000 = BeforeAfter(field_name="release_date", before=datetime(2000, 1, 1, tzinfo=timezone.utc), after=None)
    has_the_in_title = SearchFilter(field_name="title", value="The", ignore_case=True)

    # Should match only "The Matrix" (before 2000 AND has "The" in title)
    and_filter_group = FilterGroup(
        logical_operator=and_,
        filters=[before_2000, has_the_in_title],
    )

    statement = and_filter_group.append_to_statement(select(Movie), Movie)
    results = session.execute(statement).scalars().all()
    assert len(results) == 1
    assert results[0].title == "The Matrix"

    # Test OR operator
    drama_filter = SearchFilter(field_name="genre", value="Drama", ignore_case=True)

    # Should match "The Matrix", "Shawshank Redemption" (before 2000 OR is drama)
    or_filter_group = FilterGroup(
        logical_operator=or_,
        filters=[before_2000, drama_filter],
    )

    statement = or_filter_group.append_to_statement(select(Movie), Movie)
    results = session.execute(statement).scalars().all()
    assert len(results) == 2
    assert {r.title for r in results} == {"The Matrix", "Shawshank Redemption"}


def test_multi_filter_basic(session: Session, movie_model_sync: type[DeclarativeBase]) -> None:
    Movie = movie_model_sync

    # Skip mock engines
    if getattr(session.bind.dialect, "name", "") == "mock":
        pytest.skip("Mock engines not supported for filter tests")

    # Clean any existing data first, then setup fresh data
    if getattr(session.bind.dialect, "name", "") != "mock":
        session.execute(Movie.__table__.delete())
        session.commit()
    setup_movie_data(session, Movie)
    # Test basic MultiFilter with AND condition
    multi_filter = MultiFilter(
        filters={
            "and_": [
                {
                    "type": "before_after",
                    "field_name": "release_date",
                    "before": datetime(2000, 1, 1, tzinfo=timezone.utc),
                    "after": None,
                },
                {"type": "search", "field_name": "title", "value": "The", "ignore_case": True},
            ]
        }
    )

    statement = multi_filter.append_to_statement(select(Movie), Movie)
    results = session.execute(statement).scalars().all()
    assert len(results) == 1
    assert results[0].title == "The Matrix"

    # Test basic MultiFilter with OR condition
    multi_filter = MultiFilter(
        filters={
            "or_": [
                {
                    "type": "before_after",
                    "field_name": "release_date",
                    "before": datetime(2000, 1, 1, tzinfo=timezone.utc),
                    "after": None,
                },
                {"type": "search", "field_name": "genre", "value": "Drama", "ignore_case": True},
            ]
        }
    )

    statement = multi_filter.append_to_statement(select(Movie), Movie)
    results = session.execute(statement).scalars().all()
    assert len(results) == 2
    assert {r.title for r in results} == {"The Matrix", "Shawshank Redemption"}


def test_multi_filter_nested(session: Session, movie_model_sync: type[DeclarativeBase]) -> None:
    Movie = movie_model_sync

    # Skip mock engines
    if getattr(session.bind.dialect, "name", "") == "mock":
        pytest.skip("Mock engines not supported for filter tests")

    # Clean any existing data first, then setup fresh data
    if getattr(session.bind.dialect, "name", "") != "mock":
        session.execute(Movie.__table__.delete())
        session.commit()
    setup_movie_data(session, Movie)
    # Test nested AND/OR conditions
    multi_filter = MultiFilter(
        filters={
            "or_": [
                # Match any comedy movie
                {"type": "search", "field_name": "genre", "value": "Comedy", "ignore_case": True},
                # OR match any movie from before 2000 that has "The" in title
                {
                    "and_": [
                        {
                            "type": "before_after",
                            "field_name": "release_date",
                            "before": datetime(2000, 1, 1, tzinfo=timezone.utc),
                            "after": None,
                        },
                        {"type": "search", "field_name": "title", "value": "The", "ignore_case": True},
                    ]
                },
            ]
        }
    )

    statement = multi_filter.append_to_statement(select(Movie), Movie)
    results = session.execute(statement).scalars().all()
    assert len(results) == 2
    assert {r.title for r in results} == {"The Matrix", "The Hangover"}


def test_multi_filter_empty_filters(session: Session, movie_model_sync: type[DeclarativeBase]) -> None:
    Movie = movie_model_sync

    # Skip mock engines
    if getattr(session.bind.dialect, "name", "") == "mock":
        pytest.skip("Mock engines not supported for filter tests")

    # Clean any existing data first, then setup fresh data
    if getattr(session.bind.dialect, "name", "") != "mock":
        session.execute(Movie.__table__.delete())
        session.commit()
    setup_movie_data(session, Movie)
    """Test MultiFilter with empty filter lists."""
    # Test with empty filter list
    multi_filter = MultiFilter(filters={"and_": []})
    statement = multi_filter.append_to_statement(select(Movie), Movie)
    results = session.execute(statement).scalars().all()
    # Should return all movies since no filters are applied
    assert len(results) == 3

    # Test with empty filters dict
    multi_filter = MultiFilter(filters={})
    statement = multi_filter.append_to_statement(select(Movie), Movie)
    results = session.execute(statement).scalars().all()
    # Should return all movies since no filters are applied
    assert len(results) == 3


def test_multi_filter_invalid_filter_type(session: Session, movie_model_sync: type[DeclarativeBase]) -> None:
    Movie = movie_model_sync

    # Skip mock engines
    if getattr(session.bind.dialect, "name", "") == "mock":
        pytest.skip("Mock engines not supported for filter tests")

    # Clean any existing data first, then setup fresh data
    if getattr(session.bind.dialect, "name", "") != "mock":
        session.execute(Movie.__table__.delete())
        session.commit()
    setup_movie_data(session, Movie)
    """Test MultiFilter with invalid filter types."""
    # Test with non-existent filter type
    multi_filter = MultiFilter(
        filters={
            "and_": [
                {
                    "type": "non_existent_filter",
                    "field_name": "title",
                    "value": "The Matrix",
                }
            ]
        }
    )
    statement = multi_filter.append_to_statement(select(Movie), Movie)
    results = session.execute(statement).scalars().all()
    # Should return all movies since invalid filter is ignored
    assert len(results) == 3

    # Test with missing type field
    multi_filter = MultiFilter(
        filters={
            "and_": [
                {
                    "field_name": "title",
                    "value": "The Matrix",
                }
            ]
        }
    )
    statement = multi_filter.append_to_statement(select(Movie), Movie)
    results = session.execute(statement).scalars().all()
    # Should return all movies since invalid filter is ignored
    assert len(results) == 3


def test_multi_filter_invalid_filter_args(session: Session, movie_model_sync: type[DeclarativeBase]) -> None:
    Movie = movie_model_sync

    # Skip mock engines
    if getattr(session.bind.dialect, "name", "") == "mock":
        pytest.skip("Mock engines not supported for filter tests")

    # Clean any existing data first, then setup fresh data
    if getattr(session.bind.dialect, "name", "") != "mock":
        session.execute(Movie.__table__.delete())
        session.commit()
    setup_movie_data(session, Movie)
    """Test MultiFilter with invalid filter arguments."""
    # Test with missing required field
    multi_filter = MultiFilter(
        filters={
            "and_": [
                {
                    "type": "search",
                    # Missing field_name
                    "value": "The Matrix",
                }
            ]
        }
    )
    statement = multi_filter.append_to_statement(select(Movie), Movie)
    results = session.execute(statement).scalars().all()
    # Should return all movies since invalid filter is ignored
    assert len(results) == 3

    multi_filter = MultiFilter(
        filters={
            "and_": [
                {
                    "type": "search",
                    "field_name": "non_existent_field",
                    "value": "The Matrix",
                }
            ]
        }
    )
    statement = multi_filter.append_to_statement(select(Movie), Movie)
    results = session.execute(statement).scalars().all()
    # Should return all movies since invalid filter is ignored
    assert len(results) == 3


def test_multi_filter_invalid_logical_operator(session: Session, movie_model_sync: type[DeclarativeBase]) -> None:
    Movie = movie_model_sync

    # Skip mock engines
    if getattr(session.bind.dialect, "name", "") == "mock":
        pytest.skip("Mock engines not supported for filter tests")

    # Clean any existing data first, then setup fresh data
    if getattr(session.bind.dialect, "name", "") != "mock":
        session.execute(Movie.__table__.delete())
        session.commit()
    setup_movie_data(session, Movie)
    """Test MultiFilter with invalid logical operators."""
    # Test with non-existent logical operator
    multi_filter = MultiFilter(
        filters={
            "invalid_operator": [
                {
                    "type": "search",
                    "field_name": "title",
                    "value": "The Matrix",
                }
            ]
        }
    )
    statement = multi_filter.append_to_statement(select(Movie), Movie)
    results = session.execute(statement).scalars().all()
    # Should return all movies since invalid operator is ignored
    assert len(results) == 3


def test_multi_filter_complex_nested(session: Session, movie_model_sync: type[DeclarativeBase]) -> None:
    Movie = movie_model_sync

    # Skip mock engines
    if getattr(session.bind.dialect, "name", "") == "mock":
        pytest.skip("Mock engines not supported for filter tests")

    # Clean any existing data first, then setup fresh data
    if getattr(session.bind.dialect, "name", "") != "mock":
        session.execute(Movie.__table__.delete())
        session.commit()
    setup_movie_data(session, Movie)
    """Test MultiFilter with complex nested conditions."""
    multi_filter = MultiFilter(
        filters={
            "and_": [
                # First condition: Movie is from before 2000
                {
                    "type": "before_after",
                    "field_name": "release_date",
                    "before": datetime(2000, 1, 1, tzinfo=timezone.utc),
                    "after": None,
                },
                # Second condition: Nested OR group
                {
                    "or_": [
                        # Movie has "The" in title
                        {"type": "search", "field_name": "title", "value": "The", "ignore_case": True},
                        # OR movie is a drama
                        {"type": "search", "field_name": "genre", "value": "Drama", "ignore_case": True},
                    ]
                },
            ]
        }
    )

    statement = multi_filter.append_to_statement(select(Movie), Movie)
    results = session.execute(statement).scalars().all()
    # Should match "The Matrix" (before 2000 AND has "The" in title)
    # and "Shawshank Redemption" (before 2000 AND is a drama)
    assert len(results) == 2
    assert {r.title for r in results} == {"The Matrix", "Shawshank Redemption"}


def test_multi_filter_all_filter_types(session: Session, movie_model_sync: type[DeclarativeBase]) -> None:
    Movie = movie_model_sync

    # Skip mock engines
    if getattr(session.bind.dialect, "name", "") == "mock":
        pytest.skip("Mock engines not supported for filter tests")

    # Skip Spanner - has issues with complex multi-filter queries
    if getattr(session.bind.dialect, "name", "") == "spanner+spanner":
        pytest.skip("Spanner has issues with complex multi-filter queries")

    # Clean any existing data first, then setup fresh data
    if getattr(session.bind.dialect, "name", "") != "mock":
        session.execute(Movie.__table__.delete())
        session.commit()
    setup_movie_data(session, Movie)
    """Test MultiFilter with all supported filter types."""
    multi_filter = MultiFilter(
        filters={
            "or_": [
                # BeforeAfter filter
                {
                    "type": "before_after",
                    "field_name": "release_date",
                    "before": datetime(2000, 1, 1, tzinfo=timezone.utc),
                    "after": None,
                },
                # OnBeforeAfter filter
                {
                    "type": "on_before_after",
                    "field_name": "release_date",
                    "on_or_before": datetime(2009, 6, 1, tzinfo=timezone.utc),
                    "on_or_after": None,
                },
                # CollectionFilter
                {
                    "type": "collection",
                    "field_name": "title",
                    "values": ["The Matrix", "Shawshank Redemption"],
                },
                # NotInCollectionFilter
                {
                    "type": "not_in_collection",
                    "field_name": "title",
                    "values": ["The Hangover"],
                },
                # SearchFilter
                {
                    "type": "search",
                    "field_name": "title",
                    "value": "Matrix",
                    "ignore_case": True,
                },
                # NotInSearchFilter
                {
                    "type": "not_in_search",
                    "field_name": "title",
                    "value": "Hangover",
                    "ignore_case": True,
                },
                # ComparisonFilter
                {
                    "type": "comparison",
                    "field_name": "genre",
                    "operator": "eq",
                    "value": "Action",
                },
                # ExistsFilter
                {
                    "type": "exists",
                    "values": [Movie.genre == "Comedy"],
                },
                # NotExistsFilter
                {
                    "type": "not_exists",
                    "values": [Movie.genre == "SciFi"],
                },
            ]
        }
    )

    statement = multi_filter.append_to_statement(select(Movie), Movie)
    results = session.execute(statement).scalars().all()
    # Should match all movies since at least one condition is true for each
    assert len(results) == 3
    assert {r.title for r in results} == {"The Matrix", "The Hangover", "Shawshank Redemption"}


def test_comparison_filter(session: Session, movie_model_sync: type[DeclarativeBase]) -> None:
    Movie = movie_model_sync

    # Skip mock engines
    if getattr(session.bind.dialect, "name", "") == "mock":
        pytest.skip("Mock engines not supported for filter tests")

    # Clean any existing data first, then setup fresh data
    if getattr(session.bind.dialect, "name", "") != "mock":
        session.execute(Movie.__table__.delete())
        session.commit()
    setup_movie_data(session, Movie)
    """Test ComparisonFilter with various operators."""
    # Test equality operator
    eq_filter = ComparisonFilter(field_name="genre", operator="eq", value="Action")
    statement = eq_filter.append_to_statement(select(Movie), Movie)
    results = session.execute(statement).scalars().all()
    assert len(results) == 1
    assert results[0].title == "The Matrix"

    # Test inequality operator
    ne_filter = ComparisonFilter(field_name="genre", operator="ne", value="Action")
    statement = ne_filter.append_to_statement(select(Movie), Movie)
    results = session.execute(statement).scalars().all()
    assert len(results) == 2
    assert {r.title for r in results} == {"The Hangover", "Shawshank Redemption"}

    # Test greater than operator
    gt_filter = ComparisonFilter(
        field_name="release_date", operator="gt", value=datetime(2000, 1, 1, tzinfo=timezone.utc)
    )
    statement = gt_filter.append_to_statement(select(Movie), Movie)
    results = session.execute(statement).scalars().all()
    assert len(results) == 1
    assert results[0].title == "The Hangover"

    # Test less than operator
    lt_filter = ComparisonFilter(
        field_name="release_date", operator="lt", value=datetime(2000, 1, 1, tzinfo=timezone.utc)
    )
    statement = lt_filter.append_to_statement(select(Movie), Movie)
    results = session.execute(statement).scalars().all()
    assert len(results) == 2
    assert {r.title for r in results} == {"The Matrix", "Shawshank Redemption"}

    # Test greater than or equal operator
    ge_filter = ComparisonFilter(
        field_name="release_date", operator="ge", value=datetime(1999, 3, 31, tzinfo=timezone.utc)
    )
    statement = ge_filter.append_to_statement(select(Movie), Movie)
    results = session.execute(statement).scalars().all()
    assert len(results) == 2
    assert {r.title for r in results} == {"The Matrix", "The Hangover"}

    # Test less than or equal operator
    le_filter = ComparisonFilter(
        field_name="release_date", operator="le", value=datetime(1999, 3, 31, tzinfo=timezone.utc)
    )
    statement = le_filter.append_to_statement(select(Movie), Movie)
    results = session.execute(statement).scalars().all()
    assert len(results) == 2
    assert {r.title for r in results} == {"The Matrix", "Shawshank Redemption"}

    # Test invalid operator (should raise ValueError)
    invalid_filter = ComparisonFilter(field_name="genre", operator="invalid", value="Action")
    with pytest.raises(ValueError) as exc_info:
        invalid_filter.append_to_statement(select(Movie), Movie)
    assert "Invalid operator 'invalid'" in str(exc_info.value)
    assert "Must be one of:" in str(exc_info.value)

    # Test invalid operator with common mistake (using '=' instead of 'eq')
    invalid_filter = ComparisonFilter(field_name="genre", operator="=", value="Action")
    with pytest.raises(ValueError) as exc_info:
        invalid_filter.append_to_statement(select(Movie), Movie)
    assert "Invalid operator '='" in str(exc_info.value)
    assert "Must be one of:" in str(exc_info.value)

    # Test invalid operator with empty string
    invalid_filter = ComparisonFilter(field_name="genre", operator="", value="Action")
    with pytest.raises(ValueError) as exc_info:
        invalid_filter.append_to_statement(select(Movie), Movie)
    assert "Invalid operator ''" in str(exc_info.value)
    assert "Must be one of:" in str(exc_info.value)


def test_collection_filter_prefer_any(session: Session, movie_model_sync: type[DeclarativeBase]) -> None:
    Movie = movie_model_sync

    # Skip mock engines
    if getattr(session.bind.dialect, "name", "") == "mock":
        pytest.skip("Mock engines not supported for filter tests")

    # Skip Spanner - has issues with ANY operator
    if getattr(session.bind.dialect, "name", "") == "spanner+spanner":
        pytest.skip("Spanner has issues with ANY operator")

    # Clean any existing data first, then setup fresh data
    if getattr(session.bind.dialect, "name", "") != "mock":
        session.execute(Movie.__table__.delete())
        session.commit()
    setup_movie_data(session, Movie)
    """Test CollectionFilter with prefer_any parameter."""
    # Test with prefer_any=False (default, using IN)
    collection_filter: CollectionFilter[str] = CollectionFilter(
        field_name="title", values=["The Matrix", "Shawshank Redemption"]
    )
    statement = collection_filter.append_to_statement(select(Movie), Movie)
    results = session.execute(statement).scalars().all()
    assert len(results) == 2
    assert {r.title for r in results} == {"The Matrix", "Shawshank Redemption"}

    # Test with prefer_any=True (using ANY)
    # Only PostgreSQL properly supports the ANY operator with array parameters
    dialect_name = getattr(session.bind.dialect, "name", "")
    if dialect_name in ("postgresql", "psycopg", "asyncpg", "cockroachdb"):
        collection_filter = CollectionFilter[str](field_name="title", values=["The Matrix", "Shawshank Redemption"])
        statement = collection_filter.append_to_statement(select(Movie), Movie, prefer_any=True)
        results = session.execute(statement).scalars().all()
        assert len(results) == 2
        assert {r.title for r in results} == {"The Matrix", "Shawshank Redemption"}

    # Test with empty collection
    collection_filter = CollectionFilter[str](field_name="title", values=[])
    statement = collection_filter.append_to_statement(select(Movie), Movie)
    results = session.execute(statement).scalars().all()
    assert len(results) == 0

    # Test with None values
    collection_filter = CollectionFilter[str](field_name="title", values=None)
    statement = collection_filter.append_to_statement(select(Movie), Movie)
    results = session.execute(statement).scalars().all()
    assert len(results) == 3  # Should return all movies


def test_not_in_collection_filter_prefer_any(session: Session, movie_model_sync: type[DeclarativeBase]) -> None:
    Movie = movie_model_sync

    # Skip mock engines
    if getattr(session.bind.dialect, "name", "") == "mock":
        pytest.skip("Mock engines not supported for filter tests")

    # Skip Spanner - has issues with ANY operator
    if getattr(session.bind.dialect, "name", "") == "spanner+spanner":
        pytest.skip("Spanner has issues with ANY operator")

    # Clean any existing data first, then setup fresh data
    if getattr(session.bind.dialect, "name", "") != "mock":
        session.execute(Movie.__table__.delete())
        session.commit()
    setup_movie_data(session, Movie)
    """Test NotInCollectionFilter with prefer_any parameter."""
    # Test with prefer_any=False (default, using NOT IN)
    not_in_collection_filter: NotInCollectionFilter[str] = NotInCollectionFilter(
        field_name="title", values=["The Hangover"]
    )
    statement = not_in_collection_filter.append_to_statement(select(Movie), Movie)
    results = session.execute(statement).scalars().all()
    assert len(results) == 2
    assert {r.title for r in results} == {"The Matrix", "Shawshank Redemption"}

    # Test with prefer_any=True (using != ANY)
    # Only PostgreSQL properly supports the ANY operator with array parameters
    dialect_name = getattr(session.bind.dialect, "name", "")
    if dialect_name in ("postgresql", "psycopg", "asyncpg"):
        not_in_collection_filter = NotInCollectionFilter[str](field_name="title", values=["The Hangover"])
        statement = not_in_collection_filter.append_to_statement(select(Movie), Movie, prefer_any=True)
        results = session.execute(statement).scalars().all()
        assert len(results) == 2
        assert {r.title for r in results} == {"The Matrix", "Shawshank Redemption"}

    # Test with empty collection
    not_in_collection_filter = NotInCollectionFilter[str](field_name="title", values=[])
    statement = not_in_collection_filter.append_to_statement(select(Movie), Movie)
    results = session.execute(statement).scalars().all()
    assert len(results) == 3  # Should return all movies

    # Test with None values
    not_in_collection_filter = NotInCollectionFilter[str](field_name="title", values=None)
    statement = not_in_collection_filter.append_to_statement(select(Movie), Movie)
    results = session.execute(statement).scalars().all()
    assert len(results) == 3  # Should return all movies


def test_null_filter(session: Session, movie_model_sync: type[DeclarativeBase]) -> None:
    """Test NullFilter matches NULL records."""
    Movie = movie_model_sync

    # Skip mock engines
    if getattr(session.bind.dialect, "name", "") == "mock":
        pytest.skip("Mock engines not supported for filter tests")

    # Clean any existing data first, then setup fresh data
    if getattr(session.bind.dialect, "name", "") != "mock":
        session.execute(Movie.__table__.delete())
        session.commit()
    setup_movie_data(session, Movie)

    # Test IS NULL filter on director field
    null_filter = NullFilter("director")
    statement = null_filter.append_to_statement(select(Movie), Movie)
    results = session.execute(statement).scalars().all()

    # Should return only "The Hangover" which has NULL director
    assert len(results) == 1
    assert results[0].title == "The Hangover"
    assert results[0].director is None


def test_not_null_filter(session: Session, movie_model_sync: type[DeclarativeBase]) -> None:
    """Test NotNullFilter matches NOT NULL records."""
    Movie = movie_model_sync

    # Skip mock engines
    if getattr(session.bind.dialect, "name", "") == "mock":
        pytest.skip("Mock engines not supported for filter tests")

    # Clean any existing data first, then setup fresh data
    if getattr(session.bind.dialect, "name", "") != "mock":
        session.execute(Movie.__table__.delete())
        session.commit()
    setup_movie_data(session, Movie)

    # Test NotNullFilter on director field
    not_null_filter = NotNullFilter("director")
    statement = not_null_filter.append_to_statement(select(Movie), Movie)
    results = session.execute(statement).scalars().all()

    # Should return "The Matrix" and "Shawshank Redemption" which have directors
    assert len(results) == 2
    assert {r.title for r in results} == {"The Matrix", "Shawshank Redemption"}


def test_null_filter_combined_with_other_filters(session: Session, movie_model_sync: type[DeclarativeBase]) -> None:
    """Test NullFilter combined with other filters using AND logic."""
    Movie = movie_model_sync

    # Skip mock engines
    if getattr(session.bind.dialect, "name", "") == "mock":
        pytest.skip("Mock engines not supported for filter tests")

    # Clean any existing data first, then setup fresh data
    if getattr(session.bind.dialect, "name", "") != "mock":
        session.execute(Movie.__table__.delete())
        session.commit()
    setup_movie_data(session, Movie)

    # Test NullFilter combined with CollectionFilter
    # Find movies with a director AND genre is Action or Drama
    not_null_filter = NotNullFilter("director")
    collection_filter = CollectionFilter("genre", ["Action", "Drama"])

    statement = select(Movie)
    statement = not_null_filter.append_to_statement(statement, Movie)
    statement = collection_filter.append_to_statement(statement, Movie)
    results = session.execute(statement).scalars().all()

    # Should return "The Matrix" (Action) and "Shawshank Redemption" (Drama)
    assert len(results) == 2
    assert {r.title for r in results} == {"The Matrix", "Shawshank Redemption"}


def test_null_filter_with_multi_filter(session: Session, movie_model_sync: type[DeclarativeBase]) -> None:
    """Test NullFilter with MultiFilter JSON/dict input."""
    Movie = movie_model_sync

    # Skip mock engines
    if getattr(session.bind.dialect, "name", "") == "mock":
        pytest.skip("Mock engines not supported for filter tests")

    # Clean any existing data first, then setup fresh data
    if getattr(session.bind.dialect, "name", "") != "mock":
        session.execute(Movie.__table__.delete())
        session.commit()
    setup_movie_data(session, Movie)

    # Test MultiFilter with null filter
    multi_filter = MultiFilter(
        filters={
            "and_": [
                {"type": "null", "field_name": "director"},
            ]
        }
    )

    statement = multi_filter.append_to_statement(select(Movie), Movie)
    results = session.execute(statement).scalars().all()

    # Should return only "The Hangover" with NULL director
    assert len(results) == 1
    assert results[0].title == "The Hangover"


def test_not_null_filter_with_multi_filter(session: Session, movie_model_sync: type[DeclarativeBase]) -> None:
    """Test NotNullFilter with MultiFilter JSON/dict input."""
    Movie = movie_model_sync

    # Skip mock engines
    if getattr(session.bind.dialect, "name", "") == "mock":
        pytest.skip("Mock engines not supported for filter tests")

    # Clean any existing data first, then setup fresh data
    if getattr(session.bind.dialect, "name", "") != "mock":
        session.execute(Movie.__table__.delete())
        session.commit()
    setup_movie_data(session, Movie)

    # Test MultiFilter with not_null filter
    multi_filter = MultiFilter(
        filters={
            "and_": [
                {"type": "not_null", "field_name": "director"},
            ]
        }
    )

    statement = multi_filter.append_to_statement(select(Movie), Movie)
    results = session.execute(statement).scalars().all()

    # Should return "The Matrix" and "Shawshank Redemption" with directors
    assert len(results) == 2
    assert {r.title for r in results} == {"The Matrix", "Shawshank Redemption"}


def test_null_filter_empty_result(session: Session, movie_model_sync: type[DeclarativeBase]) -> None:
    """Test NullFilter returns empty list when no matches."""
    Movie = movie_model_sync

    # Skip mock engines
    if getattr(session.bind.dialect, "name", "") == "mock":
        pytest.skip("Mock engines not supported for filter tests")

    # Clean any existing data first, then setup fresh data
    if getattr(session.bind.dialect, "name", "") != "mock":
        session.execute(Movie.__table__.delete())
        session.commit()
    setup_movie_data(session, Movie)

    # Test NullFilter on title field (which is never NULL)
    null_filter = NullFilter("title")
    statement = null_filter.append_to_statement(select(Movie), Movie)
    results = session.execute(statement).scalars().all()

    # Should return empty list since no titles are NULL
    assert len(results) == 0


def test_null_filter_with_instrumented_attribute(session: Session, movie_model_sync: type[DeclarativeBase]) -> None:
    """Test NullFilter with InstrumentedAttribute (Model.field)."""
    Movie = movie_model_sync

    # Skip mock engines
    if getattr(session.bind.dialect, "name", "") == "mock":
        pytest.skip("Mock engines not supported for filter tests")

    # Clean any existing data first, then setup fresh data
    if getattr(session.bind.dialect, "name", "") != "mock":
        session.execute(Movie.__table__.delete())
        session.commit()
    setup_movie_data(session, Movie)

    # Test with InstrumentedAttribute
    null_filter = NullFilter(Movie.director)
    statement = null_filter.append_to_statement(select(Movie), Movie)
    results = session.execute(statement).scalars().all()

    # Should return only "The Hangover" which has NULL director
    assert len(results) == 1
    assert results[0].title == "The Hangover"


# Session-level teardown to ensure tables are dropped
@pytest.fixture(scope="session", autouse=True)
def cleanup_filter_tables(request: FixtureRequest) -> Generator[None, None, None]:
    """Ensure all filter test tables are dropped at session end."""
    yield

    # Clean up all cached tables at session end
    for cache_key, model in _movie_model_cache.items():
        # Tables are cleaned up by individual engine fixtures
        pass
python-advanced-alchemy-1.9.3/tests/integration/test_inheritance.py000066400000000000000000000500411516556515500256350ustar00rootroot00000000000000"""Tests for SQLAlchemy inheritance pattern support.

This module tests all three SQLAlchemy inheritance patterns:
- Single Table Inheritance (STI)
- Joined Table Inheritance (JTI)
- Concrete Table Inheritance (CTI)
"""

import datetime
from typing import Optional

import pytest
from sqlalchemy import ForeignKey, MetaData, select
from sqlalchemy.engine import Engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column

from advanced_alchemy import base

# ============================================================================
# Single Table Inheritance (STI) Tests
# ============================================================================


@pytest.mark.integration
def test_sti_basic_table_names() -> None:
    """STI: Child classes use parent table name (auto-generated)."""
    # Create isolated base with unique metadata
    test_metadata = MetaData()

    class LocalBase(DeclarativeBase):
        metadata = test_metadata

    # No explicit __tablename__ - let CommonTableAttributes generate it
    class STIEmployee(base.CommonTableAttributes, LocalBase):
        id: Mapped[int] = mapped_column(primary_key=True)
        type: Mapped[str]
        name: Mapped[str]
        __mapper_args__ = {"polymorphic_on": "type", "polymorphic_identity": "employee"}

    class STIManager(STIEmployee):
        department: Mapped[Optional[str]] = mapped_column(nullable=True)
        __mapper_args__ = {"polymorphic_identity": "manager"}

    class STIEngineer(STIEmployee):
        programming_language: Mapped[Optional[str]] = mapped_column(nullable=True)
        __mapper_args__ = {"polymorphic_identity": "engineer"}

    # Verify all use same table (auto-generated from parent class name)
    expected_name = "sti_employee"  # snake_case of STIEmployee
    assert STIEmployee.__table__.name == expected_name
    assert STIManager.__table__.name == expected_name
    assert STIEngineer.__table__.name == expected_name
    assert STIManager.__table__ is STIEmployee.__table__  # Same table object


@pytest.mark.integration
def test_sti_table_columns() -> None:
    """STI: Single table contains all columns from hierarchy."""
    test_metadata = MetaData()

    class LocalBase(DeclarativeBase):
        metadata = test_metadata

    class Employee(base.CommonTableAttributes, LocalBase):
        __tablename__ = "sti_employee_cols"
        id: Mapped[int] = mapped_column(primary_key=True)
        type: Mapped[str]
        name: Mapped[str]
        __mapper_args__ = {"polymorphic_on": "type", "polymorphic_identity": "employee"}

    class Manager(Employee):
        department: Mapped[Optional[str]] = mapped_column(default=None)
        __mapper_args__ = {"polymorphic_identity": "manager"}

    class Engineer(Employee):
        programming_language: Mapped[Optional[str]] = mapped_column(default=None)
        __mapper_args__ = {"polymorphic_identity": "engineer"}

    # Verify columns exist in single table
    columns = {col.name for col in Employee.__table__.columns}
    assert "type" in columns
    assert "name" in columns
    assert "department" in columns
    assert "programming_language" in columns


@pytest.mark.integration
def test_sti_multi_level() -> None:
    """STI: Three levels of inheritance share one table."""
    test_metadata = MetaData()

    class LocalBase(DeclarativeBase):
        metadata = test_metadata

    class Employee(base.CommonTableAttributes, LocalBase):
        __tablename__ = "sti_employee_ml"
        id: Mapped[int] = mapped_column(primary_key=True)
        type: Mapped[str]
        __mapper_args__ = {"polymorphic_on": "type", "polymorphic_identity": "employee"}

    class Manager(Employee):
        department: Mapped[Optional[str]] = mapped_column(default=None)
        __mapper_args__ = {"polymorphic_identity": "manager"}

    class SeniorManager(Manager):
        budget: Mapped[Optional[int]] = mapped_column(default=None)
        __mapper_args__ = {"polymorphic_identity": "senior_manager"}

    # All three levels use same table
    assert Employee.__table__.name == "sti_employee_ml"
    assert Manager.__table__.name == "sti_employee_ml"
    assert SeniorManager.__table__.name == "sti_employee_ml"


@pytest.mark.integration
@pytest.mark.sqlite
def test_sti_crud_operations(sqlite_engine: Engine) -> None:
    """STI: CRUD operations work correctly with polymorphic models."""
    from sqlalchemy.orm import Session as SessionType

    # Create fresh metadata and registry for this test
    test_metadata = MetaData()

    class LocalBase(DeclarativeBase):
        metadata = test_metadata

    class Employee(base.CommonTableAttributes, LocalBase):
        __tablename__ = "sti_employee_crud"
        id: Mapped[int] = mapped_column(primary_key=True)
        type: Mapped[str]
        name: Mapped[str]
        __mapper_args__ = {"polymorphic_on": "type", "polymorphic_identity": "employee"}

    class Manager(Employee):
        department: Mapped[Optional[str]] = mapped_column(default=None)
        __mapper_args__ = {"polymorphic_identity": "manager"}

    class Engineer(Employee):
        programming_language: Mapped[Optional[str]] = mapped_column(default=None)
        __mapper_args__ = {"polymorphic_identity": "engineer"}

    # Create tables
    test_metadata.create_all(sqlite_engine)

    try:
        with SessionType(sqlite_engine) as test_session:
            # Create instances
            manager = Manager(name="Alice", department="Engineering", type="manager")
            engineer = Engineer(name="Bob", programming_language="Python", type="engineer")
            employee = Employee(name="Charlie", type="employee")

            test_session.add_all([manager, engineer, employee])
            test_session.commit()

            # Query all employees
            all_employees = test_session.execute(select(Employee)).scalars().all()
            assert len(all_employees) == 3

            # Query specific type
            managers = test_session.execute(select(Manager)).scalars().all()
            assert len(managers) == 1
            assert isinstance(managers[0], Manager)
            assert managers[0].department == "Engineering"

            # Polymorphic identity check
            retrieved_manager = test_session.execute(select(Employee).where(Employee.name == "Alice")).scalar_one()
            assert isinstance(retrieved_manager, Manager)
            assert retrieved_manager.department == "Engineering"
    finally:
        test_metadata.drop_all(sqlite_engine)


# ============================================================================
# Joined Table Inheritance (JTI) Tests
# ============================================================================


@pytest.mark.integration
def test_jti_basic() -> None:
    """JTI: Child has separate table with foreign key."""
    test_metadata = MetaData()

    class LocalBase(DeclarativeBase):
        metadata = test_metadata

    class Employee(base.CommonTableAttributes, LocalBase):
        __tablename__ = "jti_employee"
        id: Mapped[int] = mapped_column(primary_key=True)
        type: Mapped[str]
        name: Mapped[str]
        __mapper_args__ = {"polymorphic_on": "type", "polymorphic_identity": "employee"}

    class Manager(Employee):
        __tablename__ = "jti_manager"
        id: Mapped[int] = mapped_column(ForeignKey("jti_employee.id"), primary_key=True)
        department: Mapped[str]
        __mapper_args__ = {"polymorphic_identity": "manager"}

    # Verify separate tables
    assert Employee.__table__.name == "jti_employee"
    assert Manager.__table__.name == "jti_manager"

    # Verify foreign key relationship
    fk_columns = [fk.parent.name for fk in Manager.__table__.foreign_keys]
    assert "id" in fk_columns


@pytest.mark.integration
def test_jti_multiple_children() -> None:
    """JTI: Multiple children each with own table."""
    test_metadata = MetaData()

    class LocalBase(DeclarativeBase):
        metadata = test_metadata

    class Employee(base.CommonTableAttributes, LocalBase):
        __tablename__ = "jti_employee_multi"
        id: Mapped[int] = mapped_column(primary_key=True)
        type: Mapped[str]
        __mapper_args__ = {"polymorphic_on": "type", "polymorphic_identity": "employee"}

    class Manager(Employee):
        __tablename__ = "jti_manager_multi"
        id: Mapped[int] = mapped_column(ForeignKey("jti_employee_multi.id"), primary_key=True)
        department: Mapped[str]
        __mapper_args__ = {"polymorphic_identity": "manager"}

    class Engineer(Employee):
        __tablename__ = "jti_engineer_multi"
        id: Mapped[int] = mapped_column(ForeignKey("jti_employee_multi.id"), primary_key=True)
        language: Mapped[str]
        __mapper_args__ = {"polymorphic_identity": "engineer"}

    # Three separate tables
    assert Employee.__table__.name == "jti_employee_multi"
    assert Manager.__table__.name == "jti_manager_multi"
    assert Engineer.__table__.name == "jti_engineer_multi"


@pytest.mark.integration
@pytest.mark.sqlite
def test_jti_crud_operations(sqlite_engine: Engine) -> None:
    """JTI: CRUD operations with joined tables."""
    from sqlalchemy.orm import Session as SessionType

    # Create fresh metadata for this test
    test_metadata = MetaData()

    class LocalBase(DeclarativeBase):
        metadata = test_metadata

    class Employee(base.CommonTableAttributes, LocalBase):
        __tablename__ = "jti_employee_crud"
        id: Mapped[int] = mapped_column(primary_key=True)
        type: Mapped[str]
        name: Mapped[str]
        __mapper_args__ = {"polymorphic_on": "type", "polymorphic_identity": "employee"}

    class Manager(Employee):
        __tablename__ = "jti_manager_crud"
        id: Mapped[int] = mapped_column(ForeignKey("jti_employee_crud.id"), primary_key=True)
        department: Mapped[str]
        __mapper_args__ = {"polymorphic_identity": "manager"}

    # Create tables
    test_metadata.create_all(sqlite_engine)

    try:
        with SessionType(sqlite_engine) as test_session:
            # Create instance
            manager = Manager(name="Alice", department="Engineering", type="manager")
            test_session.add(manager)
            test_session.commit()

            # Query
            retrieved = test_session.execute(select(Manager)).scalar_one()
            assert retrieved.name == "Alice"
            assert retrieved.department == "Engineering"

            # Query as base class
            as_employee = test_session.execute(select(Employee).where(Employee.name == "Alice")).scalar_one()
            assert isinstance(as_employee, Manager)
    finally:
        test_metadata.drop_all(sqlite_engine)


# ============================================================================
# Concrete Table Inheritance (CTI) Tests
# ============================================================================


@pytest.mark.integration
def test_cti_basic() -> None:
    """CTI: Child has independent table (no foreign key)."""
    test_metadata = MetaData()

    class LocalBase(DeclarativeBase):
        metadata = test_metadata

    class Employee(base.CommonTableAttributes, LocalBase):
        __tablename__ = "cti_employee"
        id: Mapped[int] = mapped_column(primary_key=True)
        name: Mapped[str]

    class Manager(Employee):
        __tablename__ = "cti_manager"
        id: Mapped[int] = mapped_column(primary_key=True)
        department: Mapped[str]
        __mapper_args__ = {"concrete": True}

    # Separate independent tables
    assert Employee.__table__.name == "cti_employee"
    assert Manager.__table__.name == "cti_manager"

    # No foreign keys
    assert len(list(Manager.__table__.foreign_keys)) == 0


@pytest.mark.integration
def test_cti_multiple_concrete_classes() -> None:
    """CTI: Multiple concrete subclasses with independent tables."""
    test_metadata = MetaData()

    class LocalBase(DeclarativeBase):
        metadata = test_metadata

    class Employee(base.CommonTableAttributes, LocalBase):
        __tablename__ = "cti_employee_multi"
        id: Mapped[int] = mapped_column(primary_key=True)
        name: Mapped[str]

    class Manager(Employee):
        __tablename__ = "cti_manager_multi"
        id: Mapped[int] = mapped_column(primary_key=True)
        department: Mapped[str]
        __mapper_args__ = {"concrete": True}

    class Engineer(Employee):
        __tablename__ = "cti_engineer_multi"
        id: Mapped[int] = mapped_column(primary_key=True)
        language: Mapped[str]
        __mapper_args__ = {"concrete": True}

    # All have independent tables
    assert Employee.__table__.name == "cti_employee_multi"
    assert Manager.__table__.name == "cti_manager_multi"
    assert Engineer.__table__.name == "cti_engineer_multi"

    # No foreign keys
    assert len(list(Manager.__table__.foreign_keys)) == 0
    assert len(list(Engineer.__table__.foreign_keys)) == 0


@pytest.mark.integration
@pytest.mark.sqlite
def test_cti_crud_operations(sqlite_engine: Engine) -> None:
    """CTI: CRUD operations with concrete tables."""
    from sqlalchemy.orm import Session as SessionType

    # Create fresh metadata for this test
    test_metadata = MetaData()

    class LocalBase(DeclarativeBase):
        metadata = test_metadata

    class Employee(base.CommonTableAttributes, LocalBase):
        __tablename__ = "cti_employee_crud"
        id: Mapped[int] = mapped_column(primary_key=True)
        name: Mapped[str]

    class Manager(Employee):
        __tablename__ = "cti_manager_crud"
        id: Mapped[int] = mapped_column(primary_key=True)
        name: Mapped[str]  # Must redeclare inherited columns for CTI
        department: Mapped[str]
        __mapper_args__ = {"concrete": True}

    # Create tables
    test_metadata.create_all(sqlite_engine)

    try:
        with SessionType(sqlite_engine) as test_session:
            # Create instance
            manager = Manager(name="Alice", department="Engineering")
            test_session.add(manager)
            test_session.commit()

            # Query
            retrieved = test_session.execute(select(Manager)).scalar_one()
            assert retrieved.name == "Alice"
            assert retrieved.department == "Engineering"
    finally:
        test_metadata.drop_all(sqlite_engine)


# ============================================================================
# Edge Case Tests
# ============================================================================


@pytest.mark.integration
def test_explicit_tablename_override() -> None:
    """Explicit __tablename__ always respected."""
    test_metadata = MetaData()

    class LocalBase(DeclarativeBase):
        metadata = test_metadata

    class Employee(base.CommonTableAttributes, LocalBase):
        __tablename__ = "employee_explicit"
        id: Mapped[int] = mapped_column(primary_key=True)
        type: Mapped[str]
        __mapper_args__ = {"polymorphic_on": "type", "polymorphic_identity": "employee"}

    class Manager(Employee):
        __tablename__ = "manager_explicit_override"  # Explicit override
        id: Mapped[int] = mapped_column(ForeignKey("employee_explicit.id"), primary_key=True)
        department: Mapped[str]
        __mapper_args__ = {"polymorphic_identity": "manager"}

    # Explicit tablename used (JTI pattern)
    assert Manager.__table__.name == "manager_explicit_override"


@pytest.mark.integration
def test_mixin_with_inheritance() -> None:
    """Mixins don't break inheritance detection."""
    test_metadata = MetaData()

    class LocalBase(DeclarativeBase):
        metadata = test_metadata

    class TimestampMixin:
        created_at: Mapped[datetime.datetime] = mapped_column(default=datetime.datetime.now)

    class Employee(TimestampMixin, base.CommonTableAttributes, LocalBase):
        __tablename__ = "employee_mixin"
        id: Mapped[int] = mapped_column(primary_key=True)
        type: Mapped[str]
        __mapper_args__ = {"polymorphic_on": "type", "polymorphic_identity": "employee"}

    class Manager(Employee):
        department: Mapped[Optional[str]] = mapped_column(default=None)
        __mapper_args__ = {"polymorphic_identity": "manager"}

    # STI works despite mixin
    assert Manager.__table__.name == "employee_mixin"


@pytest.mark.integration
def test_abstract_base_class() -> None:
    """Abstract base classes handled correctly."""
    test_metadata = MetaData()

    class LocalBase(DeclarativeBase):
        metadata = test_metadata

    class BaseEntity(base.CommonTableAttributes, LocalBase):
        __abstract__ = True
        created_at: Mapped[datetime.datetime] = mapped_column(default=datetime.datetime.now)

    class Employee(BaseEntity):
        __tablename__ = "employee_abstract"
        id: Mapped[int] = mapped_column(primary_key=True)
        name: Mapped[str]

    # Abstract base doesn't create table
    assert not hasattr(BaseEntity, "__table__")
    assert Employee.__table__.name == "employee_abstract"


@pytest.mark.integration
def test_no_inheritance_generates_tablename() -> None:
    """Classes without inheritance get auto-generated tablename."""
    test_metadata = MetaData()

    class LocalBase(DeclarativeBase):
        metadata = test_metadata

    class StandaloneModel(base.CommonTableAttributes, LocalBase):
        id: Mapped[int] = mapped_column(primary_key=True)
        name: Mapped[str]

    # Auto-generated from class name
    assert StandaloneModel.__table__.name == "standalone_model"


@pytest.mark.integration
@pytest.mark.filterwarnings(
    "ignore:Mapper\\[Manager\\(employee_no_poly_id\\)\\] does not indicate a 'polymorphic_identity'.*:"
    "sqlalchemy.exc.SAWarning"
)
def test_sti_without_polymorphic_identity_on_child() -> None:
    """STI child without explicit polymorphic_identity still uses parent table."""
    test_metadata = MetaData()

    class LocalBase(DeclarativeBase):
        metadata = test_metadata

    class Employee(base.CommonTableAttributes, LocalBase):
        __tablename__ = "employee_no_poly_id"
        id: Mapped[int] = mapped_column(primary_key=True)
        type: Mapped[str]
        __mapper_args__ = {"polymorphic_on": "type", "polymorphic_identity": "employee"}

    class Manager(Employee):
        department: Mapped[Optional[str]] = mapped_column(default=None)
        # No __mapper_args__ - should still detect STI from parent

    # Should use parent table even without explicit polymorphic_identity
    assert Manager.__table__.name == "employee_no_poly_id"


@pytest.mark.integration
def test_backward_compatibility_simple_models() -> None:
    """Existing simple models without inheritance work as before."""
    test_metadata = MetaData()

    class LocalBase(DeclarativeBase):
        metadata = test_metadata

    class User(base.CommonTableAttributes, LocalBase):
        id: Mapped[int] = mapped_column(primary_key=True)
        name: Mapped[str]
        email: Mapped[str]

    class Product(base.CommonTableAttributes, LocalBase):
        id: Mapped[int] = mapped_column(primary_key=True)
        title: Mapped[str]
        price: Mapped[int]

    # Auto-generated tablenames still work
    assert User.__table__.name == "user"
    assert Product.__table__.name == "product"


@pytest.mark.integration
def test_sti_with_multiple_inheritance_levels() -> None:
    """Multi-level STI inheritance hierarchy."""
    test_metadata = MetaData()

    class LocalBase(DeclarativeBase):
        metadata = test_metadata

    class Employee(base.CommonTableAttributes, LocalBase):
        __tablename__ = "employee_deep"
        id: Mapped[int] = mapped_column(primary_key=True)
        type: Mapped[str]
        __mapper_args__ = {"polymorphic_on": "type", "polymorphic_identity": "employee"}

    class Manager(Employee):
        level: Mapped[Optional[int]] = mapped_column(default=None)
        __mapper_args__ = {"polymorphic_identity": "manager"}

    class SeniorManager(Manager):
        budget: Mapped[Optional[int]] = mapped_column(default=None)
        __mapper_args__ = {"polymorphic_identity": "senior_manager"}

    class ExecutiveManager(SeniorManager):
        bonus: Mapped[Optional[int]] = mapped_column(default=None)
        __mapper_args__ = {"polymorphic_identity": "executive_manager"}

    # All levels use same table
    assert Employee.__table__.name == "employee_deep"
    assert Manager.__table__.name == "employee_deep"
    assert SeniorManager.__table__.name == "employee_deep"
    assert ExecutiveManager.__table__.name == "employee_deep"
python-advanced-alchemy-1.9.3/tests/integration/test_loader_and_execution_options.py000066400000000000000000000503231516556515500312750ustar00rootroot00000000000000from __future__ import annotations

from typing import TYPE_CHECKING
from uuid import UUID

import pytest
from sqlalchemy import Engine, ForeignKey, String
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import Mapped, Session, mapped_column, noload, relationship, selectinload, sessionmaker

from advanced_alchemy.repository import SQLAlchemyAsyncRepository, SQLAlchemySyncRepository

if TYPE_CHECKING:
    from pytest import MonkeyPatch

pytestmark = [
    pytest.mark.integration,
    pytest.mark.xdist_group("loader_execution"),
]


@pytest.mark.xdist_group("loader")
def test_loader(monkeypatch: MonkeyPatch, engine: Engine) -> None:
    # Skip mock engines as they don't support multi-row inserts with RETURNING
    if getattr(engine.dialect, "name", "") == "mock":
        pytest.skip("Mock engines don't support multi-row inserts with RETURNING")

    # Skip CockroachDB as it has issues with loader options and BigInt primary keys
    if "cockroach" in getattr(engine.dialect, "name", ""):
        pytest.skip("CockroachDB has issues with loader options and BigInt primary keys")

    from sqlalchemy.orm import DeclarativeBase

    from advanced_alchemy import base, mixins

    # Create a completely isolated registry for this test
    orm_registry = base.create_registry()

    # Use engine driver name in table names to avoid conflicts between engines sharing the same database
    # (e.g., asyncpg and psycopg both report dialect.name as "postgresql")
    engine_name = getattr(engine.dialect, "driver", getattr(engine.dialect, "name", "unknown")).replace("+", "_")

    class NewUUIDBase(mixins.UUIDPrimaryKey, base.CommonTableAttributes, DeclarativeBase):
        __abstract__ = True
        registry = orm_registry

    class NewBigIntBase(mixins.BigIntPrimaryKey, base.CommonTableAttributes, DeclarativeBase):
        __abstract__ = True
        registry = orm_registry

    monkeypatch.setattr(base, "UUIDBase", NewUUIDBase)
    monkeypatch.setattr(base, "BigIntBase", NewBigIntBase)

    class UUIDCountry(NewUUIDBase):
        __tablename__ = f"uuid_country_loader_{engine_name}"
        name: Mapped[str] = mapped_column(String(length=50))  # pyright: ignore
        states: Mapped[list[UUIDState]] = relationship(back_populates="country", uselist=True, lazy="noload")

    class UUIDState(NewUUIDBase):
        __tablename__ = f"uuid_state_loader_{engine_name}"
        name: Mapped[str] = mapped_column(String(length=50))  # pyright: ignore
        country_id: Mapped[UUID] = mapped_column(ForeignKey(f"uuid_country_loader_{engine_name}.id"))

        country: Mapped[UUIDCountry] = relationship(uselist=False, back_populates="states", lazy="raise")

    class USStateRepository(SQLAlchemySyncRepository[UUIDState]):
        model_type = UUIDState

    class CountryRepository(SQLAlchemySyncRepository[UUIDCountry]):
        model_type = UUIDCountry

    session_factory: sessionmaker[Session] = sessionmaker(engine, expire_on_commit=False)

    with engine.begin() as conn:
        # Create tables using the registry metadata
        orm_registry.metadata.create_all(conn)

    with session_factory() as db_session:
        usa = UUIDCountry(name="United States of America")
        france = UUIDCountry(name="France")
        db_session.add(usa)
        db_session.add(france)
        db_session.flush()  # Ensure countries are in session before creating states

        california = UUIDState(name="California", country_id=usa.id)
        oregon = UUIDState(name="Oregon", country_id=usa.id)
        ile_de_france = UUIDState(name="รŽle-de-France", country_id=france.id)

        repo = USStateRepository(session=db_session)
        repo.add(california)
        repo.add(oregon)
        repo.add(ile_de_france)
        db_session.commit()
        db_session.expire_all()

        si1_country_repo = CountryRepository(session=db_session, load=[noload(UUIDCountry.states)])
        usa_country_1 = si1_country_repo.get_one(
            name="United States of America",
        )
        assert len(usa_country_1.states) == 0
        si0_country_repo = CountryRepository(session=db_session)

        db_session.expire_all()
        usa_country_0 = si0_country_repo.get_one(
            name="United States of America",
            load=UUIDCountry.states,
            execution_options={"populate_existing": True},
        )
        assert len(usa_country_0.states) == 2
        db_session.expire_all()

        si2_country_repo = CountryRepository(session=db_session, load=[selectinload(UUIDCountry.states)])
        usa_country_2 = si2_country_repo.get_one(name="United States of America")
        assert len(usa_country_2.states) == 2
        db_session.expire_all()

        ia_repo = USStateRepository(session=db_session, load=UUIDState.country)
        string_california = ia_repo.get_one(name="California")
        assert string_california.name == "California"
        db_session.expire_all()

        star_repo = USStateRepository(session=db_session, load="*")
        star_california = star_repo.get_one(name="California")
        assert star_california.country.name == "United States of America"
        db_session.expire_all()

        star_country_repo = CountryRepository(session=db_session, load="*")
        usa_country_3 = star_country_repo.get_one(name="United States of America")
        assert len(usa_country_3.states) == 2
        db_session.expunge_all()
        db_session.expire_all()

        si1_country_repo = CountryRepository(session=db_session)
        usa_country_1 = si1_country_repo.get_one(
            name="United States of America",
            load=[noload(UUIDCountry.states)],
        )
        assert len(usa_country_1.states) == 0
        si0_country_repo = CountryRepository(session=db_session)
        db_session.expire_all()


@pytest.mark.xdist_group("loader")
async def test_async_loader(monkeypatch: MonkeyPatch, async_engine: AsyncEngine) -> None:
    # Skip mock engines as they don't support multi-row inserts with RETURNING
    if getattr(async_engine.dialect, "name", "") == "mock":
        pytest.skip("Mock engines don't support multi-row inserts with RETURNING")

    # Skip CockroachDB as it has issues with loader options and BigInt primary keys
    if "cockroach" in getattr(async_engine.dialect, "name", ""):
        pytest.skip("CockroachDB has issues with loader options and BigInt primary keys")

    from sqlalchemy.orm import DeclarativeBase

    from advanced_alchemy import base, mixins

    # Create a completely isolated registry for this test
    orm_registry = base.create_registry()

    # Use engine driver name in table names to avoid conflicts between engines sharing the same database
    # (e.g., asyncpg and psycopg both report dialect.name as "postgresql")
    engine_name = getattr(async_engine.dialect, "driver", getattr(async_engine.dialect, "name", "unknown")).replace(
        "+", "_"
    )

    class NewUUIDBase(mixins.UUIDPrimaryKey, base.CommonTableAttributes, DeclarativeBase):
        __abstract__ = True
        registry = orm_registry

    class NewBigIntBase(mixins.BigIntPrimaryKey, base.CommonTableAttributes, DeclarativeBase):
        __abstract__ = True
        registry = orm_registry

    monkeypatch.setattr(base, "UUIDBase", NewUUIDBase)
    monkeypatch.setattr(base, "BigIntBase", NewBigIntBase)

    class BigIntCountry(NewBigIntBase):
        __tablename__ = f"bigint_country_async_{engine_name}"
        name: Mapped[str] = mapped_column(String(length=50))  # pyright: ignore
        states: Mapped[list[BigIntState]] = relationship(back_populates="country", uselist=True)

    class BigIntState(NewBigIntBase):
        __tablename__ = f"bigint_state_async_{engine_name}"
        name: Mapped[str] = mapped_column(String(length=50))  # pyright: ignore
        country_id: Mapped[int] = mapped_column(ForeignKey(f"bigint_country_async_{engine_name}.id"))

        country: Mapped[BigIntCountry] = relationship(uselist=False, back_populates="states", lazy="raise")

    class USStateRepository(SQLAlchemyAsyncRepository[BigIntState]):
        model_type = BigIntState

    class CountryRepository(SQLAlchemyAsyncRepository[BigIntCountry]):
        model_type = BigIntCountry

    session_factory: async_sessionmaker[AsyncSession] = async_sessionmaker(async_engine, expire_on_commit=False)

    async with async_engine.begin() as conn:
        # Create tables using the registry metadata
        await conn.run_sync(orm_registry.metadata.create_all)

    async with session_factory() as db_session:
        usa = BigIntCountry(name="United States of America")
        france = BigIntCountry(name="France")
        db_session.add(usa)
        db_session.add(france)
        await db_session.flush()  # Ensure countries are in session before creating states

        california = BigIntState(name="California", country_id=usa.id)
        oregon = BigIntState(name="Oregon", country_id=usa.id)
        ile_de_france = BigIntState(name="รŽle-de-France", country_id=france.id)

        repo = USStateRepository(session=db_session)
        await repo.add(california)
        await repo.add(oregon)
        await repo.add(ile_de_france)
        await db_session.commit()
        db_session.expire_all()

        si1_country_repo = CountryRepository(session=db_session, load=[noload(BigIntCountry.states)])
        usa_country_21 = await si1_country_repo.get_one(
            name="United States of America",
        )
        assert len(usa_country_21.states) == 0
        db_session.expire_all()

        si0_country_repo = CountryRepository(session=db_session)
        usa_country_0 = await si0_country_repo.get_one(
            name="United States of America",
            load=BigIntCountry.states,
            execution_options={"populate_existing": True},
        )
        assert len(usa_country_0.states) == 2
        db_session.expire_all()

        country_repo = CountryRepository(session=db_session)
        usa_country_1 = await country_repo.get_one(
            name="United States of America",
            load=[selectinload(BigIntCountry.states)],
        )
        assert len(usa_country_1.states) == 2
        db_session.expire_all()

        si_country_repo = CountryRepository(session=db_session, load=[selectinload(BigIntCountry.states)])
        usa_country_02 = await si_country_repo.get_one(name="United States of America")
        assert len(usa_country_02.states) == 2
        db_session.expire_all()

        ia_repo = USStateRepository(session=db_session, load=BigIntState.country)
        string_california = await ia_repo.get_one(name="California")
        assert string_california.name == "California"
        db_session.expire_all()

        star_repo = USStateRepository(session=db_session, load="*")
        star_california = await star_repo.get_one(name="California")
        assert star_california.country.name == "United States of America"
        db_session.expire_all()

        star_country_repo = CountryRepository(session=db_session, load="*")
        usa_country_3 = await star_country_repo.get_one(name="United States of America")
        assert len(usa_country_3.states) == 2
        db_session.expire_all()


@pytest.mark.xdist_group("loader")
def test_default_overrides_loader(monkeypatch: MonkeyPatch, engine: Engine) -> None:
    # Skip mock engines as they don't support multi-row inserts with RETURNING
    if getattr(engine.dialect, "name", "") == "mock":
        pytest.skip("Mock engines don't support multi-row inserts with RETURNING")

    # Skip CockroachDB as it has issues with loader options and BigInt primary keys
    if "cockroach" in getattr(engine.dialect, "name", ""):
        pytest.skip("CockroachDB has issues with loader options and BigInt primary keys")

    from sqlalchemy.orm import DeclarativeBase

    from advanced_alchemy import base, mixins

    # Create a completely isolated registry for this test
    orm_registry = base.create_registry()

    # Use engine driver name in table names to avoid conflicts between engines sharing the same database
    # (e.g., asyncpg and psycopg both report dialect.name as "postgresql")
    engine_name = getattr(engine.dialect, "driver", getattr(engine.dialect, "name", "unknown")).replace("+", "_")

    class NewUUIDBase(mixins.UUIDPrimaryKey, base.CommonTableAttributes, DeclarativeBase):
        __abstract__ = True
        registry = orm_registry

    class NewBigIntBase(mixins.BigIntPrimaryKey, base.CommonTableAttributes, DeclarativeBase):
        __abstract__ = True
        registry = orm_registry

    monkeypatch.setattr(base, "UUIDBase", NewUUIDBase)
    monkeypatch.setattr(base, "BigIntBase", NewBigIntBase)

    class UUIDCountryTest(NewUUIDBase):
        __tablename__ = f"uuid_country_override_{engine_name}"
        name: Mapped[str] = mapped_column(String(length=50))  # pyright: ignore
        states: Mapped[list[UUIDStateTest]] = relationship(back_populates="country", uselist=True, lazy="selectin")

    class UUIDStateTest(NewUUIDBase):
        __tablename__ = f"uuid_state_override_{engine_name}"
        name: Mapped[str] = mapped_column(String(length=50))  # pyright: ignore
        country_id: Mapped[UUID] = mapped_column(ForeignKey(f"uuid_country_override_{engine_name}.id"))

        country: Mapped[UUIDCountryTest] = relationship(uselist=False, back_populates="states", lazy="noload")

    class USStateRepository(SQLAlchemySyncRepository[UUIDStateTest]):
        model_type = UUIDStateTest
        merge_loader_options = False
        loader_options = [noload(UUIDStateTest.country)]

    class CountryRepository(SQLAlchemySyncRepository[UUIDCountryTest]):
        inherit_lazy_relationships = False
        model_type = UUIDCountryTest

    session_factory: sessionmaker[Session] = sessionmaker(engine, expire_on_commit=False)

    with engine.begin() as conn:
        # Create tables using the registry metadata
        orm_registry.metadata.create_all(conn)

    with session_factory() as db_session:
        usa = UUIDCountryTest(name="United States of America")
        france = UUIDCountryTest(name="France")
        db_session.add(usa)
        db_session.add(france)
        db_session.flush()  # Ensure countries are in session before creating states

        california = UUIDStateTest(name="California", country_id=usa.id)
        oregon = UUIDStateTest(name="Oregon", country_id=usa.id)
        ile_de_france = UUIDStateTest(name="รŽle-de-France", country_id=france.id)

        repo = USStateRepository(session=db_session)
        repo.add(california)
        repo.add(oregon)
        repo.add(ile_de_france)
        db_session.commit()
        db_session.expire_all()

        si1_country_repo = CountryRepository(session=db_session)
        usa_country_1 = si1_country_repo.get_one(
            name="United States of America",
        )
        assert len(usa_country_1.states) == 2
        usa_country_2 = si1_country_repo.get_one(
            name="United States of America",
            load="*",
            execution_options={"populate_existing": True},
        )
        assert len(usa_country_2.states) == 2


@pytest.mark.xdist_group("loader")
async def test_default_overrides_async_loader(monkeypatch: MonkeyPatch, async_engine: AsyncEngine) -> None:
    # Skip mock engines as they don't support multi-row inserts with RETURNING
    if getattr(async_engine.dialect, "name", "") == "mock":
        pytest.skip("Mock engines don't support multi-row inserts with RETURNING")

    # Skip CockroachDB as it has issues with loader options and BigInt primary keys
    if "cockroach" in getattr(async_engine.dialect, "name", ""):
        pytest.skip("CockroachDB has issues with loader options and BigInt primary keys")

    from sqlalchemy.orm import DeclarativeBase

    from advanced_alchemy import base, mixins

    # Create a completely isolated registry for this test
    orm_registry = base.create_registry()

    # Use engine driver name in table names to avoid conflicts between engines sharing the same database
    # (e.g., asyncpg and psycopg both report dialect.name as "postgresql")
    engine_name = getattr(async_engine.dialect, "driver", getattr(async_engine.dialect, "name", "unknown")).replace(
        "+", "_"
    )

    class NewUUIDBase(mixins.UUIDPrimaryKey, base.CommonTableAttributes, DeclarativeBase):
        __abstract__ = True
        registry = orm_registry

    class NewBigIntBase(mixins.BigIntPrimaryKey, base.CommonTableAttributes, DeclarativeBase):
        __abstract__ = True
        registry = orm_registry

    monkeypatch.setattr(base, "UUIDBase", NewUUIDBase)
    monkeypatch.setattr(base, "BigIntBase", NewBigIntBase)

    class BigIntCountryTest(NewBigIntBase):
        __tablename__ = f"bigint_country_override_{engine_name}"
        name: Mapped[str] = mapped_column(String(length=50))  # pyright: ignore
        states: Mapped[list[BigIntStateTest]] = relationship(back_populates="country", uselist=True, lazy="selectin")
        notes: Mapped[list[BigIntCountryNote]] = relationship(back_populates="country", uselist=True, lazy="selectin")

    class BigIntCountryNote(NewBigIntBase):
        __tablename__ = f"bigint_note_override_{engine_name}"
        name: Mapped[str] = mapped_column(String(length=50))  # pyright: ignore
        country_id: Mapped[int] = mapped_column(ForeignKey(f"bigint_country_override_{engine_name}.id"))
        country: Mapped[BigIntCountryTest] = relationship(uselist=False, back_populates="notes", lazy="raise")

    class BigIntStateTest(NewBigIntBase):
        __tablename__ = f"bigint_state_override_{engine_name}"
        name: Mapped[str] = mapped_column(String(length=50))  # pyright: ignore
        country_id: Mapped[int] = mapped_column(ForeignKey(f"bigint_country_override_{engine_name}.id"))

        country: Mapped[BigIntCountryTest] = relationship(uselist=False, back_populates="states", lazy="raise")

    class USStateRepository(SQLAlchemyAsyncRepository[BigIntStateTest]):
        model_type = BigIntStateTest

    class CountryRepository(SQLAlchemyAsyncRepository[BigIntCountryTest]):
        model_type = BigIntCountryTest
        merge_loader_options = False
        loader_options = [noload(BigIntCountryTest.states), noload(BigIntCountryTest.notes)]

    session_factory: async_sessionmaker[AsyncSession] = async_sessionmaker(async_engine, expire_on_commit=False)

    async with async_engine.begin() as conn:
        # Create tables using the registry metadata
        await conn.run_sync(orm_registry.metadata.create_all)

    async with session_factory() as db_session:
        usa = BigIntCountryTest(name="United States of America")
        usa.notes.append(BigIntCountryNote(name="Note 1"))
        france = BigIntCountryTest(name="France")
        db_session.add(usa)
        db_session.add(france)
        await db_session.flush()  # Ensure countries are in session before creating states

        california = BigIntStateTest(name="California", country_id=usa.id)
        oregon = BigIntStateTest(name="Oregon", country_id=usa.id)
        ile_de_france = BigIntStateTest(name="รŽle-de-France", country_id=france.id)

        repo = USStateRepository(session=db_session)
        await repo.add(california)
        await repo.add(oregon)
        await repo.add(ile_de_france)
        await db_session.commit()
        db_session.expire_all()

        si1_country_repo = CountryRepository(session=db_session, load=[noload(BigIntCountryTest.states)])
        usa_country_21 = await si1_country_repo.get_one(
            name="United States of America",
        )
        assert len(usa_country_21.states) == 0
        db_session.expire_all()

        si0_country_repo = CountryRepository(session=db_session)
        usa_country_0 = await si0_country_repo.get_one(
            name="United States of America",
            load=BigIntCountryTest.states,
            execution_options={"populate_existing": True},
        )
        assert len(usa_country_0.states) == 2
        db_session.expire_all()

        country_repo = CountryRepository(session=db_session)
        usa_country_1 = await country_repo.get_one(
            name="United States of America",
            load=[selectinload(BigIntCountryTest.states)],
        )
        assert len(usa_country_1.states) == 2
        db_session.expire_all()

        si_country_repo = CountryRepository(session=db_session, load=[noload(BigIntCountryTest.notes)])
        usa_country_02 = await si_country_repo.get_one(
            name="United States of America", load=[selectinload(BigIntCountryTest.states)]
        )
        assert len(usa_country_02.notes) == 1
        db_session.expire_all()
python-advanced-alchemy-1.9.3/tests/integration/test_models.py000066400000000000000000000556571516556515500246510ustar00rootroot00000000000000"""Centralized test models and metadata management.

This module provides isolated metadata registries per database dialect and centralized
model definitions to prevent metadata pollution between test runs.

## Test Infrastructure Overview

This module solves several critical testing issues in Advanced Alchemy:

### 1. Database Locking and Hanging Tests

**Problem**: Tests were hanging due to database locks from improper session/connection management
and session-scoped async fixtures with `loop_scope="session"`.

**Solution**:
- Changed all async engine fixtures to `scope="function"`
- Removed `loop_scope="session"` which caused deadlocks with pytest-asyncio
- Enabled autocleanup fixtures with proper scoping

### 2. Engine Management Consistency

**Problem**: Multiple test files created their own engine fixtures instead of using centralized ones.

**Solution**:
- All tests now use engines from `conftest.py`
- Removed duplicate engine definitions from `test_password_hash.py` and `test_unique_mixin.py`
- Mock engines now have consistent scoping with real engines (session scope)

### 3. Metadata Isolation

**Problem**: Metadata pollution between parallel tests causing table conflicts.

**Solution**:
- `MetadataRegistry` provides isolated metadata instances per database dialect
- `DatabaseCapabilities` provides feature detection for database-specific skipping
- Worker-specific table prefixes prevent conflicts in parallel execution

### 4. Standardized Model Creation

**Problem**: Different approaches to model creation and table management everywhere.

**Solution**:
- `create_test_models()` and `create_bigint_models()` provide standardized model creation
- `get_models_for_engine()` automatically selects appropriate models based on database capabilities
- `create_tables_for_engine()` handles database-specific table creation requirements

## Usage Patterns

### For New Test Files

```python
from tests.integration.test_models import (
    DatabaseCapabilities,
    test_models_sync,
    test_models_async,
)


def test_my_feature(
    engine: Engine, test_models_sync: dict[str, type]
) -> None:
    # Skip if database doesn't support required features
    if DatabaseCapabilities.should_skip_bigint(
        engine.dialect.name
    ):
        pytest.skip("BigInt PKs not supported")

    # Use models from the standardized fixture
    Author = test_models_sync["Author"]
    Book = test_models_sync["Book"]
    # ... test implementation


async def test_my_async_feature(
    async_engine: AsyncEngine,
    test_models_async: dict[str, type],
) -> None:
    # Models are automatically created and cleaned up
    Author = test_models_async["Author"]
    # ... test implementation
```

### For Custom Models

```python
from tests.integration.test_models import (
    MetadataRegistry,
    DatabaseCapabilities,
)


def test_custom_models(engine: Engine) -> None:
    # Get isolated metadata for this engine
    base = MetadataRegistry.get_base(engine.dialect.name)

    class MyModel(base):
        __tablename__ = "my_test_table"
        id: Mapped[int] = mapped_column(primary_key=True)
        name: Mapped[str] = mapped_column(String(50))

    # Create tables
    base.metadata.create_all(engine)
    # ... test implementation

    # Cleanup happens automatically via conftest.py fixtures
```

### Database-Specific Skipping

```python
from tests.integration.test_models import (
    skip_if_unsupported,
    skip_for_dialects,
)


@skip_if_unsupported(
    "supports_bigint_pk", "supports_unique_constraints"
)
def test_advanced_features(engine: Engine) -> None:
    # Test runs only on databases that support both features
    pass


@skip_for_dialects("spanner", "cockroach")
def test_complex_queries(engine: Engine) -> None:
    # Test skipped for Spanner and CockroachDB
    pass
```

## Key Benefits

1. **No More Hanging Tests**: Function-scoped async fixtures prevent deadlocks
2. **Consistent Engine Usage**: All tests use centralized engines from conftest.py
3. **Automatic Cleanup**: Per-test cleanup ensures data isolation without manual intervention
4. **Database Compatibility**: Automatic feature detection and skipping for unsupported operations
5. **Parallel Test Safety**: Worker-specific metadata prevents conflicts in pytest-xdist execution
6. **Easy Maintenance**: Centralized model definitions and standardized patterns

## Migration Guide

To migrate existing test files:

1. Remove custom engine fixtures - use `engine` and `async_engine` from conftest.py
2. Replace custom model definitions with `test_models_sync`/`test_models_async` fixtures
3. Add database capability checks using `DatabaseCapabilities.should_skip_*()` methods
4. Remove manual cleanup code - it's handled automatically
5. Use `MetadataRegistry.get_base()` for custom models that need isolated metadata

This infrastructure ensures reliable, fast, and maintainable tests across all database backends.
"""

from __future__ import annotations

from typing import TYPE_CHECKING, Any, Callable, TypeVar

import pytest
from sqlalchemy import ForeignKey, Integer, MetaData, String, Text
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship

if TYPE_CHECKING:
    from sqlalchemy import Engine
    from sqlalchemy.ext.asyncio import AsyncEngine


class DatabaseCapabilities:
    """Registry of database-specific capabilities and limitations."""

    CAPABILITIES = {
        "postgresql": {
            "supports_bigint_pk": True,
            "supports_uuid_pk": True,
            "supports_unique_constraints": True,
            "supports_merge": True,
            "supports_sequences": True,
            "supports_exists_filters": True,
        },
        "sqlite": {
            "supports_bigint_pk": True,
            "supports_uuid_pk": True,
            "supports_unique_constraints": True,
            "supports_merge": False,
            "supports_sequences": False,
            "supports_exists_filters": True,
        },
        "duckdb": {
            "supports_bigint_pk": True,
            "supports_uuid_pk": True,
            "supports_unique_constraints": True,
            "supports_merge": False,
            "supports_sequences": False,
            "supports_exists_filters": True,
        },
        "spanner+spanner": {
            "supports_bigint_pk": False,  # Spanner has issues with bigint PKs
            "supports_uuid_pk": True,
            "supports_unique_constraints": False,
            "supports_merge": False,
            "supports_sequences": False,
            "supports_exists_filters": False,  # Spanner emulator has constraints
        },
        "cockroachdb": {
            "supports_bigint_pk": False,  # CockroachDB has issues with bigint PKs
            "supports_uuid_pk": True,
            "supports_unique_constraints": True,
            "supports_merge": False,
            "supports_sequences": False,
            "supports_exists_filters": True,
        },
        "oracle": {
            "supports_bigint_pk": True,
            "supports_uuid_pk": True,
            "supports_unique_constraints": True,
            "supports_merge": True,
            "supports_sequences": True,
            "supports_exists_filters": True,
        },
        "mssql": {
            "supports_bigint_pk": True,
            "supports_uuid_pk": True,
            "supports_unique_constraints": True,
            "supports_merge": True,
            "supports_sequences": True,
            "supports_exists_filters": True,
        },
        "mysql": {
            "supports_bigint_pk": True,
            "supports_uuid_pk": True,
            "supports_unique_constraints": True,
            "supports_merge": False,
            "supports_sequences": False,
            "supports_exists_filters": True,
        },
    }

    @classmethod
    def supports_feature(cls, dialect_name: str, feature: str) -> bool:
        """Check if a database dialect supports a specific feature."""
        dialect_key = cls._normalize_dialect_name(dialect_name)
        return cls.CAPABILITIES.get(dialect_key, {}).get(feature, True)

    @classmethod
    def should_skip_bigint(cls, dialect_name: str) -> bool:
        """Check if bigint PKs should be skipped for this dialect."""
        return not cls.supports_feature(dialect_name, "supports_bigint_pk")

    @classmethod
    def should_skip_exists_filter(cls, dialect_name: str) -> bool:
        """Check if EXISTS filter tests should be skipped for this dialect."""
        return not cls.supports_feature(dialect_name, "supports_exists_filters")

    @classmethod
    def should_skip_unique_constraints(cls, dialect_name: str) -> bool:
        """Check if unique constraint tests should be skipped for this dialect."""
        return not cls.supports_feature(dialect_name, "supports_unique_constraints")

    @classmethod
    def _normalize_dialect_name(cls, dialect_name: str) -> str:
        """Normalize dialect names to handle variations."""
        if "spanner" in dialect_name.lower():
            return "spanner+spanner"
        if "cockroach" in dialect_name.lower():
            return "cockroachdb"
        if "sqlite" in dialect_name.lower():
            return "sqlite"
        if (
            "postgresql" in dialect_name.lower()
            or "psycopg" in dialect_name.lower()
            or "asyncpg" in dialect_name.lower()
        ):
            return "postgresql"
        if "duckdb" in dialect_name.lower():
            return "duckdb"
        if "oracle" in dialect_name.lower():
            return "oracle"
        if "mssql" in dialect_name.lower() or "pyodbc" in dialect_name.lower() or "aioodbc" in dialect_name.lower():
            return "mssql"
        if "mysql" in dialect_name.lower() or "asyncmy" in dialect_name.lower():
            return "mysql"
        return dialect_name.lower()


class MetadataRegistry:
    """Manages isolated metadata instances per database dialect."""

    _registries: dict[str, MetaData] = {}
    _base_classes: dict[str, type[DeclarativeBase]] = {}

    @classmethod
    def get_metadata(cls, dialect_name: str) -> MetaData:
        """Get isolated metadata for a specific database dialect."""
        key = DatabaseCapabilities._normalize_dialect_name(dialect_name)
        if key not in cls._registries:
            cls._registries[key] = MetaData()
        return cls._registries[key]

    @classmethod
    def get_base(cls, dialect_name: str) -> type[DeclarativeBase]:
        """Get isolated DeclarativeBase for a specific database dialect."""
        key = DatabaseCapabilities._normalize_dialect_name(dialect_name)
        if key not in cls._base_classes:
            isolated_metadata = cls.get_metadata(dialect_name)

            class IsolatedBase(DeclarativeBase):
                metadata = isolated_metadata
                __abstract__ = True

            cls._base_classes[key] = IsolatedBase
        return cls._base_classes[key]

    @classmethod
    def clear_metadata(cls, dialect_name: str) -> None:
        """Clear metadata for a specific dialect."""
        key = DatabaseCapabilities._normalize_dialect_name(dialect_name)
        if key in cls._registries:
            cls._registries[key].clear()

    @classmethod
    def clear_all(cls) -> None:
        """Clear all metadata registries."""
        for metadata in cls._registries.values():
            metadata.clear()
        cls._registries.clear()
        cls._base_classes.clear()


F = TypeVar("F", bound=Callable[..., Any])


def skip_if_unsupported(*features: str) -> Callable[[F], F]:
    """Decorator to skip tests based on database capabilities."""

    def decorator(test_func: F) -> F:
        def wrapper(*args: Any, **kwargs: Any) -> Any:
            # Extract engine from fixture parameters
            for arg in args:
                if hasattr(arg, "dialect"):
                    capabilities = DatabaseCapabilities()
                    dialect_name = getattr(arg.dialect, "name", "")
                    for feature in features:
                        if not capabilities.supports_feature(dialect_name, feature):
                            pytest.skip(f"Database {dialect_name} doesn't support {feature}")
                    break
            return test_func(*args, **kwargs)

        return wrapper  # type: ignore[return-value]

    return decorator


def skip_for_dialects(*dialect_patterns: str) -> Callable[[F], F]:
    """Decorator to skip tests for specific database dialects."""

    def decorator(test_func: F) -> F:
        def wrapper(*args: Any, **kwargs: Any) -> Any:
            # Extract engine from fixture parameters
            for arg in args:
                if hasattr(arg, "dialect"):
                    dialect_name = getattr(arg.dialect, "name", "").lower()
                    for pattern in dialect_patterns:
                        if pattern.lower() in dialect_name:
                            pytest.skip(f"Test skipped for {dialect_name}")
                    break
            return test_func(*args, **kwargs)

        return wrapper  # type: ignore[return-value]

    return decorator


# Centralized model definitions that can be instantiated with different bases
def create_test_models(base: type[DeclarativeBase], table_prefix: str = "") -> dict[str, type[Any]]:
    """Create test model classes with the given base and optional table prefix.

    Args:
        base: The DeclarativeBase to use for these models
        table_prefix: Optional prefix for table names to ensure uniqueness

    Returns:
        Dictionary of model name to model class
    """
    models: dict[str, type[Any]] = {}

    # UUID-based models
    from advanced_alchemy.base import UUIDAuditBase, UUIDBase

    # Use type: ignore for dynamic base class mixing
    class Author(UUIDAuditBase, base):  # type: ignore[misc, valid-type]
        __tablename__ = f"{table_prefix}uuid_author"
        __table_args__ = {"extend_existing": True}

        name: Mapped[str] = mapped_column(String(100))
        dob: Mapped[str | None] = mapped_column(String(50), nullable=True)

    class Book(UUIDAuditBase, base):  # type: ignore[misc, valid-type]
        __tablename__ = f"{table_prefix}uuid_book"
        __table_args__ = {"extend_existing": True}

        title: Mapped[str] = mapped_column(String(250))
        author_id: Mapped[Any] = mapped_column(ForeignKey(f"{table_prefix}uuid_author.id"))
        author: Mapped[Author] = relationship(lazy="joined", innerjoin=True, viewonly=True)

    class Secret(UUIDBase, base):  # type: ignore[misc, valid-type]
        __tablename__ = f"{table_prefix}uuid_secret"
        __table_args__ = {"extend_existing": True}

        secret: Mapped[str] = mapped_column(Text())
        long_secret: Mapped[str | None] = mapped_column(Text(), nullable=True)

    class Item(UUIDBase, base):  # type: ignore[misc, valid-type]
        __tablename__ = f"{table_prefix}uuid_item"
        __table_args__ = {"extend_existing": True}

        name: Mapped[str] = mapped_column(String(50))
        quantity: Mapped[int] = mapped_column(Integer, default=0)

    class Tag(UUIDAuditBase, base):  # type: ignore[misc, valid-type]
        __tablename__ = f"{table_prefix}uuid_tag"
        __table_args__ = {"extend_existing": True}

        name: Mapped[str] = mapped_column(String(50))

    models.update(
        {
            "Author": Author,
            "Book": Book,
            "Secret": Secret,
            "Item": Item,
            "Tag": Tag,
        }
    )

    return models


def create_bigint_models(base: type[DeclarativeBase], table_prefix: str = "") -> dict[str, type[Any]]:
    """Create BigInt-based test model classes.

    Args:
        base: The DeclarativeBase to use for these models
        table_prefix: Optional prefix for table names to ensure uniqueness

    Returns:
        Dictionary of model name to model class
    """
    from advanced_alchemy.base import BigIntAuditBase, BigIntBase

    models: dict[str, type[Any]] = {}

    class BigIntAuthor(BigIntAuditBase, base):  # type: ignore[misc, valid-type]
        __tablename__ = f"{table_prefix}bigint_author"
        __table_args__ = {"extend_existing": True}

        name: Mapped[str] = mapped_column(String(100))
        dob: Mapped[str | None] = mapped_column(String(50), nullable=True)

    class BigIntBook(BigIntAuditBase, base):  # type: ignore[misc, valid-type]
        __tablename__ = f"{table_prefix}bigint_book"
        __table_args__ = {"extend_existing": True}

        title: Mapped[str] = mapped_column(String(250))
        author_id: Mapped[int] = mapped_column(ForeignKey(f"{table_prefix}bigint_author.id"))
        author: Mapped[BigIntAuthor] = relationship(lazy="joined", innerjoin=True, viewonly=True)

    class BigIntItem(BigIntBase, base):  # type: ignore[misc, valid-type]
        __tablename__ = f"{table_prefix}bigint_item"
        __table_args__ = {"extend_existing": True}

        name: Mapped[str] = mapped_column(String(50))
        quantity: Mapped[int] = mapped_column(Integer, default=0)

    models.update(
        {
            "BigIntAuthor": BigIntAuthor,
            "BigIntBook": BigIntBook,
            "BigIntItem": BigIntItem,
        }
    )

    return models


def get_models_for_engine(engine: Engine | AsyncEngine, worker_id: str = "master") -> dict[str, type[Any]]:
    """Get appropriate models for the given engine based on its capabilities.

    Args:
        engine: The database engine
        worker_id: Worker ID for table name prefixing

    Returns:
        Dictionary of model name to model class
    """
    dialect_name = getattr(engine.dialect, "name", "")
    base = MetadataRegistry.get_base(dialect_name)
    table_prefix = f"{worker_id}_" if worker_id != "master" else ""

    models: dict[str, type[Any]] = {}

    # Always include UUID models as they're universally supported
    models.update(create_test_models(base, table_prefix))

    # Only include BigInt models if the database supports them
    if not DatabaseCapabilities.should_skip_bigint(dialect_name):
        models.update(create_bigint_models(base, table_prefix))

    return models


def create_tables_for_engine(engine: Engine | AsyncEngine, models: dict[str, type[Any]] | None = None) -> None:
    """Create tables for the given engine, handling database-specific requirements.

    Args:
        engine: The database engine
        models: Optional specific models to create, otherwise creates all appropriate models
    """
    from sqlalchemy import inspect

    if models is None:
        models = get_models_for_engine(engine)

    dialect_name = getattr(engine.dialect, "name", "")
    metadata = MetadataRegistry.get_metadata(dialect_name)

    # For CockroachDB, need to create tables in dependency order
    if "cockroach" in dialect_name.lower():
        # Create tables without foreign keys first
        inspector = inspect(engine)  # type: ignore[arg-type]
        existing_tables = inspector.get_table_names()

        # First pass: tables without foreign keys
        for model in models.values():
            if model.__tablename__ not in existing_tables:
                # Check if table has foreign keys
                has_fk = any(col.foreign_keys for col in model.__table__.columns)
                if not has_fk:
                    model.__table__.create(engine, checkfirst=True)  # type: ignore[arg-type]

        # Second pass: tables with foreign keys
        for model in models.values():
            if model.__tablename__ not in existing_tables:
                model.__table__.create(engine, checkfirst=True)  # type: ignore[arg-type]
    else:
        # Standard creation for other databases
        metadata.create_all(engine)  # type: ignore[arg-type]


async def create_tables_for_async_engine(engine: AsyncEngine, models: dict[str, type[Any]] | None = None) -> None:
    """Create tables for the given async engine.

    Args:
        engine: The async database engine
        models: Optional specific models to create, otherwise creates all appropriate models
    """
    from sqlalchemy import inspect

    if models is None:
        models = get_models_for_engine(engine)

    dialect_name = getattr(engine.dialect, "name", "")
    metadata = MetadataRegistry.get_metadata(dialect_name)

    async with engine.begin() as conn:
        if "cockroach" in dialect_name.lower():
            # CockroachDB needs dependency ordering
            inspector = await conn.run_sync(lambda sync_conn: inspect(sync_conn))
            existing_tables = await conn.run_sync(lambda sync_conn: inspector.get_table_names())

            # First pass: tables without foreign keys
            for model in models.values():
                if model.__tablename__ not in existing_tables:
                    has_fk = any(col.foreign_keys for col in model.__table__.columns)
                    if not has_fk:
                        await conn.run_sync(lambda sync_conn: model.__table__.create(sync_conn, checkfirst=True))

            # Second pass: tables with foreign keys
            for model in models.values():
                if model.__tablename__ not in existing_tables:
                    await conn.run_sync(lambda sync_conn: model.__table__.create(sync_conn, checkfirst=True))
        else:
            # Standard creation for other databases
            await conn.run_sync(metadata.create_all)


def cleanup_metadata_for_engine(engine: Engine | AsyncEngine) -> None:
    """Clean up metadata for the given engine."""
    dialect_name = getattr(engine.dialect, "name", "")
    MetadataRegistry.clear_metadata(dialect_name)


# pytest fixtures for standardized model handling across tests
@pytest.fixture()
def test_models_sync(engine: Engine, request: pytest.FixtureRequest) -> dict[str, type[Any]]:
    """Get appropriate test models for the given sync engine.

    This fixture creates isolated models with proper metadata management and
    table creation/cleanup.
    """
    if getattr(engine.dialect, "name", "") == "mock":
        # For mock engines, return empty models dict
        return {}

    worker_id = getattr(request.config, "workerinput", {}).get("workerid", "master")
    models = get_models_for_engine(engine, worker_id)

    # Create tables for these models
    create_tables_for_engine(engine, models)

    # Ensure cleanup after test
    def cleanup() -> None:
        try:
            cleanup_metadata_for_engine(engine)
        except Exception:
            pass  # Ignore cleanup errors

    request.addfinalizer(cleanup)
    return models


@pytest.fixture()
async def test_models_async(async_engine: AsyncEngine, request: pytest.FixtureRequest) -> dict[str, type[Any]]:
    """Get appropriate test models for the given async engine.

    This fixture creates isolated models with proper metadata management and
    table creation/cleanup.
    """
    if getattr(async_engine.dialect, "name", "") == "mock":
        # For mock engines, return empty models dict
        return {}

    worker_id = getattr(request.config, "workerinput", {}).get("workerid", "master")
    models = get_models_for_engine(async_engine, worker_id)

    # Create tables for these models
    await create_tables_for_async_engine(async_engine, models)

    # Ensure cleanup after test
    def cleanup() -> None:
        try:
            cleanup_metadata_for_engine(async_engine)
        except Exception:
            pass  # Ignore cleanup errors

    request.addfinalizer(cleanup)
    return models
python-advanced-alchemy-1.9.3/tests/integration/test_operations.py000066400000000000000000001305171516556515500255360ustar00rootroot00000000000000# pyright: ignore[reportMissingTypeArgument,reportOperatorIssue,reportAttributeAccessIssue]
"""Integration tests for advanced_alchemy.operations module.

These tests run against actual database instances to verify that the upsert
and MERGE operations work correctly across different database backends.
"""

from __future__ import annotations

import datetime
from collections.abc import AsyncGenerator, Generator
from typing import TYPE_CHECKING, Any, Optional, cast

import pytest
from sqlalchemy import Column, Integer, MetaData, String, Table, UniqueConstraint, select
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, sessionmaker

from advanced_alchemy.operations import OnConflictUpsert
from tests.integration.helpers import async_clean_tables, clean_tables, get_worker_id

if TYPE_CHECKING:
    from pytest import FixtureRequest
    from sqlalchemy import Engine

pytestmark = [
    pytest.mark.integration,
    pytest.mark.xdist_group("operations"),
]


# Module-level cache for test table
_test_table_cache: dict[str, Table] = {}


@pytest.fixture(scope="session")
def cached_test_table(request: FixtureRequest) -> Table:
    """Create test table once per session/worker."""
    worker_id = get_worker_id(request)
    cache_key = f"operation_test_{worker_id}"

    if cache_key not in _test_table_cache:
        metadata = MetaData()
        table = Table(
            f"operation_test_table_{worker_id}",
            metadata,
            Column("id", Integer, primary_key=True, autoincrement=False),
            Column("key", String(50), nullable=False, index=True),
            Column("namespace", String(50), nullable=False, index=True),
            Column("value", String(255)),
            Column("created_at", String(50)),
            # Note: For Spanner compatibility, we should use unique indexes instead
            # but for testing purposes, we'll skip Spanner tests that require UniqueConstraint
            UniqueConstraint("key", "namespace", name=f"uq_key_namespace_{worker_id}"),
        )
        _test_table_cache[cache_key] = table

    return _test_table_cache[cache_key]


@pytest.fixture
def test_table_sync(
    cached_test_table: Table,
    request: FixtureRequest,
) -> Generator[Table, None, None]:
    """Setup test table for sync engines with fast cleanup."""
    # Get the sync engine - either from any_engine or engine fixture
    if "any_engine" in request.fixturenames:
        engine = request.getfixturevalue("any_engine")
        if isinstance(engine, AsyncEngine):
            pytest.skip("Async engine provided to sync fixture")
    else:
        engine = request.getfixturevalue("engine")

    # Skip for mock and spanner engines (Spanner doesn't support UniqueConstraint)
    dialect_name = getattr(engine.dialect, "name", "")
    if "spanner" in dialect_name:
        # Skip entire test for Spanner - it doesn't support UniqueConstraint
        pytest.skip("Spanner does not support UniqueConstraint - requires unique indexes")
    elif dialect_name != "mock":
        # Create table once per engine type
        cached_test_table.create(engine, checkfirst=True)

    yield cached_test_table

    # Fast data-only cleanup between tests
    if dialect_name not in {"mock", "spanner+spanner"}:
        with engine.begin() as conn:
            conn.execute(cached_test_table.delete())
            conn.commit()

    # Drop table at session end (handled by teardown)


@pytest.fixture
async def test_table_async(
    cached_test_table: Table,
    request: FixtureRequest,
) -> AsyncGenerator[Table, None]:
    """Setup test table for async engines with fast cleanup."""
    # Get the async engine - either from any_engine or async_engine fixture
    if "any_engine" in request.fixturenames:
        engine = request.getfixturevalue("any_engine")
        if not isinstance(engine, AsyncEngine):
            pytest.skip("Sync engine provided to async fixture")
        async_engine = engine
    else:
        async_engine = request.getfixturevalue("async_engine")

    # Skip for mock and spanner engines (Spanner doesn't support UniqueConstraint)
    dialect_name = getattr(async_engine.dialect, "name", "")
    if "spanner" in dialect_name:
        # Skip table creation for Spanner - it doesn't support UniqueConstraint
        pytest.skip("Spanner does not support UniqueConstraint - requires unique indexes")
    elif dialect_name != "mock":
        # Create table once per engine type
        async with async_engine.begin() as conn:
            await conn.run_sync(lambda sync_conn: cached_test_table.create(sync_conn, checkfirst=True))

    yield cached_test_table

    # Fast data-only cleanup between tests
    if dialect_name != "mock" and "spanner" not in dialect_name:
        async with async_engine.begin() as conn:
            await conn.execute(cached_test_table.delete())
            await conn.commit()

    # Drop table at session end (handled by teardown)


@pytest.fixture
def test_table(
    request: FixtureRequest,
) -> Table:
    """Unified test table fixture that works with any engine."""
    # Check if we have any_engine fixture
    if "any_engine" in request.fixturenames:
        engine = request.getfixturevalue("any_engine")
        if isinstance(engine, AsyncEngine):
            return cast(Table, request.getfixturevalue("test_table_async"))
        return cast(Table, request.getfixturevalue("test_table_sync"))
    # Check which fixtures are available in the request
    if "test_table_sync" in request.fixturenames:
        return cast(Table, request.getfixturevalue("test_table_sync"))
    if "test_table_async" in request.fixturenames:
        return cast(Table, request.getfixturevalue("test_table_async"))
    # Fallback to cached table for tests that don't use engines
    return cast(Table, request.getfixturevalue("cached_test_table"))


# Module-level cache for operations test model classes - separate from other tests
# Disable caching to prevent cross-test contamination during parametrized runs


@pytest.fixture
def cached_store_model(request: FixtureRequest) -> type[DeclarativeBase]:
    """Create store model dynamically based on the engine being used.

    Note: Cannot be session-scoped because different tests use different engine types.
    """
    from advanced_alchemy.base import BigIntBase, UUIDv7Base

    worker_id = get_worker_id(request)

    # Check what database engine is being used
    uses_uuid_pk = False

    # Check if any_engine is being used and what its actual value is
    if "any_engine" in request.fixturenames:
        # For parametrized fixtures, we need to check which engine is actually being used
        # The any_engine fixture resolves to one of the specific engine fixtures
        # We need to look at all fixture names to find the actual engine
        pass

    # Look through all the fixtures that the test is requesting
    # This includes both direct fixtures and indirect ones from parametrization
    for fixturename in request.fixturenames:
        if "cockroachdb" in fixturename or "spanner" in fixturename:
            uses_uuid_pk = True
            break

    # Include fixture names in cache key to differentiate database types
    fixture_suffix = "_".join(sorted(name for name in request.fixturenames if "engine" in name))
    _cache_key = f"store_model_{worker_id}_{uses_uuid_pk}_{fixture_suffix}"

    # Always create new model to prevent cross-test contamination
    # if cache_key not in _operations_store_model_cache:
    #     # Clear entire cache to avoid any conflicts with other test model types

    # Determine if we need unique constraint or just indexes
    is_spanner = any(fixturename == "spanner_engine" for fixturename in request.fixturenames)

    # Define distinct local classes to avoid pyright redefinition warnings, then select one
    class _TestStoreModelUUID(UUIDv7Base):  # pyright: ignore[reportPrivateUsage]
        __tablename__ = f"test_store_{worker_id}"

        key: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
        namespace: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
        value: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
        expires_at: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)

        # Spanner doesn't support UniqueConstraint, use indexes instead
        # CockroachDB needs UniqueConstraint for ON CONFLICT
        if is_spanner:
            __table_args__ = ()
        else:
            __table_args__ = (UniqueConstraint("key", "namespace", name=f"uq_uuid_store_key_ns_{worker_id}"),)  # type: ignore[assignment]

    class _TestStoreModelBigInt(BigIntBase):
        __tablename__ = f"test_store_{worker_id}"

        key: Mapped[str] = mapped_column(String(50), nullable=False)
        namespace: Mapped[str] = mapped_column(String(50), nullable=False)
        value: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
        expires_at: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)

        __table_args__ = (UniqueConstraint("key", "namespace", name=f"uq_store_key_ns_{worker_id}"),)

    chosen: type[DeclarativeBase] = cast(
        type[DeclarativeBase], _TestStoreModelUUID if uses_uuid_pk else _TestStoreModelBigInt
    )
    return chosen  # Return model directly without caching


@pytest.fixture
def store_model_sync(
    cached_store_model: type[DeclarativeBase],
    request: FixtureRequest,
) -> Generator[type[DeclarativeBase], None, None]:
    """Setup store model for sync engines with fast cleanup."""
    # Get the sync engine - either from any_engine or engine fixture
    if "any_engine" in request.fixturenames:
        engine = request.getfixturevalue("any_engine")
        if isinstance(engine, AsyncEngine):
            pytest.skip("Async engine provided to sync fixture")
    else:
        engine = request.getfixturevalue("engine")

    # Skip for mock and spanner engines (Spanner doesn't support UniqueConstraint)
    dialect_name = getattr(engine.dialect, "name", "")
    if "spanner" in dialect_name:
        # Skip table creation for Spanner - it doesn't support UniqueConstraint
        pytest.skip("Spanner does not support UniqueConstraint - requires unique indexes")

    # Skip BigInt models for CockroachDB - it only supports UUID primary keys
    from advanced_alchemy.base import BigIntBase

    if dialect_name.startswith("cockroach") and issubclass(cached_store_model, BigIntBase):
        pytest.skip("CockroachDB doesn't support BigInt primary keys")

    if dialect_name != "mock":
        # Create table once per engine type
        cached_store_model.metadata.create_all(engine, checkfirst=True)

    yield cached_store_model

    # Fast data-only cleanup between tests
    if dialect_name != "mock" and "spanner" not in dialect_name:
        clean_tables(engine, cached_store_model.metadata)

    # Drop table at session end (handled by teardown)


@pytest.fixture
async def store_model_async(
    cached_store_model: type[DeclarativeBase],
    request: FixtureRequest,
) -> AsyncGenerator[type[DeclarativeBase], None]:
    """Setup store model for async engines with fast cleanup."""
    # Get the async engine - either from any_engine or async_engine fixture
    if "any_engine" in request.fixturenames:
        engine = request.getfixturevalue("any_engine")
        if not isinstance(engine, AsyncEngine):
            pytest.skip("Sync engine provided to async fixture")
        async_engine = engine
    else:
        async_engine = request.getfixturevalue("async_engine")

    # Skip for mock and spanner engines (Spanner doesn't support UniqueConstraint)
    dialect_name = getattr(async_engine.dialect, "name", "")
    if "spanner" in dialect_name:
        # Skip table creation for Spanner - it doesn't support UniqueConstraint
        pytest.skip("Spanner does not support UniqueConstraint - requires unique indexes")

    # Skip BigInt models for CockroachDB - it only supports UUID primary keys
    from advanced_alchemy.base import BigIntBase

    if dialect_name.startswith("cockroach") and issubclass(cached_store_model, BigIntBase):
        pytest.skip("CockroachDB doesn't support BigInt primary keys")

    if dialect_name != "mock":
        # Create table once per engine type
        async with async_engine.begin() as conn:
            await conn.run_sync(cached_store_model.metadata.create_all)

    yield cached_store_model

    # Fast data-only cleanup between tests
    if dialect_name != "mock" and "spanner" not in dialect_name:
        await async_clean_tables(async_engine, cached_store_model.metadata)

    # Drop table at session end (handled by teardown)


@pytest.fixture
def store_model(
    request: FixtureRequest,
) -> type[DeclarativeBase]:
    """Unified store model fixture that works with any engine."""
    # Check if we have any_engine fixture
    if "any_engine" in request.fixturenames:
        engine = request.getfixturevalue("any_engine")
        if isinstance(engine, AsyncEngine):
            return cast(type[DeclarativeBase], request.getfixturevalue("store_model_async"))
        return cast(type[DeclarativeBase], request.getfixturevalue("store_model_sync"))
    # Check which fixtures are available in the request
    if "store_model_sync" in request.fixturenames:
        return cast(type[DeclarativeBase], request.getfixturevalue("store_model_sync"))
    if "store_model_async" in request.fixturenames:
        return cast(type[DeclarativeBase], request.getfixturevalue("store_model_async"))
    # Fallback to cached model for tests that don't use engines
    return cast(type[DeclarativeBase], request.getfixturevalue("cached_store_model"))


@pytest.fixture
def upsert_values() -> dict[str, Any]:
    """Sample values for upsert operations."""
    return {
        "id": 1,
        "key": "test_key",
        "namespace": "test_ns",
        "value": "test_value",
        "created_at": datetime.datetime.now().isoformat(),
    }


@pytest.fixture
def updated_values() -> dict[str, Any]:
    """Updated values for upsert operations."""
    return {
        "id": 1,
        "key": "test_key",
        "namespace": "test_ns",
        "value": "updated_value",
        "created_at": datetime.datetime.now().isoformat(),
    }


@pytest.fixture(
    params=[
        # Sync engines
        pytest.param(
            "sqlite_engine",
            marks=[pytest.mark.sqlite, pytest.mark.integration, pytest.mark.xdist_group("sqlite")],
        ),
        pytest.param(
            "duckdb_engine",
            marks=[pytest.mark.duckdb, pytest.mark.integration, pytest.mark.xdist_group("duckdb")],
        ),
        pytest.param(
            "psycopg_engine",
            marks=[pytest.mark.psycopg_sync, pytest.mark.integration, pytest.mark.xdist_group("postgres")],
        ),
        pytest.param(
            "mssql_engine",
            marks=[pytest.mark.mssql_sync, pytest.mark.integration, pytest.mark.xdist_group("mssql")],
        ),
        pytest.param(
            "oracle18c_engine",
            marks=[pytest.mark.oracledb_sync, pytest.mark.integration, pytest.mark.xdist_group("oracle18")],
        ),
        pytest.param(
            "oracle23ai_engine",
            marks=[pytest.mark.oracledb_sync, pytest.mark.integration, pytest.mark.xdist_group("oracle23")],
        ),
        pytest.param(
            "cockroachdb_engine",
            marks=[pytest.mark.cockroachdb_sync, pytest.mark.integration, pytest.mark.xdist_group("cockroachdb")],
        ),
        pytest.param(
            "spanner_engine",
            marks=[pytest.mark.spanner, pytest.mark.integration, pytest.mark.xdist_group("spanner")],
        ),
        pytest.param(
            "mock_sync_engine",
            marks=[pytest.mark.mock_sync, pytest.mark.integration, pytest.mark.xdist_group("mock")],
        ),
        # Async engines
        pytest.param(
            "aiosqlite_engine",
            marks=[pytest.mark.aiosqlite, pytest.mark.integration, pytest.mark.xdist_group("sqlite")],
        ),
        pytest.param(
            "asyncmy_engine",
            marks=[pytest.mark.asyncmy, pytest.mark.integration, pytest.mark.xdist_group("mysql")],
        ),
        pytest.param(
            "asyncpg_engine",
            marks=[pytest.mark.asyncpg, pytest.mark.integration, pytest.mark.xdist_group("postgres")],
        ),
        pytest.param(
            "psycopg_async_engine",
            marks=[pytest.mark.psycopg_async, pytest.mark.integration, pytest.mark.xdist_group("postgres")],
        ),
        pytest.param(
            "cockroachdb_async_engine",
            marks=[pytest.mark.cockroachdb_async, pytest.mark.integration, pytest.mark.xdist_group("cockroachdb")],
        ),
        pytest.param(
            "mssql_async_engine",
            marks=[pytest.mark.mssql_async, pytest.mark.integration, pytest.mark.xdist_group("mssql")],
        ),
        pytest.param(
            "oracle18c_async_engine",
            marks=[pytest.mark.oracledb_async, pytest.mark.integration, pytest.mark.xdist_group("oracle18")],
        ),
        pytest.param(
            "oracle23ai_async_engine",
            marks=[pytest.mark.oracledb_async, pytest.mark.integration, pytest.mark.xdist_group("oracle23")],
        ),
        pytest.param(
            "mock_async_engine",
            marks=[pytest.mark.mock_async, pytest.mark.integration, pytest.mark.xdist_group("mock")],
        ),
    ]
)
def any_engine(request: FixtureRequest) -> Engine | AsyncEngine:
    """Return any available engine for testing.

    Note: This fixture cannot be session-scoped because async fixtures
    must be function-scoped with pytest-asyncio.
    """
    return cast("Engine | AsyncEngine", request.getfixturevalue(request.param))


# Session-level teardown to ensure tables are dropped
@pytest.fixture(scope="session", autouse=True)
def cleanup_operations_tables(request: FixtureRequest) -> Generator[None, None, None]:
    """Ensure all operation test tables are dropped at session end."""
    yield

    # Clean up all cached tables at session end
    for cache_key, table in _test_table_cache.items():
        # Drop table from all engines if they exist
        # This is handled by individual fixtures, but we ensure cleanup here
        pass

        # for cache_key, model in _operations_store_model_cache.items():
        # Drop model tables from all engines if they exist
        # This is handled by individual fixtures, but we ensure cleanup here
        pass


async def test_supports_native_upsert_all_dialects(any_engine: Engine | AsyncEngine) -> None:
    """Test dialect support detection against actual engines."""

    if getattr(any_engine.dialect, "name", "") == "mock":
        pytest.skip("Mock engine cannot test real database operations")

    dialect_name = any_engine.dialect.name
    expected_support = dialect_name in {"postgresql", "cockroachdb", "sqlite", "mysql", "mariadb", "duckdb"}

    actual_support = OnConflictUpsert.supports_native_upsert(dialect_name)
    assert actual_support == expected_support, f"Dialect '{dialect_name}' support mismatch"


async def test_create_upsert_with_supported_dialects(
    any_engine: Engine | AsyncEngine,
    test_table: Table,
    upsert_values: dict[str, Any],
) -> None:
    """Test upsert creation against supported database dialects."""

    if getattr(any_engine.dialect, "name", "") == "mock":
        pytest.skip("Mock engine cannot test real database operations")

    dialect_name = any_engine.dialect.name

    if "spanner" in dialect_name:
        pytest.skip("Spanner does not support UniqueConstraint - requires unique indexes")

    if not OnConflictUpsert.supports_native_upsert(dialect_name):
        pytest.skip(f"Dialect '{dialect_name}' does not support native upsert")

    # Tables are already created by fixtures, no need to create here
    conflict_columns = ["key", "namespace"]
    update_columns = ["value", "created_at"]

    # Create upsert statement
    upsert_stmt = OnConflictUpsert.create_upsert(
        table=test_table,
        values=upsert_values,
        conflict_columns=conflict_columns,
        update_columns=update_columns,
        dialect_name=dialect_name,
    )

    # Verify the statement was created
    assert upsert_stmt is not None

    # Execute the upsert
    if isinstance(any_engine, AsyncEngine):
        async with any_engine.connect() as conn:
            await conn.execute(upsert_stmt)
            await conn.commit()
    else:
        with any_engine.connect() as conn:  # type: ignore[attr-defined]
            conn.execute(upsert_stmt)
            conn.commit()


async def test_upsert_insert_then_update_cycle(
    any_engine: Engine | AsyncEngine,
    test_table: Table,
    upsert_values: dict[str, Any],
    updated_values: dict[str, Any],
) -> None:
    """Test that upsert properly inserts and then updates on conflict."""

    if getattr(any_engine.dialect, "name", "") == "mock":
        pytest.skip("Mock engine cannot test real database operations")

    dialect_name = any_engine.dialect.name

    if "spanner" in dialect_name:
        pytest.skip("Spanner does not support UniqueConstraint - requires unique indexes")

    if not OnConflictUpsert.supports_native_upsert(dialect_name):
        pytest.skip(f"Dialect '{dialect_name}' does not support native upsert")

    # Tables are already created by fixtures
    conflict_columns = ["key", "namespace"]
    update_columns = ["value", "created_at"]

    # First upsert - should insert
    upsert_stmt = OnConflictUpsert.create_upsert(
        table=test_table,
        values=upsert_values,
        conflict_columns=conflict_columns,
        update_columns=update_columns,
        dialect_name=dialect_name,
    )

    if isinstance(any_engine, AsyncEngine):
        async with any_engine.connect() as conn:
            await conn.execute(upsert_stmt)
            await conn.commit()

            # Verify insert
            result = await conn.execute(
                select(test_table.c.value).where(
                    (test_table.c.key == upsert_values["key"]) & (test_table.c.namespace == upsert_values["namespace"])
                )
            )
            row = result.fetchone()
            assert row is not None
            assert row[0] == "test_value"
    else:
        with any_engine.connect() as conn:  # type: ignore[attr-defined]
            conn.execute(upsert_stmt)
            conn.commit()

            # Verify insert
            result = conn.execute(
                select(test_table.c.value).where(
                    (test_table.c.key == upsert_values["key"]) & (test_table.c.namespace == upsert_values["namespace"])
                )
            )
            row = result.fetchone()
            assert row is not None
            assert row[0] == "test_value"

    # Second upsert - should update
    upsert_stmt2 = OnConflictUpsert.create_upsert(
        table=test_table,
        values=updated_values,
        conflict_columns=conflict_columns,
        update_columns=update_columns,
        dialect_name=dialect_name,
    )

    if isinstance(any_engine, AsyncEngine):
        async with any_engine.connect() as conn:
            await conn.execute(upsert_stmt2)
            await conn.commit()

            # Verify update
            result = await conn.execute(
                select(test_table.c.value).where(
                    (test_table.c.key == updated_values["key"])
                    & (test_table.c.namespace == updated_values["namespace"])
                )
            )
            row = result.fetchone()
            assert row is not None
            assert row[0] == "updated_value"

            # Verify only one row exists
            count_result = await conn.execute(select(test_table).where(test_table.c.key == "test_key"))
            rows = count_result.fetchall()
            assert len(rows) == 1
    else:
        with any_engine.connect() as conn:  # type: ignore[attr-defined]
            conn.execute(upsert_stmt2)
            conn.commit()

            # Verify update
            result = conn.execute(
                select(test_table.c.value).where(
                    (test_table.c.key == updated_values["key"])
                    & (test_table.c.namespace == updated_values["namespace"])
                )
            )
            row = result.fetchone()
            assert row is not None
            assert row[0] == "updated_value"

            # Verify only one row exists
            count_result = conn.execute(select(test_table).where(test_table.c.key == "test_key"))
            rows = count_result.fetchall()
            assert len(rows) == 1


async def test_batch_upsert_operations(any_engine: Engine | AsyncEngine, test_table: Table) -> None:
    """Test batch upsert operations with multiple rows."""

    if getattr(any_engine.dialect, "name", "") == "mock":
        pytest.skip("Mock engine cannot test real database operations")

    dialect_name = any_engine.dialect.name

    if "spanner" in dialect_name:
        pytest.skip("Spanner does not support UniqueConstraint - requires unique indexes")

    if not OnConflictUpsert.supports_native_upsert(dialect_name):
        pytest.skip(f"Dialect '{dialect_name}' does not support native upsert")

    # Tables are already created by fixtures
    batch_values = [
        {"id": 1, "key": "key1", "namespace": "ns1", "value": "value1", "created_at": "2024-01-01"},
        {"id": 2, "key": "key2", "namespace": "ns1", "value": "value2", "created_at": "2024-01-02"},
        {"id": 3, "key": "key3", "namespace": "ns2", "value": "value3", "created_at": "2024-01-03"},
    ]

    conflict_columns = ["key", "namespace"]
    update_columns = ["value", "created_at"]

    # Create batch upsert
    upsert_stmt = OnConflictUpsert.create_upsert(
        table=test_table,
        values=batch_values,  # type: ignore[arg-type]
        conflict_columns=conflict_columns,
        update_columns=update_columns,
        dialect_name=dialect_name,
    )

    if isinstance(any_engine, AsyncEngine):
        async with any_engine.connect() as conn:
            await conn.execute(upsert_stmt)
            await conn.commit()

            # Verify all rows inserted
            result = await conn.execute(select(test_table).order_by(test_table.c.id))
            rows = result.fetchall()
            assert len(rows) == 3
            assert rows[0].value == "value1"
            assert rows[1].value == "value2"
            assert rows[2].value == "value3"
    else:
        with any_engine.connect() as conn:  # type: ignore[attr-defined]
            conn.execute(upsert_stmt)
            conn.commit()

            # Verify all rows inserted
            result = conn.execute(select(test_table).order_by(test_table.c.id))
            rows = result.fetchall()
            assert len(rows) == 3
            assert rows[0].value == "value1"
            assert rows[1].value == "value2"
            assert rows[2].value == "value3"

    # Update batch with conflicts
    updated_batch = [
        {"id": 1, "key": "key1", "namespace": "ns1", "value": "updated1", "created_at": "2024-02-01"},
        {"id": 4, "key": "key4", "namespace": "ns2", "value": "value4", "created_at": "2024-01-04"},
    ]

    upsert_stmt2 = OnConflictUpsert.create_upsert(
        table=test_table,
        values=updated_batch,  # type: ignore[arg-type]
        conflict_columns=conflict_columns,
        update_columns=update_columns,
        dialect_name=dialect_name,
    )

    if isinstance(any_engine, AsyncEngine):
        async with any_engine.connect() as conn:
            await conn.execute(upsert_stmt2)
            await conn.commit()

            # Verify mixed insert/update
            result = await conn.execute(select(test_table).order_by(test_table.c.id))
            rows = result.fetchall()
            assert len(rows) == 4
            assert rows[0].value == "updated1"  # Updated
            assert rows[3].value == "value4"  # Inserted
    else:
        with any_engine.connect() as conn:  # type: ignore[attr-defined]
            conn.execute(upsert_stmt2)
            conn.commit()

            # Verify mixed insert/update
            result = conn.execute(select(test_table).order_by(test_table.c.id))
            rows = result.fetchall()
            assert len(rows) == 4
            assert rows[0].value == "updated1"  # Updated
            assert rows[3].value == "value4"  # Inserted


async def test_merge_statement_with_oracle_postgres(
    any_engine: Engine | AsyncEngine,
    test_table: Table,
    upsert_values: dict[str, Any],
    updated_values: dict[str, Any],
) -> None:
    """Test MERGE statement for Oracle and PostgreSQL 15+."""

    if getattr(any_engine.dialect, "name", "") == "mock":
        pytest.skip("Mock engine cannot test real database operations")

    dialect_name = any_engine.dialect.name

    # Only test on supported dialects (CockroachDB doesn't support MERGE)
    if dialect_name not in {"oracle", "postgresql"}:
        pytest.skip(f"MERGE not tested for dialect '{dialect_name}'")

    # PostgreSQL needs version 15+ for MERGE
    if dialect_name == "postgresql":
        server_version_info = getattr(any_engine.dialect, "server_version_info", (0,))
        try:
            major = int(server_version_info[0]) if isinstance(server_version_info, tuple) and server_version_info else 0
        except Exception:
            major = 0
        if major < 15:
            pytest.skip("PostgreSQL MERGE requires version 15+")

    # Tables are already created by fixtures
    # First insert a record
    if isinstance(any_engine, AsyncEngine):
        async with any_engine.connect() as conn:
            await conn.execute(test_table.insert(), upsert_values)
            await conn.commit()
    else:
        with any_engine.connect() as conn:  # type: ignore[attr-defined]
            conn.execute(test_table.insert(), upsert_values)
            conn.commit()

    # Create MERGE statement for update
    merge_stmt, additional_params = OnConflictUpsert.create_merge_upsert(
        table=test_table,
        values=updated_values,
        conflict_columns=["key", "namespace"],
        update_columns=["value", "created_at"],
        dialect_name=dialect_name,
    )

    # Execute MERGE
    if isinstance(any_engine, AsyncEngine):
        async with any_engine.connect() as conn:
            if dialect_name in {"oracle", "mssql"}:
                merged_params = {**updated_values, **additional_params}
                await conn.execute(merge_stmt, merged_params)
            elif dialect_name in {"postgresql", "cockroachdb"}:
                # PostgreSQL MERGE only needs source parameters (src references are used in UPDATE/INSERT)
                pg_params = {f"src_{k}": v for k, v in updated_values.items()}
                await conn.execute(merge_stmt, pg_params)
            else:
                await conn.execute(merge_stmt, updated_values)
            await conn.commit()

            # Verify update
            result = await conn.execute(
                select(test_table.c.value).where(
                    (test_table.c.key == updated_values["key"])
                    & (test_table.c.namespace == updated_values["namespace"])
                )
            )
            row = result.fetchone()
            assert row is not None
            assert row[0] == "updated_value"
    else:
        with any_engine.connect() as conn:  # type: ignore[attr-defined]
            if dialect_name in {"oracle", "mssql"}:
                merged_params = {**updated_values, **additional_params}
                conn.execute(merge_stmt, merged_params)
            elif dialect_name in {"postgresql", "cockroachdb"}:
                # PostgreSQL needs unique parameter names for each clause
                pg_params = {
                    **{f"src_{k}": v for k, v in updated_values.items()},  # Source VALUES clause
                    **{
                        f"upd_{k}": v for k, v in updated_values.items() if k in ["value", "created_at"]
                    },  # UPDATE clause
                    **{f"ins_{k}": v for k, v in updated_values.items()},  # INSERT clause
                }
                conn.execute(merge_stmt, pg_params)
            else:
                conn.execute(merge_stmt, updated_values)
            conn.commit()

            # Verify update
            result = conn.execute(
                select(test_table.c.value).where(
                    (test_table.c.key == updated_values["key"])
                    & (test_table.c.namespace == updated_values["namespace"])
                )
            )
            row = result.fetchone()
            assert row is not None
            assert row[0] == "updated_value"

    # Test MERGE with new record (insert)
    new_values = {
        "id": 2,
        "key": "new_key",
        "namespace": "new_ns",
        "value": "new_value",
        "created_at": datetime.datetime.now().isoformat(),
    }

    merge_stmt2, additional_params2 = OnConflictUpsert.create_merge_upsert(
        table=test_table,
        values=new_values,
        conflict_columns=["key", "namespace"],
        update_columns=["value", "created_at"],
        dialect_name=dialect_name,
    )

    if isinstance(any_engine, AsyncEngine):
        async with any_engine.connect() as conn:
            if dialect_name in {"oracle", "mssql"}:
                merged_params2 = {**new_values, **additional_params2}
                await conn.execute(merge_stmt2, merged_params2)
            elif dialect_name in {"postgresql", "cockroachdb"}:
                # PostgreSQL MERGE only needs source parameters (src references are used in UPDATE/INSERT)
                pg_params2 = {f"src_{k}": v for k, v in new_values.items()}
                await conn.execute(merge_stmt2, pg_params2)
            else:
                await conn.execute(merge_stmt2, new_values)
            await conn.commit()

            # Verify insert
            result = await conn.execute(select(test_table).where(test_table.c.key == "new_key"))
            row = result.fetchone()
            assert row is not None
            assert row.value == "new_value"
    else:
        with any_engine.connect() as conn:  # type: ignore[attr-defined]
            if dialect_name in {"oracle", "mssql"}:
                merged_params2 = {**new_values, **additional_params2}
                conn.execute(merge_stmt2, merged_params2)
            elif dialect_name in {"postgresql", "cockroachdb"}:
                # PostgreSQL needs unique parameter names for each clause
                pg_params2 = {
                    **{f"src_{k}": v for k, v in new_values.items()},  # Source VALUES clause
                    **{f"upd_{k}": v for k, v in new_values.items() if k in ["value", "created_at"]},  # UPDATE clause
                    **{f"ins_{k}": v for k, v in new_values.items()},  # INSERT clause
                }
                conn.execute(merge_stmt2, pg_params2)
            else:
                conn.execute(merge_stmt2, new_values)
            conn.commit()

            # Verify insert
            result = conn.execute(select(test_table).where(test_table.c.key == "new_key"))
            row = result.fetchone()
            assert row is not None
            assert row.value == "new_value"


async def test_merge_compilation_oracle_postgres(any_engine: Engine | AsyncEngine) -> None:
    """Test MERGE statement compilation for different dialects."""

    if getattr(any_engine.dialect, "name", "") == "mock":
        pytest.skip("Mock engine cannot test compilation")

    dialect_name = any_engine.dialect.name

    # Only test on supported dialects (CockroachDB doesn't support MERGE)
    if dialect_name not in {"oracle", "postgresql"}:
        pytest.skip(f"MERGE compilation not tested for dialect '{dialect_name}'")

    # PostgreSQL needs version 15+ for MERGE
    if dialect_name == "postgresql":
        server_version_info = getattr(any_engine.dialect, "server_version_info", (0,))
        try:
            major = int(server_version_info[0]) if isinstance(server_version_info, tuple) and server_version_info else 0
        except Exception:
            major = 0
        if major < 15:
            pytest.skip("PostgreSQL MERGE requires version 15+")

    # Create a simple test table
    metadata = MetaData()
    test_compile_table = Table(
        "compile_test",
        metadata,
        Column("id", Integer, primary_key=True),
        Column("key", String(50)),
        Column("value", String(100)),
    )

    test_values = {"id": 1, "key": "test", "value": "data"}

    # Create MERGE statement
    merge_stmt, _ = OnConflictUpsert.create_merge_upsert(
        table=test_compile_table,
        values=test_values,
        conflict_columns=["key"],
        update_columns=["value"],
        dialect_name=dialect_name,
    )

    # Compile the statement
    if isinstance(any_engine, AsyncEngine):
        compiled = merge_stmt.compile(dialect=any_engine.dialect)  # type: ignore[attr-defined]
    else:
        compiled = merge_stmt.compile(dialect=any_engine.dialect)

    # Verify it compiled
    assert compiled is not None
    assert str(compiled)  # Should produce SQL string


async def test_store_upsert_integration(
    any_engine: Engine | AsyncEngine,
    store_model: type[DeclarativeBase],
    request: FixtureRequest,
) -> None:
    """Test store-like upsert pattern with model class."""

    if getattr(any_engine.dialect, "name", "") == "mock":
        pytest.skip("Mock engine cannot test real database operations")

    dialect_name = any_engine.dialect.name

    if dialect_name == "spanner+spanner":
        pytest.skip("Spanner does not support UniqueConstraint - requires unique indexes")

    # Cast to Any to avoid pyright attribute errors in test expressions
    TestStoreModel: Any = store_model

    # Tables are already created by fixtures
    # The base class will handle id generation (integer or UUID)
    store_data = {
        "key": "cache_key",
        "namespace": "default",
        "value": "cached_data",
        "expires_at": (datetime.datetime.now() + datetime.timedelta(hours=1)).isoformat(),
    }

    # CockroachDB and Spanner require the primary key to be provided for upserts
    # Determine if we're using UUID or BigInt primary keys
    if dialect_name.startswith(("cockroach", "spanner")):
        # Check if the model is using UUID or BigInt by checking the base class
        # UUIDv7Base has UUID primary keys, BigIntBase has integer primary keys
        import uuid

        from advanced_alchemy.base import UUIDv7Base

        # Check if the model inherits from UUIDv7Base
        if issubclass(TestStoreModel, UUIDv7Base):
            # UUID primary key
            store_data["id"] = str(uuid.uuid4())
        else:
            # BigInt primary key - don't add ID, let the database generate it
            pass

    # Create upsert for store pattern
    additional_params: dict[str, Any] = {}
    if OnConflictUpsert.supports_native_upsert(dialect_name):
        upsert_stmt = OnConflictUpsert.create_upsert(
            table=TestStoreModel.__table__,  # type: ignore[arg-type]
            values=store_data,
            conflict_columns=["key", "namespace"],
            update_columns=["value", "expires_at"],
            dialect_name=dialect_name,
        )
    elif dialect_name == "oracle":
        upsert_stmt, additional_params = OnConflictUpsert.create_merge_upsert(  # type: ignore[assignment]
            table=TestStoreModel.__table__,  # type: ignore[arg-type]
            values=store_data,
            conflict_columns=["key", "namespace"],
            update_columns=["value", "expires_at"],
            dialect_name=dialect_name,
        )
    else:
        pytest.skip(f"No upsert support for dialect '{dialect_name}'")
    additional_params2: dict[str, Any] = {}
    # Execute and verify
    if isinstance(any_engine, AsyncEngine):
        async_session_factory = async_sessionmaker(bind=any_engine)
        async with async_session_factory() as session:
            if dialect_name == "oracle":
                # Pass the values for MERGE statements
                merged_params = {**store_data, **additional_params}
                await session.execute(upsert_stmt, merged_params)
            else:
                await session.execute(upsert_stmt)
            await session.commit()

            # Verify insertion
            result = await session.execute(
                select(TestStoreModel).where(
                    (TestStoreModel.key == store_data["key"]) & (TestStoreModel.namespace == store_data["namespace"])
                )
            )
            obj = result.scalar_one_or_none()
            assert obj is not None
            assert obj.value == "cached_data"

        # Update with new expiration
        updated_store = store_data.copy()
        updated_store["value"] = "updated_cache"
        updated_store["expires_at"] = (datetime.datetime.now() + datetime.timedelta(hours=2)).isoformat()

        if OnConflictUpsert.supports_native_upsert(dialect_name):
            upsert_stmt2 = OnConflictUpsert.create_upsert(
                table=TestStoreModel.__table__,  # type: ignore[arg-type]
                values=updated_store,
                conflict_columns=["key", "namespace"],
                update_columns=["value", "expires_at"],
                dialect_name=dialect_name,
            )
        else:
            upsert_stmt2, additional_params2 = OnConflictUpsert.create_merge_upsert(  # type: ignore[assignment]
                table=TestStoreModel.__table__,  # type: ignore[arg-type]  # type: ignore[arg-type]
                values=updated_store,
                conflict_columns=["key", "namespace"],
                update_columns=["value", "expires_at"],
                dialect_name=dialect_name,
            )

        async with async_session_factory() as session:
            if dialect_name == "oracle":
                merged_params2 = {**updated_store, **additional_params2}
                await session.execute(upsert_stmt2, merged_params2)
            else:
                await session.execute(upsert_stmt2)
            await session.commit()

            # Verify update
            result = await session.execute(
                select(TestStoreModel).where(
                    (TestStoreModel.key == updated_store["key"])
                    & (TestStoreModel.namespace == updated_store["namespace"])
                )
            )
            obj = result.scalar_one_or_none()
            assert obj is not None
            assert obj.value == "updated_cache"
    else:
        session_factory = sessionmaker(bind=any_engine)
        with session_factory() as session:
            if dialect_name == "oracle":
                # Pass the values for MERGE statements
                merged_params = {**store_data, **additional_params}
                session.execute(upsert_stmt, merged_params)
            else:
                session.execute(upsert_stmt)
            session.commit()

            # Verify insertion
            result = session.execute(
                select(TestStoreModel).where(
                    (TestStoreModel.key == store_data["key"]) & (TestStoreModel.namespace == store_data["namespace"])
                )
            )
            obj = result.scalar_one_or_none()
            assert obj is not None
            assert obj.value == "cached_data"

        # Update with new expiration
        updated_store = store_data.copy()
        updated_store["value"] = "updated_cache"
        updated_store["expires_at"] = (datetime.datetime.now() + datetime.timedelta(hours=2)).isoformat()

        if OnConflictUpsert.supports_native_upsert(dialect_name):
            upsert_stmt2 = OnConflictUpsert.create_upsert(
                table=TestStoreModel.__table__,  # type: ignore[arg-type]
                values=updated_store,
                conflict_columns=["key", "namespace"],
                update_columns=["value", "expires_at"],
                dialect_name=dialect_name,
            )
        else:
            upsert_stmt2, additional_params2 = OnConflictUpsert.create_merge_upsert(  # type: ignore[assignment]
                table=TestStoreModel.__table__,  # type: ignore[arg-type]  # type: ignore[arg-type]
                values=updated_store,
                conflict_columns=["key", "namespace"],
                update_columns=["value", "expires_at"],
                dialect_name=dialect_name,
            )

        with session_factory() as session:
            if dialect_name == "oracle":
                merged_params2 = {**updated_store, **additional_params2}
                session.execute(upsert_stmt2, merged_params2)
            else:
                session.execute(upsert_stmt2)
            session.commit()

            # Verify update
            result = session.execute(
                select(TestStoreModel).where(
                    (TestStoreModel.key == updated_store["key"])
                    & (TestStoreModel.namespace == updated_store["namespace"])
                )
            )
            obj = result.scalar_one_or_none()
            assert obj is not None
            assert obj.value == "updated_cache"
python-advanced-alchemy-1.9.3/tests/integration/test_oracledb_json.py000066400000000000000000000024761516556515500261610ustar00rootroot00000000000000"""Unit tests for the SQLAlchemy Repository implementation for psycopg."""

from __future__ import annotations

import platform
from typing import TYPE_CHECKING

import pytest
from sqlalchemy.dialects import oracle
from sqlalchemy.schema import CreateTable

from tests.fixtures.uuid.models import UUIDEventLog

if TYPE_CHECKING:
    from sqlalchemy import Engine

pytestmark = [
    pytest.mark.skipif(platform.uname()[4] != "x86_64", reason="oracle not available on this platform"),
    pytest.mark.integration,
    pytest.mark.xdist_group("oracledb_json"),
]


@pytest.mark.xdist_group("oracle18")
def test_18c_json_constraint_generation(oracle18c_engine: Engine) -> None:
    ddl = str(CreateTable(UUIDEventLog.__table__).compile(oracle18c_engine, dialect=oracle.dialect()))  # type: ignore
    assert "BLOB" in ddl.upper()
    assert "JSON" in ddl.upper()
    with oracle18c_engine.begin() as conn:
        UUIDEventLog.metadata.create_all(conn)


@pytest.mark.xdist_group("oracle23")
def test_23c_json_constraint_generation(oracle23ai_engine: Engine) -> None:
    ddl = str(CreateTable(UUIDEventLog.__table__).compile(oracle23ai_engine, dialect=oracle.dialect()))  # type: ignore
    assert "BLOB" in ddl.upper()
    assert "JSON" in ddl.upper()
    with oracle23ai_engine.begin() as conn:
        UUIDEventLog.metadata.create_all(conn)
python-advanced-alchemy-1.9.3/tests/integration/test_password_hash.py000066400000000000000000000245601516556515500262200ustar00rootroot00000000000000from __future__ import annotations

from typing import TYPE_CHECKING, Optional

import pytest
from passlib.context import CryptContext
from pwdlib.hashers.argon2 import Argon2Hasher as PwdlibArgon2Hasher
from sqlalchemy import Engine, String
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker
from sqlalchemy.orm import Mapped, Session, mapped_column, sessionmaker

from advanced_alchemy.base import BigIntBase
from advanced_alchemy.types import EncryptedString, PasswordHash
from advanced_alchemy.types.encrypted_string import FernetBackend, PGCryptoBackend
from advanced_alchemy.types.password_hash.argon2 import Argon2Hasher
from advanced_alchemy.types.password_hash.base import HashedPassword
from advanced_alchemy.types.password_hash.passlib import PasslibHasher
from advanced_alchemy.types.password_hash.pwdlib import PwdlibHasher
from tests.integration.test_models import DatabaseCapabilities

if TYPE_CHECKING:
    from pytest import MonkeyPatch

pytestmark = [
    pytest.mark.integration,
    pytest.mark.xdist_group("password_hash"),
]


# Define the User model using PasswordHash
class User(BigIntBase):
    __tablename__ = "test_user_password_hash"
    name: Mapped[str] = mapped_column(String(50))
    passlib_password: Mapped[Optional[str]] = mapped_column(
        PasswordHash(backend=PasslibHasher(context=CryptContext(schemes=["argon2"])))
    )
    argon2_password: Mapped[Optional[str]] = mapped_column(PasswordHash(backend=Argon2Hasher()))
    pwdlib_password: Mapped[Optional[str]] = mapped_column(
        PasswordHash(backend=PwdlibHasher(hasher=PwdlibArgon2Hasher()))
    )

    __table_args__ = {"info": {"allow_eager": True}}


@pytest.fixture()
def password_test_tables(engine: Engine) -> None:
    """Create password test tables for sync engines."""
    if getattr(engine.dialect, "name", "") != "mock" and not getattr(engine.dialect, "name", "").startswith("spanner"):
        User.metadata.create_all(engine)


@pytest.fixture()
async def password_test_tables_async(async_engine: AsyncEngine) -> None:
    """Create password test tables for async engines."""
    if getattr(async_engine.dialect, "name", "") != "mock" and not getattr(async_engine.dialect, "name", "").startswith(
        "spanner"
    ):
        async with async_engine.begin() as conn:
            await conn.run_sync(User.metadata.create_all)


def test_password_hash_sync(engine: Engine, password_test_tables: None, monkeypatch: MonkeyPatch) -> None:
    """Test password hashing with Argon2 and Passlib backends using sync engines."""
    # Skip for unsupported backends
    if DatabaseCapabilities.should_skip_bigint(engine.dialect.name):
        pytest.skip(f"{engine.dialect.name} doesn't support bigint PKs well")

    # Skip mock engine - it doesn't support auto-generated primary keys
    if engine.dialect.name == "mock":
        pytest.skip("Mock engine doesn't support auto-generated primary keys")

    # Skip Spanner - doesn't support direct UNIQUE constraints
    if engine.dialect.name.startswith("spanner"):
        pytest.skip("Spanner doesn't support direct UNIQUE constraints")

    # Skip CockroachDB - it doesn't support BigInt primary keys
    if engine.dialect.name.startswith("cockroach"):
        pytest.skip("CockroachDB doesn't support BigInt primary keys")
    session_factory: sessionmaker[Session] = sessionmaker(engine, expire_on_commit=False)

    # Test with session
    with session_factory() as db_session:
        # Create user with passlib password
        user1 = User(name="user1", passlib_password="password123")
        db_session.add(user1)
        db_session.flush()
        db_session.refresh(user1)

        # Verify password hash is created correctly
        assert isinstance(user1.passlib_password, HashedPassword)
        assert user1.passlib_password.hash_string.startswith("$argon2")  # type: ignore[unreachable]
        assert user1.passlib_password.verify("password123")
        assert not user1.passlib_password.verify("wrong_password")

        # Test non-string password inputs
        assert not user1.passlib_password.verify(123)  # type: ignore[arg-type]
        assert not user1.passlib_password.verify(123.45)  # type: ignore[arg-type]
        assert not user1.passlib_password.verify(True)  # type: ignore[arg-type]
        assert not user1.passlib_password.verify(None)  # type: ignore[arg-type]
        assert not user1.passlib_password.verify(["password123"])  # type: ignore[arg-type]
        assert not user1.passlib_password.verify({"password": "password123"})  # type: ignore[arg-type]

        # Create user with argon2 password
        user2 = User(name="user2", argon2_password="secret123")
        db_session.add(user2)
        db_session.flush()
        db_session.refresh(user2)

        # Verify password hash is created correctly
        assert isinstance(user2.argon2_password, HashedPassword)
        assert user2.argon2_password.hash_string.startswith("$argon2")
        assert user2.argon2_password.verify("secret123")
        assert not user2.argon2_password.verify("wrong_secret")

        # Test non-string password inputs with argon2
        assert not user2.argon2_password.verify(123)  # type: ignore[arg-type]
        assert not user2.argon2_password.verify(123.45)  # type: ignore[arg-type]
        assert not user2.argon2_password.verify(True)  # type: ignore[arg-type]
        assert not user2.argon2_password.verify(None)  # type: ignore[arg-type]
        assert not user2.argon2_password.verify(["secret123"])  # type: ignore[arg-type]
        assert not user2.argon2_password.verify({"password": "secret123"})  # type: ignore[arg-type]

        # Test updating password
        user2.argon2_password = "newsecret123"
        db_session.flush()
        db_session.refresh(user2)
        assert isinstance(user2.argon2_password, HashedPassword)
        assert user2.argon2_password.verify("newsecret123")
        assert not user2.argon2_password.verify("secret123")

        # Test setting password to None
        user2.argon2_password = None
        db_session.flush()
        db_session.refresh(user2)
        assert user2.argon2_password is None


async def test_password_hash_async(
    async_engine: AsyncEngine, password_test_tables_async: None, monkeypatch: MonkeyPatch
) -> None:
    """Test password hashing with Argon2 and Passlib backends using async engines."""
    # Skip for unsupported backends
    if DatabaseCapabilities.should_skip_bigint(async_engine.dialect.name):
        pytest.skip(f"{async_engine.dialect.name} doesn't support bigint PKs well")

    # Skip mock engine - it doesn't support auto-generated primary keys
    if async_engine.dialect.name == "mock":
        pytest.skip("Mock engine doesn't support auto-generated primary keys")

    # Skip Spanner - doesn't support direct UNIQUE constraints
    if async_engine.dialect.name.startswith("spanner"):
        pytest.skip("Spanner doesn't support direct UNIQUE constraints")

    # Skip CockroachDB - it doesn't support BigInt primary keys
    if async_engine.dialect.name.startswith("cockroach"):
        pytest.skip("CockroachDB doesn't support BigInt primary keys")
    session_factory = async_sessionmaker(async_engine, expire_on_commit=False)

    # Test with async session
    async with session_factory() as db_session:
        # Create user with passlib password
        user1 = User(name="user1_async", passlib_password="password123")
        db_session.add(user1)
        await db_session.flush()
        await db_session.refresh(user1)

        # Verify password hash is created correctly
        assert isinstance(user1.passlib_password, HashedPassword)
        assert user1.passlib_password.hash_string.startswith("$argon2")  # type: ignore[unreachable]
        assert user1.passlib_password.verify("password123")
        assert not user1.passlib_password.verify("wrong_password")

        # Create user with argon2 password
        user2 = User(name="user2_async", argon2_password="secret123")
        db_session.add(user2)
        await db_session.flush()
        await db_session.refresh(user2)

        # Verify password hash is created correctly
        assert isinstance(user2.argon2_password, HashedPassword)
        assert user2.argon2_password.hash_string.startswith("$argon2")
        assert user2.argon2_password.verify("secret123")
        assert not user2.argon2_password.verify("wrong_secret")

        # Test updating password
        user2.argon2_password = "newsecret123"
        await db_session.flush()
        await db_session.refresh(user2)
        assert isinstance(user2.argon2_password, HashedPassword)
        assert user2.argon2_password.verify("newsecret123")
        assert not user2.argon2_password.verify("secret123")

        # Test setting password to None
        user2.argon2_password = None
        await db_session.flush()
        await db_session.refresh(user2)
        assert user2.argon2_password is None


def test_password_hash_repr() -> None:
    """Test __repr__() method for PasswordHash with different backends."""
    # Test Argon2Hasher backend
    argon2_hash = PasswordHash(backend=Argon2Hasher(), length=128)
    assert repr(argon2_hash) == "PasswordHash(backend=sa.Argon2Hasher(), length=128)"

    # Test PasslibHasher backend
    passlib_hash = PasswordHash(backend=PasslibHasher(context=CryptContext(schemes=["argon2"])), length=256)
    assert repr(passlib_hash) == "PasswordHash(backend=sa.PasslibHasher(), length=256)"

    # Test PwdlibHasher backend
    pwdlib_hash = PasswordHash(backend=PwdlibHasher(hasher=PwdlibArgon2Hasher()), length=512)
    assert repr(pwdlib_hash) == "PasswordHash(backend=sa.PwdlibHasher(), length=512)"


def test_encrypted_string_repr() -> None:
    """Test __repr__() method for EncryptedString with different backends."""
    # Test FernetBackend (default)
    enc_str_fernet = EncryptedString(key="test_key", backend=FernetBackend, length=100)
    assert repr(enc_str_fernet) == "EncryptedString(key='test_key', backend=FernetBackend, length=100)"

    # Test PGCryptoBackend
    enc_str_pgcrypto = EncryptedString(key=b"test_bytes_key", backend=PGCryptoBackend, length=200)
    assert repr(enc_str_pgcrypto) == "EncryptedString(key=b'test_bytes_key', backend=PGCryptoBackend, length=200)"

    # Test with callable key
    def get_key() -> str:
        return "dynamic_key"

    # The repr should include the callable object itself
    enc_str_callable = EncryptedString(key=get_key, backend=FernetBackend)
    assert repr(enc_str_callable) == "EncryptedString(key=get_key, backend=FernetBackend, length=None)"
python-advanced-alchemy-1.9.3/tests/integration/test_repository.py000066400000000000000000001666771516556515500256120ustar00rootroot00000000000000"""Integration tests for the SQLAlchemy Repository implementation using session-based fixtures."""

import asyncio
import datetime
from collections.abc import Generator
from typing import TYPE_CHECKING, Any, Literal, Optional, Union, cast
from unittest.mock import create_autospec
from uuid import UUID

import pytest
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
from sqlalchemy.orm import Session
from time_machine import travel

from advanced_alchemy.exceptions import NotFoundError
from advanced_alchemy.filters import (
    BeforeAfter,
    OrderBy,
    SearchFilter,
)
from advanced_alchemy.repository import SQLAlchemyAsyncRepository
from advanced_alchemy.repository._util import DEFAULT_ERROR_MESSAGE_TEMPLATES
from advanced_alchemy.repository.memory import (
    SQLAlchemyAsyncMockRepository,
    SQLAlchemySyncMockRepository,
)
from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService
from tests.helpers import maybe_async

# Python 3.9 compatibility for typing.TypeAlias
try:  # Python >= 3.10
    from typing import TypeAlias  # type: ignore[attr-defined]
except Exception:  # Python 3.9 fallback
    from typing_extensions import TypeAlias  # type: ignore[assignment]

if TYPE_CHECKING:
    from time_machine import Coordinates

pytestmark = [
    pytest.mark.integration,
    pytest.mark.xdist_group("repository"),
]
xfail = pytest.mark.xfail

# Type aliases for data and repository/service components
RawRecordData: TypeAlias = "list[dict[str, Any]]"
RepositoryPKType = Literal["uuid", "bigint"]
AnyRepository: TypeAlias = "Union[SQLAlchemyAsyncRepository[Any], SQLAlchemyAsyncMockRepository[Any]]"
AnyService: TypeAlias = SQLAlchemyAsyncRepositoryService[Any, "AnyRepository"]  # pyright: ignore

mock_engines = {"mock_async_engine", "mock_sync_engine"}


# Helper functions for repository creation
def create_repository(
    session: "Union[Session, AsyncSession]", model_type: type, repository_class: "Optional[type]" = None
) -> "Any":
    """Create a repository instance for the given session and model type."""
    if repository_class is None:
        if isinstance(session, AsyncSession):
            base_repository_class = SQLAlchemyAsyncRepository  # type: ignore[assignment]
        else:
            from advanced_alchemy.repository import SQLAlchemySyncRepository

            base_repository_class = SQLAlchemySyncRepository  # type: ignore[assignment]
    else:
        base_repository_class = repository_class  # type: ignore[assignment]

    # Create a dynamic repository class with the model_type as a class attribute
    repository_class_name = f"DynamicRepository_{model_type.__name__}"

    # Add a create method that handles dict data and maps to add for test compatibility
    async def create(self: Any, data: Any, **kwargs: Any) -> Any:
        # If data is a dict, convert it to a model instance
        if isinstance(data, dict):
            model_instance = model_type(**data)
        else:
            model_instance = data
        return await self.add(model_instance, **kwargs)

    # Add a create_many method that handles list of dict data
    async def create_many(self: Any, data: "list[Any]", **kwargs: Any) -> "list[Any]":
        # Convert dict items to model instances
        model_instances = []
        for item in data:
            if isinstance(item, dict):
                model_instances.append(model_type(**item))
            else:
                model_instances.append(item)
        return await self.add_many(model_instances, **kwargs)  # type: ignore[no-any-return]

    def create_sync(self: Any, data: Any, **kwargs: Any) -> Any:
        # Sync version for sync repositories
        if isinstance(data, dict):
            model_instance = model_type(**data)
        else:
            model_instance = data
        return self.add(model_instance, **kwargs)

    # Sync version of create_many
    def create_many_sync(self: Any, data: "list[Any]", **kwargs: Any) -> "list[Any]":
        # Convert dict items to model instances
        model_instances = []
        for item in data:
            if isinstance(item, dict):
                model_instances.append(model_type(**item))
            else:
                model_instances.append(item)
        return self.add_many(model_instances, **kwargs)  # type: ignore[no-any-return]

    # Choose the right create methods based on repository type
    create_method = create if isinstance(session, AsyncSession) else create_sync
    create_many_method = create_many if isinstance(session, AsyncSession) else create_many_sync

    DynamicRepository = type(
        repository_class_name,
        (base_repository_class,),
        {"model_type": model_type, "create": create_method, "create_many": create_many_method},
    )

    return DynamicRepository(session=session)


def create_service(
    session: "Union[Session, AsyncSession]", model_type: type, service_class: "Optional[type]" = None
) -> Any:
    """Create a service instance for the given session and model type."""
    # Create a repository first, since services operate on repositories
    repository = create_repository(session, model_type)

    if service_class is None:
        if isinstance(session, AsyncSession):
            from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService

            base_service_class = SQLAlchemyAsyncRepositoryService
        else:
            from advanced_alchemy.service import SQLAlchemySyncRepositoryService

            base_service_class = SQLAlchemySyncRepositoryService  # type: ignore[assignment]
    else:
        base_service_class = service_class  # type: ignore[assignment]

    # Create a dynamic service class that knows about the repository
    service_class_name = f"DynamicService_{model_type.__name__}"

    # Set the repository_type to the same type as our dynamic repository
    repository_type = type(repository)
    DynamicService = type(service_class_name, (base_service_class,), {"repository_type": repository_type})

    # Initialize the service
    return DynamicService(session=session)


# Helper functions for session-based testing
def get_model_from_session(
    session_data: "tuple[Union[Session, AsyncSession], dict[str, type]]", model_name: str
) -> type:
    """Extract a model type from session data tuple."""
    _, models = session_data
    return models[model_name]


def get_repository_from_session(
    session_data: "tuple[Union[Session, AsyncSession], dict[str, type]]", model_name: str
) -> Any:
    """Create a repository from session data tuple."""
    session, models = session_data
    model_type = models[model_name]
    return create_repository(session, model_type)


def get_service_from_session(
    session_data: "tuple[Union[Session, AsyncSession], dict[str, type]]", model_name: str
) -> Any:
    """Create a service from session data tuple."""
    session, models = session_data
    model_type = models[model_name]
    return create_service(session, model_type)


@pytest.fixture(autouse=True)
def _clear_in_memory_db() -> "Generator[None, None, None]":  # pyright: ignore[reportUnusedFunction]
    try:
        yield
    finally:
        SQLAlchemyAsyncMockRepository.__database_clear__()
        SQLAlchemySyncMockRepository.__database_clear__()


@pytest.fixture()
def frozen_datetime() -> "Generator[Coordinates, None, None]":
    with travel(lambda: datetime.datetime.now(datetime.timezone.utc), tick=False) as frozen:
        yield frozen


# Test functions using new session-based pattern
async def test_repo_count_method(seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]") -> None:
    """Test SQLAlchemy count."""
    session, models = seeded_test_session_async
    author_repo = create_repository(session, models["author"])
    assert await maybe_async(author_repo.count()) == 2


async def test_repo_count_method_with_filters(
    seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]",
) -> None:
    """Test SQLAlchemy count with filters."""
    session, models = seeded_test_session_async
    author_repo = create_repository(session, models["author"])

    # Get the first author name from seeded data
    if hasattr(session, "bind") and getattr(session.bind, "dialect", {}).name == "mock":
        # Mock repository handling
        assert (
            await maybe_async(
                author_repo.count(
                    **{author_repo.model_type.name.key: "Agatha Christie"},
                ),
            )
            == 1
        )
    else:
        # Real repository handling
        assert (
            await maybe_async(
                author_repo.count(
                    author_repo.model_type.name == "Agatha Christie",
                ),
            )
            == 1
        )


async def test_repo_list_and_count_method(seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]") -> None:
    """Test SQLAlchemy list with count in asyncpg."""
    session, models = seeded_test_session_async
    author_repo = create_repository(session, models["author"])

    data, count = await maybe_async(author_repo.list_and_count())
    assert len(data) == 2
    assert count == 2


async def test_repo_list_and_count_basic_method(
    seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]",
) -> None:
    """Test SQLAlchemy list and count."""
    session, models = seeded_test_session_async
    author_repo = create_repository(session, models["author"])

    data, count = await maybe_async(author_repo.list_and_count())
    assert len(data) == 2
    assert count == 2


async def test_repo_list_method_with_filters(seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]") -> None:
    """Test SQLAlchemy list with filters."""
    session, models = seeded_test_session_async
    author_repo = create_repository(session, models["author"])

    # Test filtering by name
    if hasattr(session, "bind") and getattr(session.bind, "dialect", {}).name == "mock":
        # Mock repository handling
        data = await maybe_async(author_repo.list(**{author_repo.model_type.name.key: "Agatha Christie"}))
    else:
        # Real repository handling
        data = await maybe_async(author_repo.list(author_repo.model_type.name == "Agatha Christie"))

    assert len(data) == 1
    assert data[0].name == "Agatha Christie"


async def test_repo_exists_method(seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]") -> None:
    """Test repository exists method."""
    session, models = seeded_test_session_async
    author_repo = create_repository(session, models["author"])

    # Get first author ID
    authors = await maybe_async(author_repo.list())
    first_author_id = authors[0].id

    assert await maybe_async(author_repo.exists(id=first_author_id)) is True

    # Test with non-existent ID
    non_existent_id = UUID("00000000-0000-0000-0000-000000000000") if hasattr(first_author_id, "hex") else 99999
    assert await maybe_async(author_repo.exists(id=non_existent_id)) is False


async def test_repo_get_method(seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]") -> None:
    """Test repository get method."""
    session, models = seeded_test_session_async
    author_repo = create_repository(session, models["author"])

    # Get first author ID
    authors = await maybe_async(author_repo.list())
    first_author_id = authors[0].id

    author = await maybe_async(author_repo.get(first_author_id))
    assert author.id == first_author_id


async def test_repo_get_one_or_none_method(seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]") -> None:
    """Test repository get_one_or_none method."""
    session, models = seeded_test_session_async
    author_repo = create_repository(session, models["author"])

    # Get first author ID
    authors = await maybe_async(author_repo.list())
    first_author_id = authors[0].id

    author = await maybe_async(author_repo.get_one_or_none(id=first_author_id))
    assert author is not None
    assert author.id == first_author_id

    # Test with non-existent ID
    non_existent_id = UUID("00000000-0000-0000-0000-000000000000") if hasattr(first_author_id, "hex") else 99999
    author = await maybe_async(author_repo.get_one_or_none(id=non_existent_id))
    assert author is None


async def test_repo_create_method(seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]") -> None:
    """Test repository create method."""
    session, models = seeded_test_session_async
    author_repo = create_repository(session, models["author"])

    new_author_data = {"name": "Test Author", "dob": datetime.datetime.now(datetime.timezone.utc).date()}

    new_author = await maybe_async(author_repo.create(new_author_data))
    assert new_author.name == "Test Author"
    assert new_author.id is not None

    # Verify it was actually created
    total_count = await maybe_async(author_repo.count())
    assert total_count == 3


async def test_repo_update_method(seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]") -> None:
    """Test repository update method."""
    session, models = seeded_test_session_async
    author_repo = create_repository(session, models["author"])

    # Get first author
    authors = await maybe_async(author_repo.list())
    author = authors[0]

    original_name = author.name
    original_created_at = author.created_at
    original_updated_at = author.updated_at

    # Update the author
    author.name = "Updated Name"
    updated_author = await maybe_async(author_repo.update(author))

    assert updated_author.name == "Updated Name"
    assert updated_author.name != original_name
    assert updated_author.created_at == original_created_at
    assert updated_author.updated_at > original_updated_at


async def test_service_update_with_dict_data_refreshes_timestamp(
    seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]",
    frozen_datetime: "Coordinates",
) -> None:
    """Service update should refresh audit timestamps when supplied with dict payloads."""
    session, models = seeded_test_session_async
    author_service = get_service_from_session((session, models), "author")

    authors = await maybe_async(author_service.list())
    author = authors[0]

    original_created_at = author.created_at
    original_updated_at = author.updated_at

    frozen_datetime.shift(datetime.timedelta(seconds=5))
    update_payload = {"name": "Dict Driven Update"}

    updated_author = await maybe_async(author_service.update(update_payload, item_id=author.id))

    assert updated_author.name == "Dict Driven Update"
    assert updated_author.created_at == original_created_at
    assert updated_author.updated_at > original_updated_at


async def test_repo_update_many_method_stale_data_fix(
    seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]",
) -> None:
    """Test repository update_many returns refreshed data from database (Issue #1)."""
    session, models = seeded_test_session_async
    author_repo = create_repository(session, models["author"])

    # Get first two authors
    authors = await maybe_async(author_repo.list())
    authors = authors[:2]

    # Create update data that only updates name, leaving other fields unchanged
    update_data = [{"id": authors[0].id, "name": "Updated Author 1"}, {"id": authors[1].id, "name": "Updated Author 2"}]

    # Store original created_at/updated_at for comparison
    original_created_at = authors[0].created_at
    original_updated_at = authors[0].updated_at

    # Update using update_many
    updated_authors = await maybe_async(author_repo.update_many(update_data))

    # Critical test: returned objects should have ALL attributes populated from database
    # This was the bug - returned objects had None for non-updated fields
    assert len(updated_authors) == 2
    for updated_author in updated_authors:
        # These should be updated
        assert updated_author.name in ["Updated Author 1", "Updated Author 2"]

        # These should still be populated from database (not None)
        assert updated_author.created_at is not None
        assert updated_author.updated_at is not None
        assert updated_author.id is not None

        # updated_at should be newer than before
        if updated_author.id == authors[0].id:
            assert updated_author.created_at == original_created_at
            assert updated_author.updated_at > original_updated_at


async def test_repo_update_many_mixed_types(seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]") -> None:
    """Test repository update_many with mixed input types (dicts and model instances)."""
    session, models = seeded_test_session_async
    author_repo = create_repository(session, models["author"])

    # Get authors to update
    authors = await maybe_async(author_repo.list())
    authors = authors[:2]

    # Test mixed input types: dict and model instance
    authors[1].name = "Updated via Model Instance"
    update_data = [
        {"id": authors[0].id, "name": "Updated via Dict"},  # Dict
        authors[1],  # Model instance
    ]

    # This should not raise AttributeError (Issue #3)
    updated_authors = await maybe_async(author_repo.update_many(update_data))

    assert len(updated_authors) == 2
    updated_names = {author.name for author in updated_authors}
    assert "Updated via Dict" in updated_names
    assert "Updated via Model Instance" in updated_names


async def test_repo_delete_method(seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]") -> None:
    """Test repository delete method."""
    session, models = seeded_test_session_async
    author_repo = create_repository(session, models["author"])

    # Get first author
    authors = await maybe_async(author_repo.list())
    author = authors[0]
    author_id = author.id

    # Delete the author
    deleted_author = await maybe_async(author_repo.delete(author_id))
    assert deleted_author.id == author_id

    # Verify it was deleted
    remaining_authors = await maybe_async(author_repo.list())
    assert len(remaining_authors) == 1

    remaining_ids = [a.id for a in remaining_authors]
    assert author_id not in remaining_ids


async def test_repo_health_check(seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]") -> None:
    """Test repository health check."""
    session, models = seeded_test_session_async
    author_repo = create_repository(session, models["author"])

    # Health check should not raise an exception - it's a class method that needs session
    assert await maybe_async(author_repo.check_health(session)) is True


# Service tests using new session-based pattern
async def test_service_count_method(seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]") -> None:
    """Test service count method."""
    _session, _models = seeded_test_session_async
    author_service = get_service_from_session(seeded_test_session_async, "author")

    count = await maybe_async(author_service.count())
    assert count == 2


async def test_service_list_method(seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]") -> None:
    """Test service list method."""
    _session, _models = seeded_test_session_async
    author_service = get_service_from_session(seeded_test_session_async, "author")

    authors = await maybe_async(author_service.list())
    assert len(authors) == 2


async def test_service_get_method(seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]") -> None:
    """Test service get method."""
    _session, _models = seeded_test_session_async
    author_service = get_service_from_session(seeded_test_session_async, "author")

    # Get first author ID
    authors = await maybe_async(author_service.list())
    first_author_id = authors[0].id

    author = await maybe_async(author_service.get(first_author_id))
    assert author.id == first_author_id


async def test_service_create_method(seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]") -> None:
    """Test service create method."""
    _session, _models = seeded_test_session_async
    author_service = get_service_from_session(seeded_test_session_async, "author")

    new_author_data = {"name": "Service Test Author", "dob": datetime.datetime.now(datetime.timezone.utc).date()}

    new_author = await maybe_async(author_service.create(new_author_data))
    assert new_author.name == "Service Test Author"
    assert new_author.id is not None


async def test_service_update_method(seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]") -> None:
    """Test service update method."""
    _session, _models = seeded_test_session_async
    author_service = get_service_from_session(seeded_test_session_async, "author")

    # Get first author
    authors = await maybe_async(author_service.list())
    author = authors[0]
    author_id = author.id

    original_name = author.name
    original_created_at = author.created_at
    original_updated_at = author.updated_at

    # Update via service - correct parameter order is (data, item_id)
    author.name = "Service Updated Name"
    updated_author = await maybe_async(author_service.update(author, item_id=author_id))

    assert updated_author.name == "Service Updated Name"
    assert updated_author.name != original_name
    assert updated_author.created_at == original_created_at
    assert updated_author.updated_at > original_updated_at


async def test_service_delete_method(seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]") -> None:
    """Test service delete method."""
    _session, _models = seeded_test_session_async
    author_service = get_service_from_session(seeded_test_session_async, "author")

    # Get first author
    authors = await maybe_async(author_service.list())
    author_id = authors[0].id

    # Delete via service
    deleted_author = await maybe_async(author_service.delete(author_id))
    assert deleted_author.id == author_id

    # Verify deletion
    remaining_authors = await maybe_async(author_service.list())
    assert len(remaining_authors) == 1


# Additional filter tests
async def test_repo_filter_before_after(seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]") -> None:
    """Test repository with BeforeAfter filter."""
    session, models = seeded_test_session_async
    author_repo = create_repository(session, models["author"])

    # Test date filtering
    cutoff_date = datetime.datetime(2023, 4, 1, tzinfo=datetime.timezone.utc)
    filter_obj = BeforeAfter(field_name="created_at", before=cutoff_date, after=None)

    authors = await maybe_async(author_repo.list(filter_obj))
    # Should get authors created before April 1, 2023
    assert len(authors) >= 1  # At least one author should match


async def test_repo_filter_search(seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]") -> None:
    """Test repository with SearchFilter."""
    session, models = seeded_test_session_async
    author_repo = create_repository(session, models["author"])

    # Search for 'Christie' in name
    search_filter = SearchFilter(field_name="name", value="Christie")

    authors = await maybe_async(author_repo.list(search_filter))
    assert len(authors) == 1
    assert "Christie" in authors[0].name


async def test_repo_filter_order_by(seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]") -> None:
    """Test repository with OrderBy filter."""
    session, models = seeded_test_session_async
    author_repo = create_repository(session, models["author"])

    # Order by name ascending
    order_filter = OrderBy(field_name="name")

    authors = await maybe_async(author_repo.list(order_filter))
    assert len(authors) == 2

    # Verify ordering
    names = [author.name for author in authors]
    assert names == sorted(names)


# Pagination tests
async def test_service_paginated_list(seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]") -> None:
    """Test service paginated list."""
    from advanced_alchemy.filters import LimitOffset

    _session, _models = seeded_test_session_async
    author_service = get_service_from_session(seeded_test_session_async, "author")

    # Test pagination using LimitOffset filter with consistent ordering
    paginated = await maybe_async(author_service.list(LimitOffset(limit=1, offset=0), OrderBy(field_name="name")))

    assert len(paginated) == 1


# Error handling tests
async def test_repo_error_messages(seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]") -> None:
    """Test repository error handling."""
    session, models = seeded_test_session_async
    author_repo = create_repository(session, models["author"])

    # Test NotFoundError for non-existent ID
    non_existent_id = (
        UUID("00000000-0000-0000-0000-000000000000")
        if hasattr(author_repo.model_type.id.type, "python_type") and author_repo.model_type.id.type.python_type == UUID
        else 99999
    )

    with pytest.raises(NotFoundError):
        await maybe_async(author_repo.get(non_existent_id))


def _make_mock_session(engine: AsyncEngine) -> AsyncSession:
    session = cast(AsyncSession, create_autospec(AsyncSession, instance=True))
    session.bind = engine
    session.get_bind.return_value = engine
    return session


@pytest.mark.mock_async
def test_repo_error_message_overrides_are_isolated(
    mock_async_engine: AsyncEngine, uuid_models_dba: "dict[str, type]"
) -> None:
    author_model = cast(type[Any], uuid_models_dba["author"])
    default_not_found = DEFAULT_ERROR_MESSAGE_TEMPLATES.get("not_found")

    class BaseRepo(SQLAlchemyAsyncRepository[Any]):
        model_type = author_model

    class RepoA(BaseRepo):
        error_messages = {"not_found": "Author A not found"}

    class RepoB(BaseRepo):
        error_messages = {"not_found": "Author B not found"}

    repo_a_first = RepoA(session=_make_mock_session(mock_async_engine))
    repo_b = RepoB(session=_make_mock_session(mock_async_engine))
    repo_a_second = RepoA(session=_make_mock_session(mock_async_engine))

    assert repo_a_first.error_messages is not DEFAULT_ERROR_MESSAGE_TEMPLATES
    assert repo_a_first.error_messages is not repo_b.error_messages
    assert repo_a_first.error_messages["not_found"] == "Author A not found"
    assert repo_b.error_messages["not_found"] == "Author B not found"
    assert repo_a_second.error_messages["not_found"] == "Author A not found"
    assert DEFAULT_ERROR_MESSAGE_TEMPLATES["not_found"] == default_not_found


@pytest.mark.mock_async
def test_mock_repo_error_message_overrides_are_isolated(
    mock_async_engine: AsyncEngine, uuid_models_dba: "dict[str, type]"
) -> None:
    author_model = cast(type[Any], uuid_models_dba["author"])

    class BaseMockRepo(SQLAlchemyAsyncMockRepository[Any]):
        model_type = author_model

    class RepoA(BaseMockRepo):
        error_messages = {"not_found": "Mock Author A not found"}

    class RepoB(BaseMockRepo):
        error_messages = {"not_found": "Mock Author B not found"}

    repo_a = RepoA(session=_make_mock_session(mock_async_engine))
    repo_b = RepoB(session=_make_mock_session(mock_async_engine))

    assert repo_a.error_messages is not repo_b.error_messages
    assert repo_a.error_messages["not_found"] == "Mock Author A not found"
    assert repo_b.error_messages["not_found"] == "Mock Author B not found"


# Comprehensive tests for GitHub issue #535 and bug_fix.md issues
async def test_service_pydantic_partial_update_github_535(
    seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]",
) -> None:
    """Test service update with Pydantic models using exclude_unset for partial updates (GitHub Issue #535)."""
    pydantic = pytest.importorskip("pydantic")

    _session, _models = seeded_test_session_async
    author_service = get_service_from_session(seeded_test_session_async, "author")

    # Create an author
    author = await maybe_async(author_service.create({"name": "Original Name", "dob": datetime.date(1990, 1, 1)}))
    original_dob = author.dob

    # Create a Pydantic model for partial update with optional fields
    class AuthorUpdateSchema(pydantic.BaseModel):  # type: ignore[name-defined,misc]
        name: "Optional[str]" = None
        dob: "Optional[datetime.date]" = None

    # Partial update with only name field set (dob is unset)
    partial_update = AuthorUpdateSchema(name="Updated Name")
    assert partial_update.name == "Updated Name"
    assert "dob" not in partial_update.model_fields_set

    # Update via service - should only update name, leave dob unchanged
    updated_author = await maybe_async(author_service.update(partial_update, item_id=author.id))

    # Verify: name was updated, but dob remains unchanged
    assert updated_author.name == "Updated Name"
    assert updated_author.dob == original_dob  # Should be unchanged
    assert updated_author.id == author.id


async def test_service_msgspec_partial_update_github_535(
    seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]",
) -> None:
    """Test service update with msgspec structs using UNSET for partial updates (GitHub Issue #535)."""
    msgspec = pytest.importorskip("msgspec")

    _session, _models = seeded_test_session_async
    author_service = get_service_from_session(seeded_test_session_async, "author")

    # Create an author
    author = await maybe_async(author_service.create({"name": "Original Name", "dob": datetime.date(1990, 1, 1)}))
    original_dob = author.dob

    # Create a msgspec struct for partial update with UNSET field
    class AuthorUpdateSchema(msgspec.Struct):  # type: ignore[name-defined,misc]
        name: "str" = msgspec.UNSET
        dob: "datetime.date" = msgspec.UNSET

    # Partial update with only name field set (dob is UNSET)
    partial_update = AuthorUpdateSchema(name="Updated Name")
    assert partial_update.name == "Updated Name"
    assert partial_update.dob is msgspec.UNSET

    # Update via service - should only update name, leave dob unchanged
    updated_author = await maybe_async(author_service.update(partial_update, item_id=author.id))

    # Verify: name was updated, but dob remains unchanged
    assert updated_author.name == "Updated Name"
    assert updated_author.dob == original_dob  # Should be unchanged
    assert updated_author.id == author.id


async def test_service_update_many_schema_types_github_535(
    seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]",
) -> None:
    """Test service update_many with different schema types (GitHub Issue #535)."""
    pydantic = pytest.importorskip("pydantic")
    msgspec = pytest.importorskip("msgspec")

    _session, _models = seeded_test_session_async
    author_service = get_service_from_session(seeded_test_session_async, "author")

    # Create multiple authors
    author1 = await maybe_async(author_service.create({"name": "Author One", "dob": datetime.date(1990, 1, 1)}))
    author2 = await maybe_async(author_service.create({"name": "Author Two", "dob": datetime.date(1991, 2, 2)}))

    original_dob1 = author1.dob
    original_dob2 = author2.dob
    original_created_at1 = author1.created_at
    original_created_at2 = author2.created_at
    original_updated_at1 = author1.updated_at
    original_updated_at2 = author2.updated_at

    # Get ID type from model for dynamic schema creation
    # For Pydantic compatibility, we need to map database-specific types to Python types
    from uuid import UUID as PythonUUID

    actual_id_type = type(author1.id)
    if hasattr(actual_id_type, "__name__") and "UUID" in actual_id_type.__name__:
        id_type = PythonUUID  # Use standard UUID for database UUID types
    else:
        id_type = int  # type: ignore[assignment]

    class AuthorUpdateSchema(pydantic.BaseModel):  # type: ignore[name-defined,misc]
        id: id_type  # type: ignore[valid-type]
        name: "Optional[str]" = None
        dob: "Optional[datetime.date]" = None

    # Create msgspec schema for partial updates
    class AuthorUpdateMsgspecSchema(msgspec.Struct):  # type: ignore[name-defined,misc]
        id: id_type  # type: ignore[valid-type]
        name: "str" = msgspec.UNSET
        dob: "datetime.date" = msgspec.UNSET

    # Test update_many with mixed schema types (Pydantic, msgspec, dict)
    update_data = [
        AuthorUpdateSchema(id=author1.id, name="Updated Author One"),  # Pydantic with UNSET dob
        AuthorUpdateMsgspecSchema(id=author2.id, name="Updated Author Two"),  # msgspec with UNSET dob
    ]

    # Sleep to ensure timestamp difference for databases with lower precision
    await asyncio.sleep(1.1)

    # Update via service - should only update names, leave dobs unchanged
    updated_authors = await maybe_async(author_service.update_many(update_data))

    # Verify updates
    assert len(updated_authors) == 2

    # Find updated authors by ID
    updated_author1 = next(a for a in updated_authors if a.id == author1.id)
    updated_author2 = next(a for a in updated_authors if a.id == author2.id)

    # Verify: names were updated, but dobs remain unchanged
    assert updated_author1.name == "Updated Author One"
    assert updated_author1.dob == original_dob1  # Should be unchanged
    assert updated_author1.created_at == original_created_at1
    assert updated_author1.updated_at > original_updated_at1

    assert updated_author2.name == "Updated Author Two"
    assert updated_author2.dob == original_dob2  # Should be unchanged
    assert updated_author2.created_at == original_created_at2
    assert updated_author2.updated_at > original_updated_at2


async def test_repo_update_many_non_returning_backend_refresh(
    seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]",
) -> None:
    """Test repository update_many refreshes data on non-RETURNING backends (Issue #1 from bug_fix.md)."""
    session, models = seeded_test_session_async
    author_repo = create_repository(session, models["author"])

    # Create multiple authors
    authors = await maybe_async(
        author_repo.create_many(
            [
                {"name": "Author A", "dob": datetime.date(1990, 1, 1)},
                {"name": "Author B", "dob": datetime.date(1991, 2, 2)},
            ]
        )
    )

    # Prepare update data with partial changes
    update_data = [
        {"id": authors[0].id, "name": "Updated Author A"},  # Only updating name
        {"id": authors[1].id, "name": "Updated Author B"},  # Only updating name
    ]

    # Update via repository
    updated_authors = await maybe_async(author_repo.update_many(update_data))

    # Verify returned objects have ALL attributes populated (not just updated ones)
    assert len(updated_authors) == 2

    for updated_author in updated_authors:
        # Verify all attributes are populated from database, not just updated ones
        assert updated_author.name is not None and "Updated" in updated_author.name
        assert updated_author.dob is not None  # Should be populated from database, not None
        assert updated_author.id is not None
        assert updated_author.created_at is not None  # Audit fields should be populated
        assert updated_author.updated_at is not None


async def test_service_mixed_input_types_update_many(
    seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]",
) -> None:
    """Test service update_many with mixed input types (dicts, Pydantic models, msgspec structs)."""
    pydantic = pytest.importorskip("pydantic")
    msgspec = pytest.importorskip("msgspec")

    _session, _models = seeded_test_session_async
    author_service = get_service_from_session(seeded_test_session_async, "author")

    # Create multiple authors
    authors = await maybe_async(
        author_service.create_many(
            [
                {"name": "Author 1", "dob": datetime.date(1990, 1, 1)},
                {"name": "Author 2", "dob": datetime.date(1991, 2, 2)},
                {"name": "Author 3", "dob": datetime.date(1992, 3, 3)},
            ]
        )
    )

    # Get ID type from model for dynamic schema creation
    # For Pydantic compatibility, we need to map database-specific types to Python types
    from uuid import UUID as PythonUUID

    actual_id_type = type(authors[0].id)
    if hasattr(actual_id_type, "__name__") and "UUID" in actual_id_type.__name__:
        id_type = PythonUUID  # Use standard UUID for database UUID types
    else:
        id_type = int  # type: ignore[assignment]

    # Create schema classes
    class AuthorUpdatePydantic(pydantic.BaseModel):  # type: ignore[name-defined,misc]
        id: id_type  # type: ignore[valid-type]
        name: "Optional[str]" = None
        dob: "Optional[datetime.date]" = None

    class AuthorUpdateMsgspec(msgspec.Struct):  # type: ignore[name-defined,misc]
        id: id_type  # type: ignore[valid-type]
        name: "str" = msgspec.UNSET
        dob: "datetime.date" = msgspec.UNSET

    # Test with mixed input types
    mixed_update_data = [
        {"id": authors[0].id, "name": "Dict Updated"},  # Dictionary
        AuthorUpdatePydantic(id=authors[1].id, name="Pydantic Updated"),  # Pydantic model
        AuthorUpdateMsgspec(id=authors[2].id, name="Msgspec Updated"),  # msgspec struct
    ]

    # Update via service
    updated_authors = await maybe_async(author_service.update_many(mixed_update_data))

    # Verify all updates worked correctly
    assert len(updated_authors) == 3

    # Create a mapping by ID for verification
    updated_by_id = {author.id: author for author in updated_authors}

    # Verify each update matches the expected result
    assert updated_by_id[authors[0].id].name == "Dict Updated"
    assert updated_by_id[authors[1].id].name == "Pydantic Updated"
    assert updated_by_id[authors[2].id].name == "Msgspec Updated"

    # Verify all attributes are properly populated (not stale/None)
    for author in updated_authors:
        assert author.dob is not None
        assert author.created_at is not None
        assert author.updated_at is not None


async def test_repo_update_with_model_instance_partial_fields_github_560(
    seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]",
) -> None:
    """Test repository update with model instances for partial updates (GitHub Issue #560).

    This test verifies that when updating with a model instance where only some fields
    are explicitly set, the unset fields do not overwrite existing data with None.
    """
    _session, models = seeded_test_session_async
    author_model = models["author"]
    author_repo = get_repository_from_session(seeded_test_session_async, "author")

    # Create an author with all fields populated
    author = await maybe_async(author_repo.create({"name": "Original Name", "dob": datetime.date(1990, 1, 1)}))
    original_dob = author.dob
    original_id = author.id

    # Create a partial update using a model instance with only id and name set
    # This mimics the pattern: Author(id=1, name="Updated Name")
    # SQLAlchemy initializes 'dob' to None, but it wasn't explicitly set by the user
    partial_update = author_model(id=original_id, name="Updated Name")

    # Update via repository - should only update name, leave dob unchanged
    updated_author = await maybe_async(author_repo.update(partial_update))

    # Verify: name was updated, but dob remains unchanged (not overwritten with None)
    assert updated_author.name == "Updated Name"
    assert updated_author.dob == original_dob  # Should be unchanged
    assert updated_author.id == original_id


async def test_repo_update_many_with_model_instances_partial_fields_github_560(
    seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]",
) -> None:
    """Test repository update_many with model instances for partial updates (GitHub Issue #560).

    This test verifies that update_many correctly handles model instances with partially
    set fields, preventing None values from overwriting existing data.
    """
    _session, models = seeded_test_session_async
    author_model = models["author"]
    author_repo = get_repository_from_session(seeded_test_session_async, "author")

    # Create multiple authors with all fields populated
    author1 = await maybe_async(author_repo.create({"name": "Author One", "dob": datetime.date(1990, 1, 1)}))
    author2 = await maybe_async(author_repo.create({"name": "Author Two", "dob": datetime.date(1991, 2, 2)}))

    original_dob1 = author1.dob
    original_dob2 = author2.dob

    # Create partial updates using model instances with only id and name set
    partial_updates = [
        author_model(id=author1.id, name="Updated One"),
        author_model(id=author2.id, name="Updated Two"),
    ]

    # Update via repository - should only update names, leave dobs unchanged
    updated_authors = await maybe_async(author_repo.update_many(partial_updates))

    # Verify: names were updated, but dobs remain unchanged
    assert len(updated_authors) == 2
    updated_by_id = {author.id: author for author in updated_authors}

    assert updated_by_id[author1.id].name == "Updated One"
    assert updated_by_id[author1.id].dob == original_dob1  # Should be unchanged

    assert updated_by_id[author2.id].name == "Updated Two"
    assert updated_by_id[author2.id].dob == original_dob2  # Should be unchanged


# =============================================================================
# Composite Primary Key Tests
# =============================================================================


async def test_composite_pk_get_by_tuple(
    seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]",
) -> None:
    """Test get() with composite primary key using tuple format."""
    session, models = seeded_test_session_async
    if "user_role" not in models:
        pytest.skip("user_role model not available")

    user_role_repo = create_repository(session, models["user_role"])

    # Get using tuple format (user_id, role_id)
    result = await maybe_async(user_role_repo.get((1, 10)))

    assert result is not None
    assert result.user_id == 1
    assert result.role_id == 10
    assert result.is_active is True


async def test_composite_pk_get_by_dict(
    seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]",
) -> None:
    """Test get() with composite primary key using dict format."""
    session, models = seeded_test_session_async
    if "user_role" not in models:
        pytest.skip("user_role model not available")

    user_role_repo = create_repository(session, models["user_role"])

    # Get using dict format
    result = await maybe_async(user_role_repo.get({"user_id": 1, "role_id": 20}))

    assert result is not None
    assert result.user_id == 1
    assert result.role_id == 20
    assert result.is_active is True


async def test_composite_pk_get_not_found(
    seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]",
) -> None:
    """Test get() with composite primary key raises NotFoundError for missing record."""
    session, models = seeded_test_session_async
    if "user_role" not in models:
        pytest.skip("user_role model not available")

    user_role_repo = create_repository(session, models["user_role"])

    with pytest.raises(NotFoundError):
        await maybe_async(user_role_repo.get((999, 999)))


async def test_composite_pk_delete_by_tuple(
    seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]",
) -> None:
    """Test delete() with composite primary key using tuple format."""
    session, models = seeded_test_session_async
    if "user_role" not in models:
        pytest.skip("user_role model not available")

    user_role_repo = create_repository(session, models["user_role"])

    # Delete using tuple format
    deleted = await maybe_async(user_role_repo.delete((2, 10)))

    assert deleted is not None
    assert deleted.user_id == 2
    assert deleted.role_id == 10

    # Verify it's actually deleted
    with pytest.raises(NotFoundError):
        await maybe_async(user_role_repo.get((2, 10)))


async def test_composite_pk_delete_by_dict(
    seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]",
) -> None:
    """Test delete() with composite primary key using dict format."""
    session, models = seeded_test_session_async
    if "user_role" not in models:
        pytest.skip("user_role model not available")

    user_role_repo = create_repository(session, models["user_role"])

    # Delete using dict format
    deleted = await maybe_async(user_role_repo.delete({"user_id": 1, "role_id": 20}))

    assert deleted is not None
    assert deleted.user_id == 1
    assert deleted.role_id == 20

    # Verify it's actually deleted
    with pytest.raises(NotFoundError):
        await maybe_async(user_role_repo.get({"user_id": 1, "role_id": 20}))


async def test_composite_pk_delete_many_by_tuples(
    seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]",
) -> None:
    """Test delete_many() with composite primary key using list of tuples."""
    session, models = seeded_test_session_async
    if "user_role" not in models:
        pytest.skip("user_role model not available")

    user_role_repo = create_repository(session, models["user_role"])

    # Delete multiple using tuple format
    deleted = await maybe_async(
        user_role_repo.delete_many(
            [
                (1, 10),
                (1, 20),
            ]
        )
    )

    assert len(deleted) == 2

    # Verify they're actually deleted
    with pytest.raises(NotFoundError):
        await maybe_async(user_role_repo.get((1, 10)))
    with pytest.raises(NotFoundError):
        await maybe_async(user_role_repo.get((1, 20)))

    # The remaining record should still exist
    remaining = await maybe_async(user_role_repo.get((2, 10)))
    assert remaining is not None


async def test_composite_pk_delete_many_by_dicts(
    seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]",
) -> None:
    """Test delete_many() with composite primary key using list of dicts."""
    session, models = seeded_test_session_async
    if "user_role" not in models:
        pytest.skip("user_role model not available")

    user_role_repo = create_repository(session, models["user_role"])

    # Delete multiple using dict format
    deleted = await maybe_async(
        user_role_repo.delete_many(
            [
                {"user_id": 1, "role_id": 10},
                {"user_id": 2, "role_id": 10},
            ]
        )
    )

    assert len(deleted) == 2

    # Verify they're actually deleted
    with pytest.raises(NotFoundError):
        await maybe_async(user_role_repo.get((1, 10)))
    with pytest.raises(NotFoundError):
        await maybe_async(user_role_repo.get((2, 10)))


async def test_composite_pk_count(
    seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]",
) -> None:
    """Test count() works with composite primary key model."""
    session, models = seeded_test_session_async
    if "user_role" not in models:
        pytest.skip("user_role model not available")

    user_role_repo = create_repository(session, models["user_role"])

    count = await maybe_async(user_role_repo.count())
    assert count == 3  # We seeded 3 user_role records


async def test_composite_pk_list(
    seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]",
) -> None:
    """Test list() works with composite primary key model."""
    session, models = seeded_test_session_async
    if "user_role" not in models:
        pytest.skip("user_role model not available")

    user_role_repo = create_repository(session, models["user_role"])

    results = await maybe_async(user_role_repo.list())
    assert len(results) == 3


async def test_single_pk_with_tuple_raises_error(
    seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]",
) -> None:
    """Test that passing a tuple to a single-PK model raises ValueError."""
    session, models = seeded_test_session_async
    author_repo = create_repository(session, models["author"])

    with pytest.raises(ValueError, match="single primary key"):
        await maybe_async(author_repo.get((1, 2)))


async def test_single_pk_with_dict_raises_error(
    seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]",
) -> None:
    """Test that passing a dict to a single-PK model raises ValueError."""
    session, models = seeded_test_session_async
    author_repo = create_repository(session, models["author"])

    with pytest.raises(ValueError, match="single primary key"):
        await maybe_async(author_repo.get({"id": 1}))


# =============================================================================
# Composite Primary Key Tests - Update/Upsert Operations
# =============================================================================


async def test_composite_pk_update(
    seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]",
) -> None:
    """Test update() with composite primary key model."""
    session, models = seeded_test_session_async
    if "user_role" not in models:
        pytest.skip("user_role model not available")

    user_role_repo = create_repository(session, models["user_role"])
    UserRole = models["user_role"]

    # Get existing record
    existing = await maybe_async(user_role_repo.get((1, 10)))
    assert existing is not None
    assert existing.is_active is True

    # Update the record (create a new instance with the same PK)
    updated_role = UserRole(user_id=1, role_id=10, is_active=False)
    result = await maybe_async(user_role_repo.update(updated_role))

    assert result is not None
    assert result.user_id == 1
    assert result.role_id == 10
    assert result.is_active is False


async def test_composite_pk_update_many(
    seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]",
) -> None:
    """Test update_many() with composite primary key model."""
    session, models = seeded_test_session_async
    if "user_role" not in models:
        pytest.skip("user_role model not available")

    user_role_repo = create_repository(session, models["user_role"])
    UserRole = models["user_role"]

    # Update multiple records
    updates = [
        UserRole(user_id=1, role_id=10, is_active=False),
        UserRole(user_id=1, role_id=20, is_active=False),
    ]
    results = await maybe_async(user_role_repo.update_many(updates))

    assert len(results) == 2

    # Verify the updates were applied
    role1 = await maybe_async(user_role_repo.get((1, 10)))
    role2 = await maybe_async(user_role_repo.get((1, 20)))
    assert role1.is_active is False
    assert role2.is_active is False


async def test_composite_pk_upsert_update(
    seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]",
) -> None:
    """Test upsert() updates existing record with composite primary key."""
    session, models = seeded_test_session_async
    if "user_role" not in models:
        pytest.skip("user_role model not available")

    user_role_repo = create_repository(session, models["user_role"])
    UserRole = models["user_role"]

    # Upsert with existing PK should update
    upserted = UserRole(user_id=1, role_id=10, is_active=False)
    result = await maybe_async(user_role_repo.upsert(upserted))

    assert result is not None
    assert result.user_id == 1
    assert result.role_id == 10
    assert result.is_active is False

    # Verify via get
    fetched = await maybe_async(user_role_repo.get((1, 10)))
    assert fetched.is_active is False


async def test_composite_pk_upsert_create(
    seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]",
) -> None:
    """Test upsert() creates new record with composite primary key."""
    session, models = seeded_test_session_async
    if "user_role" not in models:
        pytest.skip("user_role model not available")

    user_role_repo = create_repository(session, models["user_role"])
    UserRole = models["user_role"]

    # Upsert with non-existing PK should create
    new_role = UserRole(user_id=99, role_id=99, is_active=True)
    result = await maybe_async(user_role_repo.upsert(new_role))

    assert result is not None
    assert result.user_id == 99
    assert result.role_id == 99
    assert result.is_active is True

    # Verify via get
    fetched = await maybe_async(user_role_repo.get((99, 99)))
    assert fetched is not None
    assert fetched.is_active is True


async def test_composite_pk_upsert_many_mixed(
    seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]",
) -> None:
    """Test upsert_many() with mix of creates and updates for composite PKs."""
    session, models = seeded_test_session_async
    if "user_role" not in models:
        pytest.skip("user_role model not available")

    user_role_repo = create_repository(session, models["user_role"])
    UserRole = models["user_role"]

    # Mix of existing (should update) and new (should create) records
    data = [
        UserRole(user_id=1, role_id=10, is_active=False),  # Existing - update
        UserRole(user_id=88, role_id=88, is_active=True),  # New - create
    ]
    results = await maybe_async(user_role_repo.upsert_many(data))

    assert len(results) == 2

    # Verify the existing one was updated
    updated = await maybe_async(user_role_repo.get((1, 10)))
    assert updated.is_active is False

    # Verify the new one was created
    created = await maybe_async(user_role_repo.get((88, 88)))
    assert created is not None
    assert created.is_active is True


async def test_composite_pk_upsert_many_all_new(
    seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]",
) -> None:
    """Test upsert_many() with all new records for composite PKs."""
    session, models = seeded_test_session_async
    if "user_role" not in models:
        pytest.skip("user_role model not available")

    user_role_repo = create_repository(session, models["user_role"])
    UserRole = models["user_role"]

    # All new records
    data = [
        UserRole(user_id=100, role_id=100, is_active=True),
        UserRole(user_id=101, role_id=101, is_active=False),
    ]
    results = await maybe_async(user_role_repo.upsert_many(data))

    assert len(results) == 2

    # Verify both were created
    for user_id, role_id in [(100, 100), (101, 101)]:
        created = await maybe_async(user_role_repo.get((user_id, role_id)))
        assert created is not None


async def test_repo_update_partial_does_not_clear_relationships_github_684(
    seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]",
) -> None:
    """Test that partial update with a model instance does not clear relationships (GitHub Issue #684).

    When updating with a model instance where only scalar fields are set
    (e.g., Author(id=..., name="New Name")), the unset relationship fields
    should NOT overwrite existing relationships with None/[].

    Regression test for: https://github.com/litestar-org/advanced-alchemy/issues/684
    """
    session, models = seeded_test_session_async
    author_model = models["author"]
    book_model = models["book"]
    author_repo = create_repository(session, author_model)
    book_repo = create_repository(session, book_model)

    # Create an author with a book using the relationship (so ORM tracks it)
    author = author_model(name="Alice", dob=datetime.date(1980, 1, 1))
    book = book_model(title="Great Book")
    author.books = [book]
    author = await maybe_async(author_repo.add(author))
    author_id = author.id
    await session.flush()

    # Expire the session so the next get() does a fresh load with selectin
    session.expire_all()

    # Verify the relationship is set
    fetched_author = await maybe_async(author_repo.get(author_id))
    assert fetched_author.books is not None
    assert len(fetched_author.books) == 1
    book_id = fetched_author.books[0].id

    # Expire again to clear the identity map before the update
    session.expire_all()

    # Partial update: only change name, do NOT touch the books relationship
    partial_update = author_model(id=author_id, name="Bob")
    updated_author = await maybe_async(author_repo.update(partial_update))

    # Verify: name was updated
    assert updated_author.name == "Bob"

    # Expire and re-fetch to verify DB state
    session.expire_all()
    refetched = await maybe_async(author_repo.get(author_id))
    assert refetched.books is not None, "BUG: books relationship was silently cleared during partial update"
    assert len(refetched.books) == 1, "BUG: books relationship was silently cleared during partial update"
    assert refetched.books[0].id == book_id

    # Also verify the book still exists and is associated
    refetched_book = await maybe_async(book_repo.get(book_id))
    assert refetched_book.author_id == author_id, "BUG: book's author_id was cleared"


async def test_repo_update_partial_does_not_crash_non_nullable_fk_github_684(
    seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]",
) -> None:
    """Test that partial update on parent doesn't cause IntegrityError for non-nullable FK children (GitHub #684).

    When a child has a non-nullable FK to a parent, partially updating the parent
    (without touching the relationship) must not attempt to set the relationship to None,
    which would violate the NOT NULL constraint.

    Regression test for: https://github.com/litestar-org/advanced-alchemy/issues/684
    """
    session, models = seeded_test_session_async
    author_model = models["author"]
    book_model = models["book"]
    author_repo = create_repository(session, author_model)
    book_repo = create_repository(session, book_model)

    # Create an author with a book (book.author_id is non-nullable)
    author = author_model(name="Charlie", dob=datetime.date(1990, 5, 5))
    book = book_model(title="Non-Nullable FK Book")
    author.books = [book]
    author = await maybe_async(author_repo.add(author))
    author_id = author.id
    await session.flush()
    session.expire_all()

    # Fetch the book to get its ID
    fetched_author = await maybe_async(author_repo.get(author_id))
    assert len(fetched_author.books) == 1
    book_id = fetched_author.books[0].id
    session.expire_all()

    # Partial update of the book: only change title, don't touch author relationship
    # The book's `author` relationship (with non-nullable FK) should not be cleared
    partial_book = book_model(id=book_id, title="Updated Title", author_id=author_id)
    # This should NOT raise IntegrityError
    updated_book = await maybe_async(book_repo.update(partial_book))

    assert updated_book.title == "Updated Title"
    assert updated_book.author_id == author_id


async def test_repo_update_explicit_relationship_still_works_github_684(
    seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]",
) -> None:
    """Test that explicitly setting a relationship during update still works correctly (GitHub #684).

    The fix for #684 should only skip relationships that were NOT explicitly set.
    When a relationship IS explicitly set, it should still be updated normally.

    Regression test for: https://github.com/litestar-org/advanced-alchemy/issues/684
    """
    session, models = seeded_test_session_async
    author_model = models["author"]
    book_model = models["book"]
    author_repo = create_repository(session, author_model)
    book_repo = create_repository(session, book_model)

    # Create two authors
    author1 = await maybe_async(author_repo.add(author_model(name="Author1", dob=datetime.date(1980, 1, 1))))
    author2 = await maybe_async(author_repo.add(author_model(name="Author2", dob=datetime.date(1985, 2, 2))))
    await session.flush()

    # Save IDs before any expire_all() calls to avoid MissingGreenlet
    author1_id = author1.id
    author2_id = author2.id

    # Create a book linked to author1 via the relationship
    book = book_model(title="Transferable Book")
    book.author = author1
    book = await maybe_async(book_repo.add(book))
    book_id = book.id
    await session.flush()
    session.expire_all()

    # Verify initial state
    fetched_book = await maybe_async(book_repo.get(book_id))
    assert fetched_book.author_id == author1_id
    session.expire_all()

    # Explicitly update the book's author_id FK column to point to author2
    # This verifies that explicitly set FK columns (which back relationships)
    # are still properly applied during update
    update_book = book_model(id=book_id, title="Transferred Book", author_id=author2_id)
    updated_book = await maybe_async(book_repo.update(update_book))

    assert updated_book.title == "Transferred Book"
    assert updated_book.author_id == author2_id

    # Verify in DB
    session.expire_all()
    refetched = await maybe_async(book_repo.get(book_id))
    assert refetched.author_id == author2_id
python-advanced-alchemy-1.9.3/tests/integration/test_routing.py000066400000000000000000000565151516556515500250470ustar00rootroot00000000000000"""Integration tests for read/write routing functionality.

These tests verify that the routing module correctly routes read operations to
replicas and write operations to the primary database with real sessions.
"""

import asyncio
from collections.abc import Generator
from pathlib import Path
from uuid import uuid4

import pytest
from sqlalchemy import Engine, String, create_engine, select
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column

from advanced_alchemy.config.routing import RoutingConfig, RoutingStrategy
from advanced_alchemy.routing import (
    RandomSelector,
    RoundRobinSelector,
    RoutingAsyncSession,
    RoutingAsyncSessionMaker,
    RoutingSyncSession,
    RoutingSyncSessionMaker,
    primary_context,
    replica_context,
    reset_routing_context,
)

pytestmark = [
    pytest.mark.integration,
    pytest.mark.xdist_group("routing"),
]


class RoutingTestBase(DeclarativeBase):
    """Base class for routing test models."""

    pass


class User(RoutingTestBase):
    """Simple user model for routing tests."""

    __tablename__ = "routing_users"

    id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
    name: Mapped[str] = mapped_column(String(100))


@pytest.fixture()
def routing_test_db_paths(tmp_path: Path) -> dict[str, Path]:
    """Create temporary database file paths for primary and replicas."""
    return {
        "primary": tmp_path / "primary.db",
        "replica1": tmp_path / "replica1.db",
        "replica2": tmp_path / "replica2.db",
    }


@pytest.fixture()
def sync_routing_engines(routing_test_db_paths: dict[str, Path]) -> Generator[dict[str, Engine], None, None]:
    """Create sync engines for primary and replicas."""
    engines = {
        "primary": create_engine(f"sqlite:///{routing_test_db_paths['primary']}"),
        "replica1": create_engine(f"sqlite:///{routing_test_db_paths['replica1']}"),
        "replica2": create_engine(f"sqlite:///{routing_test_db_paths['replica2']}"),
    }

    for engine in engines.values():
        RoutingTestBase.metadata.create_all(engine)

    yield engines

    for engine in engines.values():
        engine.dispose()


@pytest.fixture()
def async_routing_engines(
    routing_test_db_paths: dict[str, Path],
) -> Generator[dict[str, AsyncEngine], None, None]:
    """Create async engines for primary and replicas."""
    engines = {
        "primary": create_async_engine(f"sqlite+aiosqlite:///{routing_test_db_paths['primary']}"),
        "replica1": create_async_engine(f"sqlite+aiosqlite:///{routing_test_db_paths['replica1']}"),
        "replica2": create_async_engine(f"sqlite+aiosqlite:///{routing_test_db_paths['replica2']}"),
    }

    yield engines


@pytest.fixture()
def routing_config() -> RoutingConfig:
    """Create a basic routing configuration."""
    return RoutingConfig(
        primary_connection_string="sqlite:///primary.db",
        read_replicas=["sqlite:///replica1.db", "sqlite:///replica2.db"],
        routing_strategy=RoutingStrategy.ROUND_ROBIN,
        sticky_after_write=True,
        reset_stickiness_on_commit=True,
    )


@pytest.fixture()
def sync_session_maker(
    routing_test_db_paths: dict[str, Path],
) -> Generator[RoutingSyncSessionMaker, None, None]:
    """Create a sync routing session maker with real databases."""
    config = RoutingConfig(
        primary_connection_string=f"sqlite:///{routing_test_db_paths['primary']}",
        read_replicas=[
            f"sqlite:///{routing_test_db_paths['replica1']}",
            f"sqlite:///{routing_test_db_paths['replica2']}",
        ],
        routing_strategy=RoutingStrategy.ROUND_ROBIN,
        sticky_after_write=True,
    )

    maker = RoutingSyncSessionMaker(routing_config=config)

    RoutingTestBase.metadata.create_all(maker.primary_engine)
    for engine in maker.replica_engines:
        RoutingTestBase.metadata.create_all(engine)

    yield maker

    maker.close_all()


@pytest.fixture()
def async_session_maker(
    routing_test_db_paths: dict[str, Path],
) -> Generator[RoutingAsyncSessionMaker, None, None]:
    """Create an async routing session maker with real databases."""
    config = RoutingConfig(
        primary_connection_string=f"sqlite+aiosqlite:///{routing_test_db_paths['primary']}",
        read_replicas=[
            f"sqlite+aiosqlite:///{routing_test_db_paths['replica1']}",
            f"sqlite+aiosqlite:///{routing_test_db_paths['replica2']}",
        ],
        routing_strategy=RoutingStrategy.ROUND_ROBIN,
        sticky_after_write=True,
    )

    maker = RoutingAsyncSessionMaker(routing_config=config)

    yield maker


class TestRoutingSyncSession:
    """Integration tests for sync routing sessions."""

    def test_write_goes_to_primary(self, sync_session_maker: RoutingSyncSessionMaker) -> None:
        """Test that INSERT statements are routed to primary."""
        reset_routing_context()
        session = sync_session_maker()

        try:
            user = User(name="Test User")
            session.add(user)
            session.commit()

            with Session(sync_session_maker.primary_engine) as primary_session:
                result = primary_session.execute(select(User).where(User.name == "Test User")).scalar_one_or_none()
                assert result is not None
                assert result.name == "Test User"

        finally:
            session.close()
            reset_routing_context()

    def test_read_after_write_stickiness(self, sync_session_maker: RoutingSyncSessionMaker) -> None:
        """Test that reads stick to primary after a write."""
        reset_routing_context()
        session = sync_session_maker()

        try:
            with Session(sync_session_maker.primary_engine) as primary_session:
                primary_session.add(User(id="primary-user", name="Primary User"))
                primary_session.commit()

            session.add(User(name="Trigger Write"))
            session.flush()

            stmt = select(User).where(User.id == "primary-user")
            result = session.execute(stmt).scalar_one_or_none()
            assert result is not None
            assert result.name == "Primary User"

        finally:
            session.rollback()
            session.close()
            reset_routing_context()

    def test_commit_resets_stickiness(self, sync_session_maker: RoutingSyncSessionMaker) -> None:
        """Test that commit resets stickiness when configured."""
        reset_routing_context()
        session = sync_session_maker()

        try:
            user = User(name="Test User")
            session.add(user)
            session.commit()

            from advanced_alchemy.routing.context import stick_to_primary_var

            assert not stick_to_primary_var.get()

        finally:
            session.close()
            reset_routing_context()

    def test_rollback_resets_stickiness(self, sync_session_maker: RoutingSyncSessionMaker) -> None:
        """Test that rollback always resets stickiness."""
        reset_routing_context()
        session = sync_session_maker()

        try:
            session.add(User(name="Test User"))
            session.flush()

            from advanced_alchemy.routing.context import stick_to_primary_var

            assert stick_to_primary_var.get()

            session.rollback()

            assert not stick_to_primary_var.get()

        finally:
            session.close()
            reset_routing_context()

    def test_primary_context_forces_primary(self, sync_session_maker: RoutingSyncSessionMaker) -> None:
        """Test that primary_context() forces operations to primary."""
        reset_routing_context()
        session = sync_session_maker()

        try:
            with Session(sync_session_maker.primary_engine) as primary_session:
                primary_session.add(User(id="primary-only", name="Primary Only"))
                primary_session.commit()

            with primary_context():
                result = session.execute(select(User).where(User.id == "primary-only")).scalar_one_or_none()
                assert result is not None
                assert result.name == "Primary Only"

        finally:
            session.close()
            reset_routing_context()

    def test_replica_context_allows_replicas_after_write(self, sync_session_maker: RoutingSyncSessionMaker) -> None:
        """Test that replica_context() allows replicas even after writes."""
        reset_routing_context()
        session = sync_session_maker()

        try:
            session.add(User(name="Trigger Write"))
            session.flush()

            from advanced_alchemy.routing.context import stick_to_primary_var

            assert stick_to_primary_var.get()

            with replica_context():
                from advanced_alchemy.routing.context import force_primary_var

                assert not force_primary_var.get()

        finally:
            session.rollback()
            session.close()
            reset_routing_context()


class TestRoutingAsyncSession:
    """Integration tests for async routing sessions."""

    @pytest.mark.asyncio
    async def test_async_write_goes_to_primary(self, async_session_maker: RoutingAsyncSessionMaker) -> None:
        """Test that async INSERT statements are routed to primary."""
        reset_routing_context()

        async with async_session_maker.primary_engine.begin() as conn:
            await conn.run_sync(RoutingTestBase.metadata.create_all)
        for replica_engine in async_session_maker.replica_engines:
            async with replica_engine.begin() as conn:
                await conn.run_sync(RoutingTestBase.metadata.create_all)

        session = async_session_maker()

        try:
            user = User(name="Async Test User")
            session.add(user)
            await session.commit()

            async with AsyncSession(async_session_maker.primary_engine) as primary_session:
                result = await primary_session.execute(select(User).where(User.name == "Async Test User"))
                user_result = result.scalar_one_or_none()
                assert user_result is not None
                assert user_result.name == "Async Test User"

        finally:
            await session.close()
            reset_routing_context()

        await async_session_maker.close_all()

    @pytest.mark.asyncio
    async def test_async_primary_context(self, async_session_maker: RoutingAsyncSessionMaker) -> None:
        """Test that primary_context() works with async sessions."""
        reset_routing_context()

        async with async_session_maker.primary_engine.begin() as conn:
            await conn.run_sync(RoutingTestBase.metadata.create_all)

        async with AsyncSession(async_session_maker.primary_engine) as primary_session:
            primary_session.add(User(id="async-primary", name="Async Primary"))
            await primary_session.commit()

        session = async_session_maker()

        try:
            with primary_context():
                result = await session.execute(select(User).where(User.id == "async-primary"))
                user = result.scalar_one_or_none()
                assert user is not None
                assert user.name == "Async Primary"

        finally:
            await session.close()
            reset_routing_context()

        await async_session_maker.close_all()

    @pytest.mark.asyncio
    async def test_async_commit_resets_stickiness(self, async_session_maker: RoutingAsyncSessionMaker) -> None:
        """Test that async commit resets stickiness."""
        reset_routing_context()

        async with async_session_maker.primary_engine.begin() as conn:
            await conn.run_sync(RoutingTestBase.metadata.create_all)

        session = async_session_maker()

        try:
            from advanced_alchemy.routing.context import stick_to_primary_var

            session.add(User(name="Commit Test User"))
            await session.flush()

            assert stick_to_primary_var.get()

            await session.commit()

            assert not stick_to_primary_var.get()

        finally:
            await session.close()
            reset_routing_context()

        await async_session_maker.close_all()


class TestRoutingSyncSessionMaker:
    """Integration tests for sync session maker."""

    def test_creates_sessions_with_routing(self, routing_test_db_paths: dict[str, Path]) -> None:
        """Test that session maker creates properly configured routing sessions."""
        config = RoutingConfig(
            primary_connection_string=f"sqlite:///{routing_test_db_paths['primary']}",
            read_replicas=[f"sqlite:///{routing_test_db_paths['replica1']}"],
        )

        maker = RoutingSyncSessionMaker(routing_config=config)

        try:
            session = maker()
            assert isinstance(session, RoutingSyncSession)
            assert session._default_engine is maker.primary_engine
        finally:
            maker.close_all()

    def test_round_robin_selector(self, routing_test_db_paths: dict[str, Path]) -> None:
        """Test that round-robin strategy is applied."""
        config = RoutingConfig(
            primary_connection_string=f"sqlite:///{routing_test_db_paths['primary']}",
            read_replicas=[
                f"sqlite:///{routing_test_db_paths['replica1']}",
                f"sqlite:///{routing_test_db_paths['replica2']}",
            ],
            routing_strategy=RoutingStrategy.ROUND_ROBIN,
        )

        maker = RoutingSyncSessionMaker(routing_config=config)

        try:
            assert maker._selectors["read"] is not None
            selector = maker._selectors["read"]
            assert isinstance(selector, RoundRobinSelector)

            first = selector.next()
            second = selector.next()
            third = selector.next()

            assert first == third
            assert first != second

        finally:
            maker.close_all()

    def test_random_selector(self, routing_test_db_paths: dict[str, Path]) -> None:
        """Test that random strategy is applied."""
        config = RoutingConfig(
            primary_connection_string=f"sqlite:///{routing_test_db_paths['primary']}",
            read_replicas=[
                f"sqlite:///{routing_test_db_paths['replica1']}",
                f"sqlite:///{routing_test_db_paths['replica2']}",
            ],
            routing_strategy=RoutingStrategy.RANDOM,
        )

        maker = RoutingSyncSessionMaker(routing_config=config)

        try:
            assert isinstance(maker._selectors["read"], RandomSelector)
        finally:
            maker.close_all()


class TestRoutingAsyncSessionMaker:
    """Integration tests for async session maker."""

    @pytest.mark.asyncio
    async def test_creates_async_sessions_with_routing(self, routing_test_db_paths: dict[str, Path]) -> None:
        """Test that async session maker creates properly configured sessions."""
        config = RoutingConfig(
            primary_connection_string=f"sqlite+aiosqlite:///{routing_test_db_paths['primary']}",
            read_replicas=[f"sqlite+aiosqlite:///{routing_test_db_paths['replica1']}"],
        )

        maker = RoutingAsyncSessionMaker(routing_config=config)

        try:
            session = maker()
            assert isinstance(session, RoutingAsyncSession)
            assert session.primary_engine is maker.primary_engine
        finally:
            await maker.close_all()


class TestContextIsolation:
    """Tests for context variable isolation between concurrent requests."""

    def test_sync_context_isolation(self, sync_session_maker: RoutingSyncSessionMaker) -> None:
        """Test that context variables are isolated per execution context."""
        from concurrent.futures import ThreadPoolExecutor
        from threading import Barrier

        results: dict[str, bool] = {}
        barrier = Barrier(2)

        def task1() -> None:
            reset_routing_context()
            session = sync_session_maker()
            try:
                session.add(User(name="Task 1"))
                session.flush()

                from advanced_alchemy.routing.context import stick_to_primary_var

                barrier.wait()
                results["task1_sticky"] = stick_to_primary_var.get()
            finally:
                session.rollback()
                session.close()
                reset_routing_context()

        def task2() -> None:
            reset_routing_context()
            session = sync_session_maker()
            try:
                barrier.wait()
                from advanced_alchemy.routing.context import stick_to_primary_var

                results["task2_sticky"] = stick_to_primary_var.get()
            finally:
                session.close()
                reset_routing_context()

        with ThreadPoolExecutor(max_workers=2) as executor:
            f1 = executor.submit(task1)
            f2 = executor.submit(task2)
            f1.result()
            f2.result()

        assert results["task1_sticky"] is True
        assert results["task2_sticky"] is False

    @pytest.mark.asyncio
    async def test_async_context_isolation(self, async_session_maker: RoutingAsyncSessionMaker) -> None:
        """Test that async context variables are isolated per task."""
        async with async_session_maker.primary_engine.begin() as conn:
            await conn.run_sync(RoutingTestBase.metadata.create_all)

        results: dict[str, bool] = {}
        event = asyncio.Event()

        async def task1() -> None:
            reset_routing_context()
            session = async_session_maker()
            try:
                session.add(User(name="Async Task 1"))
                await session.flush()

                from advanced_alchemy.routing.context import stick_to_primary_var

                event.set()
                await asyncio.sleep(0.1)
                results["task1_sticky"] = stick_to_primary_var.get()
            finally:
                await session.rollback()
                await session.close()
                reset_routing_context()

        async def task2() -> None:
            reset_routing_context()
            session = async_session_maker()
            try:
                await event.wait()

                from advanced_alchemy.routing.context import stick_to_primary_var

                results["task2_sticky"] = stick_to_primary_var.get()
            finally:
                await session.close()
                reset_routing_context()

        await asyncio.gather(task1(), task2())

        assert results["task1_sticky"] is True
        assert results["task2_sticky"] is False

        await async_session_maker.close_all()


class TestEdgeCases:
    """Edge case tests for routing functionality."""

    def test_no_replicas_routes_to_primary(self, routing_test_db_paths: dict[str, Path]) -> None:
        """Test that without replicas, all operations go to primary."""
        config = RoutingConfig(
            primary_connection_string=f"sqlite:///{routing_test_db_paths['primary']}",
            read_replicas=[],
        )

        maker = RoutingSyncSessionMaker(routing_config=config)
        RoutingTestBase.metadata.create_all(maker.primary_engine)

        try:
            reset_routing_context()
            session = maker()

            with Session(maker.primary_engine) as direct_session:
                direct_session.add(User(id="no-replica-test", name="No Replica User"))
                direct_session.commit()

            result = session.execute(select(User).where(User.id == "no-replica-test")).scalar_one_or_none()
            assert result is not None
            assert result.name == "No Replica User"

        finally:
            session.close()
            maker.close_all()
            reset_routing_context()

    def test_routing_disabled(self, routing_test_db_paths: dict[str, Path]) -> None:
        """Test that disabled routing sends all operations to primary."""
        config = RoutingConfig(
            primary_connection_string=f"sqlite:///{routing_test_db_paths['primary']}",
            read_replicas=[f"sqlite:///{routing_test_db_paths['replica1']}"],
            enabled=False,
        )

        maker = RoutingSyncSessionMaker(routing_config=config)
        RoutingTestBase.metadata.create_all(maker.primary_engine)

        try:
            reset_routing_context()
            session = maker()

            with Session(maker.primary_engine) as direct_session:
                direct_session.add(User(id="disabled-test", name="Disabled Routing User"))
                direct_session.commit()

            result = session.execute(select(User).where(User.id == "disabled-test")).scalar_one_or_none()
            assert result is not None

        finally:
            session.close()
            maker.close_all()
            reset_routing_context()

    def test_for_update_routes_to_primary(self, sync_session_maker: RoutingSyncSessionMaker) -> None:
        """Test that FOR UPDATE queries route to primary."""
        reset_routing_context()

        with Session(sync_session_maker.primary_engine) as direct_session:
            direct_session.add(User(id="for-update-test", name="For Update User"))
            direct_session.commit()

        session = sync_session_maker()

        try:
            stmt = select(User).where(User.id == "for-update-test").with_for_update()
            result = session.execute(stmt).scalar_one_or_none()
            assert result is not None
            assert result.name == "For Update User"

        finally:
            session.close()
            reset_routing_context()


class TestNestedContexts:
    """Tests for nested context manager behavior."""

    def test_nested_primary_contexts(self, sync_session_maker: RoutingSyncSessionMaker) -> None:
        """Test that nested primary_context managers work correctly."""
        reset_routing_context()

        with primary_context():
            from advanced_alchemy.routing.context import force_primary_var

            assert force_primary_var.get()

            with primary_context():
                assert force_primary_var.get()

            assert force_primary_var.get()

        assert not force_primary_var.get()
        reset_routing_context()

    def test_nested_replica_contexts(self, sync_session_maker: RoutingSyncSessionMaker) -> None:
        """Test that nested replica_context managers work correctly."""
        reset_routing_context()
        session = sync_session_maker()

        try:
            session.add(User(name="Sticky User"))
            session.flush()

            from advanced_alchemy.routing.context import (
                force_primary_var,
                stick_to_primary_var,
            )

            assert stick_to_primary_var.get()

            with replica_context():
                assert not force_primary_var.get()

                with replica_context():
                    assert not force_primary_var.get()

                assert not force_primary_var.get()

        finally:
            session.rollback()
            session.close()
            reset_routing_context()

    def test_mixed_contexts(self, sync_session_maker: RoutingSyncSessionMaker) -> None:
        """Test mixing primary_context and replica_context."""
        reset_routing_context()

        from advanced_alchemy.routing.context import force_primary_var

        with primary_context():
            assert force_primary_var.get()

            with replica_context():
                assert not force_primary_var.get()

            assert force_primary_var.get()

        assert not force_primary_var.get()
        reset_routing_context()
python-advanced-alchemy-1.9.3/tests/integration/test_sqlquery_service.py000066400000000000000000000343041516556515500267550ustar00rootroot00000000000000from __future__ import annotations

from pathlib import Path

import pytest
from msgspec import Struct
from pydantic import BaseModel
from sqlalchemy import Engine, String, select
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column

from advanced_alchemy import base, mixins
from advanced_alchemy.repository import (
    SQLAlchemyAsyncRepository,
    SQLAlchemySyncRepository,
)
from advanced_alchemy.service import SQLAlchemyAsyncQueryService, SQLAlchemySyncQueryService
from advanced_alchemy.service._async import SQLAlchemyAsyncRepositoryService
from advanced_alchemy.service._sync import SQLAlchemySyncRepositoryService
from advanced_alchemy.service.typing import (
    is_msgspec_struct,
    is_msgspec_struct_with_field,
    is_msgspec_struct_without_field,
    is_pydantic_model,
    is_pydantic_model_with_field,
    is_pydantic_model_without_field,
)
from advanced_alchemy.utils.fixtures import open_fixture, open_fixture_async

pytestmark = [  # type: ignore
    pytest.mark.integration,
    pytest.mark.xdist_group("sqlquery_service"),
]
here = Path(__file__).parent
fixture_path = here.parent.parent / "examples"
state_registry = base.create_registry()


@pytest.fixture()
def sqlquery_test_tables(engine: Engine) -> None:
    """Create sqlquery test tables for sync engines."""
    if getattr(engine.dialect, "name", "") != "mock":
        state_registry.metadata.create_all(engine)


@pytest.fixture()
async def sqlquery_test_tables_async(async_engine: AsyncEngine) -> None:
    """Create sqlquery test tables for async engines."""
    if getattr(async_engine.dialect, "name", "") != "mock":
        async with async_engine.begin() as conn:
            await conn.run_sync(state_registry.metadata.create_all)


class UUIDBase(mixins.UUIDPrimaryKey, base.CommonTableAttributes, DeclarativeBase):
    """Base for all SQLAlchemy declarative models with UUID primary keys."""

    registry = state_registry


class USState(UUIDBase):
    __tablename__ = "us_state_lookup"  # type: ignore[assignment]
    abbreviation: Mapped[str] = mapped_column(String(5))
    name: Mapped[str] = mapped_column(String(50))


class USStateStruct(Struct):
    abbreviation: str
    name: str


class USStateBaseModel(BaseModel):
    abbreviation: str
    name: str


class USStateSyncRepository(SQLAlchemySyncRepository[USState]):
    """US State repository."""

    model_type = USState


class USStateSyncService(SQLAlchemySyncRepositoryService[USState, USStateSyncRepository]):
    """US State repository."""

    repository_type = USStateSyncRepository


class USStateAsyncRepository(SQLAlchemyAsyncRepository[USState]):
    """US State repository."""

    model_type = USState


class USStateAsyncService(SQLAlchemyAsyncRepositoryService[USState, USStateAsyncRepository]):
    """US State repository."""

    repository_type = USStateAsyncRepository


class StateQuery(base.SQLQuery):
    """Nonsensical query to test custom SQL queries."""

    __table__ = select(  # type: ignore
        USState.abbreviation.label("state_abbreviation"),
        USState.name.label("state_name"),
    ).alias("state_lookup")
    __mapper_args__ = {
        "primary_key": [USState.abbreviation],
    }
    state_abbreviation: str
    state_name: str


class StateQueryStruct(Struct):
    state_abbreviation: str
    state_name: str


class StateQueryBaseModel(BaseModel):
    state_abbreviation: str
    state_name: str


@pytest.mark.xdist_group("sqlquery")
def test_sync_fixture_and_query(engine: Engine, sqlquery_test_tables: None) -> None:
    # Skip mock engines as they don't support proper query operations
    if getattr(engine.dialect, "name", "") == "mock":
        pytest.skip("Mock engines don't support proper query operations")

    with Session(engine) as session:
        state_service = USStateSyncService(session=session)
        query_service = SQLAlchemySyncQueryService(session=session)
        fixture = open_fixture(fixture_path, USStateSyncRepository.model_type.__tablename__)  # type: ignore[has-type]
        _add_objs = state_service.create_many(
            data=[USStateStruct(**raw_obj) for raw_obj in fixture],
        )
        _ordered_objs = state_service.list(order_by=(USState.name, True))
        assert _ordered_objs[0].name == "Wyoming"
        _ordered_objs_2 = state_service.list_and_count(order_by=[(USState.name, True)])
        assert _ordered_objs_2[0][0].name == "Wyoming"
        query_count = query_service.repository.count(statement=select(StateQuery))
        assert query_count > 0
        list_query_objs, list_query_count = query_service.repository.list_and_count(
            statement=select(StateQuery),
        )
        assert list_query_count >= 50
        _paginated_objs = query_service.to_schema(
            data=list_query_objs,
            total=list_query_count,
        )

        _pydantic_paginated_objs = query_service.to_schema(
            data=list_query_objs,
            total=list_query_count,
            schema_type=StateQueryBaseModel,
        )
        assert isinstance(_pydantic_paginated_objs.items[0], StateQueryBaseModel)
        _msgspec_paginated_objs = query_service.to_schema(
            data=list_query_objs,
            total=list_query_count,
            schema_type=StateQueryStruct,
        )
        assert isinstance(_msgspec_paginated_objs.items[0], StateQueryStruct)
        _list_service_objs = query_service.repository.list(statement=select(StateQuery))
        assert len(_list_service_objs) >= 50
        _get_ones = query_service.repository.list(statement=select(StateQuery), state_name="Alabama")
        assert len(_get_ones) == 1
        _get_one = query_service.repository.get_one(statement=select(StateQuery), state_name="Alabama")
        assert _get_one.state_name == "Alabama"
        _get_one_or_none_1 = query_service.repository.get_one_or_none(
            statement=select(StateQuery).where(StateQuery.state_name == "Texas"),  # type: ignore
        )
        assert _get_one_or_none_1 is not None
        assert _get_one_or_none_1.state_name == "Texas"
        _obj = query_service.to_schema(
            data=_get_one_or_none_1,
        )
        _pydantic_obj = query_service.to_schema(
            data=_get_one_or_none_1,
            schema_type=StateQueryBaseModel,
        )
        assert isinstance(_pydantic_obj, StateQueryBaseModel)
        assert is_pydantic_model(_pydantic_obj)
        assert is_pydantic_model_with_field(_pydantic_obj, "state_abbreviation")
        assert not is_pydantic_model_without_field(_pydantic_obj, "state_abbreviation")

        _msgspec_obj = query_service.to_schema(
            data=_get_one_or_none_1,
            schema_type=StateQueryStruct,
        )
        assert isinstance(_msgspec_obj, StateQueryStruct)
        assert is_msgspec_struct(_msgspec_obj)
        assert is_msgspec_struct_with_field(_msgspec_obj, "state_abbreviation")
        assert not is_msgspec_struct_without_field(_msgspec_obj, "state_abbreviation")

        _get_one_or_none = query_service.repository.get_one_or_none(
            statement=select(StateQuery).filter_by(state_name="Nope"),
        )
        assert _get_one_or_none is None


@pytest.mark.xdist_group("sqlquery")
async def test_async_fixture_and_query(async_engine: AsyncEngine, sqlquery_test_tables_async: None) -> None:
    # Skip mock engines as they don't support proper query operations
    if getattr(async_engine.dialect, "name", "") == "mock":
        pytest.skip("Mock engines don't support proper query operations")

    async with AsyncSession(async_engine) as session:
        state_service = USStateAsyncService(session=session)

        query_service = SQLAlchemyAsyncQueryService(session=session)
        fixture = await open_fixture_async(fixture_path, USStateSyncRepository.model_type.__tablename__)
        _add_objs = await state_service.create_many(
            data=[USStateBaseModel(**raw_obj) for raw_obj in fixture],
        )
        _ordered_objs = await state_service.list(order_by=(USState.name, True))
        assert _ordered_objs[0].name == "Wyoming"
        _ordered_objs_2 = await state_service.list_and_count(order_by=(USState.name, True))
        assert _ordered_objs_2[0][0].name == "Wyoming"
        query_count = await query_service.repository.count(statement=select(StateQuery))
        assert query_count > 0
        list_query_objs, list_query_count = await query_service.repository.list_and_count(
            statement=select(StateQuery),
        )
        assert list_query_count >= 50
        _paginated_objs = query_service.to_schema(
            list_query_objs,
            total=list_query_count,
        )

        _pydantic_paginated_objs = query_service.to_schema(
            data=list_query_objs,
            total=list_query_count,
            schema_type=StateQueryBaseModel,
        )
        assert isinstance(_pydantic_paginated_objs.items[0], StateQueryBaseModel)
        _msgspec_paginated_objs = query_service.to_schema(
            data=list_query_objs,
            total=list_query_count,
            schema_type=StateQueryStruct,
        )
        assert isinstance(_msgspec_paginated_objs.items[0], StateQueryStruct)
        _list_service_objs = await query_service.repository.list(statement=select(StateQuery))
        assert len(_list_service_objs) >= 50
        _get_ones = await query_service.repository.list(statement=select(StateQuery), state_name="Alabama")
        assert len(_get_ones) == 1
        _get_one = await query_service.repository.get_one(statement=select(StateQuery), state_name="Alabama")
        assert _get_one.state_name == "Alabama"
        _get_one_or_none_1 = await query_service.repository.get_one_or_none(
            statement=select(StateQuery).where(StateQuery.state_name == "Texas"),  # type: ignore
        )
        assert _get_one_or_none_1 is not None
        assert _get_one_or_none_1.state_name == "Texas"
        _obj = query_service.to_schema(
            data=_get_one_or_none_1,
        )
        _pydantic_obj = query_service.to_schema(
            data=_get_one_or_none_1,
            schema_type=StateQueryBaseModel,
        )
        assert isinstance(_pydantic_obj, StateQueryBaseModel)
        assert is_pydantic_model(_pydantic_obj)
        assert is_pydantic_model_with_field(_pydantic_obj, "state_abbreviation")
        assert not is_pydantic_model_without_field(_pydantic_obj, "state_abbreviation")

        _msgspec_obj = query_service.to_schema(
            data=_get_one_or_none_1,
            schema_type=StateQueryStruct,
        )
        assert isinstance(_msgspec_obj, StateQueryStruct)
        assert is_msgspec_struct(_msgspec_obj)
        assert is_msgspec_struct_with_field(_msgspec_obj, "state_abbreviation")
        _get_one_or_none = await query_service.repository.get_one_or_none(
            select(StateQuery).filter_by(state_name="Nope")
        )
        assert not is_msgspec_struct_without_field(_msgspec_obj, "state_abbreviation")
        assert _get_one_or_none is None


@pytest.mark.xdist_group("sqlquery")
async def test_async_query_repository_instantiation(async_engine: AsyncEngine) -> None:
    """Test that SQLAlchemyAsyncQueryRepository can be instantiated without super().__init__() error."""
    from advanced_alchemy.repository import SQLAlchemyAsyncQueryRepository

    async with AsyncSession(async_engine) as session:
        # Test direct instantiation - this should not raise TypeError
        repository = SQLAlchemyAsyncQueryRepository(session=session)
        assert repository is not None
        assert repository.session == session
        assert repository.error_messages is None
        assert repository.wrap_exceptions is True

        # Test with optional parameters
        repository_with_params = SQLAlchemyAsyncQueryRepository(
            session=session, error_messages={"not_found": "Custom not found"}, wrap_exceptions=False
        )
        assert repository_with_params.session == session
        assert repository_with_params.error_messages == {"not_found": "Custom not found"}
        assert repository_with_params.wrap_exceptions is False


@pytest.mark.xdist_group("sqlquery")
def test_sync_query_repository_instantiation(engine: Engine) -> None:
    """Test that SQLAlchemySyncQueryRepository can be instantiated without super().__init__() error."""
    from advanced_alchemy.repository import SQLAlchemySyncQueryRepository

    with Session(engine) as session:
        # Test direct instantiation - this should not raise TypeError
        repository = SQLAlchemySyncQueryRepository(session=session)
        assert repository is not None
        assert repository.session == session
        assert repository.error_messages is None
        assert repository.wrap_exceptions is True

        # Test with optional parameters
        repository_with_params = SQLAlchemySyncQueryRepository(
            session=session, error_messages={"not_found": "Custom not found"}, wrap_exceptions=False
        )
        assert repository_with_params.session == session
        assert repository_with_params.error_messages == {"not_found": "Custom not found"}
        assert repository_with_params.wrap_exceptions is False


@pytest.mark.xdist_group("sqlquery")
async def test_async_query_service_with_repository_instantiation(async_engine: AsyncEngine) -> None:
    """Test that SQLAlchemyAsyncQueryService using the repository works correctly."""
    from advanced_alchemy.service import SQLAlchemyAsyncQueryService

    async with AsyncSession(async_engine) as session:
        # This should not raise TypeError when creating the repository internally
        query_service = SQLAlchemyAsyncQueryService(session=session)
        assert query_service is not None
        assert query_service.repository is not None
        assert query_service.repository.session == session


@pytest.mark.xdist_group("sqlquery")
def test_sync_query_service_with_repository_instantiation(engine: Engine) -> None:
    """Test that SQLAlchemySyncQueryService using the repository works correctly."""
    from advanced_alchemy.service import SQLAlchemySyncQueryService

    with Session(engine) as session:
        # This should not raise TypeError when creating the repository internally
        query_service = SQLAlchemySyncQueryService(session=session)
        assert query_service is not None
        assert query_service.repository is not None
        assert query_service.repository.session == session
python-advanced-alchemy-1.9.3/tests/integration/test_unique_mixin.py000066400000000000000000000157731516556515500260730ustar00rootroot00000000000000from __future__ import annotations

from collections.abc import Hashable
from typing import TYPE_CHECKING

import pytest
from sqlalchemy import ColumnElement, Engine, String, UniqueConstraint, func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column

from advanced_alchemy import base, mixins
from advanced_alchemy.base import create_registry
from advanced_alchemy.exceptions import MultipleResultsFoundError
from advanced_alchemy.mixins import UniqueMixin
from tests.integration.test_models import DatabaseCapabilities

if TYPE_CHECKING:
    from collections.abc import Iterator
    from typing import Any

pytestmark = [
    pytest.mark.integration,
    pytest.mark.xdist_group("unique_mixin"),
]


@pytest.fixture(name="rows")
def generate_mock_data() -> Iterator[list[dict[str, Any]]]:
    rows = [{"col_1": i, "col_2": f"value_{i}", "col_3": i} for i in range(1, 3)]
    # Duplicate the last row in the list to violate the unique constraint
    rows.extend([rows[-1]] * 3)  # 3 is arbitrary
    yield rows


custom_registry = create_registry()


class CustomBigIntBase(mixins.BigIntPrimaryKey, base.CommonTableAttributes, DeclarativeBase):
    """Base for all SQLAlchemy declarative models with BigInt primary keys using custom registry."""

    registry = custom_registry


@pytest.fixture()
def unique_test_tables(engine: Engine) -> None:
    """Create unique mixin test tables for sync engines."""
    # Skip for databases that don't support unique constraints
    if DatabaseCapabilities.should_skip_unique_constraints(engine.dialect.name):
        pytest.skip(f"{engine.dialect.name} doesn't support unique constraints")
    if getattr(engine.dialect, "name", "") != "mock":
        custom_registry.metadata.create_all(engine)


@pytest.fixture()
async def unique_test_tables_async(async_engine: AsyncEngine) -> None:
    """Create unique mixin test tables for async engines."""
    # Skip for databases that don't support unique constraints
    if DatabaseCapabilities.should_skip_unique_constraints(async_engine.dialect.name):
        pytest.skip(f"{async_engine.dialect.name} doesn't support unique constraints")
    if getattr(async_engine.dialect, "name", "") != "mock":
        async with async_engine.begin() as conn:
            await conn.run_sync(custom_registry.metadata.create_all)


class BigIntModelWithUniqueValue(UniqueMixin, CustomBigIntBase):
    col_1: Mapped[int]
    col_2: Mapped[str] = mapped_column(String(50))
    col_3: Mapped[int]

    __table_args__ = (UniqueConstraint("col_1", "col_3"),)

    @classmethod
    def unique_hash(cls, col_1: int, col_2: int, col_3: str) -> Hashable:
        return (col_1, col_3)

    @classmethod
    def unique_filter(cls, col_1: int, col_2: int, col_3: str) -> ColumnElement[bool]:
        return (cls.col_1 == col_1) & (cls.col_3 == col_3)


class BigIntModelWithMaybeUniqueValue(UniqueMixin, CustomBigIntBase):
    col_1: Mapped[int]
    col_2: Mapped[str] = mapped_column(String(50))
    col_3: Mapped[int]

    @classmethod
    def unique_hash(cls, col_1: int, col_2: int, col_3: str) -> Hashable:
        return (col_1, col_3)

    @classmethod
    def unique_filter(cls, col_1: int, col_2: int, col_3: str) -> ColumnElement[bool]:
        return (cls.col_1 == col_1) & (cls.col_3 == col_3)


def test_as_unique_sync(engine: Engine, unique_test_tables: None, rows: list[dict[str, Any]]) -> None:
    # Skip for databases that don't support unique constraints
    if DatabaseCapabilities.should_skip_unique_constraints(engine.dialect.name):
        pytest.skip(f"{engine.dialect.name} doesn't support unique constraints")
    # Skip for Spanner and CockroachDB - BigInt PK issues
    if DatabaseCapabilities.should_skip_bigint(engine.dialect.name):
        pytest.skip(f"{engine.dialect.name} doesn't support bigint PKs well")
    # Skip for mock engines - they don't handle multi-row INSERT properly
    if getattr(engine.dialect, "name", "") == "mock":
        pytest.skip("Mock engines don't support multi-row INSERT for unique mixin tests")
    with Session(engine) as session:
        session.add_all(BigIntModelWithUniqueValue(**row) for row in rows)
        with pytest.raises(IntegrityError):
            # An exception should be raised when not using ``as_unique_sync``
            session.flush()

    with Session(engine) as session:
        session.add_all(BigIntModelWithUniqueValue.as_unique_sync(session, **row) for row in rows)
        statement = select(func.count()).select_from(BigIntModelWithUniqueValue)
        count = session.scalar(statement)
        assert count == 2

    with Session(engine) as session:
        # Add non unique rows on purpose to check if the mixin triggers ``MultipleResultsFound``
        session.add_all(BigIntModelWithMaybeUniqueValue(**row) for row in rows)
        # flush here so that when the mixin queries the db, the non unique rows are in the transaction
        session.flush()
        with pytest.raises(MultipleResultsFoundError):
            session.add_all(BigIntModelWithMaybeUniqueValue.as_unique_sync(session, **row) for row in rows)


async def test_as_unique_async(
    async_engine: AsyncEngine, unique_test_tables_async: None, rows: list[dict[str, Any]]
) -> None:
    # Skip for databases that don't support unique constraints
    if DatabaseCapabilities.should_skip_unique_constraints(async_engine.dialect.name):
        pytest.skip(f"{async_engine.dialect.name} doesn't support unique constraints")
    # Skip for Spanner and CockroachDB - BigInt PK issues
    if DatabaseCapabilities.should_skip_bigint(async_engine.dialect.name):
        pytest.skip(f"{async_engine.dialect.name} doesn't support bigint PKs well")
    # Skip for mock engines - they don't handle multi-row INSERT properly
    if getattr(async_engine.dialect, "name", "") == "mock":
        pytest.skip("Mock engines don't support multi-row INSERT for unique mixin tests")
    async with AsyncSession(async_engine) as session:
        session.add_all(BigIntModelWithUniqueValue(**row) for row in rows)
        with pytest.raises(IntegrityError):
            # An exception should be raised when not using ``as_unique_async``
            await session.flush()

    async with AsyncSession(async_engine) as session:
        session.add_all([await BigIntModelWithUniqueValue.as_unique_async(session, **row) for row in rows])
        statement = select(func.count()).select_from(BigIntModelWithUniqueValue)
        count = await session.scalar(statement)
        assert count == 2

    async with AsyncSession(async_engine) as session:
        # Add non unique rows on purpose to check if the mixin triggers ``MultipleResultsFound``
        session.add_all(BigIntModelWithMaybeUniqueValue(**row) for row in rows)
        # flush here so that when the mixin queries the db, the non unique rows are in the transaction
        await session.flush()
        with pytest.raises(MultipleResultsFoundError):
            session.add_all([await BigIntModelWithMaybeUniqueValue.as_unique_async(session, **row) for row in rows])
python-advanced-alchemy-1.9.3/tests/unit/000077500000000000000000000000001516556515500203675ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/tests/unit/__init__.py000066400000000000000000000000001516556515500224660ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/tests/unit/fixtures.py000066400000000000000000000013141516556515500226110ustar00rootroot00000000000000from __future__ import annotations

from advanced_alchemy.config import SQLAlchemyAsyncConfig, SQLAlchemySyncConfig

# Keep the original sync configs for backward compatibility
configs = [SQLAlchemySyncConfig(connection_string="sqlite:///:memory:")]

# Add async configs for new external config loading tests
async_configs = [
    SQLAlchemyAsyncConfig(
        connection_string="sqlite+aiosqlite:///:memory:",
        bind_key="default",
    ),
    SQLAlchemyAsyncConfig(
        connection_string="sqlite+aiosqlite:///:memory:",
        bind_key="secondary",
    ),
]

# Single config for basic tests
config = SQLAlchemyAsyncConfig(
    connection_string="sqlite+aiosqlite:///:memory:",
    bind_key="default",
)
python-advanced-alchemy-1.9.3/tests/unit/test_attrs_integration.py000066400000000000000000000271031516556515500255430ustar00rootroot00000000000000"""Tests for attrs integration in Advanced Alchemy services."""

from __future__ import annotations

from typing import Any, Optional

import pytest
from attrs import define, field

from advanced_alchemy.service.typing import (
    ATTRS_INSTALLED,
    AttrsInstance,
    is_attrs_instance,
    is_attrs_instance_with_field,
    is_attrs_instance_without_field,
    is_attrs_schema,
    is_schema,
    is_schema_with_field,
    is_schema_without_field,
    schema_dump,
)

pytestmark = [
    pytest.mark.unit,
]

# attrs test classes and fixtures


@define
class SimpleAttrsInstance:
    """Simple attrs class for testing."""

    name: str
    age: int


@define
class AttrsWithOptional:
    """attrs class with optional fields."""

    name: str
    email: Optional[str] = None
    active: bool = True


@define
class AttrsWithDefaults:
    """attrs class with field defaults."""

    title: str
    count: int = field(default=0)
    tags: list[str] = field(factory=list)


@define
class NestedAttrsInstance:
    """attrs class with nested attrs."""

    user: SimpleAttrsInstance
    metadata: dict[str, Any] = field(factory=dict)


class TestAttrsDetection:
    """Test attrs class detection functions."""

    def test_attrs_installed_flag(self) -> None:
        """Test that ATTRS_INSTALLED flag is a boolean."""
        assert isinstance(ATTRS_INSTALLED, bool)

    @pytest.mark.skipif(not ATTRS_INSTALLED, reason="attrs not installed")
    def test_is_attrs_instance_with_attrs_instance(self) -> None:
        """Test is_attrs_instance with actual attrs class instance."""
        instance = SimpleAttrsInstance(name="test", age=30)
        assert is_attrs_instance(instance)

    @pytest.mark.skipif(not ATTRS_INSTALLED, reason="attrs not installed")
    def test_is_attrs_instance_with_complex_attrs(self) -> None:
        """Test is_attrs_instance with complex attrs instances."""
        simple_instance = SimpleAttrsInstance(name="John", age=25)
        nested_instance = NestedAttrsInstance(user=simple_instance, metadata={"role": "admin"})

        assert is_attrs_instance(simple_instance)
        assert is_attrs_instance(nested_instance)

    @pytest.mark.skipif(not ATTRS_INSTALLED, reason="attrs not installed")
    def test_is_attrs_schema_with_attrs_classes(self) -> None:
        """Test is_attrs_schema with attrs class types."""
        assert is_attrs_schema(SimpleAttrsInstance)
        assert is_attrs_schema(AttrsWithOptional)
        assert is_attrs_schema(AttrsWithDefaults)
        assert is_attrs_schema(NestedAttrsInstance)

    def test_is_attrs_schema_with_non_attrs_classes(self) -> None:
        """Test is_attrs_schema with non-attrs class types."""
        assert not is_attrs_schema(dict)
        assert not is_attrs_schema(list)
        assert not is_attrs_schema(str)
        assert not is_attrs_schema(int)

        class RegularClass:
            pass

        assert not is_attrs_schema(RegularClass)

    def test_is_attrs_class_with_non_attrs_instance(self) -> None:
        """Test is_attrs_class with non-attrs objects."""
        assert not is_attrs_instance({})
        assert not is_attrs_instance([])
        assert not is_attrs_instance("string")
        assert not is_attrs_instance(42)

    def test_is_attrs_class_with_regular_class(self) -> None:
        """Test is_attrs_class with regular Python class."""

        class RegularClass:
            def __init__(self, name: str) -> None:
                self.name = name

        instance = RegularClass("test")
        assert not is_attrs_instance(instance)

    @pytest.mark.skipif(not ATTRS_INSTALLED, reason="attrs not installed")
    def test_is_attrs_instance_with_field(self) -> None:
        """Test is_attrs_class_with_field function."""
        instance = SimpleAttrsInstance(name="test", age=30)

        assert is_attrs_instance_with_field(instance, "name")
        assert is_attrs_instance_with_field(instance, "age")
        assert not is_attrs_instance_with_field(instance, "nonexistent")

    @pytest.mark.skipif(not ATTRS_INSTALLED, reason="attrs not installed")
    def test_is_attrs_instance_without_field(self) -> None:
        """Test is_attrs_class_without_field function."""
        instance = SimpleAttrsInstance(name="test", age=30)

        assert not is_attrs_instance_without_field(instance, "name")
        assert not is_attrs_instance_without_field(instance, "age")
        assert is_attrs_instance_without_field(instance, "nonexistent")

    def test_is_attrs_class_field_checks_with_non_attrs(self) -> None:
        """Test field checking functions with non-attrs objects."""
        regular_obj = {"name": "test", "age": 30}

        assert not is_attrs_instance_with_field(regular_obj, "name")
        assert not is_attrs_instance_without_field(regular_obj, "name")


class TestSchemaIntegration:
    """Test attrs integration with schema functions."""

    @pytest.mark.skipif(not ATTRS_INSTALLED, reason="attrs not installed")
    def test_is_schema_with_attrs_class(self) -> None:
        """Test is_schema function includes attrs classes."""
        instance = SimpleAttrsInstance(name="test", age=30)
        assert is_schema(instance)

    @pytest.mark.skipif(not ATTRS_INSTALLED, reason="attrs not installed")
    def test_is_schema_with_field_attrs(self) -> None:
        """Test is_schema_with_field with attrs classes."""
        instance = SimpleAttrsInstance(name="test", age=30)

        assert is_schema_with_field(instance, "name")
        assert is_schema_with_field(instance, "age")
        assert not is_schema_with_field(instance, "nonexistent")

    @pytest.mark.skipif(not ATTRS_INSTALLED, reason="attrs not installed")
    def test_is_schema_without_field_attrs(self) -> None:
        """Test is_schema_without_field with attrs classes."""
        instance = SimpleAttrsInstance(name="test", age=30)

        assert not is_schema_without_field(instance, "name")
        assert not is_schema_without_field(instance, "age")
        assert is_schema_without_field(instance, "nonexistent")


class TestSchemaDump:
    """Test schema_dump function with attrs classes."""

    @pytest.mark.skipif(not ATTRS_INSTALLED, reason="attrs not installed")
    def test_schema_dump_simple_attrs(self) -> None:
        """Test schema_dump with simple attrs class."""
        instance = SimpleAttrsInstance(name="John", age=30)
        result = schema_dump(instance)

        expected = {"name": "John", "age": 30}
        assert result == expected
        assert isinstance(result, dict)

    @pytest.mark.skipif(not ATTRS_INSTALLED, reason="attrs not installed")
    def test_schema_dump_attrs_with_optional(self) -> None:
        """Test schema_dump with attrs class having optional fields."""
        instance = AttrsWithOptional(name="Jane", email="jane@example.com", active=True)
        result = schema_dump(instance)

        expected = {"name": "Jane", "email": "jane@example.com", "active": True}
        assert result == expected

    @pytest.mark.skipif(not ATTRS_INSTALLED, reason="attrs not installed")
    def test_schema_dump_attrs_with_defaults(self) -> None:
        """Test schema_dump with attrs class having field defaults."""
        instance = AttrsWithDefaults(title="Test", count=5, tags=["tag1", "tag2"])
        result = schema_dump(instance)

        expected = {"title": "Test", "count": 5, "tags": ["tag1", "tag2"]}
        assert result == expected

    @pytest.mark.skipif(not ATTRS_INSTALLED, reason="attrs not installed")
    def test_schema_dump_nested_attrs(self) -> None:
        """Test schema_dump with nested attrs classes."""
        user = SimpleAttrsInstance(name="John", age=25)
        instance = NestedAttrsInstance(user=user, metadata={"role": "admin"})
        result = schema_dump(instance)

        expected = {"user": {"name": "John", "age": 25}, "metadata": {"role": "admin"}}
        assert result == expected

    @pytest.mark.skipif(not ATTRS_INSTALLED, reason="attrs not installed")
    def test_schema_dump_attrs_collection_types(self) -> None:
        """Test schema_dump retains collection types."""
        instance = AttrsWithDefaults(title="Test", tags=["a", "b", "c"])
        result = schema_dump(instance)

        assert isinstance(result["tags"], list)
        assert result["tags"] == ["a", "b", "c"]

    def test_schema_dump_non_attrs_unchanged(self) -> None:
        """Test schema_dump with non-attrs objects remains unchanged."""
        # Dict should pass through unchanged
        dict_data = {"name": "test", "age": 30}
        assert schema_dump(dict_data) == dict_data

    def test_schema_dump_none_value(self) -> None:
        """Test that schema_dump correctly handles None values.

        schema_dump should gracefully handle None input values without raising
        AttributeError, returning None as-is through the fallback mechanism.
        This is important for nullable database fields that may contain None.
        """
        # schema_dump should handle None without raising AttributeError
        result = schema_dump(None)
        assert result is None

    @pytest.mark.skipif(not ATTRS_INSTALLED, reason="attrs not installed")
    def test_schema_dump_exclude_unset_parameter(self) -> None:
        """Test schema_dump exclude_unset parameter (attrs always includes all fields)."""
        instance = SimpleAttrsInstance(name="test", age=30)

        # attrs.asdict doesn't have exclude_unset concept, should return all fields
        result_with_unset = schema_dump(instance, exclude_unset=True)
        result_without_unset = schema_dump(instance, exclude_unset=False)

        expected = {"name": "test", "age": 30}
        assert result_with_unset == expected
        assert result_without_unset == expected


class TestAttrsInstanceProtocol:
    """Test AttrsInstance protocol."""

    def test_attrs_class_protocol_exists(self) -> None:
        """Test that AttrsInstance protocol is available."""
        assert AttrsInstance is not None

    @pytest.mark.skipif(not ATTRS_INSTALLED, reason="attrs not installed")
    def test_attrs_class_protocol_with_real_attrs(self) -> None:
        """Test AttrsInstance protocol with real attrs class."""
        instance = SimpleAttrsInstance(name="test", age=30)

        # Should have __attrs_attrs__ attribute when attrs is installed
        assert hasattr(instance.__class__, "__attrs_attrs__")

    def test_attrs_class_protocol_type_annotation(self) -> None:
        """Test AttrsInstance can be used in type annotations."""

        # This test ensures the protocol is properly defined for type checking
        def process_attrs(obj: AttrsInstance) -> dict[str, Any]:
            return {"processed": True}

        # Should not raise type errors
        assert callable(process_attrs)


class TestErrorHandling:
    """Test error handling in attrs integration."""

    @pytest.mark.skipif(ATTRS_INSTALLED, reason="attrs is installed")
    def test_attrs_functions_when_not_installed(self) -> None:
        """Test attrs functions behave correctly when attrs not installed."""
        # When attrs not installed, detection should return False
        dummy_obj = SimpleAttrsInstance(name="test", age=30)

        assert not is_attrs_instance(dummy_obj)
        assert not is_attrs_instance_with_field(dummy_obj, "name")
        assert not is_attrs_instance_without_field(dummy_obj, "name")

    @pytest.mark.skipif(ATTRS_INSTALLED, reason="attrs is installed")
    def test_schema_dump_fallback_when_attrs_not_installed(self) -> None:
        """Test schema_dump falls back to __dict__ when attrs not installed."""
        dummy_obj = SimpleAttrsInstance(name="test", age=30)
        result = schema_dump(dummy_obj)

        # Should fall back to __dict__ access
        expected = {"name": "test", "age": 30}
        assert result == expected
python-advanced-alchemy-1.9.3/tests/unit/test_base.py000066400000000000000000000133541516556515500227200ustar00rootroot00000000000000# pyright: reportUnusedImport=false
from __future__ import annotations

import warnings
from typing import cast

from sqlalchemy import Table, create_engine
from sqlalchemy.dialects import mssql, oracle, postgresql
from sqlalchemy.orm import declarative_mixin
from sqlalchemy.schema import CreateTable

from tests.helpers import purge_module


def test_deprecated_classes_functionality() -> None:
    """Test that mixins classes maintain have base functionality."""
    purge_module(["advanced_alchemy.base", "advanced_alchemy.mixins"], __file__)

    warnings.filterwarnings("ignore", category=DeprecationWarning)
    from sqlalchemy import exc as sa_exc

    warnings.filterwarnings("ignore", category=sa_exc.SAWarning)

    # Test instantiation and basic attributes
    from advanced_alchemy.mixins import (
        AuditColumns,
        NanoIDPrimaryKey,
        UUIDPrimaryKey,
        UUIDv6PrimaryKey,
        UUIDv7PrimaryKey,
    )

    uuidv7_pk = UUIDv7PrimaryKey()
    uuidv6_pk = UUIDv6PrimaryKey()
    uuid_pk = UUIDPrimaryKey()
    nanoid_pk = NanoIDPrimaryKey()
    audit = AuditColumns()

    # Verify the classes have the expected attributes
    assert hasattr(uuidv7_pk, "id")
    assert hasattr(uuidv7_pk, "_sentinel")
    assert hasattr(uuidv6_pk, "id")
    assert hasattr(uuidv6_pk, "_sentinel")
    assert hasattr(uuid_pk, "id")
    assert hasattr(uuid_pk, "_sentinel")
    assert hasattr(nanoid_pk, "id")
    assert hasattr(nanoid_pk, "_sentinel")
    assert hasattr(audit, "created_at")
    assert hasattr(audit, "updated_at")


def test_identity_primary_key_generates_identity_ddl() -> None:
    """Test that IdentityPrimaryKey generates proper IDENTITY DDL for PostgreSQL."""
    from advanced_alchemy.base import BigIntBase
    from advanced_alchemy.mixins.bigint import IdentityPrimaryKey

    @declarative_mixin
    class TestMixin(IdentityPrimaryKey):
        pass

    class IdentityPrimaryKeyModel(TestMixin, BigIntBase):
        __tablename__ = "test_identity"

    # Get the CREATE TABLE statement
    create_stmt = CreateTable(cast(Table, IdentityPrimaryKeyModel.__table__))

    # Test with PostgreSQL dialect
    pg_ddl = str(create_stmt.compile(dialect=postgresql.dialect()))  # type: ignore[no-untyped-call,unused-ignore]

    # Should contain GENERATED BY DEFAULT AS IDENTITY
    assert "GENERATED BY DEFAULT AS IDENTITY" in pg_ddl
    assert "BIGSERIAL" not in pg_ddl.upper()
    assert "START WITH 1" in pg_ddl
    assert "INCREMENT BY 1" in pg_ddl


def test_identity_audit_base_generates_identity_ddl() -> None:
    """Test that IdentityAuditBase generates proper IDENTITY DDL for PostgreSQL."""
    from advanced_alchemy.base import IdentityAuditBase

    class IdentityAuditBaseModel(IdentityAuditBase):
        __tablename__ = "test_identity_audit"

    # Get the CREATE TABLE statement
    create_stmt = CreateTable(cast(Table, IdentityAuditBaseModel.__table__))

    # Test with PostgreSQL dialect
    pg_ddl = str(create_stmt.compile(dialect=postgresql.dialect()))  # type: ignore[no-untyped-call,unused-ignore]

    # Should contain GENERATED BY DEFAULT AS IDENTITY
    assert "GENERATED BY DEFAULT AS IDENTITY" in pg_ddl
    assert "BIGSERIAL" not in pg_ddl.upper()


def test_bigint_primary_key_still_uses_sequence() -> None:
    """Test that BigIntPrimaryKey still uses sequences as before."""
    from advanced_alchemy.base import BigIntBase
    from advanced_alchemy.mixins.bigint import BigIntPrimaryKey

    @declarative_mixin
    class TestMixin(BigIntPrimaryKey):
        pass

    class BigIntPrimaryKeyModel(TestMixin, BigIntBase):
        __tablename__ = "test_bigint"

    # Get the CREATE TABLE statement
    create_stmt = CreateTable(cast(Table, BigIntPrimaryKeyModel.__table__))

    # Test with PostgreSQL dialect
    pg_ddl = str(create_stmt.compile(dialect=postgresql.dialect()))  # type: ignore[no-untyped-call,unused-ignore]

    # BigIntPrimaryKey should use a Sequence (not IDENTITY)
    assert "GENERATED" not in pg_ddl
    assert "IDENTITY" not in pg_ddl.upper()
    # The sequence is defined on the column but rendered separately
    assert BigIntPrimaryKeyModel.__table__.c.id.default is not None
    assert BigIntPrimaryKeyModel.__table__.c.id.default.name == "test_bigint_id_seq"


def test_identity_ddl_for_oracle() -> None:
    """Test Identity DDL generation for Oracle."""
    from advanced_alchemy.base import IdentityAuditBase

    class OracleIdentityAuditBaseModel(IdentityAuditBase):
        __tablename__ = "test_oracle"

    create_stmt = CreateTable(cast(Table, OracleIdentityAuditBaseModel.__table__))
    oracle_ddl = str(create_stmt.compile(dialect=oracle.dialect()))  # type: ignore[no-untyped-call,unused-ignore]

    # Oracle should generate IDENTITY
    assert "GENERATED BY DEFAULT AS IDENTITY" in oracle_ddl


def test_identity_ddl_for_mssql() -> None:
    """Test Identity DDL generation for SQL Server."""
    from advanced_alchemy.base import IdentityAuditBase

    class MSSQLIdentityAuditBaseModel(IdentityAuditBase):
        __tablename__ = "test_mssql"

    create_stmt = CreateTable(cast(Table, MSSQLIdentityAuditBaseModel.__table__))
    mssql_ddl = str(create_stmt.compile(dialect=mssql.dialect()))  # type: ignore[no-untyped-call,unused-ignore]

    # SQL Server should generate IDENTITY
    assert "IDENTITY(1,1)" in mssql_ddl


def test_identity_works_with_sqlite() -> None:
    """Test that Identity columns work with SQLite (fallback to autoincrement)."""
    from advanced_alchemy.base import IdentityAuditBase

    class SQLiteIdentityAuditBaseModel(IdentityAuditBase):
        __tablename__ = "test_sqlite"

    # Create an in-memory SQLite engine
    engine = create_engine("sqlite:///:memory:")
    cast(Table, SQLiteIdentityAuditBaseModel.__table__).create(engine)

    # Should not raise any errors
    assert True  # If we get here, it worked
python-advanced-alchemy-1.9.3/tests/unit/test_cache/000077500000000000000000000000001516556515500224715ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/tests/unit/test_cache/__init__.py000066400000000000000000000000151516556515500245760ustar00rootroot00000000000000# ruff: noqa
python-advanced-alchemy-1.9.3/tests/unit/test_cache/test_cache_config.py000066400000000000000000000046051516556515500264770ustar00rootroot00000000000000"""Unit tests for CacheConfig dataclass."""

from __future__ import annotations

from advanced_alchemy.cache.config import CacheConfig


def test_cache_config_defaults() -> None:
    """Test CacheConfig has sensible defaults."""
    config = CacheConfig()

    assert config.backend == "dogpile.cache.null"
    assert config.expiration_time == 3600
    assert config.arguments == {}
    assert config.key_prefix == "aa:"
    assert config.enabled is True
    assert config.serializer is None
    assert config.deserializer is None
    assert config.region_factory is None


def test_cache_config_custom_backend() -> None:
    """Test CacheConfig with custom backend."""
    config = CacheConfig(
        backend="dogpile.cache.memory",
        expiration_time=300,
    )

    assert config.backend == "dogpile.cache.memory"
    assert config.expiration_time == 300


def test_cache_config_redis_arguments() -> None:
    """Test CacheConfig with Redis-specific arguments."""
    config = CacheConfig(
        backend="dogpile.cache.redis",
        expiration_time=600,
        arguments={
            "host": "localhost",
            "port": 6379,
            "db": 0,
        },
    )

    assert config.backend == "dogpile.cache.redis"
    assert config.arguments["host"] == "localhost"
    assert config.arguments["port"] == 6379
    assert config.arguments["db"] == 0


def test_cache_config_disabled() -> None:
    """Test CacheConfig can be disabled."""
    config = CacheConfig(enabled=False)

    assert config.enabled is False


def test_cache_config_custom_key_prefix() -> None:
    """Test CacheConfig with custom key prefix."""
    config = CacheConfig(key_prefix="myapp:")

    assert config.key_prefix == "myapp:"


def test_cache_config_custom_serializers() -> None:
    """Test CacheConfig with custom serializer/deserializer."""

    def custom_serializer(obj: object) -> bytes:
        return b"serialized"

    def custom_deserializer(data: bytes, model_class: type) -> object:
        return model_class()

    config = CacheConfig(
        serializer=custom_serializer,
        deserializer=custom_deserializer,
    )

    assert config.serializer is custom_serializer
    assert config.deserializer is custom_deserializer


def test_cache_config_no_expiration() -> None:
    """Test CacheConfig with no expiration (set to -1)."""
    config = CacheConfig(expiration_time=-1)

    assert config.expiration_time == -1
python-advanced-alchemy-1.9.3/tests/unit/test_cache/test_cache_listeners.py000066400000000000000000000253731516556515500272470ustar00rootroot00000000000000"""Unit tests for cache-related listeners in advanced_alchemy._listeners."""

from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from sqlalchemy.orm import Session

from advanced_alchemy._listeners import (
    AsyncCacheListener,
    BaseCacheListener,
    CacheInvalidationListener,
    CacheInvalidationTracker,
    SyncCacheListener,
    get_cache_tracker,
    setup_cache_listeners,
)

# --- CacheInvalidationTracker Tests ---


def test_cache_invalidation_tracker_add_invalidation() -> None:
    mock_manager = MagicMock()
    tracker = CacheInvalidationTracker(mock_manager)
    tracker.add_invalidation("User", 1, "group1")

    assert ("User", 1, "group1") in tracker._pending_invalidations
    assert "User" in tracker._pending_model_bumps


def test_cache_invalidation_tracker_commit() -> None:
    mock_manager = MagicMock()
    tracker = CacheInvalidationTracker(mock_manager)
    tracker.add_invalidation("User", 1, "group1")

    tracker.commit()

    mock_manager.bump_model_version_sync.assert_called_with("User")
    mock_manager.invalidate_entity_sync.assert_called_with("User", 1, "group1")
    assert not tracker._pending_invalidations
    assert not tracker._pending_model_bumps


def test_cache_invalidation_tracker_rollback() -> None:
    mock_manager = MagicMock()
    tracker = CacheInvalidationTracker(mock_manager)
    tracker.add_invalidation("User", 1, "group1")

    tracker.rollback()

    mock_manager.bump_model_version_sync.assert_not_called()
    assert not tracker._pending_invalidations
    assert not tracker._pending_model_bumps


@pytest.mark.asyncio
async def test_cache_invalidation_tracker_commit_async() -> None:
    mock_manager = AsyncMock()
    tracker = CacheInvalidationTracker(mock_manager)
    tracker.add_invalidation("User", 1, "group1")

    await tracker.commit_async()

    mock_manager.bump_model_version_async.assert_called_with("User")
    mock_manager.invalidate_entity_async.assert_called_with("User", 1, "group1")
    assert not tracker._pending_invalidations
    assert not tracker._pending_model_bumps


# --- get_cache_tracker Tests ---


def test_get_cache_tracker_existing() -> None:
    session = MagicMock(spec=Session)
    tracker = MagicMock()
    session.info = {"_aa_cache_tracker": tracker}

    result = get_cache_tracker(session)
    assert result is tracker


def test_get_cache_tracker_create() -> None:
    session = MagicMock(spec=Session)
    session.info = {}
    mock_manager = MagicMock()

    result = get_cache_tracker(session, cache_manager=mock_manager, create=True)
    assert isinstance(result, CacheInvalidationTracker)
    assert session.info["_aa_cache_tracker"] is result


def test_get_cache_tracker_no_create() -> None:
    session = MagicMock(spec=Session)
    session.info = {}

    result = get_cache_tracker(session, create=False)
    assert result is None


# --- SyncCacheListener Tests ---


def test_sync_cache_listener_after_commit() -> None:
    session = MagicMock(spec=Session)
    tracker = MagicMock()
    session.info = {"_aa_cache_tracker": tracker, "enable_cache_listener": True}

    SyncCacheListener.after_commit(session)

    tracker.commit.assert_called_once()
    assert "_aa_cache_tracker" not in session.info


def test_sync_cache_listener_after_rollback() -> None:
    session = MagicMock(spec=Session)
    tracker = MagicMock()
    session.info = {"_aa_cache_tracker": tracker, "enable_cache_listener": True}

    SyncCacheListener.after_rollback(session)

    tracker.rollback.assert_called_once()
    assert "_aa_cache_tracker" not in session.info


def test_sync_cache_listener_disabled() -> None:
    session = MagicMock(spec=Session)
    tracker = MagicMock()
    session.info = {"_aa_cache_tracker": tracker, "enable_cache_listener": False}

    SyncCacheListener.after_commit(session)

    tracker.commit.assert_not_called()


# --- AsyncCacheListener Tests ---


@pytest.mark.asyncio
async def test_async_cache_listener_after_commit() -> None:
    session = MagicMock(spec=Session)
    tracker = MagicMock()
    # Mock commit_async to verify it's scheduled
    tracker.commit_async = AsyncMock()
    session.info = {"_aa_cache_tracker": tracker, "enable_cache_listener": True}

    AsyncCacheListener.after_commit(session)

    # Since it creates a task, we verify it popped the tracker
    assert "_aa_cache_tracker" not in session.info


def test_async_cache_listener_after_rollback() -> None:
    session = MagicMock(spec=Session)
    tracker = MagicMock()
    session.info = {"_aa_cache_tracker": tracker, "enable_cache_listener": True}

    AsyncCacheListener.after_rollback(session)

    tracker.rollback.assert_called_once()
    assert "_aa_cache_tracker" not in session.info


# --- CacheInvalidationListener Tests ---


def test_cache_invalidation_listener_after_commit_sync_context() -> None:
    session = MagicMock(spec=Session)
    tracker = MagicMock()
    session.info = {"_aa_cache_tracker": tracker, "enable_cache_listener": True}

    try:
        CacheInvalidationListener.after_commit(session)
    except RuntimeError:
        # If no loop, it calls tracker.commit()
        pass


@pytest.mark.asyncio
async def test_cache_invalidation_listener_after_commit_async_context() -> None:
    from advanced_alchemy._listeners import _active_cache_operations

    session = MagicMock(spec=Session)
    tracker = MagicMock()
    tracker.commit_async = AsyncMock()
    session.info = {"_aa_cache_tracker": tracker, "enable_cache_listener": True}

    CacheInvalidationListener.after_commit(session)

    # In async context (pytest-asyncio provides one), it should create a task
    assert len(_active_cache_operations) > 0
    task = next(iter(_active_cache_operations))
    await task
    assert "_aa_cache_tracker" not in session.info
    tracker.commit_async.assert_called_once()


# --- BaseCacheListener Tests ---


class TestBaseCacheListener(BaseCacheListener):
    pass


def test_base_cache_listener_is_listener_enabled() -> None:
    session = MagicMock(spec=Session)
    session.info = {}
    session.bind = None
    session.execution_options = None
    assert TestBaseCacheListener._is_listener_enabled(session) is True

    session.info = {"enable_cache_listener": False}
    assert TestBaseCacheListener._is_listener_enabled(session) is False


def test_base_cache_listener_execution_options() -> None:
    session = MagicMock(spec=Session)
    session.info = {}
    session.bind = None

    # Test execution_options via session
    session.execution_options = {"enable_cache_listener": False}
    assert TestBaseCacheListener._is_listener_enabled(session) is False

    session.execution_options = {"enable_cache_listener": True}
    assert TestBaseCacheListener._is_listener_enabled(session) is True


# --- Additional AsyncCacheListener Tests ---


def test_async_cache_listener_disabled() -> None:
    """AsyncCacheListener.after_commit returns early when listener is disabled."""
    session = MagicMock(spec=Session)
    tracker = MagicMock()
    session.info = {"_aa_cache_tracker": tracker, "enable_cache_listener": False}

    AsyncCacheListener.after_commit(session)

    # Tracker should not be touched
    tracker.commit.assert_not_called()
    # Tracker should still be in info (not popped)
    assert "_aa_cache_tracker" in session.info


def test_async_cache_listener_no_tracker_commit() -> None:
    """AsyncCacheListener.after_commit with no tracker is a no-op."""
    session = MagicMock(spec=Session)
    session.info = {"enable_cache_listener": True}
    session.bind = None
    session.execution_options = None

    AsyncCacheListener.after_commit(session)
    # No exception raised, no tracker to pop


def test_async_cache_listener_no_tracker_rollback() -> None:
    """AsyncCacheListener.after_rollback with no tracker is a no-op."""
    session = MagicMock(spec=Session)
    session.info = {"enable_cache_listener": True}

    AsyncCacheListener.after_rollback(session)
    # No exception raised


def test_sync_cache_listener_no_tracker_commit() -> None:
    """SyncCacheListener.after_commit with no tracker is a no-op."""
    session = MagicMock(spec=Session)
    session.info = {"enable_cache_listener": True}
    session.bind = None
    session.execution_options = None

    SyncCacheListener.after_commit(session)
    # No exception raised


def test_sync_cache_listener_no_tracker_rollback() -> None:
    """SyncCacheListener.after_rollback with no tracker is a no-op."""
    session = MagicMock(spec=Session)
    session.info = {"enable_cache_listener": True}

    SyncCacheListener.after_rollback(session)
    # No exception raised


# --- Additional CacheInvalidationListener Tests ---


def test_cache_invalidation_listener_sync_commit() -> None:
    """CacheInvalidationListener.after_commit calls tracker.commit() in sync context."""
    session = MagicMock(spec=Session)
    tracker = MagicMock()
    session.info = {"_aa_cache_tracker": tracker, "enable_cache_listener": True}

    # Ensure no running event loop (sync context)
    with patch("advanced_alchemy._listeners.asyncio.get_running_loop", side_effect=RuntimeError):
        CacheInvalidationListener.after_commit(session)

    tracker.commit.assert_called_once()
    assert "_aa_cache_tracker" not in session.info


def test_cache_invalidation_listener_rollback() -> None:
    """CacheInvalidationListener.after_rollback calls tracker.rollback()."""
    session = MagicMock(spec=Session)
    tracker = MagicMock()
    session.info = {"_aa_cache_tracker": tracker}

    CacheInvalidationListener.after_rollback(session)

    tracker.rollback.assert_called_once()
    assert "_aa_cache_tracker" not in session.info


def test_cache_invalidation_listener_rollback_no_tracker() -> None:
    """CacheInvalidationListener.after_rollback with no tracker is a no-op."""
    session = MagicMock(spec=Session)
    session.info = {}

    CacheInvalidationListener.after_rollback(session)
    # No exception raised


def test_cache_invalidation_listener_disabled() -> None:
    """CacheInvalidationListener.after_commit returns early when disabled."""
    session = MagicMock(spec=Session)
    tracker = MagicMock()
    session.info = {"_aa_cache_tracker": tracker, "enable_cache_listener": False}

    CacheInvalidationListener.after_commit(session)

    tracker.commit.assert_not_called()


# --- Additional get_cache_tracker Tests ---


def test_get_cache_tracker_create_no_manager_returns_none() -> None:
    """get_cache_tracker with create=True but no cache_manager returns None."""
    session = MagicMock(spec=Session)
    session.info = {}

    result = get_cache_tracker(session, cache_manager=None, create=True)
    assert result is None


# --- Setup Tests ---


def test_setup_cache_listeners() -> None:
    with (
        patch("advanced_alchemy._listeners.event.listen") as mock_listen,
        patch("sqlalchemy.event.contains", return_value=False),
    ):
        setup_cache_listeners()
        assert mock_listen.called
python-advanced-alchemy-1.9.3/tests/unit/test_cache/test_cache_manager.py000066400000000000000000000340651516556515500266470ustar00rootroot00000000000000"""Unit tests for CacheManager."""

from __future__ import annotations

import asyncio
import time
from typing import Any
from unittest.mock import MagicMock

import pytest

from advanced_alchemy.cache.config import CacheConfig
from advanced_alchemy.cache.manager import DOGPILE_CACHE_INSTALLED, CacheManager


@pytest.fixture
def memory_config() -> CacheConfig:
    """Create a memory cache configuration."""
    return CacheConfig(backend="dogpile.cache.memory", expiration_time=300)


@pytest.fixture
def disabled_config() -> CacheConfig:
    """Create a disabled cache configuration."""
    return CacheConfig(enabled=False)


@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed")
def test_cache_manager_lazy_initialization(memory_config: CacheConfig) -> None:
    """Test CacheManager lazy initializes the region."""
    manager = CacheManager(memory_config)

    # Region should be None initially
    assert manager._region is None

    # Accessing region should initialize it
    region = manager.region
    assert region is not None
    assert manager._region is region


@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed")
def test_cache_manager_creates_region_with_config(memory_config: CacheConfig) -> None:
    """Test CacheManager creates region with correct configuration."""
    manager = CacheManager(memory_config)

    region = manager.region

    # Verify region is configured (dogpile.cache doesn't expose config easily,
    # but we can test it works)
    assert region is not None


def test_cache_manager_disabled_returns_null_region(disabled_config: CacheConfig) -> None:
    """Test CacheManager returns NullRegion when disabled."""
    manager = CacheManager(disabled_config)

    region = manager.region

    # Should be NullRegion
    from advanced_alchemy.cache._null import NullRegion

    assert isinstance(region, NullRegion)


@pytest.mark.skipif(DOGPILE_CACHE_INSTALLED, reason="Test requires dogpile.cache NOT installed")
def test_cache_manager_without_dogpile_returns_null_region() -> None:
    """Test CacheManager returns NullRegion when dogpile.cache not installed."""
    config = CacheConfig(backend="dogpile.cache.memory")
    manager = CacheManager(config)

    region = manager.region

    from advanced_alchemy.cache._null import NullRegion

    assert isinstance(region, NullRegion)


@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed")
def test_cache_manager_get_or_create_caches_value(memory_config: CacheConfig) -> None:
    """Test get_or_create caches the created value."""
    manager = CacheManager(memory_config)
    call_count = 0

    def creator() -> str:
        nonlocal call_count
        call_count += 1
        return "test_value"

    # First call should invoke creator
    result1 = manager.get_or_create_sync("test_key", creator)
    assert result1 == "test_value"
    assert call_count == 1

    # Second call should use cached value
    result2 = manager.get_or_create_sync("test_key", creator)
    assert result2 == "test_value"
    assert call_count == 1  # Creator not called again


@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed")
def test_cache_manager_get_or_create_with_custom_expiration(memory_config: CacheConfig) -> None:
    """Test get_or_create with custom expiration time."""
    manager = CacheManager(memory_config)

    result = manager.get_or_create_sync("key", lambda: "value", expiration_time=60)

    assert result == "value"


def test_cache_manager_get_or_create_disabled_always_calls_creator(disabled_config: CacheConfig) -> None:
    """Test get_or_create bypasses cache when disabled."""
    manager = CacheManager(disabled_config)
    call_count = 0

    def creator() -> str:
        nonlocal call_count
        call_count += 1
        return "value"

    # Both calls should invoke creator
    result1 = manager.get_or_create_sync("key", creator)
    result2 = manager.get_or_create_sync("key", creator)

    assert result1 == "value"
    assert result2 == "value"
    assert call_count == 2


@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed")
def test_cache_manager_get_set_delete(memory_config: CacheConfig) -> None:
    """Test get, set, and delete operations."""
    from advanced_alchemy.cache.manager import DOGPILE_NO_VALUE

    manager = CacheManager(memory_config)

    # Initially empty
    result_initial = manager.get_sync("test_key")
    assert result_initial is DOGPILE_NO_VALUE or result_initial is None

    # Set value
    manager.set_sync("test_key", "test_value")

    # Get value
    result = manager.get_sync("test_key")
    assert result == "test_value"

    # Delete value
    manager.delete_sync("test_key")

    # Should be empty again
    result_after_delete = manager.get_sync("test_key")
    assert result_after_delete is DOGPILE_NO_VALUE or result_after_delete is None


def test_cache_manager_get_disabled_returns_no_value(disabled_config: CacheConfig) -> None:
    """Test get returns NO_VALUE when disabled."""
    manager = CacheManager(disabled_config)

    result = manager.get_sync("any_key")

    # Should return dogpile's NO_VALUE
    from advanced_alchemy.cache.manager import DOGPILE_NO_VALUE

    assert result is DOGPILE_NO_VALUE


def test_cache_manager_set_disabled_is_noop(disabled_config: CacheConfig) -> None:
    """Test set does nothing when disabled."""
    manager = CacheManager(disabled_config)

    # Should not raise
    manager.set_sync("key", "value")


@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed")
def test_cache_manager_make_key_adds_prefix(memory_config: CacheConfig) -> None:
    """Test _make_key adds the configured prefix."""
    manager = CacheManager(memory_config)

    key = manager._make_key("test_key")

    assert key == "aa:test_key"


@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed")
def test_cache_manager_get_entity(memory_config: CacheConfig) -> None:
    """Test get_entity retrieves and deserializes cached entity."""
    from advanced_alchemy._serialization import encode_json

    manager = CacheManager(memory_config)

    # Manually cache a serialized entity (using JSON directly to avoid SQLAlchemy complexity)
    fake_entity_data = {
        "__aa_model__": "TestModel",
        "__aa_table__": "test_model",
        "id": "12345678-1234-5678-1234-567812345678",
        "name": "Test Entity",
    }
    serialized = encode_json(fake_entity_data).encode("utf-8")
    manager.set_sync("test_model:get:1", serialized)

    # Note: This test is simplified - we skip deserialization test because it requires
    # a real SQLAlchemy model. The integration tests cover full serialization/deserialization.


@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed")
def test_cache_manager_get_entity_not_found_returns_none(memory_config: CacheConfig) -> None:
    """Test get_entity returns None when entity not in cache."""

    manager = CacheManager(memory_config)

    # Use a mock class to avoid SQLAlchemy model creation complexity
    MockModel = MagicMock()

    cached = manager.get_entity_sync("test_model", 999, MockModel)

    assert cached is None


@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed")
def test_cache_manager_get_entity_deserialization_error_returns_none(memory_config: CacheConfig) -> None:
    """Test get_entity returns None and deletes corrupted cache on deserialization error."""

    from advanced_alchemy.cache.manager import DOGPILE_NO_VALUE

    MockModel = MagicMock()

    manager = CacheManager(memory_config)

    # Put corrupted data in cache
    manager.set_sync("test_model:get:1", b"corrupted_data")

    # Should return None and log error
    cached = manager.get_entity_sync("test_model", 1, MockModel)

    assert cached is None

    # Corrupted entry should be deleted
    result = manager.get_sync("test_model:get:1")
    assert result is DOGPILE_NO_VALUE or result is None


@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed")
def test_cache_manager_set_entity_serialization_succeeds() -> None:
    """Test set_entity can cache data (serialization tested in integration)."""
    # Note: Full serialization test requires real SQLAlchemy models in a session.
    # This is covered by integration tests. Here we just test the basic flow works.
    pass


@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed")
def test_cache_manager_set_entity_serialization_error_logs_and_continues(memory_config: CacheConfig) -> None:
    """Test set_entity logs error and continues on serialization failure."""

    class UnserializableModel:
        """Model that cannot be serialized."""

        def __init__(self) -> None:
            self.data = lambda: None  # Functions can't be serialized

    manager = CacheManager(memory_config)

    # Should not raise, just log error
    manager.set_entity_sync("test_model", 1, UnserializableModel())


@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed")
def test_cache_manager_invalidate_entity(memory_config: CacheConfig) -> None:
    """Test invalidate_entity removes entity from cache."""
    from advanced_alchemy.cache.manager import DOGPILE_NO_VALUE

    manager = CacheManager(memory_config)

    # Set a value
    manager.set_sync("users:get:1", b"test_data")

    # Invalidate
    manager.invalidate_entity_sync("users", 1)

    # Should be gone
    result = manager.get_sync("users:get:1")
    assert result is DOGPILE_NO_VALUE or result is None


@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed")
def test_cache_manager_bump_model_version(memory_config: CacheConfig) -> None:
    """Test bump_model_version changes version token."""
    manager = CacheManager(memory_config)

    # Initial version should be 0
    assert manager.get_model_version_sync("users") == "0"

    # Bump version
    version1 = manager.bump_model_version_sync("users")
    assert version1 != "0"
    assert manager.get_model_version_sync("users") == version1

    # Bump again
    version2 = manager.bump_model_version_sync("users")
    assert version2 != version1
    assert manager.get_model_version_sync("users") == version2


@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed")
def test_cache_manager_get_model_version_from_cache(memory_config: CacheConfig) -> None:
    """Test get_model_version retrieves version from distributed cache."""
    manager = CacheManager(memory_config)

    # Manually set version in cache
    manager.set_sync("users:version", "token")

    # Should retrieve from cache
    version = manager.get_model_version_sync("users")
    assert version == "token"


@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed")
def test_cache_manager_invalidate_all(memory_config: CacheConfig) -> None:
    """Test invalidate_all clears entire cache."""
    from advanced_alchemy.cache.manager import DOGPILE_NO_VALUE

    manager = CacheManager(memory_config)

    # Set some values
    manager.set_sync("key1", "value1")
    manager.set_sync("key2", "value2")
    manager._model_versions["users"] = "token"

    # Invalidate all
    manager.invalidate_all_sync()

    # All should be cleared
    result1 = manager.get_sync("key1")
    result2 = manager.get_sync("key2")
    assert result1 is DOGPILE_NO_VALUE or result1 is None
    assert result2 is DOGPILE_NO_VALUE or result2 is None
    assert manager._model_versions == {}


@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed")
def test_cache_manager_custom_serializers(memory_config: CacheConfig) -> None:
    """Test CacheManager with custom serializer/deserializer."""

    def custom_serializer(obj: Any) -> bytes:
        return b"custom"

    def custom_deserializer(data: bytes, model_class: type) -> Any:
        return "deserialized"

    memory_config.serializer = custom_serializer
    memory_config.deserializer = custom_deserializer

    manager = CacheManager(memory_config)

    # set_entity should use custom serializer
    manager.set_entity_sync("test", 1, {"data": "test"})

    # get_entity should use custom deserializer
    result = manager.get_entity_sync("test", 1, str)
    assert result == "deserialized"


def test_cache_manager_handles_region_creation_failure() -> None:
    """Test CacheManager handles region creation failure gracefully."""
    config = CacheConfig(backend="invalid.backend.that.does.not.exist")

    manager = CacheManager(config)

    # Should return NullRegion on failure
    region = manager.region

    from advanced_alchemy.cache._null import NullRegion

    assert isinstance(region, NullRegion)


@pytest.mark.asyncio
async def test_cache_manager_get_async_does_not_block_event_loop() -> None:
    """Ensure cache I/O is offloaded and doesn't block the loop."""

    class SlowRegion:
        def get(self, key: str, expiration_time: int | None = None) -> Any:
            time.sleep(0.2)
            return "value"

        def set(self, key: str, value: Any) -> None:
            return

        def delete(self, key: str) -> None:
            return

        def invalidate(self) -> None:
            return

    config = CacheConfig(region_factory=lambda _cfg: SlowRegion())
    manager = CacheManager(config)

    ticks = 0

    async def ticker() -> None:
        nonlocal ticks
        for _ in range(5):
            await asyncio.sleep(0.05)
            ticks += 1

    result, _ = await asyncio.gather(manager.get_async("key"), ticker())

    assert result == "value"
    assert ticks >= 3


@pytest.mark.asyncio
async def test_cache_manager_singleflight_async_coalesces() -> None:
    """Ensure async singleflight invokes creator only once per key."""
    manager = CacheManager(CacheConfig(backend="dogpile.cache.null"))

    call_count = 0

    async def creator() -> str:
        nonlocal call_count
        call_count += 1
        await asyncio.sleep(0.05)
        return "ok"

    results = await asyncio.gather(*[manager.singleflight_async("k", creator) for _ in range(25)])

    assert call_count == 1
    assert results == ["ok"] * 25
python-advanced-alchemy-1.9.3/tests/unit/test_cache/test_cache_serializers.py000066400000000000000000000234561516556515500275730ustar00rootroot00000000000000"""Unit tests for cache serialization utilities."""

import datetime
from decimal import Decimal
from uuid import UUID

import pytest
from sqlalchemy import LargeBinary, Numeric, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column

from advanced_alchemy._serialization import (
    _decode_typed_marker,
    decode_complex_type,
    decode_json,
    encode_complex_type,
    encode_json,
)
from advanced_alchemy.cache.serializers import default_deserializer, default_serializer


class CacheBase(DeclarativeBase):
    """Declarative base for cache serializer tests."""


class CacheModel(CacheBase):
    """Model used to test cache serialization round-trips."""

    __tablename__ = "cache_model"

    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(String(length=50))
    created_at: Mapped[datetime.datetime] = mapped_column()
    payload: Mapped[bytes] = mapped_column(LargeBinary())
    amount: Mapped[Decimal] = mapped_column(Numeric())


class OtherCacheModel(CacheBase):
    """Secondary model for mismatch tests."""

    __tablename__ = "other_cache_model"

    id: Mapped[int] = mapped_column(primary_key=True)


def test_encode_complex_type_datetime() -> None:
    """Test encoding datetime objects."""
    dt = datetime.datetime(2025, 12, 14, 10, 30, 0)

    result = encode_complex_type(dt)

    assert result == {"__type__": "datetime", "value": "2025-12-14T10:30:00"}


def test_encode_complex_type_date() -> None:
    """Test encoding date objects."""
    d = datetime.date(2025, 12, 14)

    result = encode_complex_type(d)

    assert result == {"__type__": "date", "value": "2025-12-14"}


def test_encode_complex_type_time() -> None:
    """Test encoding time objects."""
    t = datetime.time(10, 30, 45)

    result = encode_complex_type(t)

    assert result == {"__type__": "time", "value": "10:30:45"}


def test_encode_complex_type_timedelta() -> None:
    """Test encoding timedelta objects."""
    td = datetime.timedelta(hours=2, minutes=30)

    result = encode_complex_type(td)

    assert result == {"__type__": "timedelta", "value": 9000.0}


def test_encode_complex_type_decimal() -> None:
    """Test encoding Decimal objects."""
    dec = Decimal("123.45")

    result = encode_complex_type(dec)

    assert result == {"__type__": "decimal", "value": "123.45"}


def test_encode_complex_type_bytes() -> None:
    """Test encoding bytes objects."""
    b = b"\x01\x02\xff"

    result = encode_complex_type(b)

    assert result == {"__type__": "bytes", "value": "0102ff"}


def test_encode_complex_type_uuid() -> None:
    """Test encoding UUID objects."""
    u = UUID("12345678-1234-5678-1234-567812345678")

    result = encode_complex_type(u)

    assert result == {"__type__": "uuid", "value": "12345678-1234-5678-1234-567812345678"}


def test_encode_complex_type_set() -> None:
    """Test encoding set objects."""
    s = {1, 2, 3}

    result = encode_complex_type(s)

    assert result["__type__"] == "set"
    assert set(result["value"]) == {1, 2, 3}


def test_encode_complex_type_unsupported_type() -> None:
    """Test encoding unsupported type returns None."""
    result = encode_complex_type(lambda: None)
    assert result is None


def test_decode_typed_marker_datetime() -> None:
    """Test decoding datetime objects."""
    data = {"__type__": "datetime", "value": "2025-12-14T10:30:00"}

    result = _decode_typed_marker(data)

    assert isinstance(result, datetime.datetime)
    assert result == datetime.datetime(2025, 12, 14, 10, 30, 0)


def test_decode_typed_marker_date() -> None:
    """Test decoding date objects."""
    data = {"__type__": "date", "value": "2025-12-14"}

    result = _decode_typed_marker(data)

    assert isinstance(result, datetime.date)
    assert result == datetime.date(2025, 12, 14)


def test_decode_typed_marker_time() -> None:
    """Test decoding time objects."""
    data = {"__type__": "time", "value": "10:30:45"}

    result = _decode_typed_marker(data)

    assert isinstance(result, datetime.time)
    assert result == datetime.time(10, 30, 45)


def test_decode_typed_marker_timedelta() -> None:
    """Test decoding timedelta objects."""
    data = {"__type__": "timedelta", "value": 9000.0}

    result = _decode_typed_marker(data)

    assert isinstance(result, datetime.timedelta)
    assert result == datetime.timedelta(hours=2, minutes=30)


def test_decode_typed_marker_decimal() -> None:
    """Test decoding Decimal objects."""
    data = {"__type__": "decimal", "value": "123.45"}

    result = _decode_typed_marker(data)

    assert isinstance(result, Decimal)
    assert result == Decimal("123.45")


def test_decode_typed_marker_bytes() -> None:
    """Test decoding bytes objects."""
    data = {"__type__": "bytes", "value": "0102ff"}

    result = _decode_typed_marker(data)

    assert isinstance(result, bytes)
    assert result == b"\x01\x02\xff"


def test_decode_typed_marker_uuid() -> None:
    """Test decoding UUID objects."""
    data = {"__type__": "uuid", "value": "12345678-1234-5678-1234-567812345678"}

    result = _decode_typed_marker(data)

    assert isinstance(result, UUID)
    assert result == UUID("12345678-1234-5678-1234-567812345678")


def test_decode_typed_marker_set() -> None:
    """Test decoding set objects."""
    data = {"__type__": "set", "value": [1, 2, 3]}

    result = _decode_typed_marker(data)

    assert isinstance(result, set)
    assert result == {1, 2, 3}


def test_decode_complex_type_non_special_type() -> None:
    """Test decoding non-special type returns original dict."""
    data = {"foo": "bar"}

    result = decode_complex_type(data)

    assert result == {"foo": "bar"}


def test_roundtrip_json_encoding() -> None:
    """Test roundtrip encoding and decoding using AA JSON utilities."""
    test_data = {
        "datetime": datetime.datetime(2025, 12, 14, 10, 30, 0),
        "date": datetime.date(2025, 12, 14),
        "time": datetime.time(10, 30, 45),
        "timedelta": datetime.timedelta(hours=2),
        "decimal": Decimal("99.99"),
        "bytes": b"\xff\xfe",
        "uuid": UUID("12345678-1234-5678-1234-567812345678"),
        "set": {1, 2, 3},
    }

    encoded: dict[str, object] = {}
    for k, v in test_data.items():
        if (enc := encode_complex_type(v)) is not None:
            encoded[k] = enc
        else:
            encoded[k] = v

    # Encode via AA serializer
    json_str = encode_json(encoded)

    # Decode via AA parser + our special-type walker
    raw = decode_json(json_str)
    result = decode_complex_type(raw)

    assert result["datetime"] == test_data["datetime"]
    assert result["date"] == test_data["date"]
    assert result["time"] == test_data["time"]
    assert result["timedelta"] == test_data["timedelta"]
    assert result["decimal"] == test_data["decimal"]
    assert result["bytes"] == test_data["bytes"]
    assert result["uuid"] == test_data["uuid"]
    assert result["set"] == test_data["set"]


def test_decode_typed_marker_unknown_type() -> None:
    """Test _decode_typed_marker returns original dict for unknown __type__."""
    data = {"__type__": "unknown_type", "value": "something"}

    result = _decode_typed_marker(data)

    # Should return the original dict unchanged
    assert result == data
    assert isinstance(result, dict)


def test_default_serializer_plain_values_use_else_branch() -> None:
    """Test that plain int/str values go through the else branch (encode_complex_type returns None)."""

    class PlainModel(CacheBase):
        """Model with only plain-type columns."""

        __tablename__ = "plain_model"

        id: Mapped[int] = mapped_column(primary_key=True)
        name: Mapped[str] = mapped_column(String(length=50))

    instance = PlainModel(id=42, name="test")
    data = default_serializer(instance)
    decoded = decode_json(data)

    # Plain int and str should be stored directly (else branch)
    assert decoded["id"] == 42
    assert decoded["name"] == "test"
    assert decoded["__aa_model__"] == "PlainModel"


def test_default_serializer_encodes_complex_types() -> None:
    """default_serializer should encode complex types and include metadata."""
    instance = CacheModel(
        id=1,
        name="alpha",
        created_at=datetime.datetime(2025, 12, 14, 10, 30, 0),
        payload=b"\x00\xff",
        amount=Decimal("12.34"),
    )

    data = default_serializer(instance)
    decoded = decode_json(data)

    assert decoded["__aa_model__"] == "CacheModel"
    assert decoded["__aa_table__"] == "cache_model"
    assert decoded["created_at"] == {"__type__": "datetime", "value": "2025-12-14T10:30:00"}
    assert decoded["payload"] == {"__type__": "bytes", "value": "00ff"}
    assert decoded["amount"] == {"__type__": "decimal", "value": "12.34"}


def test_default_deserializer_roundtrip() -> None:
    """default_deserializer should restore decoded types and values."""
    instance = CacheModel(
        id=7,
        name="beta",
        created_at=datetime.datetime(2025, 1, 1, 9, 0, 0),
        payload=b"\xaa\xbb",
        amount=Decimal("99.99"),
    )

    serialized = default_serializer(instance)
    restored = default_deserializer(serialized, CacheModel)

    assert isinstance(restored, CacheModel)
    assert restored.id == 7
    assert restored.name == "beta"
    assert restored.created_at == datetime.datetime(2025, 1, 1, 9, 0, 0)
    assert restored.payload == b"\xaa\xbb"
    assert restored.amount == Decimal("99.99")


def test_default_deserializer_model_mismatch() -> None:
    """default_deserializer should reject mismatched model types."""
    instance = CacheModel(
        id=3,
        name="gamma",
        created_at=datetime.datetime(2025, 6, 1, 12, 0, 0),
        payload=b"\x01",
        amount=Decimal("1.00"),
    )

    serialized = default_serializer(instance)

    with pytest.raises(ValueError, match="Cannot deserialize CacheModel data as OtherCacheModel"):
        default_deserializer(serialized, OtherCacheModel)
python-advanced-alchemy-1.9.3/tests/unit/test_cattrs_integration.py000066400000000000000000000235541516556515500257140ustar00rootroot00000000000000"""Tests for cattrs integration with attrs support in Advanced Alchemy services."""

from __future__ import annotations

import unittest.mock as mock
from typing import Optional

import pytest

from advanced_alchemy.service.typing import (
    ATTRS_INSTALLED,
    CATTRS_INSTALLED,
    schema_dump,
)

# pyright: reportAttributeAccessIssue=false

pytestmark = [
    pytest.mark.unit,
    pytest.mark.skipif(not ATTRS_INSTALLED, reason="attrs not installed"),
]

if ATTRS_INSTALLED:
    from attrs import define

    @define
    class SimpleAttrsModel:
        """Simple attrs model for testing."""

        name: str
        age: int
        email: Optional[str] = None


class TestCattrsIntegration:
    """Test cattrs integration scenarios."""

    @pytest.mark.skipif(not CATTRS_INSTALLED, reason="cattrs not installed")
    def test_schema_dump_with_cattrs_enabled(self) -> None:
        """Test schema_dump uses cattrs when available."""
        instance = SimpleAttrsModel(name="John", age=30, email="john@example.com")

        result = schema_dump(instance)

        assert isinstance(result, dict)
        assert result["name"] == "John"
        assert result["age"] == 30
        assert result["email"] == "john@example.com"

    def test_schema_dump_with_cattrs_disabled(self) -> None:
        """Test schema_dump falls back to attrs.asdict when cattrs is disabled."""
        from advanced_alchemy.service import typing as service_typing

        instance = SimpleAttrsModel(name="Jane", age=25)

        # Mock CATTRS_INSTALLED to be False
        with mock.patch.object(service_typing, "CATTRS_INSTALLED", False):
            result = schema_dump(instance)

        assert isinstance(result, dict)
        assert result["name"] == "Jane"
        assert result["age"] == 25
        assert result["email"] is None

    @pytest.mark.skipif(not CATTRS_INSTALLED, reason="cattrs not installed")
    def test_to_schema_with_cattrs_priority(self) -> None:
        """Test that to_schema uses cattrs over attrs when both are available."""
        from advanced_alchemy.service._util import ResultConverter

        converter = ResultConverter()
        data = {"name": "Alice", "age": 28, "email": "alice@example.com"}

        result = converter.to_schema(data, schema_type=SimpleAttrsModel)

        assert isinstance(result, SimpleAttrsModel)
        assert result.name == "Alice"
        assert result.age == 28
        assert result.email == "alice@example.com"

    def test_to_schema_attrs_fallback_when_cattrs_disabled(self) -> None:
        """Test that to_schema falls back to attrs when cattrs is disabled."""
        from advanced_alchemy.service import _util
        from advanced_alchemy.service import typing as service_typing
        from advanced_alchemy.service._util import ResultConverter

        converter = ResultConverter()
        data = {"name": "Bob", "age": 35, "email": "bob@example.com"}

        # Mock CATTRS_INSTALLED to be False in both modules
        with (
            mock.patch.object(service_typing, "CATTRS_INSTALLED", False),
            mock.patch.object(_util, "CATTRS_INSTALLED", False),
        ):
            result = converter.to_schema(data, schema_type=SimpleAttrsModel)

        assert isinstance(result, SimpleAttrsModel)
        assert result.name == "Bob"
        assert result.age == 35
        assert result.email == "bob@example.com"

    def test_to_schema_sequence_with_cattrs_disabled(self) -> None:
        """Test that to_schema handles sequences correctly when cattrs is disabled."""
        from advanced_alchemy.service import _util
        from advanced_alchemy.service import typing as service_typing
        from advanced_alchemy.service._util import ResultConverter
        from advanced_alchemy.service.pagination import OffsetPagination

        converter = ResultConverter()
        data = [
            {"name": "Charlie", "age": 40},
            {"name": "Diana", "age": 45},
        ]

        # Mock CATTRS_INSTALLED to be False in both modules
        with (
            mock.patch.object(service_typing, "CATTRS_INSTALLED", False),
            mock.patch.object(_util, "CATTRS_INSTALLED", False),
        ):
            result = converter.to_schema(data, schema_type=SimpleAttrsModel)

        assert isinstance(result, OffsetPagination)
        assert len(result.items) == 2
        assert all(isinstance(item, SimpleAttrsModel) for item in result.items)
        assert result.items[0].name == "Charlie"
        assert result.items[1].name == "Diana"

    @pytest.mark.skipif(not CATTRS_INSTALLED, reason="cattrs not installed")
    def test_cattrs_structure_direct_usage(self) -> None:
        """Test direct usage of cattrs structure function."""
        from advanced_alchemy.service.typing import structure

        data = {"name": "Eve", "age": 33, "email": "eve@example.com"}
        result = structure(data, SimpleAttrsModel)

        assert isinstance(result, SimpleAttrsModel)
        assert result.name == "Eve"
        assert result.age == 33
        assert result.email == "eve@example.com"

    @pytest.mark.skipif(not CATTRS_INSTALLED, reason="cattrs not installed")
    def test_cattrs_unstructure_direct_usage(self) -> None:
        """Test direct usage of cattrs unstructure function."""
        from advanced_alchemy.service.typing import unstructure

        instance = SimpleAttrsModel(name="Frank", age=28)
        result = unstructure(instance)

        assert isinstance(result, dict)
        assert result["name"] == "Frank"
        assert result["age"] == 28
        assert result["email"] is None

    def test_performance_with_cached_field_names(self) -> None:
        """Test that field name caching improves performance."""
        from advanced_alchemy.service import _util
        from advanced_alchemy.service import typing as service_typing
        from advanced_alchemy.service._util import ResultConverter, _get_attrs_field_names

        converter = ResultConverter()

        # Clear cache to ensure we're testing caching behavior
        _get_attrs_field_names.cache_clear()

        # Mock CATTRS_INSTALLED in both modules to be False to use attrs path
        with (
            mock.patch.object(service_typing, "CATTRS_INSTALLED", False),
            mock.patch.object(_util, "CATTRS_INSTALLED", False),
        ):
            # First call should populate the cache
            data1 = {"name": "Grace", "age": 30}
            result1 = converter.to_schema(data1, schema_type=SimpleAttrsModel)

            # Check cache info - should have 1 miss after first call
            cache_info_after_first = _get_attrs_field_names.cache_info()

            # Second call should use cached field names
            data2 = {"name": "Henry", "age": 35}
            result2 = converter.to_schema(data2, schema_type=SimpleAttrsModel)

            # Check cache info - should have 1 hit now
            cache_info_after_second = _get_attrs_field_names.cache_info()

        assert isinstance(result1, SimpleAttrsModel)
        assert isinstance(result2, SimpleAttrsModel)
        assert result1.name == "Grace"
        assert result2.name == "Henry"

        # Verify caching is working - should have 1 miss and 1 hit
        assert cache_info_after_first.misses == 1
        assert cache_info_after_second.hits >= 1
        assert cache_info_after_second.currsize >= 1

    def test_integration_with_both_libraries_available(self) -> None:
        """Test behavior when both cattrs and attrs are available."""
        if not CATTRS_INSTALLED:
            pytest.skip("cattrs not available for this test")

        from advanced_alchemy.service._util import ResultConverter

        converter = ResultConverter()
        data = {"name": "Ivy", "age": 29, "email": "ivy@example.com"}

        # Should prefer cattrs path when both are available
        result = converter.to_schema(data, schema_type=SimpleAttrsModel)

        assert isinstance(result, SimpleAttrsModel)
        assert result.name == "Ivy"
        assert result.age == 29
        assert result.email == "ivy@example.com"

    def test_edge_case_missing_fields(self) -> None:
        """Test handling of missing fields in data."""
        from advanced_alchemy.service import _util
        from advanced_alchemy.service import typing as service_typing
        from advanced_alchemy.service._util import ResultConverter

        converter = ResultConverter()
        # Data missing the 'age' field
        data = {"name": "Jack"}

        # Mock CATTRS_INSTALLED to be False in both modules to test attrs path
        with (
            mock.patch.object(service_typing, "CATTRS_INSTALLED", False),
            mock.patch.object(_util, "CATTRS_INSTALLED", False),
        ):
            # This should handle missing fields gracefully
            with pytest.raises(TypeError):  # attrs will complain about missing required field
                converter.to_schema(data, schema_type=SimpleAttrsModel)

    def test_extra_fields_filtered_out(self) -> None:
        """Test that extra fields not in attrs schema are filtered out."""
        from advanced_alchemy.service import _util
        from advanced_alchemy.service import typing as service_typing
        from advanced_alchemy.service._util import ResultConverter

        converter = ResultConverter()
        # Data with extra field 'phone' not in SimpleAttrsModel
        data = {"name": "Kate", "age": 32, "email": "kate@example.com", "phone": "123-456-7890"}

        # Mock CATTRS_INSTALLED to be False in both modules to test attrs filtering path
        with (
            mock.patch.object(service_typing, "CATTRS_INSTALLED", False),
            mock.patch.object(_util, "CATTRS_INSTALLED", False),
        ):
            result = converter.to_schema(data, schema_type=SimpleAttrsModel)

        assert isinstance(result, SimpleAttrsModel)
        assert result.name == "Kate"
        assert result.age == 32
        assert result.email == "kate@example.com"
        # Extra field should not cause issues
        assert not hasattr(result, "phone")
python-advanced-alchemy-1.9.3/tests/unit/test_cli.py000066400000000000000000000424211516556515500225520ustar00rootroot00000000000000from __future__ import annotations

import os
import tempfile
from collections.abc import Generator
from pathlib import Path
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch

import pytest
from click.testing import CliRunner
from sqlalchemy.ext.asyncio import AsyncEngine

from advanced_alchemy.cli import add_migration_commands, get_alchemy_group

if TYPE_CHECKING:
    from click import Group


@pytest.fixture
def cli_runner() -> Generator[CliRunner, None, None]:
    """Create a Click CLI test runner."""
    yield CliRunner()


@pytest.fixture
def mock_config() -> Generator[MagicMock, None, None]:
    """Create a mock SQLAlchemy config."""
    config = MagicMock()
    config.bind_key = "default"
    config.alembic_config.script_location = "migrations"
    config.get_engine.return_value = MagicMock(spec=AsyncEngine)
    yield config


@pytest.fixture
def mock_context(mock_config: MagicMock) -> Generator[MagicMock, None, None]:
    """Create a mock Click context."""
    ctx = MagicMock()
    ctx.obj = {"configs": [mock_config]}
    yield ctx


@pytest.fixture
def database_cli(mock_context: MagicMock) -> Generator[Group, None, None]:
    """Create the database CLI group."""
    cli_group = get_alchemy_group()
    cli_group = add_migration_commands()
    cli_group.ctx = mock_context  # pyright: ignore[reportAttributeAccessIssue]
    yield cli_group


def test_show_current_revision(cli_runner: CliRunner, database_cli: Group, mock_context: MagicMock) -> None:
    """Test the show-current-revision command."""
    with patch("advanced_alchemy.alembic.commands.AlembicCommands") as mock_alembic:
        result = cli_runner.invoke(
            database_cli,
            ["--config", "tests.unit.fixtures.configs", "show-current-revision"],
        )
        assert result.exit_code == 0
        mock_alembic.assert_called_once()
        mock_alembic.return_value.current.assert_called_once_with(verbose=False)


@pytest.mark.parametrize("no_prompt", [True, False])
def test_downgrade_database(
    cli_runner: CliRunner, database_cli: Group, mock_context: MagicMock, no_prompt: bool
) -> None:
    """Test the downgrade command."""
    with patch("advanced_alchemy.alembic.commands.AlembicCommands") as mock_alembic:
        args = ["--config", "tests.unit.fixtures.configs", "downgrade"]
        if no_prompt:
            args.append("--no-prompt")

        result = cli_runner.invoke(database_cli, args)

        if no_prompt:
            assert result.exit_code == 0
            mock_alembic.assert_called_once()
            mock_alembic.return_value.downgrade.assert_called_once_with(revision="-1", sql=False, tag=None)
        else:
            # it's going to be -1 because we abort the task since we don't fill in the prompt
            assert result.exit_code == 1
            # When prompting is enabled, we need to check if the confirmation was shown
            assert "Are you sure you want to downgrade" in result.output


@pytest.mark.parametrize("no_prompt", [True, False])
def test_upgrade_database(cli_runner: CliRunner, database_cli: Group, mock_context: MagicMock, no_prompt: bool) -> None:
    """Test the upgrade command."""
    with patch("advanced_alchemy.alembic.commands.AlembicCommands") as mock_alembic:
        args = ["--config", "tests.unit.fixtures.configs", "upgrade", "head"]
        if no_prompt:
            args.append("--no-prompt")

        result = cli_runner.invoke(database_cli, args)

        if no_prompt:
            assert result.exit_code == 0
            mock_alembic.assert_called_once()
            mock_alembic.return_value.upgrade.assert_called_once_with(revision="head", sql=False, tag=None)
        else:
            # it's going to be -1 because we abort the task since we don't fill in the prompt
            assert result.exit_code == 1
            assert "Are you sure you want migrate the database" in result.output


def test_init_alembic(cli_runner: CliRunner, database_cli: Group, mock_context: MagicMock) -> None:
    """Test the init command."""
    with patch("advanced_alchemy.alembic.commands.AlembicCommands") as mock_alembic:
        result = cli_runner.invoke(
            database_cli,
            ["--config", "tests.unit.fixtures.configs", "init", "--no-prompt", "migrations"],
        )
        assert result.exit_code == 0
        mock_alembic.assert_called_once()
        mock_alembic.return_value.init.assert_called_once_with(directory="migrations", multidb=False, package=True)


def test_make_migrations(cli_runner: CliRunner, database_cli: Group, mock_context: MagicMock) -> None:
    """Test the make-migrations command."""
    with patch("advanced_alchemy.alembic.commands.AlembicCommands") as mock_alembic:
        result = cli_runner.invoke(
            database_cli,
            ["--config", "tests.unit.fixtures.configs", "make-migrations", "--no-prompt", "-m", "test migration"],
        )
        assert result.exit_code == 0
        mock_alembic.assert_called_once()
        mock_alembic.return_value.revision.assert_called_once()


def test_drop_all(cli_runner: CliRunner, database_cli: Group, mock_context: MagicMock) -> None:
    """Test the drop-all command."""

    result = cli_runner.invoke(database_cli, ["--config", "tests.unit.fixtures.configs", "drop-all", "--no-prompt"])
    assert result.exit_code == 0


def test_dump_data(cli_runner: CliRunner, database_cli: Group, mock_context: MagicMock, tmp_path: Path) -> None:
    """Test the dump-data command."""

    result = cli_runner.invoke(
        database_cli,
        ["--config", "tests.unit.fixtures.configs", "dump-data", "--table", "test_table", "--dir", str(tmp_path)],
    )

    assert result.exit_code == 0


def test_stamp(cli_runner: CliRunner, database_cli: Group, mock_context: MagicMock) -> None:
    """Test the stamp command."""
    with patch("advanced_alchemy.alembic.commands.AlembicCommands") as mock_alembic:
        result = cli_runner.invoke(
            database_cli,
            ["--config", "tests.unit.fixtures.configs", "stamp", "head"],
        )

        assert result.exit_code == 0
        mock_alembic.assert_called_once()
        mock_alembic.return_value.stamp.assert_called_once_with(revision="head", sql=False, tag=None, purge=False)


def test_stamp_with_options(cli_runner: CliRunner, database_cli: Group, mock_context: MagicMock) -> None:
    """Test the stamp command with all options."""
    with patch("advanced_alchemy.alembic.commands.AlembicCommands") as mock_alembic:
        result = cli_runner.invoke(
            database_cli,
            ["--config", "tests.unit.fixtures.configs", "stamp", "--sql", "--tag", "v1.0", "--purge", "head"],
        )

        assert result.exit_code == 0
        mock_alembic.assert_called_once()
        mock_alembic.return_value.stamp.assert_called_once_with(revision="head", sql=True, tag="v1.0", purge=True)


def test_check(cli_runner: CliRunner, database_cli: Group, mock_context: MagicMock) -> None:
    """Test the check command."""
    with patch("advanced_alchemy.alembic.commands.AlembicCommands") as mock_alembic:
        result = cli_runner.invoke(
            database_cli,
            ["--config", "tests.unit.fixtures.configs", "check"],
        )

        assert result.exit_code == 0
        mock_alembic.assert_called_once()
        mock_alembic.return_value.check.assert_called_once()


def test_edit(cli_runner: CliRunner, database_cli: Group, mock_context: MagicMock) -> None:
    """Test the edit command."""
    with patch("advanced_alchemy.alembic.commands.AlembicCommands") as mock_alembic:
        result = cli_runner.invoke(
            database_cli,
            ["--config", "tests.unit.fixtures.configs", "edit", "abc123"],
        )

        assert result.exit_code == 0
        mock_alembic.assert_called_once()
        mock_alembic.return_value.edit.assert_called_once_with(revision="abc123")


def test_ensure_version(cli_runner: CliRunner, database_cli: Group, mock_context: MagicMock) -> None:
    """Test the ensure-version command."""
    with patch("advanced_alchemy.alembic.commands.AlembicCommands") as mock_alembic:
        result = cli_runner.invoke(
            database_cli,
            ["--config", "tests.unit.fixtures.configs", "ensure-version"],
        )

        assert result.exit_code == 0
        mock_alembic.assert_called_once()
        mock_alembic.return_value.ensure_version.assert_called_once_with(sql=False)


def test_heads(cli_runner: CliRunner, database_cli: Group, mock_context: MagicMock) -> None:
    """Test the heads command."""
    with patch("advanced_alchemy.alembic.commands.AlembicCommands") as mock_alembic:
        result = cli_runner.invoke(
            database_cli,
            ["--config", "tests.unit.fixtures.configs", "heads", "--verbose", "--resolve-dependencies"],
        )

        assert result.exit_code == 0
        mock_alembic.assert_called_once()
        mock_alembic.return_value.heads.assert_called_once_with(verbose=True, resolve_dependencies=True)


def test_history(cli_runner: CliRunner, database_cli: Group, mock_context: MagicMock) -> None:
    """Test the history command."""
    with patch("advanced_alchemy.alembic.commands.AlembicCommands") as mock_alembic:
        result = cli_runner.invoke(
            database_cli,
            [
                "--config",
                "tests.unit.fixtures.configs",
                "history",
                "--verbose",
                "--rev-range",
                "base:head",
                "--indicate-current",
            ],
        )

        assert result.exit_code == 0
        mock_alembic.assert_called_once()
        mock_alembic.return_value.history.assert_called_once_with(
            rev_range="base:head",
            verbose=True,
            indicate_current=True,
        )


def test_merge(cli_runner: CliRunner, database_cli: Group, mock_context: MagicMock) -> None:
    """Test the merge command."""
    with patch("advanced_alchemy.alembic.commands.AlembicCommands") as mock_alembic:
        result = cli_runner.invoke(
            database_cli,
            ["--config", "tests.unit.fixtures.configs", "merge", "--no-prompt", "-m", "test merge", "heads"],
        )

        assert result.exit_code == 0
        mock_alembic.assert_called_once()
        mock_alembic.return_value.merge.assert_called_once_with(
            revisions="heads",
            message="test merge",
            branch_label=None,
            rev_id=None,
        )


def test_show(cli_runner: CliRunner, database_cli: Group, mock_context: MagicMock) -> None:
    """Test the show command."""
    with patch("advanced_alchemy.alembic.commands.AlembicCommands") as mock_alembic:
        result = cli_runner.invoke(
            database_cli,
            ["--config", "tests.unit.fixtures.configs", "show", "head"],
        )

        assert result.exit_code == 0
        mock_alembic.assert_called_once()
        mock_alembic.return_value.show.assert_called_once_with(rev="head")


def test_branches(cli_runner: CliRunner, database_cli: Group, mock_context: MagicMock) -> None:
    """Test the branches command."""
    with patch("advanced_alchemy.alembic.commands.AlembicCommands") as mock_alembic:
        result = cli_runner.invoke(
            database_cli,
            ["--config", "tests.unit.fixtures.configs", "branches", "--verbose"],
        )

        assert result.exit_code == 0
        mock_alembic.assert_called_once()
        mock_alembic.return_value.branches.assert_called_once_with(verbose=True)


def test_list_templates(cli_runner: CliRunner, database_cli: Group, mock_context: MagicMock) -> None:
    """Test the list-templates command."""
    with patch("advanced_alchemy.alembic.commands.AlembicCommands") as mock_alembic:
        result = cli_runner.invoke(
            database_cli,
            ["--config", "tests.unit.fixtures.configs", "list-templates"],
        )

        assert result.exit_code == 0
        mock_alembic.assert_called_once()
        mock_alembic.return_value.list_templates.assert_called_once()


def test_cli_group_creation() -> None:
    """Test that the CLI group is created correctly."""
    cli_group = add_migration_commands()
    assert cli_group.name == "alchemy"
    # Original commands
    assert "show-current-revision" in cli_group.commands
    assert "upgrade" in cli_group.commands
    assert "downgrade" in cli_group.commands
    assert "init" in cli_group.commands
    assert "make-migrations" in cli_group.commands
    assert "drop-all" in cli_group.commands
    assert "dump-data" in cli_group.commands
    assert "stamp" in cli_group.commands
    # New commands added for Alembic CLI alignment
    assert "check" in cli_group.commands
    assert "edit" in cli_group.commands
    assert "ensure-version" in cli_group.commands
    assert "heads" in cli_group.commands
    assert "history" in cli_group.commands
    assert "merge" in cli_group.commands
    assert "show" in cli_group.commands
    assert "branches" in cli_group.commands
    assert "list-templates" in cli_group.commands


def test_external_config_loading(cli_runner: CliRunner) -> None:
    """Test loading config from external module in current working directory."""
    with tempfile.TemporaryDirectory() as temp_dir:
        temp_path = Path(temp_dir)

        # Create an external config file in the temp directory
        config_file = temp_path / "external_config.py"
        config_file.write_text("""
from advanced_alchemy.config import SQLAlchemyAsyncConfig

config = SQLAlchemyAsyncConfig(
    connection_string="sqlite+aiosqlite:///:memory:",
    bind_key="external",
)
""")

        # Change to the temp directory
        original_cwd = os.getcwd()
        try:
            os.chdir(temp_dir)

            # Test that the external config can be loaded
            cli_group = add_migration_commands()

            # Use a minimal command that doesn't require database setup
            # but still needs the config to be loaded successfully
            result = cli_runner.invoke(cli_group, ["--config", "external_config.config", "--help"])

            # Should succeed without import errors
            assert result.exit_code == 0
            assert "Error loading config" not in result.output
            assert "No module named" not in result.output

        finally:
            os.chdir(original_cwd)


def test_external_config_loading_multiple_configs(cli_runner: CliRunner) -> None:
    """Test loading multiple configs from external module."""
    with tempfile.TemporaryDirectory() as temp_dir:
        temp_path = Path(temp_dir)

        # Create an external config file with multiple configs
        config_file = temp_path / "multi_config.py"
        config_file.write_text("""
from advanced_alchemy.config import SQLAlchemyAsyncConfig

configs = [
    SQLAlchemyAsyncConfig(
        connection_string="sqlite+aiosqlite:///:memory:",
        bind_key="primary",
    ),
    SQLAlchemyAsyncConfig(
        connection_string="sqlite+aiosqlite:///:memory:",
        bind_key="secondary",
    ),
]
""")

        # Change to the temp directory
        original_cwd = os.getcwd()
        try:
            os.chdir(temp_dir)

            cli_group = add_migration_commands()
            result = cli_runner.invoke(cli_group, ["--config", "multi_config.configs", "--help"])

            # Should succeed without import errors
            assert result.exit_code == 0
            assert "Error loading config" not in result.output
            assert "No module named" not in result.output

        finally:
            os.chdir(original_cwd)


def test_external_config_loading_nonexistent_module(cli_runner: CliRunner) -> None:
    """Test appropriate error when external module doesn't exist."""
    with tempfile.TemporaryDirectory() as temp_dir:
        # Change to empty temp directory
        original_cwd = os.getcwd()
        try:
            os.chdir(temp_dir)

            cli_group = add_migration_commands()
            # Use actual command to trigger config loading, not --help
            result = cli_runner.invoke(cli_group, ["--config", "nonexistent_module.config", "show-current-revision"])

            # Should fail with appropriate error
            assert result.exit_code == 1
            assert "Error loading config" in result.output
            assert "No module named 'nonexistent_module'" in result.output

        finally:
            os.chdir(original_cwd)


def test_external_config_loading_nonexistent_attribute(cli_runner: CliRunner) -> None:
    """Test appropriate error when module exists but attribute doesn't."""
    with tempfile.TemporaryDirectory() as temp_dir:
        temp_path = Path(temp_dir)

        # Create an external config file without the expected attribute
        config_file = temp_path / "bad_config.py"
        config_file.write_text("""
# This module exists but doesn't have a 'missing_attr' attribute
from advanced_alchemy.config import SQLAlchemyAsyncConfig

some_other_var = "not a config"
""")

        # Change to the temp directory
        original_cwd = os.getcwd()
        try:
            os.chdir(temp_dir)

            cli_group = add_migration_commands()
            # Use actual command to trigger config loading, not --help
            result = cli_runner.invoke(cli_group, ["--config", "bad_config.missing_attr", "show-current-revision"])

            # Should fail with appropriate error
            assert result.exit_code == 1
            assert "Error loading config" in result.output
            # The actual error message may vary, but it should indicate the attribute issue

        finally:
            os.chdir(original_cwd)
python-advanced-alchemy-1.9.3/tests/unit/test_config/000077500000000000000000000000001516556515500226735ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/tests/unit/test_config/__init__.py000066400000000000000000000000001516556515500247720ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/tests/unit/test_config/test_async_config.py000066400000000000000000000123151516556515500267500ustar00rootroot00000000000000"""Unit tests for SQLAlchemyAsyncConfig.create_session_maker and __post_init__."""

from unittest.mock import MagicMock, patch

import pytest
from sqlalchemy.ext.asyncio import async_sessionmaker

from advanced_alchemy.config.asyncio import SQLAlchemyAsyncConfig
from advanced_alchemy.config.common import GenericSQLAlchemyConfig
from advanced_alchemy.exceptions import ImproperConfigurationError


def test_create_session_maker_returns_existing() -> None:
    """When session_maker is already set, return it without creating a new one."""
    existing_maker = MagicMock()
    config = SQLAlchemyAsyncConfig(connection_string="sqlite+aiosqlite:///")
    config.session_maker = existing_maker

    result = config.create_session_maker()
    assert result is existing_maker


def test_create_session_maker_standard_path() -> None:
    """Standard path creates a sessionmaker and registers all listeners."""
    mock_session_maker = MagicMock(spec=async_sessionmaker)

    config = SQLAlchemyAsyncConfig(connection_string="sqlite+aiosqlite:///")

    with (
        patch.object(
            GenericSQLAlchemyConfig,
            "create_session_maker",
            return_value=mock_session_maker,
        ),
        patch("sqlalchemy.event.listen") as mock_listen,
        patch("advanced_alchemy.config.asyncio.sync_sessionmaker") as mock_sync_maker_factory,
    ):
        mock_sync_maker = MagicMock()
        mock_sync_maker_factory.return_value = mock_sync_maker
        result = config.create_session_maker()

    assert result is mock_session_maker
    # Should register: before_flush, after_commit, after_rollback for file object
    #                  before_flush for timestamp
    #                  after_commit, after_rollback for cache
    assert mock_listen.call_count == 6

    # Verify session_maker.configure was called with the sync_maker
    mock_session_maker.configure.assert_called_once_with(sync_session_class=mock_sync_maker)

    # Verify listeners are attached to the sync_maker, not the async session_maker
    for call in mock_listen.call_args_list:
        assert call.args[0] is mock_sync_maker
        assert call.args[0] is not mock_session_maker

    # Verify file object listeners
    listener_events = [c.args[1] for c in mock_listen.call_args_list]
    assert "before_flush" in listener_events
    assert "after_commit" in listener_events
    assert "after_rollback" in listener_events


def test_create_session_maker_file_object_listener_disabled() -> None:
    """Skips file object listener registration when disabled."""
    mock_session_maker = MagicMock(spec=async_sessionmaker)

    config = SQLAlchemyAsyncConfig(
        connection_string="sqlite+aiosqlite:///",
        enable_file_object_listener=False,
    )

    with (
        patch.object(
            GenericSQLAlchemyConfig,
            "create_session_maker",
            return_value=mock_session_maker,
        ),
        patch("sqlalchemy.event.listen") as mock_listen,
    ):
        config.create_session_maker()

    # Without file object listener: timestamp (1) + cache (2) = 3
    assert mock_listen.call_count == 3


def test_create_session_maker_timestamp_listener_disabled() -> None:
    """Skips timestamp listener when disabled."""
    mock_session_maker = MagicMock(spec=async_sessionmaker)

    config = SQLAlchemyAsyncConfig(
        connection_string="sqlite+aiosqlite:///",
        enable_touch_updated_timestamp_listener=False,
    )

    with (
        patch.object(
            GenericSQLAlchemyConfig,
            "create_session_maker",
            return_value=mock_session_maker,
        ),
        patch("sqlalchemy.event.listen") as mock_listen,
    ):
        config.create_session_maker()

    # Without timestamp: file object (3) + cache (2) = 5
    assert mock_listen.call_count == 5


def test_post_init_routing_and_connection_string_conflict() -> None:
    """Raises ImproperConfigurationError when both routing_config and connection_string are set."""
    mock_routing = MagicMock()
    mock_routing.primary_connection_string = "sqlite+aiosqlite:///"

    with pytest.raises(ImproperConfigurationError, match="Provide either"):
        SQLAlchemyAsyncConfig(
            connection_string="sqlite+aiosqlite:///",
            routing_config=mock_routing,
        )


def test_post_init_routing_config_sets_connection_string() -> None:
    """Routing config populates connection_string from primary."""
    mock_routing = MagicMock()
    mock_routing.primary_connection_string = "sqlite+aiosqlite:///routed"
    mock_routing.get_engine_configs.return_value = []

    config = SQLAlchemyAsyncConfig(routing_config=mock_routing)
    assert config.connection_string == "sqlite+aiosqlite:///routed"


def test_post_init_routing_config_fallback_to_engine_configs() -> None:
    """Falls back to engine configs when primary_connection_string is None."""
    mock_engine_config = MagicMock()
    mock_engine_config.connection_string = "sqlite+aiosqlite:///fallback"

    mock_routing = MagicMock()
    mock_routing.primary_connection_string = None
    mock_routing.default_group = "default"
    mock_routing.get_engine_configs.return_value = [mock_engine_config]

    config = SQLAlchemyAsyncConfig(routing_config=mock_routing)
    assert config.connection_string == "sqlite+aiosqlite:///fallback"
python-advanced-alchemy-1.9.3/tests/unit/test_config/test_sync_config.py000066400000000000000000000107171516556515500266130ustar00rootroot00000000000000"""Unit tests for SQLAlchemySyncConfig.create_session_maker and __post_init__."""

from unittest.mock import MagicMock, patch

import pytest
from sqlalchemy.orm import sessionmaker

from advanced_alchemy.config.common import GenericSQLAlchemyConfig
from advanced_alchemy.config.sync import SQLAlchemySyncConfig
from advanced_alchemy.exceptions import ImproperConfigurationError


def test_create_session_maker_returns_existing() -> None:
    """When session_maker is already set, return it without creating a new one."""
    existing_maker = MagicMock()
    config = SQLAlchemySyncConfig(connection_string="sqlite:///")
    config.session_maker = existing_maker

    result = config.create_session_maker()
    assert result is existing_maker


def test_create_session_maker_standard_path() -> None:
    """Standard path creates a sessionmaker and registers all listeners."""
    mock_session_maker = MagicMock(spec=sessionmaker)

    config = SQLAlchemySyncConfig(connection_string="sqlite:///")

    with (
        patch.object(
            GenericSQLAlchemyConfig,
            "create_session_maker",
            return_value=mock_session_maker,
        ),
        patch("sqlalchemy.event.listen") as mock_listen,
    ):
        result = config.create_session_maker()

    assert result is mock_session_maker
    # Should register: before_flush, after_commit, after_rollback for file object
    #                  before_flush for timestamp
    #                  after_commit, after_rollback for cache
    assert mock_listen.call_count == 6

    listener_events = [c.args[1] for c in mock_listen.call_args_list]
    assert "before_flush" in listener_events
    assert "after_commit" in listener_events
    assert "after_rollback" in listener_events


def test_create_session_maker_file_object_listener_disabled() -> None:
    """Skips file object listener registration when disabled."""
    mock_session_maker = MagicMock(spec=sessionmaker)

    config = SQLAlchemySyncConfig(
        connection_string="sqlite:///",
        enable_file_object_listener=False,
    )

    with (
        patch.object(
            GenericSQLAlchemyConfig,
            "create_session_maker",
            return_value=mock_session_maker,
        ),
        patch("sqlalchemy.event.listen") as mock_listen,
    ):
        config.create_session_maker()

    # Without file object listener: timestamp (1) + cache (2) = 3
    assert mock_listen.call_count == 3


def test_create_session_maker_timestamp_listener_disabled() -> None:
    """Skips timestamp listener when disabled."""
    mock_session_maker = MagicMock(spec=sessionmaker)

    config = SQLAlchemySyncConfig(
        connection_string="sqlite:///",
        enable_touch_updated_timestamp_listener=False,
    )

    with (
        patch.object(
            GenericSQLAlchemyConfig,
            "create_session_maker",
            return_value=mock_session_maker,
        ),
        patch("sqlalchemy.event.listen") as mock_listen,
    ):
        config.create_session_maker()

    # Without timestamp: file object (3) + cache (2) = 5
    assert mock_listen.call_count == 5


def test_post_init_routing_and_connection_string_conflict() -> None:
    """Raises ImproperConfigurationError when both routing_config and connection_string are set."""
    mock_routing = MagicMock()
    mock_routing.primary_connection_string = "sqlite:///"

    with pytest.raises(ImproperConfigurationError, match="Provide either"):
        SQLAlchemySyncConfig(
            connection_string="sqlite:///",
            routing_config=mock_routing,
        )


def test_post_init_routing_config_sets_connection_string() -> None:
    """Routing config populates connection_string from primary."""
    mock_routing = MagicMock()
    mock_routing.primary_connection_string = "sqlite:///routed"
    mock_routing.get_engine_configs.return_value = []

    config = SQLAlchemySyncConfig(routing_config=mock_routing)
    assert config.connection_string == "sqlite:///routed"


def test_post_init_routing_config_fallback_to_engine_configs() -> None:
    """Falls back to engine configs when primary_connection_string is None."""
    mock_engine_config = MagicMock()
    mock_engine_config.connection_string = "sqlite:///fallback"

    mock_routing = MagicMock()
    mock_routing.primary_connection_string = None
    mock_routing.default_group = "default"
    mock_routing.get_engine_configs.return_value = [mock_engine_config]

    config = SQLAlchemySyncConfig(routing_config=mock_routing)
    assert config.connection_string == "sqlite:///fallback"
python-advanced-alchemy-1.9.3/tests/unit/test_docs.py000066400000000000000000000016611516556515500227340ustar00rootroot00000000000000import re
import runpy
from pathlib import Path

ROOT = Path(__file__).resolve().parents[2]
DOCS_ROOT = ROOT / "docs"
USAGE_DOCS_ROOT = DOCS_ROOT / "usage"
PYTHON_SNIPPET_PATTERN = re.compile(r"^\.\.\s+(?:code-block::\s+python|testcode::)\s*$", re.MULTILINE)


def test_usage_docs_with_python_examples_are_tracked() -> None:
    """Ensure docs with Python examples are either executable or explicitly classified."""
    sybil_config = runpy.run_path(str(DOCS_ROOT / "conftest.py"))
    executable_docs = set(sybil_config["EXECUTABLE_DOCS"])
    non_executable_docs = set(sybil_config["NON_EXECUTABLE_DOCS"])

    discovered_docs = {
        path.relative_to(DOCS_ROOT).as_posix()
        for path in USAGE_DOCS_ROOT.rglob("*.rst")
        if PYTHON_SNIPPET_PATTERN.search(path.read_text(encoding="utf-8"))
    }

    assert executable_docs.isdisjoint(non_executable_docs)
    assert discovered_docs == executable_docs.union(non_executable_docs)
python-advanced-alchemy-1.9.3/tests/unit/test_exceptions.py000066400000000000000000000135761516556515500241750ustar00rootroot00000000000000import pytest
from sqlalchemy.exc import (
    IntegrityError as SQLAlchemyIntegrityError,
)
from sqlalchemy.exc import (
    InvalidRequestError as SQLAlchemyInvalidRequestError,
)
from sqlalchemy.exc import (
    MultipleResultsFound,
    SQLAlchemyError,
    StatementError,
)

from advanced_alchemy.exceptions import (
    DuplicateKeyError,
    IntegrityError,
    InvalidRequestError,
    MultipleResultsFoundError,
    NotFoundError,
    RepositoryError,
    wrap_sqlalchemy_exception,
)


def test_wrap_sqlalchemy_exception_multiple_results_found() -> None:
    with pytest.raises(MultipleResultsFoundError), wrap_sqlalchemy_exception():
        raise MultipleResultsFound()


@pytest.mark.parametrize("dialect_name", ["postgresql", "sqlite", "mysql"])
def test_wrap_sqlalchemy_exception_integrity_error_duplicate_key(dialect_name: str) -> None:
    error_message = {
        "postgresql": 'duplicate key value violates unique constraint "uq_%(table_name)s_%(column_0_name)s"',
        "sqlite": "UNIQUE constraint failed: %(table_name)s.%(column_0_name)s",
        "mysql": "1062 (23000): Duplicate entry '%(value)s' for key '%(table_name)s.%(column_0_name)s'",
    }
    with (
        pytest.raises(DuplicateKeyError),
        wrap_sqlalchemy_exception(
            dialect_name=dialect_name,
            error_messages={"duplicate_key": error_message[dialect_name]},
        ),
    ):
        if dialect_name == "postgresql":
            exception = SQLAlchemyIntegrityError(
                "INSERT INTO table (id) VALUES (1)",
                {"table_name": "table", "column_0_name": "id"},
                Exception(
                    'duplicate key value violates unique constraint "uq_table_id"\nDETAIL:  Key (id)=(1) already exists.',
                ),
            )
        elif dialect_name == "sqlite":
            exception = SQLAlchemyIntegrityError(
                "INSERT INTO table (id) VALUES (1)",
                {"table_name": "table", "column_0_name": "id"},
                Exception("UNIQUE constraint failed: table.id"),
            )
        else:
            exception = SQLAlchemyIntegrityError(
                "INSERT INTO table (id) VALUES (1)",
                {"table_name": "table", "column_0_name": "id", "value": "1"},
                Exception("1062 (23000): Duplicate entry '1' for key 'table.id'"),
            )

        raise exception


def test_wrap_sqlalchemy_exception_integrity_error_other() -> None:
    with pytest.raises(IntegrityError), wrap_sqlalchemy_exception():
        raise SQLAlchemyIntegrityError("original", {}, Exception("original"))


def test_wrap_sqlalchemy_exception_invalid_request_error() -> None:
    with pytest.raises(InvalidRequestError), wrap_sqlalchemy_exception():
        raise SQLAlchemyInvalidRequestError("original", {}, Exception("original"))


def test_wrap_sqlalchemy_exception_statement_error() -> None:
    with pytest.raises(IntegrityError), wrap_sqlalchemy_exception():
        raise StatementError("original", None, {}, Exception("original"))  # pyright: ignore[reportArgumentType]


def test_wrap_sqlalchemy_exception_sqlalchemy_error() -> None:
    with pytest.raises(RepositoryError), wrap_sqlalchemy_exception():
        raise SQLAlchemyError("original")


def test_wrap_sqlalchemy_exception_attribute_error() -> None:
    with pytest.raises(RepositoryError), wrap_sqlalchemy_exception():
        raise AttributeError("original")


def test_wrap_sqlalchemy_exception_not_found_error() -> None:
    with pytest.raises(NotFoundError, match="No rows matched the specified data"), wrap_sqlalchemy_exception():
        raise NotFoundError("No item found when one was expected")


def test_wrap_sqlalchemy_exception_no_wrap() -> None:
    with pytest.raises(SQLAlchemyError), wrap_sqlalchemy_exception(wrap_exceptions=False):
        raise SQLAlchemyError("original")
    with pytest.raises(SQLAlchemyIntegrityError), wrap_sqlalchemy_exception(wrap_exceptions=False):
        raise SQLAlchemyIntegrityError(statement="select 1", params=None, orig=BaseException())
    with pytest.raises(MultipleResultsFound), wrap_sqlalchemy_exception(wrap_exceptions=False):
        raise MultipleResultsFound()
    with pytest.raises(SQLAlchemyInvalidRequestError), wrap_sqlalchemy_exception(wrap_exceptions=False):
        raise SQLAlchemyInvalidRequestError()
    with pytest.raises(AttributeError), wrap_sqlalchemy_exception(wrap_exceptions=False):
        raise AttributeError()
    with (
        pytest.raises(NotFoundError, match="No item found when one was expected"),
        wrap_sqlalchemy_exception(wrap_exceptions=False),
    ):
        raise NotFoundError("No item found when one was expected")


def test_custom_not_found_error_message() -> None:
    with (
        pytest.raises(NotFoundError, match="Custom Error"),
        wrap_sqlalchemy_exception(error_messages={"not_found": "Custom Error"}),
    ):
        raise NotFoundError("original")


def test_wrap_sqlalchemy_exception_custom_error_message() -> None:
    def custom_message(exc: Exception) -> str:
        return f"Custom: {exc}"

    with (
        pytest.raises(RepositoryError) as excinfo,
        wrap_sqlalchemy_exception(
            error_messages={"other": custom_message},
        ),
    ):
        raise SQLAlchemyError("original")

    assert str(excinfo.value) == "Custom: original"


def test_wrap_sqlalchemy_exception_no_error_messages() -> None:
    with pytest.raises(RepositoryError) as excinfo, wrap_sqlalchemy_exception():
        raise SQLAlchemyError("original")

    assert str(excinfo.value) == "An exception occurred: original"


def test_wrap_sqlalchemy_exception_no_match() -> None:
    with (
        pytest.raises(IntegrityError) as excinfo,
        wrap_sqlalchemy_exception(
            dialect_name="postgresql",
            error_messages={"integrity": "Integrity error"},
        ),
    ):
        raise SQLAlchemyIntegrityError("original", {}, Exception("original"))

    assert str(excinfo.value) == "Integrity error"
python-advanced-alchemy-1.9.3/tests/unit/test_extensions/000077500000000000000000000000001516556515500236255ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/tests/unit/test_extensions/__init__.py000066400000000000000000000000001516556515500257240ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/tests/unit/test_extensions/test_fastapi/000077500000000000000000000000001516556515500263135ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/tests/unit/test_extensions/test_fastapi/__init__.py000066400000000000000000000000001516556515500304120ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/tests/unit/test_extensions/test_fastapi/test_extension.py000066400000000000000000000411211516556515500317370ustar00rootroot00000000000000import sys
from collections.abc import AsyncGenerator, Generator
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, Annotated, Callable, Literal, Union, cast
from unittest.mock import MagicMock

import pytest
from fastapi import Depends, FastAPI, HTTPException, Request, Response
from fastapi.testclient import TestClient
from pytest import FixtureRequest
from pytest_mock import MockerFixture
from sqlalchemy import Engine
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
from sqlalchemy.orm import Session
from typing_extensions import assert_type

from advanced_alchemy.exceptions import ImproperConfigurationError
from advanced_alchemy.extensions.fastapi import AdvancedAlchemy, SQLAlchemyAsyncConfig, SQLAlchemySyncConfig

AnyConfig = Union[SQLAlchemyAsyncConfig, SQLAlchemySyncConfig]
pytestmark = pytest.mark.xfail(
    condition=sys.version_info < (3, 9),
    reason="Certain versions of Starlette and FastAPI are stated to still support 3.8, but there are documented incompatibilities on various versions that have not been yanked.  Marking 3.8 as an acceptable failure for now.",
)


@pytest.fixture()
def app() -> FastAPI:
    return FastAPI()


@pytest.fixture()
def client(app: FastAPI) -> Generator[TestClient, None, None]:
    with TestClient(app=app, raise_server_exceptions=False) as client:
        yield client


@pytest.fixture()
def sync_config() -> SQLAlchemySyncConfig:
    return SQLAlchemySyncConfig(connection_string="sqlite:///:memory:")


@pytest.fixture()
def async_config() -> SQLAlchemyAsyncConfig:
    return SQLAlchemyAsyncConfig(connection_string="sqlite+aiosqlite:///:memory:")


@pytest.fixture(params=["sync_config", "async_config"])
def config(request: FixtureRequest) -> AnyConfig:
    return cast(AnyConfig, request.getfixturevalue(request.param))


@pytest.fixture()
def alchemy(config: AnyConfig, app: FastAPI) -> AdvancedAlchemy:
    return AdvancedAlchemy(config, app=app)


async def test_infer_types_from_config(async_config: SQLAlchemyAsyncConfig, sync_config: SQLAlchemySyncConfig) -> None:
    if TYPE_CHECKING:
        alchemy = AdvancedAlchemy(config=[async_config, sync_config])
        assert alchemy.get_sync_config() is sync_config
        assert alchemy.get_async_config() is async_config

        assert_type(alchemy.get_sync_engine(), Engine)
        assert_type(alchemy.get_async_engine(), AsyncEngine)

        assert_type(alchemy.get_sync_config().create_session_maker(), Callable[[], Session])
        assert_type(alchemy.get_async_config().create_session_maker(), Callable[[], AsyncSession])

        with alchemy.with_sync_session() as db_session:
            assert_type(db_session, Session)
        async with alchemy.with_async_session() as async_session:
            assert_type(async_session, AsyncSession)


def test_init_app_not_called_raises(config: SQLAlchemySyncConfig) -> None:
    alchemy = AdvancedAlchemy(config)
    with pytest.raises(ImproperConfigurationError):
        alchemy.app


def test_inject_sync_engine() -> None:
    app = FastAPI()
    mock = MagicMock()
    config = SQLAlchemySyncConfig(connection_string="sqlite:///:memory:")
    alchemy = AdvancedAlchemy(config=config, app=app)

    @app.get("/")
    def handler(engine: Annotated[Engine, Depends(alchemy.provide_engine())]) -> Response:
        mock(engine)
        return Response(status_code=200)

    with TestClient(app=app) as client:
        resp = client.get("/")
        assert resp.status_code == 200
        call_args = mock.call_args[0]
        assert call_args[0] is config.get_engine()


def test_inject_async_engine() -> None:
    app = FastAPI()
    mock = MagicMock()
    config = SQLAlchemyAsyncConfig(connection_string="sqlite+aiosqlite:///:memory:")
    alchemy = AdvancedAlchemy(config=config, app=app)

    @app.get("/")
    def handler(engine: Annotated[AsyncEngine, Depends(alchemy.provide_engine())]) -> Response:
        mock(engine)
        return Response(status_code=200)

    with TestClient(app=app) as client:
        resp = client.get("/")
        assert resp.status_code == 200
        call_args = mock.call_args[0]
        assert call_args[0] is config.get_engine()


def test_inject_sync_session() -> None:
    app = FastAPI()
    mock = MagicMock()
    config = SQLAlchemySyncConfig(connection_string="sqlite:///:memory:")
    alchemy = AdvancedAlchemy(config=config, app=app)
    SessionDependency = Annotated[Session, Depends(alchemy.get_sync_session)]

    def some_dependency(session: SessionDependency) -> None:  # pyright: ignore[reportInvalidTypeForm,reportMissingTypeArgument,reportUnknownParameterType]
        mock(session)

    @app.get("/")
    def handler(session: SessionDependency, something: Annotated[None, Depends(some_dependency)]) -> None:  # pyright: ignore[reportInvalidTypeForm,reportMissingTypeArgument,reportUnknownParameterType,reportUnknownArgumentType]
        mock(session)

    with TestClient(app=app) as client:
        client.get("/")
        assert mock.call_count == 2
        call_1_args = mock.call_args_list[0].args
        call_2_args = mock.call_args_list[1].args
        assert call_1_args[0] is call_2_args[0]
        call_1_session = call_1_args[0]
        call_2_session = call_2_args[0]
        assert isinstance(call_1_session, Session)
        assert call_1_session is call_2_session


async def test_inject_async_session() -> None:
    app = FastAPI()
    mock = MagicMock()
    config = SQLAlchemyAsyncConfig(connection_string="sqlite+aiosqlite:///:memory:")
    alchemy = AdvancedAlchemy(config=config, app=app)
    SessionDependency = Annotated[AsyncSession, Depends(alchemy.provide_session())]

    async def some_dependency(session: SessionDependency) -> None:  # pyright: ignore[reportInvalidTypeForm,reportMissingTypeArgument,reportUnknownParameterType]
        mock(session)

    @app.get("/")
    async def handler(session: SessionDependency, something: Annotated[None, Depends(some_dependency)]) -> None:  # pyright: ignore[reportInvalidTypeForm,reportMissingTypeArgument,reportUnknownParameterType,reportUnknownArgumentType]
        mock(session)

    with TestClient(app=app) as client:
        client.get("/")
        assert mock.call_count == 2
        call_1_args = mock.call_args_list[0].args
        call_2_args = mock.call_args_list[1].args
        assert call_1_args[0] is call_2_args[0]
        call_1_session = call_1_args[0]
        call_2_session = call_2_args[0]
        assert isinstance(call_1_session, AsyncSession)
        assert call_1_session is call_2_session


@pytest.mark.parametrize(
    "status_code", [200, 201, 202, 204, 206, 300, 301, 305, 307, 308, 400, 401, 404, 450, 500, 900]
)
@pytest.mark.parametrize("autocommit_strategy", ["manual", "autocommit", "autocommit_include_redirect"])
def test_sync_commit_strategies(
    mocker: MockerFixture,
    status_code: int,
    autocommit_strategy: Literal["manual", "autocommit", "autocommit_include_redirect"],
) -> None:
    app = FastAPI()
    config = SQLAlchemySyncConfig(connection_string="sqlite:///:memory:", commit_mode=autocommit_strategy)
    alchemy = AdvancedAlchemy(config=config, app=app)
    mock_commit = mocker.patch("sqlalchemy.orm.Session.commit")
    mock_close = mocker.patch("sqlalchemy.orm.Session.close")
    mock_rollback = mocker.patch("sqlalchemy.orm.Session.rollback")
    SessionDependency = Annotated[Session, Depends(alchemy.provide_session())]

    @app.get("/")
    def handler(session: SessionDependency) -> Response:  # pyright: ignore[reportInvalidTypeForm,reportMissingTypeArgument,reportUnknownParameterType]
        return Response(status_code=status_code)

    with TestClient(app=app) as client:
        response = client.get("/")
        assert response.status_code == status_code

        if autocommit_strategy == "manual":
            mock_commit.call_count = 0
            mock_close.call_count = 1
            mock_rollback.call_count = 0
        elif autocommit_strategy == "autocommit" and status_code < 300:
            mock_commit.call_count = 1
            mock_close.call_count = 1
            mock_rollback.call_count = 0
        elif autocommit_strategy == "autocommit" and status_code >= 300:
            mock_commit.call_count = 0
            mock_close.call_count = 1
            mock_rollback.call_count = 1
        elif autocommit_strategy == "autocommit_include_redirect" and status_code < 400:
            mock_commit.call_count = 1
            mock_close.call_count = 1
            mock_rollback.call_count = 0
        elif autocommit_strategy == "autocommit_include_redirect" and status_code >= 400:
            mock_commit.call_count = 0
            mock_close.call_count = 1
            mock_rollback.call_count = 1


@pytest.mark.parametrize(
    "status_code", [200, 201, 202, 204, 206, 300, 301, 305, 307, 308, 400, 401, 404, 450, 500, 900]
)
@pytest.mark.parametrize("autocommit_strategy", ["manual", "autocommit", "autocommit_include_redirect"])
def test_async_commit_strategies(
    mocker: MockerFixture,
    status_code: int,
    autocommit_strategy: Literal["manual", "autocommit", "autocommit_include_redirect"],
) -> None:
    app = FastAPI()
    config = SQLAlchemyAsyncConfig(connection_string="sqlite+aiosqlite:///:memory:", commit_mode=autocommit_strategy)
    alchemy = AdvancedAlchemy(config=config, app=app)
    mock_commit = mocker.patch("sqlalchemy.ext.asyncio.AsyncSession.commit")
    mock_close = mocker.patch("sqlalchemy.ext.asyncio.AsyncSession.close")
    mock_rollback = mocker.patch("sqlalchemy.ext.asyncio.AsyncSession.rollback")

    @app.get("/")
    def handler(session: Annotated[AsyncSession, Depends(alchemy.provide_session())]) -> Response:
        return Response(status_code=status_code)

    with TestClient(app=app) as client:
        response = client.get("/")
        assert response.status_code == status_code

        if autocommit_strategy == "manual":
            mock_commit.call_count = 0
            mock_close.call_count = 1
            mock_rollback.call_count = 0
        elif autocommit_strategy == "autocommit" and status_code < 300:
            mock_commit.call_count = 1
            mock_close.call_count = 1
            mock_rollback.call_count = 0
        elif autocommit_strategy == "autocommit" and status_code >= 300:
            mock_commit.call_count = 0
            mock_close.call_count = 1
            mock_rollback.call_count = 1
        elif autocommit_strategy == "autocommit_include_redirect" and status_code < 400:
            mock_commit.call_count = 1
            mock_close.call_count = 1
            mock_rollback.call_count = 0
        elif autocommit_strategy == "autocommit_include_redirect" and status_code >= 400:
            mock_commit.call_count = 0
            mock_close.call_count = 1
            mock_rollback.call_count = 1


@pytest.mark.parametrize("autocommit_strategy", ["manual", "autocommit", "autocommit_include_redirect"])
def test_sync_session_close_on_exception(
    mocker: MockerFixture,
    autocommit_strategy: Literal["manual", "autocommit", "autocommit_include_redirect"],
) -> None:
    app = FastAPI()
    config = SQLAlchemySyncConfig(
        connection_string="sqlite+pysqlite://",
        commit_mode=autocommit_strategy,
    )
    alchemy = AdvancedAlchemy(config=config, app=app)
    mock_commit = mocker.patch("sqlalchemy.orm.Session.commit")
    mock_close = mocker.patch("sqlalchemy.orm.Session.close")
    mock_rollback = mocker.patch("sqlalchemy.orm.Session.rollback")

    def provide_session(request: Request) -> Session:
        return alchemy.get_sync_session(request)

    @app.get("/")
    def handler(sync_db_session: Annotated[Session, Depends(provide_session)]) -> str:
        raise HTTPException(status_code=500, detail="Intentional error for testing")

    with TestClient(app=app, raise_server_exceptions=False) as client:
        _ = client.get("/")
        assert _.status_code == 500
        assert _.json().get("detail") == "Intentional error for testing"
        mock_commit.call_count = 0
        mock_close.call_count = 1
        mock_rollback.call_count = 0


@pytest.mark.parametrize("autocommit_strategy", ["manual", "autocommit", "autocommit_include_redirect"])
def test_async_session_close_on_exception(
    mocker: MockerFixture,
    autocommit_strategy: Literal["manual", "autocommit", "autocommit_include_redirect"],
) -> None:
    app = FastAPI()
    config = SQLAlchemyAsyncConfig(
        connection_string="sqlite+aiosqlite://",
        commit_mode=autocommit_strategy,
    )
    alchemy = AdvancedAlchemy(config=config, app=app)
    mock_commit = mocker.patch("sqlalchemy.ext.asyncio.AsyncSession.commit")
    mock_close = mocker.patch("sqlalchemy.ext.asyncio.AsyncSession.close")
    mock_rollback = mocker.patch("sqlalchemy.ext.asyncio.AsyncSession.rollback")

    def provide_session(request: Request) -> AsyncSession:
        return alchemy.get_async_session(request)

    @app.get("/")
    def handler(async_db_session: Annotated[AsyncSession, Depends(provide_session)]) -> str:
        raise HTTPException(status_code=500, detail="Intentional error for testing")

    with TestClient(app=app, raise_server_exceptions=False) as client:
        _ = client.get("/")
        assert _.status_code == 500
        assert _.json().get("detail") == "Intentional error for testing"
        mock_commit.call_count = 0
        mock_close.call_count = 1
        mock_rollback.call_count = 0


def test_multiple_sync_instances(app: FastAPI) -> None:
    mock = MagicMock()
    config_1 = SQLAlchemySyncConfig(connection_string="sqlite:///:memory:")
    config_2 = SQLAlchemySyncConfig(connection_string="sqlite:///temp.db", bind_key="config_2")

    alchemy_1 = AdvancedAlchemy([config_1, config_2], app=app)

    def provide_engine_1() -> Engine:
        return alchemy_1.get_sync_engine()

    def provide_engine_2() -> Engine:
        return alchemy_1.get_sync_engine("config_2")

    @app.get("/")
    def handler(
        session_1: Annotated[Session, Depends(lambda: alchemy_1.provide_session())],
        session_2: Annotated[Session, Depends(lambda: alchemy_1.provide_session("config_2"))],
        engine_1: Annotated[Engine, Depends(lambda: alchemy_1.provide_engine())],
        engine_2: Annotated[Engine, Depends(lambda: alchemy_1.provide_engine("config_2"))],
    ) -> None:
        assert session_1 is not session_2
        assert engine_1 is not engine_2
        mock(session=session_1, engine=engine_1)
        mock(session=session_2, engine=engine_2)

    with TestClient(app=app) as client:
        client.get("/")
        assert alchemy_1.get_sync_config().bind_key != alchemy_1.get_sync_config("config_2").bind_key
        assert alchemy_1.get_sync_config().session_maker != alchemy_1.get_sync_config("config_2").session_maker

        assert alchemy_1.get_sync_config().get_engine() is not alchemy_1.get_sync_config("config_2").get_engine()
        assert (
            alchemy_1.get_sync_config().create_session_maker()
            is not alchemy_1.get_sync_config("config_2").create_session_maker()
        )
        assert mock.call_args_list[0].kwargs["session"] is not mock.call_args_list[1].kwargs["session"]
        assert mock.call_args_list[0].kwargs["engine"] is not mock.call_args_list[1].kwargs["engine"]


async def test_lifespan_startup_shutdown_called_fastapi(mocker: MockerFixture, app: FastAPI, config: AnyConfig) -> None:
    mock_startup = mocker.patch.object(AdvancedAlchemy, "on_startup")
    mock_shutdown = mocker.patch.object(AdvancedAlchemy, "on_shutdown")
    _alchemy = AdvancedAlchemy(config, app=app)

    with TestClient(app=app) as _client:  # TestClient context manager triggers lifespan events
        pass  # App starts up and shuts down within this context

    mock_startup.assert_called_once()
    mock_shutdown.assert_called_once()


async def test_lifespan_with_custom_lifespan_fastapi(mocker: MockerFixture, app: FastAPI, config: AnyConfig) -> None:
    mock_aa_startup = mocker.patch.object(AdvancedAlchemy, "on_startup")
    mock_aa_shutdown = mocker.patch.object(AdvancedAlchemy, "on_shutdown")
    mock_custom_startup = mocker.MagicMock()
    mock_custom_shutdown = mocker.MagicMock()

    @asynccontextmanager
    async def custom_lifespan(app_in: FastAPI) -> AsyncGenerator[None, None]:
        mock_custom_startup()
        yield
        mock_custom_shutdown()

    app.router.lifespan_context = custom_lifespan  # type: ignore[assignment] # Set a custom lifespan on the app
    _alchemy = AdvancedAlchemy(config, app=app)

    with TestClient(app=app) as _client:  # TestClient context manager triggers lifespan events
        pass  # App starts up and shuts down within this context

    mock_aa_startup.assert_called_once()
    mock_aa_shutdown.assert_called_once()
    mock_custom_startup.assert_called_once()
    mock_custom_shutdown.assert_called_once()

    # Optionally assert the order of calls if needed, e.g., using mocker.call_order
python-advanced-alchemy-1.9.3/tests/unit/test_extensions/test_fastapi/test_providers.py000066400000000000000000000702401516556515500317440ustar00rootroot00000000000000"""Tests for the FastAPI DI module."""

import inspect
import sys  # Import sys
import typing
from collections.abc import AsyncGenerator
from datetime import datetime
from typing import Annotated, Union, cast
from unittest.mock import patch
from uuid import UUID

import pytest
from fastapi import Depends, FastAPI, Request
from fastapi.testclient import TestClient
from sqlalchemy import String
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, mapped_column

# Assuming necessary classes are importable from the new provider module
from advanced_alchemy.base import UUIDBase
from advanced_alchemy.extensions.fastapi import SQLAlchemyAsyncConfig
from advanced_alchemy.extensions.fastapi.providers import (
    DEPENDENCY_DEFAULTS,
    DependencyCache,
    DependencyDefaults,
    FieldNameType,
    FilterConfig,
    _create_filter_aggregate_function_fastapi,  # pyright: ignore[reportPrivateUsage]
    dep_cache,  # Import the global cache instance
    provide_filters,
)
from advanced_alchemy.filters import (
    BeforeAfter,
    CollectionFilter,
    FilterTypes,
    LimitOffset,
    OrderBy,
    SearchFilter,
)
from advanced_alchemy.repository import SQLAlchemyAsyncRepository
from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService
from advanced_alchemy.utils.singleton import SingletonMeta


def test_dependency_cache_singleton() -> None:
    """Test that the global dep_cache instance is a singleton."""
    # Do not clear SingletonMeta._instances, so that dep_cache remains the global singleton
    new_cache = DependencyCache()
    assert new_cache is dep_cache


def test_add_get_dependencies_cache() -> None:
    """Test adding and retrieving dependencies from cache."""
    # Create a new instance to avoid test interference
    with patch.dict(SingletonMeta._instances, {}, clear=True):  # pyright: ignore[reportPrivateUsage]
        cache = DependencyCache()
        key = hash(tuple(sorted({"id_filter": True}.items())))
        deps1 = {"filters": Depends(lambda: "service")}
        cache.add_dependencies(key, deps1)  # type: ignore
        assert cache.get_dependencies(key) == deps1  # type: ignore

        # Test retrieving non-existent key
        assert cache.get_dependencies(hash("nonexistent")) is None  # type: ignore[unreachable]


def test_create_filter_dependencies_cache_hit() -> None:
    """Test create_filter_dependencies with cache hit."""
    # Setup cache with a pre-existing entry
    config = cast(FilterConfig, {"id_filter": UUID})
    mock_deps = lambda: "cached_dependency"  # type: ignore  # noqa: E731

    # Use a patch to capture the actual key
    with patch.object(dep_cache, "get_dependencies", return_value=mock_deps) as mock_get:
        with patch.object(dep_cache, "add_dependencies") as mock_add:
            with patch(
                "advanced_alchemy.extensions.fastapi.providers._create_filter_aggregate_function_fastapi"
            ) as mock_create:
                deps = provide_filters(config)

                # Verify cache was checked
                assert mock_get.call_count == 1, "Cache get_dependencies should be called exactly once"

                # Verify result is from cache
                assert deps == mock_deps  # type: ignore

                # Verify aggregate function builder was NOT called
                mock_create.assert_not_called()  # type: ignore[unreachable]

                # Verify cache wasn't updated again
                mock_add.assert_not_called()


def test_create_filter_dependencies_cache_miss() -> None:
    """Test create_filter_dependencies with cache miss."""
    config = cast(FilterConfig, {"created_at": True})
    cache_key = hash(tuple(sorted(config.items())))
    mock_agg_func = lambda: [  # noqa: E731
        BeforeAfter(field_name="created_at", before=None, after=None)
    ]  # Dummy aggregate function

    with patch.object(dep_cache, "get_dependencies", return_value=None) as mock_get:  # Simulate cache miss
        with patch.object(dep_cache, "add_dependencies") as mock_add:
            # Mock the builder to return our dummy aggregate function
            with patch(
                "advanced_alchemy.extensions.fastapi.providers._create_filter_aggregate_function_fastapi",
                return_value=mock_agg_func,
            ) as mock_create:
                deps = provide_filters(config)

                # Verify cache was checked
                mock_get.assert_called_once_with(cache_key)

                # Verify _create_filter_aggregate_function_fastapi was called
                mock_create.assert_called_once_with(config, DEPENDENCY_DEFAULTS)

                # Verify cache was updated with the created dependencies
                # We need to compare the structure, not object identity of Depends(mock_agg_func)
                mock_add.assert_called_once()

                assert deps is mock_agg_func


# --- Aggregate Dependency Function Builder Tests --- #


def test_create_filter_aggregate_function_fastapi() -> None:
    """Test the signature and direct call of the aggregated dependency function."""

    aggregate_func = _create_filter_aggregate_function_fastapi(
        {
            "id_filter": UUID,
            "created_at": True,
            "pagination_type": "limit_offset",
            "search": "title",
            "sort_field": "id",
        },
        DEPENDENCY_DEFAULTS,
    )
    assert callable(aggregate_func)

    # Check signature has correct parameters and Depends defaults
    sig = inspect.signature(aggregate_func)
    assert DEPENDENCY_DEFAULTS.ID_FILTER_DEPENDENCY_KEY in sig.parameters
    assert DEPENDENCY_DEFAULTS.CREATED_FILTER_DEPENDENCY_KEY in sig.parameters
    assert DEPENDENCY_DEFAULTS.LIMIT_OFFSET_FILTER_DEPENDENCY_KEY in sig.parameters
    assert DEPENDENCY_DEFAULTS.SEARCH_FILTER_DEPENDENCY_KEY in sig.parameters
    assert DEPENDENCY_DEFAULTS.ORDER_BY_FILTER_DEPENDENCY_KEY in sig.parameters

    # Check return annotation origin type (list) and its argument (FilterTypes)
    assert hasattr(sig.return_annotation, "__origin__")
    assert typing.get_origin(sig.return_annotation) is Annotated
    assert sig.return_annotation.__args__[0] == list[FilterTypes]

    for param_name, param in sig.parameters.items():
        # Check annotation (Optional[...] for filters that can return None)
        if param_name in (
            DEPENDENCY_DEFAULTS.LIMIT_OFFSET_FILTER_DEPENDENCY_KEY,
            DEPENDENCY_DEFAULTS.ORDER_BY_FILTER_DEPENDENCY_KEY,
        ):
            # These parameters should not be Optional
            assert param.annotation is not None
            assert typing.get_origin(param.annotation) is Annotated
            inner_type = param.annotation.__args__[0]
            assert not (
                hasattr(inner_type, "__origin__")
                and inner_type.__origin__ is Union
                and type(None) in inner_type.__args__
            )
        else:
            # Other parameters should be Optional (Union[..., None])
            assert typing.get_origin(param.annotation) is Annotated
            inner_type = param.annotation.__args__[0]
            assert hasattr(inner_type, "__origin__")
            assert inner_type.__origin__ is Union
            assert type(None) in inner_type.__args__

    # Directly call the aggregate function with mock filter objects
    mock_id_filter = CollectionFilter(field_name="id", values=["1"])
    mock_created_filter = BeforeAfter(field_name="created_at", before=datetime.now(), after=None)
    mock_limit_offset = LimitOffset(limit=10, offset=0)
    mock_search_filter = SearchFilter(field_name={"title"}, value="test", ignore_case=False)
    mock_order_by = OrderBy(field_name="id", sort_order="asc")

    result = aggregate_func(
        id_filter=mock_id_filter,
        created_filter=mock_created_filter,
        limit_offset=mock_limit_offset,
        search_filter=mock_search_filter,
        order_by=mock_order_by,
    )

    assert isinstance(result, list)
    assert len(result) == 5
    assert mock_id_filter in result
    assert mock_created_filter in result
    assert mock_limit_offset in result
    assert mock_search_filter in result
    assert mock_order_by in result

    # Test with None values for filters that can return None
    mock_search_filter_none = SearchFilter(field_name={"title"}, value=None, ignore_case=False)  # type: ignore[arg-type]
    mock_order_by_none = OrderBy(field_name=None, sort_order="asc")  # type: ignore[arg-type]

    result_some_none = aggregate_func(
        id_filter=None,  # Simulate no 'ids' param provided
        created_filter=None,  # Simulate no date params provided
        limit_offset=mock_limit_offset,  # Always present
        search_filter=mock_search_filter_none,  # Aggregate func should filter this out based on value=None
        order_by=mock_order_by_none,  # Aggregate func should filter this out based on field_name=None
    )
    assert len(result_some_none) == 1  # Only LimitOffset should remain
    assert mock_limit_offset in result_some_none
    assert mock_id_filter not in result_some_none
    assert mock_created_filter not in result_some_none
    assert mock_search_filter not in result_some_none
    assert mock_order_by not in result_some_none


# --- Main create_filter_dependencies Tests --- #


def test_create_filter_dependencies_empty_config() -> None:
    """Test create_filter_dependencies with an empty config."""
    deps = provide_filters({})  # type: ignore
    assert callable(deps)


def test_create_filter_dependencies_all_filters() -> None:
    """Test create_filter_dependencies enabling all filters returns the aggregate."""

    dep_cache.dependencies.clear()
    deps = provide_filters(
        {
            "id_filter": UUID,
            "created_at": True,
            "updated_at": True,
            "pagination_type": "limit_offset",
            "search": "name, description",
            "sort_field": "created_at",
        }
    )
    assert callable(deps)

    sig = inspect.signature(deps)
    assert DEPENDENCY_DEFAULTS.ID_FILTER_DEPENDENCY_KEY in sig.parameters
    assert DEPENDENCY_DEFAULTS.CREATED_FILTER_DEPENDENCY_KEY in sig.parameters
    assert DEPENDENCY_DEFAULTS.UPDATED_FILTER_DEPENDENCY_KEY in sig.parameters
    assert DEPENDENCY_DEFAULTS.LIMIT_OFFSET_FILTER_DEPENDENCY_KEY in sig.parameters
    assert DEPENDENCY_DEFAULTS.SEARCH_FILTER_DEPENDENCY_KEY in sig.parameters
    assert DEPENDENCY_DEFAULTS.ORDER_BY_FILTER_DEPENDENCY_KEY in sig.parameters


# --- Integration Test with OpenAPI Schema Check --- #


def test_create_filter_dependencies_integration_and_openapi() -> None:
    """Test integration with FastAPI using TestClient and check OpenAPI schema."""

    # Clear cache before test
    dep_cache.dependencies.clear()
    deps = provide_filters(
        {
            "id_filter": UUID,
            "pagination_type": "limit_offset",
            "search": "name",
            "sort_field": "name",  # Enables OrderBy
            "created_at": True,
        }
    )

    app = FastAPI()

    @app.get("/items")
    async def get_items(filters: Annotated[list[FilterTypes], Depends(deps)]) -> list[str]:
        # Return simple representation for verification
        return [type(f).__name__ for f in filters]

    client = TestClient(app)

    # === Runtime Test ===
    # Restore original client.get call
    # Test case: Apply multiple filters
    response = client.get(
        "/items?ids=123e4567-e89b-12d3-a456-426614174000&ids=123e4567-e89b-12d3-a456-426614174001¤tPage=2&pageSize=5&searchString=apple&orderBy=name&sortOrder=asc&createdAfter=2023-01-01T00:00:00Z"
    )
    assert response.status_code == 200
    data = cast(list[str], response.json())
    assert isinstance(data, list)
    # Check that all expected filter types were created and collected
    assert "CollectionFilter" in data
    assert "LimitOffset" in data
    assert "SearchFilter" in data
    assert "OrderBy" in data
    assert "BeforeAfter" in data
    assert len(data) == 5

    # Test case: Only defaults (expect LimitOffset, OrderBy)
    response = client.get("/items")
    assert response.status_code == 200
    data = cast(list[str], response.json())
    assert isinstance(data, list)
    assert "LimitOffset" in data
    assert "OrderBy" in data
    assert "CollectionFilter" not in data
    assert "SearchFilter" not in data
    assert "BeforeAfter" not in data
    assert len(data) == 2

    # === OpenAPI Schema Test ===
    schema = client.get("/openapi.json").json()

    # Check parameters for the /items endpoint
    path_item = schema.get("paths", {}).get("/items", {}).get("get", {})
    parameters = path_item.get("parameters", [])

    # Verify parameters from each configured filter type are present
    param_names = {p["name"] for p in parameters}

    # Expected params based on config:
    # id_filter -> ids
    # pagination -> currentPage, pageSize
    # search -> searchString, searchIgnoreCase
    # sort_field -> orderBy, sortOrder
    # created_at -> createdBefore, createdAfter
    expected_params = {
        "ids",
        "currentPage",
        "pageSize",
        "searchString",
        "searchIgnoreCase",
        "orderBy",
        "sortOrder",
        "createdBefore",
        "createdAfter",
    }

    assert param_names == expected_params

    # Optionally, check details of a specific parameter
    ids_param = next((p for p in parameters if p["name"] == "ids"), None)
    assert ids_param is not None
    assert ids_param["in"] == "query"
    assert ids_param["required"] is False
    # Check schema structure for array type in anyOf
    assert "anyOf" in ids_param["schema"]
    array_schema = next(s for s in ids_param["schema"]["anyOf"] if s.get("type") == "array")
    assert array_schema["type"] == "array"
    assert array_schema["items"]["type"] == "string"

    page_size_param = next((p for p in parameters if p["name"] == "pageSize"), None)
    assert page_size_param is not None
    assert page_size_param["in"] == "query"
    assert page_size_param["required"] is False
    assert page_size_param["schema"]["type"] == "integer"
    assert page_size_param["schema"]["default"] == 20  # Default from DependencyDefaults


# Custom Defaults Test (remains largely the same logic)
def test_custom_dependency_defaults_fastapi() -> None:
    """Test using custom dependency defaults with FastAPI provider."""

    class CustomDefaults(DependencyDefaults):
        LIMIT_OFFSET_FILTER_DEPENDENCY_KEY = "paging"
        ORDER_BY_FILTER_DEPENDENCY_KEY = "ordering"
        DEFAULT_PAGINATION_SIZE = 5

    custom_defaults = CustomDefaults()
    config = cast(
        FilterConfig,
        {
            "id_filter": UUID,
            "pagination_type": "limit_offset",
            "sort_field": "name",  # uses custom ORDER_BY_DEPENDENCY_KEY
        },
    )

    # Clear cache before test
    dep_cache.dependencies.clear()
    deps = provide_filters(config, dep_defaults=custom_defaults)

    sig = inspect.signature(deps)
    assert "id_filter" in sig.parameters  # Uses standard key if not overridden
    assert "paging" in sig.parameters  # Custom key used for limit/offset param name
    assert "ordering" in sig.parameters  # Custom key used for order by param name

    # Test integration and OpenAPI with custom defaults
    app = FastAPI()

    @app.get("/custom")
    async def get_custom(filters: Annotated[list[FilterTypes], Depends(deps)] = []) -> list[str]:
        return [type(f).__name__ for f in filters]

    client = TestClient(app)

    # Restore original client.get call
    response = client.get(
        "/custom?ids=123e4567-e89b-12d3-a456-426614174000¤tPage=3&orderBy=id"
    )  # pageSize defaults to 5 (CustomDefaults)

    assert response.status_code == 200
    data = cast(list[str], response.json())
    assert isinstance(data, list)
    assert "CollectionFilter" in data
    assert "LimitOffset" in data
    assert "OrderBy" in data
    assert len(data) == 3

    # Check OpenAPI uses custom default page size
    schema = client.get("/openapi.json").json()
    custom_path_item = schema.get("paths", {}).get("/custom", {}).get("get", {})
    custom_parameters = custom_path_item.get("parameters", [])

    custom_page_size_param = next((p for p in custom_parameters if p["name"] == "pageSize"), None)
    assert custom_page_size_param is not None
    assert custom_page_size_param["schema"]["default"] == 5  # Custom default


def test_openapi_schema_comprehensive() -> None:
    """Test comprehensive filter generation with all filter types."""
    # Create a filter configuration with all supported filter types
    deps = provide_filters(
        {
            "id_filter": UUID,
            "created_at": True,
            "updated_at": True,
            "pagination_type": "limit_offset",
            "search": "name,description,email",  # Multiple search fields
            "sort_field": {"name", "created_at", "email"},  # Multiple sort fields (using set)
            "not_in_fields": {FieldNameType("status", str), FieldNameType("category", str)},  # Not-in fields
            "in_fields": {FieldNameType("tag", str), FieldNameType("author_id", str)},  # In fields
        }
    )

    # Check the signature of the generated function to ensure it has all required parameters
    sig = inspect.signature(deps)
    param_names = set(sig.parameters.keys())
    expected_param_names = {
        "id_filter",
        "created_filter",
        "updated_filter",
        "limit_offset_filter",
        "search_filter",
        "order_by_filter",
        "status_not_in_filter",
        "category_not_in_filter",
        "tag_in_filter",
        "author_id_in_filter",
    }
    assert param_names == expected_param_names

    # Test that function parameters have the correct types and defaults
    for name, param in sig.parameters.items():
        # Check types for known parameters
        if name == "id_filter":
            assert "UUID" in str(param.annotation), f"id_filter should have UUID type, got {param.annotation}"
        elif name in ("created_filter", "updated_filter"):
            assert "BeforeAfter" in str(param.annotation), (
                f"{name} should have BeforeAfter type, got {param.annotation}"
            )
        elif name == "limit_offset":
            assert "LimitOffset" in str(param.annotation), (
                f"limit_offset should have LimitOffset type, got {param.annotation}"
            )
        elif name == "search_filter":
            assert "SearchFilter" in str(param.annotation), (
                f"search_filter should have SearchFilter type, got {param.annotation}"
            )
        elif name == "order_by":
            assert "OrderBy" in str(param.annotation), f"order_by should have OrderBy type, got {param.annotation}"
        elif "not_in" in name:
            assert "NotInCollectionFilter" in str(param.annotation), (
                f"{name} should have NotInCollectionFilter type, got {param.annotation}"
            )
        elif "in_filter" in name:
            assert "CollectionFilter" in str(param.annotation), (
                f"{name} should have CollectionFilter type, got {param.annotation}"
            )

    # Check that the return annotation is list[FilterTypes]
    assert deps.__annotations__["return"] == list[FilterTypes]

    # Test direct call with filter values
    mock_id_filter = CollectionFilter(field_name="id", values=["123e4567-e89b-12d3-a456-426614174000"])
    mock_created_filter = BeforeAfter(field_name="created_at", before=datetime.now(), after=None)
    mock_limit_offset = LimitOffset(limit=10, offset=0)
    mock_search_filter = SearchFilter(field_name={"name"}, value="test", ignore_case=False)
    mock_order_by = OrderBy(field_name="name", sort_order="asc")

    result = deps(
        id_filter=mock_id_filter,
        created_filter=mock_created_filter,
        updated_filter=None,
        limit_offset=mock_limit_offset,
        search_filter=mock_search_filter,
        order_by=mock_order_by,
        status_not_in_filter=None,
        category_not_in_filter=None,
        tag_in_filter=None,
        author_id_in_filter=None,
    )

    # Verify that the results contain the expected filter objects
    assert isinstance(result, list)
    assert len(result) == 5
    assert mock_id_filter in result
    assert mock_created_filter in result
    assert mock_limit_offset in result
    assert mock_search_filter in result
    assert mock_order_by in result


def test_openapi_schema_edge_cases() -> None:
    """Test OpenAPI schema generation for edge cases and special configurations."""
    # Test with minimal configuration

    app = FastAPI()

    @app.get("/minimal")
    async def get_minimal(
        filters: Annotated[list[FilterTypes], Depends(provide_filters({"pagination_type": "limit_offset"}))],
    ) -> list[str]:
        return [type(f).__name__ for f in filters]

    # Test with all optional filters disabled
    @app.get("/no-optionals")
    async def get_no_optionals(
        filters: Annotated[
            list[FilterTypes],
            Depends(
                provide_filters(
                    {
                        "pagination_type": "limit_offset",
                        "sort_field": "id",
                    },
                ),
            ),
        ],
    ) -> list[str]:
        return [type(f).__name__ for f in filters]

    # Test with custom validation
    @app.get("/custom-validation")
    async def get_custom_validation(
        filters: Annotated[
            list[FilterTypes],
            Depends(
                provide_filters(
                    {
                        "id_filter": UUID,
                        "pagination_type": "limit_offset",
                        "sort_field": "id",
                        "search": "email",
                        "created_at": True,
                    },
                )
            ),
        ],
    ) -> list[str]:
        return [type(f).__name__ for f in filters]

    client = TestClient(app)

    # Test minimal schema
    schema = client.get("/openapi.json").json()
    minimal_path_item = schema.get("paths", {}).get("/minimal", {}).get("get", {})
    minimal_parameters = minimal_path_item.get("parameters", [])
    assert len(minimal_parameters) == 2  # Only pagination params
    assert {p["name"] for p in minimal_parameters} == {"currentPage", "pageSize"}

    # Test no optionals schema
    no_optionals_path_item = schema.get("paths", {}).get("/no-optionals", {}).get("get", {})
    no_optionals_parameters = no_optionals_path_item.get("parameters", [])
    assert len(no_optionals_parameters) == 4  # Pagination + sort params
    assert {p["name"] for p in no_optionals_parameters} >= {"currentPage", "pageSize", "orderBy", "sortOrder"}

    # Test custom validation schema
    custom_validation_path_item = schema.get("paths", {}).get("/custom-validation", {}).get("get", {})
    custom_validation_parameters = custom_validation_path_item.get("parameters", [])
    assert len(custom_validation_parameters) == 9  # All configured params
    assert {p["name"] for p in custom_validation_parameters} >= {
        "ids",
        "currentPage",
        "pageSize",
        "orderBy",
        "sortOrder",
        "searchString",
        "searchIgnoreCase",
        "createdBefore",
        "createdAfter",
    }

    # Test runtime behavior for minimal config
    response = client.get("/minimal")
    assert response.status_code == 200
    data = response.json()
    assert isinstance(data, list)
    assert len(data) == 1  # type: ignore[arg-type]
    assert "LimitOffset" in data

    # Test runtime behavior for no optionals
    response = client.get("/no-optionals")
    assert response.status_code == 200
    data = response.json()
    assert isinstance(data, list)
    assert len(data) == 2  # type: ignore[arg-type]
    assert "LimitOffset" in data
    assert "OrderBy" in data

    # Test runtime behavior for custom validation
    response = client.get(
        "/custom-validation?ids=123e4567-e89b-12d3-a456-426614174000¤tPage=2&pageSize=5&orderBy=id&searchString=test&createdAfter=2023-01-01T00:00:00Z"
    )
    assert response.status_code == 200
    data = response.json()
    assert isinstance(data, list)
    assert len(data) == 5  # type: ignore[arg-type]
    assert "CollectionFilter" in data
    assert "LimitOffset" in data
    assert "OrderBy" in data
    assert "SearchFilter" in data
    assert "BeforeAfter" in data


class SimpleDishkaTable(UUIDBase):
    name: Mapped[str] = mapped_column(String(length=50), index=True)


class SimpleDishkaService(SQLAlchemyAsyncRepositoryService[SimpleDishkaTable]):
    class Repo(SQLAlchemyAsyncRepository[SimpleDishkaTable]):
        model_type = SimpleDishkaTable

    repository_type = Repo


@pytest.mark.skipif(sys.version_info < (3, 10), reason="Dishka integration requires Python 3.10+")
async def test_provide_filters_with_dishka_integration(monkeypatch: pytest.MonkeyPatch) -> None:
    """Test provide_filters integration with FastAPI and Dishka."""
    from dishka import (  # type: ignore # pyright: ignore
        Provider,  # type: ignore
        Scope,  # type: ignore
        make_async_container,  # type: ignore
        provide,  # type: ignore
    )
    from dishka.integrations.fastapi import (  # type: ignore # pyright: ignore
        FastapiProvider,  # type: ignore
        FromDishka,  # type: ignore
        inject,  # type: ignore
        setup_dishka,  # type: ignore
    )

    # Clear cache before test
    dep_cache.dependencies.clear()
    sqlalchemy_config = SQLAlchemyAsyncConfig(connection_string="sqlite+aiosqlite:///:memory:")

    class SimpleDishkaProvider(Provider):  # type: ignore
        @provide(scope=Scope.REQUEST)  # type: ignore
        async def provide_session(self, request: Request) -> AsyncGenerator[AsyncSession, None]:
            async with sqlalchemy_config.get_session() as session:
                yield session

        @provide(scope=Scope.REQUEST)  # type: ignore
        async def provide_simple_dishka_service(self, db_session: FromDishka[AsyncSession]) -> SimpleDishkaService:  # type: ignore
            return SimpleDishkaService(session=db_session)  # type: ignore

    filter_deps = provide_filters(
        {
            "id_filter": UUID,
            "pagination_type": "limit_offset",
            "search": "name",
            "created_at": True,
        }
    )

    app = FastAPI()
    container = make_async_container(SimpleDishkaProvider(), FastapiProvider())  # type: ignore
    setup_dishka(container=container, app=app)

    @app.get("/diska-items")
    @inject  # pyright: ignore
    async def get_diska_items(
        filters: Annotated[list[FilterTypes], Depends(filter_deps)],
        simple_model_service: FromDishka[SimpleDishkaService],  # type: ignore
    ) -> dict[str, typing.Any]:
        # Return filter types and dummy service value for verification
        return {
            "filters": [type(f).__name__ for f in filters],
            "simple_model_table_name": simple_model_service.model_type.__tablename__,
        }

    client = TestClient(app)

    # Test case: Apply multiple filters
    response = client.get(
        "/diska-items?ids=123e4567-e89b-12d3-a456-426614174000¤tPage=1&pageSize=10&searchString=test&createdAfter=2023-01-01T00:00:00Z"
    )
    assert response.status_code == 200
    data = response.json()
    assert isinstance(data, dict)
    assert data["simple_model_table_name"] == "simple_dishka_table"
    filter_types = data.get("filters", [])  # type: ignore
    assert isinstance(filter_types, list)
    assert "CollectionFilter" in filter_types
    assert "LimitOffset" in filter_types
    assert "SearchFilter" in filter_types
    assert "BeforeAfter" in filter_types
    # OrderBy is not explicitly configured but might have defaults, let's check if it's NOT there unless configured
    assert "OrderBy" not in filter_types  # OrderBy was not configured in this specific provide_filters call
    assert len(filter_types) == 4  # type: ignore

    # Test case: Only defaults (expect LimitOffset)
    response = client.get("/diska-items")
    assert response.status_code == 200
    data = response.json()
    assert isinstance(data, dict)
    assert data["simple_model_table_name"] == "simple_dishka_table"
    filter_types = data.get("filters", [])  # type: ignore
    assert isinstance(filter_types, list)
    assert "LimitOffset" in filter_types  # Default pagination
    assert "CollectionFilter" not in filter_types
    assert "SearchFilter" not in filter_types
    assert "BeforeAfter" not in filter_types
    assert "OrderBy" not in filter_types
    assert len(filter_types) == 1  # type: ignore

    await container.close()
python-advanced-alchemy-1.9.3/tests/unit/test_extensions/test_fastapi/test_session_lifecycle.py000066400000000000000000000532041516556515500334320ustar00rootroot00000000000000"""Tests for session lifecycle with generator-managed dependencies.

This module tests the fix for GitHub issue #647 where asyncpg connections
were not properly returned to the pool when using provide_service().
"""

import sys
from typing import Annotated, Literal
from unittest.mock import AsyncMock, Mock

import pytest
from fastapi import Depends, FastAPI, Request, Response
from fastapi.testclient import TestClient
from pytest_mock import MockerFixture
from sqlalchemy import String
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, Session, mapped_column

from advanced_alchemy.base import UUIDBase
from advanced_alchemy.extensions.fastapi import AdvancedAlchemy, SQLAlchemyAsyncConfig, SQLAlchemySyncConfig
from advanced_alchemy.extensions.fastapi.providers import _should_commit_for_status
from advanced_alchemy.repository import SQLAlchemyAsyncRepository, SQLAlchemySyncRepository
from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService, SQLAlchemySyncRepositoryService

pytestmark = pytest.mark.xfail(
    condition=sys.version_info < (3, 9),
    reason=(
        "Certain versions of Starlette and FastAPI are stated to still support 3.8, "
        "but there are documented incompatibilities."
    ),
)

CommitMode = Literal["manual", "autocommit", "autocommit_include_redirect"]


class Widget(UUIDBase):
    """Minimal model for dependency tests."""

    __tablename__ = "fastapi_session_widget"

    name: Mapped[str] = mapped_column(String(length=50))


class WidgetAsyncRepository(SQLAlchemyAsyncRepository[Widget]):
    """Async repository for Widget."""

    model_type = Widget


class WidgetAsyncService(SQLAlchemyAsyncRepositoryService[Widget, WidgetAsyncRepository]):
    """Async service for Widget."""

    repository_type = WidgetAsyncRepository


class WidgetSyncRepository(SQLAlchemySyncRepository[Widget]):
    """Sync repository for Widget."""

    model_type = Widget


class WidgetSyncService(SQLAlchemySyncRepositoryService[Widget, WidgetSyncRepository]):
    """Sync service for Widget."""

    repository_type = WidgetSyncRepository


def _make_async_app(commit_mode: CommitMode = "manual") -> tuple[FastAPI, SQLAlchemyAsyncConfig, AdvancedAlchemy]:
    app = FastAPI()
    config = SQLAlchemyAsyncConfig(
        connection_string="sqlite+aiosqlite:///:memory:",
        commit_mode=commit_mode,
    )
    alchemy = AdvancedAlchemy(config=config, app=app)
    return app, config, alchemy


def _make_sync_app(commit_mode: CommitMode = "manual") -> tuple[FastAPI, SQLAlchemySyncConfig, AdvancedAlchemy]:
    app = FastAPI()
    config = SQLAlchemySyncConfig(
        connection_string="sqlite:///:memory:",
        commit_mode=commit_mode,
    )
    alchemy = AdvancedAlchemy(config=config, app=app)
    return app, config, alchemy


def _patch_async_session_methods(mocker: MockerFixture) -> tuple[AsyncMock, AsyncMock, AsyncMock]:
    commit = mocker.patch("sqlalchemy.ext.asyncio.AsyncSession.commit", new_callable=AsyncMock)
    rollback = mocker.patch("sqlalchemy.ext.asyncio.AsyncSession.rollback", new_callable=AsyncMock)
    close = mocker.patch("sqlalchemy.ext.asyncio.AsyncSession.close", new_callable=AsyncMock)
    return commit, rollback, close


def _patch_sync_session_methods(mocker: MockerFixture) -> tuple[Mock, Mock, Mock]:
    commit = mocker.patch("sqlalchemy.orm.Session.commit")
    rollback = mocker.patch("sqlalchemy.orm.Session.rollback")
    close = mocker.patch("sqlalchemy.orm.Session.close")
    return commit, rollback, close


@pytest.mark.parametrize("status_code", [200, 201, 202, 204, 299])
def test_should_commit_autocommit_on_2xx(status_code: int) -> None:
    """Verify autocommit mode commits on 2xx status codes."""
    assert _should_commit_for_status(status_code, "autocommit") is True


@pytest.mark.parametrize("status_code", [300, 301, 302, 399, 400, 401, 500])
def test_should_commit_autocommit_no_commit_on_non_2xx(status_code: int) -> None:
    """Verify autocommit mode does not commit on non-2xx status codes."""
    assert _should_commit_for_status(status_code, "autocommit") is False


@pytest.mark.parametrize("status_code", [200, 201, 299, 300, 301, 399])
def test_should_commit_autocommit_include_redirect_on_2xx_3xx(status_code: int) -> None:
    """Verify autocommit_include_redirect mode commits on 2xx and 3xx status codes."""
    assert _should_commit_for_status(status_code, "autocommit_include_redirect") is True


@pytest.mark.parametrize("status_code", [400, 401, 404, 500, 503])
def test_should_commit_autocommit_include_redirect_no_commit_on_4xx_5xx(status_code: int) -> None:
    """Verify autocommit_include_redirect mode does not commit on 4xx/5xx status codes."""
    assert _should_commit_for_status(status_code, "autocommit_include_redirect") is False


@pytest.mark.parametrize("status_code", [200, 201, 300, 400, 500])
def test_should_commit_manual_never_commits(status_code: int) -> None:
    """Verify manual mode never commits regardless of status code."""
    assert _should_commit_for_status(status_code, "manual") is False


def test_generator_session_marked_as_managed() -> None:
    """Verify generator-managed sessions mark request state."""
    app, config, alchemy = _make_async_app()

    @app.get("/")
    def handler(
        request: Request,
        service: Annotated[WidgetAsyncService, Depends(alchemy.provide_service(WidgetAsyncService))],
    ) -> dict[str, bool]:
        key = f"{config.session_key}_generator_managed"
        return {"managed": hasattr(request.state, key)}

    with TestClient(app=app) as client:
        response = client.get("/")
        assert response.status_code == 200
        assert response.json() == {"managed": True}


def test_middleware_skips_generator_managed_sessions(mocker: MockerFixture) -> None:
    """Verify middleware does not close generator-managed sessions."""
    app, config, alchemy = _make_async_app(commit_mode="autocommit")
    mock_handler = mocker.patch.object(config, "session_handler", new_callable=AsyncMock)

    @app.get("/")
    def handler(
        service: Annotated[WidgetAsyncService, Depends(alchemy.provide_service(WidgetAsyncService))],
    ) -> Response:
        return Response(status_code=200)

    with TestClient(app=app) as client:
        response = client.get("/")
        assert response.status_code == 200

    mock_handler.assert_not_awaited()


def test_generator_session_closed_after_cleanup(mocker: MockerFixture) -> None:
    """Verify generator-managed sessions close in the dependency finally block."""
    app, config, alchemy = _make_async_app(commit_mode="autocommit")
    mock_handler = mocker.patch.object(config, "session_handler", new_callable=AsyncMock)
    _commit, _rollback, close = _patch_async_session_methods(mocker)

    @app.get("/")
    def handler(
        service: Annotated[WidgetAsyncService, Depends(alchemy.provide_service(WidgetAsyncService))],
    ) -> Response:
        return Response(status_code=200)

    with TestClient(app=app) as client:
        response = client.get("/")
        assert response.status_code == 200

    close.assert_awaited_once()
    mock_handler.assert_not_awaited()


@pytest.mark.parametrize(
    ("status_code", "commit_mode", "should_commit"),
    [
        (200, "autocommit", True),
        (201, "autocommit", True),
        (299, "autocommit", True),
        (300, "autocommit", False),
        (400, "autocommit", False),
        (200, "autocommit_include_redirect", True),
        (301, "autocommit_include_redirect", True),
        (399, "autocommit_include_redirect", True),
        (400, "autocommit_include_redirect", False),
        (200, "manual", False),
    ],
)
def test_async_generator_commit_strategy(
    mocker: MockerFixture,
    status_code: int,
    commit_mode: CommitMode,
    should_commit: bool,
) -> None:
    """Verify async generator-managed commit strategy matches commit_mode and status."""
    app, config, alchemy = _make_async_app(commit_mode=commit_mode)
    mock_handler = mocker.patch.object(config, "session_handler", new_callable=AsyncMock)
    commit, rollback, close = _patch_async_session_methods(mocker)

    @app.get("/")
    def handler(
        service: Annotated[WidgetAsyncService, Depends(alchemy.provide_service(WidgetAsyncService))],
    ) -> Response:
        return Response(status_code=status_code)

    with TestClient(app=app) as client:
        response = client.get("/")
        assert response.status_code == status_code

    if should_commit:
        commit.assert_awaited_once()
        rollback.assert_not_awaited()
    else:
        rollback.assert_awaited_once()
        commit.assert_not_awaited()

    close.assert_awaited_once()
    mock_handler.assert_not_awaited()


def test_async_generator_exception_triggers_rollback(mocker: MockerFixture) -> None:
    """Verify exceptions in handler trigger rollback in generator cleanup."""
    app, config, alchemy = _make_async_app(commit_mode="autocommit")
    mock_handler = mocker.patch.object(config, "session_handler", new_callable=AsyncMock)
    commit, rollback, close = _patch_async_session_methods(mocker)

    @app.get("/")
    def handler(
        service: Annotated[WidgetAsyncService, Depends(alchemy.provide_service(WidgetAsyncService))],
    ) -> Response:
        raise RuntimeError("boom")

    client = TestClient(app=app, raise_server_exceptions=False)
    response = client.get("/")
    assert response.status_code == 500

    commit.assert_not_awaited()
    rollback.assert_awaited_once()
    close.assert_awaited_once()
    mock_handler.assert_not_awaited()


@pytest.mark.parametrize(
    ("status_code", "commit_mode", "should_commit"),
    [
        (200, "autocommit", True),
        (400, "autocommit", False),
        (200, "manual", False),
    ],
)
def test_sync_generator_commit_strategy(
    mocker: MockerFixture,
    status_code: int,
    commit_mode: CommitMode,
    should_commit: bool,
) -> None:
    """Verify sync generator-managed commit strategy matches commit_mode and status."""
    app, config, alchemy = _make_sync_app(commit_mode=commit_mode)
    mock_handler = mocker.patch.object(config, "session_handler", new_callable=AsyncMock)
    commit, rollback, close = _patch_sync_session_methods(mocker)

    @app.get("/")
    def handler(
        service: Annotated[WidgetSyncService, Depends(alchemy.provide_service(WidgetSyncService))],
    ) -> Response:
        return Response(status_code=status_code)

    with TestClient(app=app) as client:
        response = client.get("/")
        assert response.status_code == status_code

    if should_commit:
        commit.assert_called_once()
        rollback.assert_not_called()
    else:
        rollback.assert_called_once()
        commit.assert_not_called()

    close.assert_called_once()
    mock_handler.assert_not_awaited()


def test_non_generator_session_uses_middleware_async(mocker: MockerFixture) -> None:
    """Verify provide_session uses middleware cleanup for async sessions."""
    app, _config, alchemy = _make_async_app(commit_mode="autocommit")
    commit, _rollback, close = _patch_async_session_methods(mocker)

    @app.get("/")
    def handler(session: Annotated[AsyncSession, Depends(alchemy.provide_session())]) -> Response:
        return Response(status_code=200)

    with TestClient(app=app) as client:
        response = client.get("/")
        assert response.status_code == 200

    commit.assert_awaited_once()
    close.assert_awaited_once()


def test_direct_session_uses_middleware_async(mocker: MockerFixture) -> None:
    """Verify direct session access uses middleware cleanup for async sessions."""
    app, _config, alchemy = _make_async_app(commit_mode="autocommit")
    commit, _rollback, close = _patch_async_session_methods(mocker)

    @app.get("/")
    def handler(request: Request) -> Response:
        _session = alchemy.get_session(request)
        return Response(status_code=200)

    with TestClient(app=app) as client:
        response = client.get("/")
        assert response.status_code == 200

    commit.assert_awaited_once()
    close.assert_awaited_once()


def test_non_generator_session_uses_middleware_sync(mocker: MockerFixture) -> None:
    """Verify provide_session uses middleware cleanup for sync sessions."""
    app, _config, alchemy = _make_sync_app(commit_mode="autocommit")
    commit, _rollback, close = _patch_sync_session_methods(mocker)

    @app.get("/")
    def handler(session: Annotated[Session, Depends(alchemy.provide_session())]) -> Response:
        return Response(status_code=200)

    with TestClient(app=app) as client:
        response = client.get("/")
        assert response.status_code == 200

    commit.assert_called_once()
    close.assert_called_once()


def test_direct_session_uses_middleware_sync(mocker: MockerFixture) -> None:
    """Verify direct session access uses middleware cleanup for sync sessions."""
    app, _config, alchemy = _make_sync_app(commit_mode="autocommit")
    commit, _rollback, close = _patch_sync_session_methods(mocker)

    @app.get("/")
    def handler(request: Request) -> Response:
        _session = alchemy.get_session(request)
        return Response(status_code=200)

    with TestClient(app=app) as client:
        response = client.get("/")
        assert response.status_code == 200

    commit.assert_called_once()
    close.assert_called_once()


def test_async_generator_exception_preserves_original_error(mocker: MockerFixture) -> None:
    """Verify original exception type and message are preserved through provide_service generator."""
    app, _config, alchemy = _make_async_app(commit_mode="autocommit")
    _commit, rollback, close = _patch_async_session_methods(mocker)

    @app.get("/")
    def handler(
        service: Annotated[WidgetAsyncService, Depends(alchemy.provide_service(WidgetAsyncService))],
    ) -> Response:
        raise ValueError("original error message")

    client = TestClient(app=app, raise_server_exceptions=False)
    response = client.get("/")
    assert response.status_code == 500
    rollback.assert_awaited_once()
    close.assert_awaited_once()


def test_async_generator_rollback_failure_does_not_mask_original(mocker: MockerFixture) -> None:
    """Verify that if rollback() raises, the original exception is still preserved and close still runs."""
    app, _config, alchemy = _make_async_app(commit_mode="autocommit")
    _commit, _rollback, close = _patch_async_session_methods(mocker)
    mocker.patch(
        "sqlalchemy.ext.asyncio.AsyncSession.rollback",
        new_callable=AsyncMock,
        side_effect=RuntimeError("rollback failed"),
    )

    @app.get("/")
    def handler(
        service: Annotated[WidgetAsyncService, Depends(alchemy.provide_service(WidgetAsyncService))],
    ) -> Response:
        raise ValueError("original error message")

    client = TestClient(app=app, raise_server_exceptions=False)
    response = client.get("/")
    assert response.status_code == 500
    # Session close should still happen even if rollback fails
    close.assert_awaited_once()


def test_async_generator_close_failure_does_not_mask_original(mocker: MockerFixture) -> None:
    """Verify that if close() raises, the original exception is still preserved."""
    app, _config, alchemy = _make_async_app(commit_mode="autocommit")
    _commit, rollback, _close = _patch_async_session_methods(mocker)
    mocker.patch(
        "sqlalchemy.ext.asyncio.AsyncSession.close",
        new_callable=AsyncMock,
        side_effect=RuntimeError("close failed"),
    )

    @app.get("/")
    def handler(
        service: Annotated[WidgetAsyncService, Depends(alchemy.provide_service(WidgetAsyncService))],
    ) -> Response:
        raise ValueError("original error message")

    client = TestClient(app=app, raise_server_exceptions=False)
    response = client.get("/")
    assert response.status_code == 500
    rollback.assert_awaited_once()


def test_sync_generator_rollback_failure_does_not_mask_original(mocker: MockerFixture) -> None:
    """Verify that if sync rollback() raises, the original exception is still preserved."""
    app, _config, alchemy = _make_sync_app(commit_mode="autocommit")
    _commit, _rollback, close = _patch_sync_session_methods(mocker)
    mocker.patch(
        "sqlalchemy.orm.Session.rollback",
        side_effect=RuntimeError("rollback failed"),
    )

    @app.get("/")
    def handler(
        service: Annotated[WidgetSyncService, Depends(alchemy.provide_service(WidgetSyncService))],
    ) -> Response:
        raise ValueError("original error message")

    client = TestClient(app=app, raise_server_exceptions=False)
    response = client.get("/")
    assert response.status_code == 500
    close.assert_called_once()


def test_sync_generator_close_failure_does_not_mask_original(mocker: MockerFixture) -> None:
    """Verify that if sync close() raises, the original exception is still preserved."""
    app, _config, alchemy = _make_sync_app(commit_mode="autocommit")
    _commit, rollback, _close = _patch_sync_session_methods(mocker)
    mocker.patch(
        "sqlalchemy.orm.Session.close",
        side_effect=RuntimeError("close failed"),
    )

    @app.get("/")
    def handler(
        service: Annotated[WidgetSyncService, Depends(alchemy.provide_service(WidgetSyncService))],
    ) -> Response:
        raise ValueError("original error message")

    client = TestClient(app=app, raise_server_exceptions=False)
    response = client.get("/")
    assert response.status_code == 500
    rollback.assert_called_once()


def test_async_pydantic_validation_error_preserved(mocker: MockerFixture) -> None:
    """Verify Pydantic ValidationError in route handler is not overridden by session state."""
    from pydantic import BaseModel

    app, _config, alchemy = _make_async_app(commit_mode="autocommit")
    _commit, rollback, close = _patch_async_session_methods(mocker)

    class StrictModel(BaseModel):
        value: int

    @app.post("/")
    def handler(
        service: Annotated[WidgetAsyncService, Depends(alchemy.provide_service(WidgetAsyncService))],
    ) -> dict[str, str]:
        # This will raise pydantic.ValidationError because "not_a_number" is not int
        StrictModel(value="not_a_number")  # type: ignore[arg-type]
        return {"ok": "true"}

    client = TestClient(app=app, raise_server_exceptions=False)
    response = client.post("/")
    assert response.status_code == 500
    # The key check: session was cleaned up properly
    rollback.assert_awaited_once()
    close.assert_awaited_once()


def test_async_cleanup_exception_suppressed_when_original_exists(mocker: MockerFixture) -> None:
    """Verify cleanup exceptions are always suppressed when an original exception exists."""
    app, _config, alchemy = _make_async_app(commit_mode="autocommit")
    _commit, _rollback, close = _patch_async_session_methods(mocker)
    mocker.patch(
        "sqlalchemy.ext.asyncio.AsyncSession.rollback",
        new_callable=AsyncMock,
        side_effect=RuntimeError("rollback failed"),
    )

    @app.get("/")
    def handler(
        service: Annotated[
            WidgetAsyncService,
            Depends(alchemy.provide_service(WidgetAsyncService)),
        ],
    ) -> Response:
        raise ValueError("original error")

    client = TestClient(app=app, raise_server_exceptions=False)
    response = client.get("/")
    # Cleanup exception is suppressed; original error produces 500
    assert response.status_code == 500
    # Session still closed despite rollback failure
    close.assert_awaited_once()


def test_async_middleware_captures_status_from_successful_response() -> None:
    """Verify the pure ASGI middleware correctly captures response status code."""
    app, _config, alchemy = _make_async_app(commit_mode="autocommit")

    @app.get("/")
    def handler(
        request: Request,
        service: Annotated[WidgetAsyncService, Depends(alchemy.provide_service(WidgetAsyncService))],
    ) -> Response:
        return Response(status_code=201)

    # Use a middleware to capture what status was stored

    with TestClient(app=app) as client:
        response = client.get("/")
        assert response.status_code == 201


def test_async_middleware_handles_app_exception_without_swallowing() -> None:
    """Verify middleware sets response_status=500 on exception and re-raises."""
    app, _config, alchemy = _make_async_app(commit_mode="manual")

    @app.get("/")
    def handler(
        service: Annotated[WidgetAsyncService, Depends(alchemy.provide_service(WidgetAsyncService))],
    ) -> Response:
        raise RuntimeError("boom")

    client = TestClient(app=app, raise_server_exceptions=False)
    response = client.get("/")
    # FastAPI's exception handler should catch this and return 500
    assert response.status_code == 500


def test_multiple_generator_sessions_tracked_independently() -> None:
    """Verify multiple configs track generator-managed sessions separately."""
    app = FastAPI()
    config_one = SQLAlchemyAsyncConfig(
        connection_string="sqlite+aiosqlite:///:memory:",
        bind_key="db1",
        session_key="db1_session",
    )
    config_two = SQLAlchemyAsyncConfig(
        connection_string="sqlite+aiosqlite:///:memory:",
        bind_key="db2",
        session_key="db2_session",
    )
    alchemy = AdvancedAlchemy(config=[config_one, config_two], app=app)

    @app.get("/")
    def handler(
        request: Request,
        service_one: Annotated[WidgetAsyncService, Depends(alchemy.provide_service(WidgetAsyncService, key="db1"))],
        service_two: Annotated[WidgetAsyncService, Depends(alchemy.provide_service(WidgetAsyncService, key="db2"))],
    ) -> dict[str, bool]:
        return {
            "db1": hasattr(request.state, f"{config_one.session_key}_generator_managed"),
            "db2": hasattr(request.state, f"{config_two.session_key}_generator_managed"),
        }

    with TestClient(app=app) as client:
        response = client.get("/")
        assert response.status_code == 200
        assert response.json() == {"db1": True, "db2": True}
python-advanced-alchemy-1.9.3/tests/unit/test_extensions/test_flask.py000066400000000000000000000615071516556515500263470ustar00rootroot00000000000000"""Tests for the Flask extension."""

from __future__ import annotations

import warnings
from collections.abc import Generator, Sequence
from pathlib import Path
from typing import cast

import pytest
from flask import Flask, Response
from msgspec import Struct
from pydantic import BaseModel
from sqlalchemy import String, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column

from advanced_alchemy import base, mixins
from advanced_alchemy._listeners import is_async_context
from advanced_alchemy.exceptions import ImproperConfigurationError
from advanced_alchemy.extensions.flask import (
    AdvancedAlchemy,
    FlaskServiceMixin,
    SQLAlchemyAsyncConfig,
    SQLAlchemySyncConfig,
)
from advanced_alchemy.repository import SQLAlchemyAsyncRepository, SQLAlchemySyncRepository
from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService, SQLAlchemySyncRepositoryService

metadata = base.metadata_registry.get("flask_testing")


class NewBigIntBase(mixins.BigIntPrimaryKey, base.CommonTableAttributes, DeclarativeBase):
    """Base model with a big integer primary key."""

    __metadata__ = metadata


class User(NewBigIntBase):
    """Test user model."""

    __tablename__ = "users_testing"

    name: Mapped[str] = mapped_column(String(50))


class UserSchema(Struct):
    """Test user pydantic model."""

    name: str


class UserPydantic(BaseModel):
    """Test user pydantic model."""

    name: str


class UserService(SQLAlchemySyncRepositoryService[User], FlaskServiceMixin):
    """Test user service."""

    class Repo(SQLAlchemySyncRepository[User]):
        model_type = User

    repository_type = Repo


class AsyncUserService(SQLAlchemyAsyncRepositoryService[User], FlaskServiceMixin):
    """Test user service."""

    class Repo(SQLAlchemyAsyncRepository[User]):
        model_type = User

    repository_type = Repo


@pytest.fixture(scope="session")
def tmp_path_session(tmp_path_factory: pytest.TempPathFactory) -> Path:
    return cast("Path", tmp_path_factory.mktemp("test_extensions_flask"))


@pytest.fixture(scope="session")
def setup_database(tmp_path_session: Path) -> Generator[Path, None, None]:
    # Create a new database for each test
    db_path = tmp_path_session / "test.db"
    config = SQLAlchemySyncConfig(connection_string=f"sqlite:///{db_path}", metadata=metadata)
    engine = config.get_engine()
    User._sa_registry.metadata.create_all(engine)  # pyright: ignore[reportPrivateUsage]
    with config.get_session() as session:
        assert isinstance(session, Session)
        table_exists = session.execute(text("SELECT COUNT(*) FROM users_testing")).scalar_one()
    assert table_exists >= 0
    yield db_path


def test_sync_extension_init(setup_database: Path) -> None:
    app = Flask(__name__)

    with app.app_context():
        config = SQLAlchemySyncConfig(connection_string=f"sqlite:///{setup_database}", metadata=metadata)
        extension = AdvancedAlchemy(config, app)
        assert "advanced_alchemy" in app.extensions
        assert isinstance(extension, AdvancedAlchemy)
        session = extension.get_sync_session()
        assert is_async_context() is False
        assert isinstance(session, Session)


def test_sync_extension_init_with_app(setup_database: Path) -> None:
    app = Flask(__name__)

    with app.app_context():
        config = SQLAlchemySyncConfig(connection_string=f"sqlite:///{setup_database}", metadata=metadata)
        extension = AdvancedAlchemy(config, app)
        assert "advanced_alchemy" in app.extensions
        assert isinstance(extension, AdvancedAlchemy)
        session = extension.get_sync_session()
        assert is_async_context() is False
        assert isinstance(session, Session)


def test_sync_extension_multiple_init(setup_database: Path) -> None:
    app = Flask(__name__)

    with (
        app.app_context(),
        pytest.raises(ImproperConfigurationError, match="Advanced Alchemy extension is already registered"),
    ):
        config = SQLAlchemySyncConfig(connection_string=f"sqlite:///{setup_database}", metadata=metadata)
        extension = AdvancedAlchemy(config, app)
        extension.init_app(app)


def test_async_extension_init(setup_database: Path) -> None:
    app = Flask(__name__)

    with app.app_context():
        config = SQLAlchemyAsyncConfig(
            bind_key="async", connection_string=f"sqlite+aiosqlite:///{setup_database}", metadata=metadata
        )
        extension = AdvancedAlchemy(config, app)
        assert "advanced_alchemy" in app.extensions
        session = extension.get_session("async")
        assert isinstance(session, AsyncSession)
        # Note: is_async_context() is deprecated since v1.9.0. The actual return value
        # depends on whether an event loop is running in the calling thread.
        # Flask's portal provider runs async in a separate thread, so the calling thread
        # may not have an event loop. We just verify the deprecation warning is raised.
        with warnings.catch_warnings(record=True) as w:
            warnings.simplefilter("always")
            is_async_context()
            assert len(w) >= 1
            assert any("deprecated" in str(warning.message).lower() for warning in w)
        extension.portal_provider.stop()


def test_async_extension_init_single_config_no_bind_key(setup_database: Path) -> None:
    app = Flask(__name__)

    with app.app_context():
        config = SQLAlchemyAsyncConfig(connection_string=f"sqlite+aiosqlite:///{setup_database}", metadata=metadata)
        extension = AdvancedAlchemy(config, app)
        assert "advanced_alchemy" in app.extensions
        session = extension.get_session()
        assert isinstance(session, AsyncSession)
        extension.portal_provider.stop()


def test_async_extension_init_with_app(setup_database: Path) -> None:
    app = Flask(__name__)

    with app.app_context():
        config = SQLAlchemyAsyncConfig(
            bind_key="async", connection_string=f"sqlite+aiosqlite:///{setup_database}", metadata=metadata
        )
        extension = AdvancedAlchemy(config, app)
        assert "advanced_alchemy" in app.extensions
        session = extension.get_session("async")
        assert isinstance(session, AsyncSession)
        extension.portal_provider.stop()


def test_async_extension_multiple_init(setup_database: Path) -> None:
    app = Flask(__name__)

    with (
        app.app_context(),
        pytest.raises(ImproperConfigurationError, match="Advanced Alchemy extension is already registered"),
    ):
        config = SQLAlchemyAsyncConfig(
            connection_string=f"sqlite+aiosqlite:///{setup_database}", bind_key="async", metadata=metadata
        )
        extension = AdvancedAlchemy(config, app)
        extension.init_app(app)


def test_sync_and_async_extension_init(setup_database: Path) -> None:
    app = Flask(__name__)

    with app.app_context():
        extension = AdvancedAlchemy(
            [
                SQLAlchemySyncConfig(connection_string=f"sqlite:///{setup_database}"),
                SQLAlchemyAsyncConfig(
                    connection_string=f"sqlite+aiosqlite:///{setup_database}", bind_key="async", metadata=metadata
                ),
            ],
            app,
        )
        assert "advanced_alchemy" in app.extensions
        session = extension.get_session()
        assert isinstance(session, Session)


def test_multiple_binds(setup_database: Path) -> None:
    app = Flask(__name__)

    with app.app_context():
        extension = AdvancedAlchemy(
            [
                SQLAlchemySyncConfig(
                    connection_string=f"sqlite:///{setup_database}", bind_key="db1", metadata=metadata
                ),
                SQLAlchemySyncConfig(
                    connection_string=f"sqlite:///{setup_database}", bind_key="db2", metadata=metadata
                ),
            ],
            app,
        )

        session = extension.get_session("db1")
        assert isinstance(session, Session)
        session = extension.get_session("db2")
        assert isinstance(session, Session)


def test_multiple_binds_async(setup_database: Path) -> None:
    app = Flask(__name__)

    with app.app_context():
        configs: Sequence[SQLAlchemyAsyncConfig] = [
            SQLAlchemyAsyncConfig(
                connection_string=f"sqlite+aiosqlite:///{setup_database}", bind_key="db1", metadata=metadata
            ),
            SQLAlchemyAsyncConfig(
                connection_string=f"sqlite+aiosqlite:///{setup_database}", bind_key="db2", metadata=metadata
            ),
        ]
        extension = AdvancedAlchemy(configs, app)

        session = extension.get_session("db1")
        assert isinstance(session, AsyncSession)
        session = extension.get_session("db2")
        assert isinstance(session, AsyncSession)
        extension.portal_provider.stop()


def test_mixed_binds(setup_database: Path) -> None:
    app = Flask(__name__)

    with app.app_context():
        configs: Sequence[SQLAlchemyAsyncConfig | SQLAlchemySyncConfig] = [
            SQLAlchemySyncConfig(connection_string=f"sqlite:///{setup_database}", bind_key="sync", metadata=metadata),
            SQLAlchemyAsyncConfig(
                connection_string=f"sqlite+aiosqlite:///{setup_database}", bind_key="async", metadata=metadata
            ),
        ]
        extension = AdvancedAlchemy(configs, app)

        session = extension.get_session("sync")
        assert isinstance(session, Session)
        session.close()
        session = extension.get_session("async")
        assert isinstance(session, AsyncSession)
        extension.portal_provider.portal.call(session.close)
        extension.portal_provider.stop()


def test_sync_autocommit(setup_database: Path) -> None:
    app = Flask(__name__)

    with app.test_client() as client:
        config = SQLAlchemySyncConfig(
            connection_string=f"sqlite:///{setup_database}", commit_mode="autocommit", metadata=metadata
        )

        extension = AdvancedAlchemy(config, app)

        @app.route("/test", methods=["POST"])
        def test_route() -> tuple[dict[str, str], int]:
            session = extension.get_session()
            assert isinstance(session, Session)
            user = User(name="test")
            session.add(user)
            return {"status": "success"}, 200

        # Test successful response (should commit)
        response = client.post("/test")
        assert response.status_code == 200

        # Verify the data was committed
        session = extension.get_session()
        assert isinstance(session, Session)
        result = session.execute(select(User).where(User.name == "test"))
        assert result.scalar_one().name == "test"


def test_sync_autocommit_include_redirect(setup_database: Path) -> None:
    app = Flask(__name__)

    with app.test_client() as client:
        config = SQLAlchemySyncConfig(
            connection_string=f"sqlite:///{setup_database}",
            commit_mode="autocommit_include_redirect",
            metadata=metadata,
        )

        extension = AdvancedAlchemy(config, app)

        @app.route("/test", methods=["POST"])
        def test_route() -> tuple[str, int, dict[str, str]]:
            session = extension.get_session()
            assert isinstance(session, Session)
            session.add(User(name="test_redirect"))
            return "", 302, {"Location": "/redirected"}

        # Test redirect response (should commit with autocommit_include_redirect)
        response = client.post("/test")
        assert response.status_code == 302

        # Verify the data was committed
        session = extension.get_session()
        assert isinstance(session, Session)
        result = session.execute(select(User).where(User.name == "test_redirect"))
        assert result.scalar_one().name == "test_redirect"


def test_sync_no_autocommit_on_error(setup_database: Path) -> None:
    app = Flask(__name__)

    with app.test_client() as client:
        config = SQLAlchemySyncConfig(
            connection_string=f"sqlite:///{setup_database}", commit_mode="autocommit", metadata=metadata
        )
        extension = AdvancedAlchemy(config, app)

        @app.route("/test", methods=["POST"])
        def test_route() -> tuple[dict[str, str], int]:
            session = extension.get_session()
            assert isinstance(session, Session)
            user = User(name="test_error")
            session.add(user)
            return {"error": "test error"}, 500

        # Test error response (should not commit)
        response = client.post("/test")
        assert response.status_code == 500

        # Verify the data was not committed
        session = extension.get_session()
        assert isinstance(session, Session)
        result = session.execute(select(User).where(User.name == "test_error"))
        assert result.first() is None


def test_async_autocommit(setup_database: Path) -> None:
    app = Flask(__name__)

    with app.test_client() as client:
        config = SQLAlchemyAsyncConfig(
            connection_string=f"sqlite+aiosqlite:///{setup_database}", commit_mode="autocommit", metadata=metadata
        )
        extension = AdvancedAlchemy(config, app)

        @app.route("/test", methods=["POST"])
        def test_route() -> tuple[dict[str, str], int]:
            session = extension.get_session()
            assert isinstance(session, AsyncSession)
            session.add(User(name="test_async"))
            return {"status": "success"}, 200

        # Test successful response (should commit)
        response = client.post("/test")
        assert response.status_code == 200

        # Verify the data was committed
        session = extension.get_session()
        assert isinstance(session, AsyncSession)

        result = extension.portal_provider.portal.call(session.execute, select(User).where(User.name == "test_async"))
        assert result.scalar_one().name == "test_async"
    extension.portal_provider.stop()


def test_async_autocommit_include_redirect(setup_database: Path) -> None:
    app = Flask(__name__)

    with app.test_client() as client:
        config = SQLAlchemyAsyncConfig(
            connection_string=f"sqlite+aiosqlite:///{setup_database}",
            commit_mode="autocommit_include_redirect",
            metadata=metadata,
        )
        extension = AdvancedAlchemy(config, app)

        @app.route("/test", methods=["POST"])
        def test_route() -> tuple[str, int, dict[str, str]]:
            session = extension.get_session()
            assert isinstance(session, AsyncSession)
            user = User(name="test_async_redirect")  # type: ignore
            session.add(user)
            return "", 302, {"Location": "/redirected"}

        # Test redirect response (should commit with autocommit_include_redirect)
        response = client.post("/test")
        assert response.status_code == 302
        session = extension.get_session()
        assert isinstance(session, AsyncSession)

        result = extension.portal_provider.portal.call(
            session.execute, select(User).where(User.name == "test_async_redirect")
        )
        assert result.scalar_one().name == "test_async_redirect"
    extension.portal_provider.stop()


def test_async_no_autocommit_on_error(setup_database: Path) -> None:
    app = Flask(__name__)

    with app.test_client() as client:
        config = SQLAlchemyAsyncConfig(
            connection_string=f"sqlite+aiosqlite:///{setup_database}", commit_mode="autocommit", metadata=metadata
        )

        extension = AdvancedAlchemy(config, app)

        @app.route("/test", methods=["POST"])
        def test_route() -> tuple[dict[str, str], int]:
            session = extension.get_session()
            assert isinstance(session, AsyncSession)
            user = User(name="test_async_error")  # type: ignore
            session.add(user)
            return {"error": "test async error"}, 500

        # Test error response (should not commit)
        response = client.post("/test")
        assert response.status_code == 500

        session = extension.get_session()
        assert isinstance(session, AsyncSession)

        async def get_user() -> User | None:
            result = await session.execute(select(User).where(User.name == "test_async_error"))
            return result.scalar_one_or_none()

        # Verify the data was not committed
        user = extension.portal_provider.portal.call(get_user)
        assert user is None
    extension.portal_provider.stop()


def test_async_portal_cleanup(setup_database: Path) -> None:
    app = Flask(__name__)

    with app.test_client() as client:
        config = SQLAlchemyAsyncConfig(
            connection_string=f"sqlite+aiosqlite:///{setup_database}", commit_mode="manual", metadata=metadata
        )
        extension = AdvancedAlchemy(config, app)

        @app.route("/test", methods=["POST"])
        def test_route() -> tuple[dict[str, str], int]:
            session = extension.get_session()
            assert isinstance(session, AsyncSession)
            user = User(name="test_async_cleanup")  # type: ignore
            session.add(user)
            return {"status": "success"}, 200

        # Test successful response (should not commit since we're using MANUAL mode)
        response = client.post("/test")
        assert response.status_code == 200
        session = extension.get_session()
        assert isinstance(session, AsyncSession)

        # Verify the data was not committed (MANUAL mode)
        result = extension.portal_provider.portal.call(
            session.execute, select(User).where(User.name == "test_async_cleanup")
        )
        assert result.first() is None
    extension.portal_provider.stop()


def test_async_portal_explicit_stop(setup_database: Path) -> None:
    app = Flask(__name__)

    with app.test_client() as client:
        config = SQLAlchemyAsyncConfig(
            connection_string=f"sqlite+aiosqlite:///{setup_database}",
            metadata=metadata,
            commit_mode="manual",
        )
        extension = AdvancedAlchemy(config, app)

        @app.route("/test", methods=["POST"])
        def test_route() -> tuple[dict[str, str], int]:
            session = extension.get_session()
            assert isinstance(session, AsyncSession)
            user = User(name="test_async_explicit_stop")  # type: ignore
            session.add(user)
            return {"status": "success"}, 200

        # Test successful response (should not commit since we're using MANUAL mode)
        response = client.post("/test")
        assert response.status_code == 200

    with app.app_context():
        session = extension.get_session()
        assert isinstance(session, AsyncSession)

        # Verify the data was not committed (MANUAL mode)
        result = extension.portal_provider.portal.call(
            session.scalar, select(User).where(User.name == "test_async_explicit_stop")
        )
        assert result is None
    extension.portal_provider.stop()


def test_async_portal_explicit_stop_with_commit(setup_database: Path) -> None:
    app = Flask(__name__)

    @app.route("/test", methods=["POST"])
    def test_route() -> tuple[dict[str, str], int]:
        session = extension.get_session()
        assert isinstance(session, AsyncSession)

        async def create_user() -> None:
            user = User(name="test_async_explicit_stop_with_commit")  # type: ignore
            session.add(user)
            await session.commit()  # type: ignore

        extension.portal_provider.portal.call(create_user)
        return {"status": "success"}, 200

    with app.test_client() as client:
        config = SQLAlchemyAsyncConfig(
            connection_string=f"sqlite+aiosqlite:///{setup_database}",
            metadata=metadata,
            commit_mode="manual",
        )
        extension = AdvancedAlchemy(config, app)

        # Test successful response
        response = client.post("/test")
        assert response.status_code == 200

        # Verify in a new session
        session = extension.get_session()
        assert isinstance(session, AsyncSession)

        async def get_user() -> User | None:
            async with session:
                result = await session.execute(select(User).where(User.name == "test_async_explicit_stop_with_commit"))
                return result.scalar_one_or_none()

        user = extension.portal_provider.portal.call(get_user)
        assert isinstance(user, User)
        assert user.name == "test_async_explicit_stop_with_commit"
    extension.portal_provider.stop()


def test_sync_service_jsonify_msgspec(setup_database: Path) -> None:
    app = Flask(__name__)

    with app.test_client() as client:
        config = SQLAlchemySyncConfig(
            connection_string=f"sqlite:///{setup_database}", metadata=metadata, commit_mode="autocommit"
        )

        extension = AdvancedAlchemy(config, app)

        @app.route("/test", methods=["POST"])
        def test_route() -> Response:
            service = UserService(extension.get_sync_session())
            user = service.create({"name": "service_test"})
            return service.jsonify(service.to_schema(user, schema_type=UserSchema))

        # Test successful response (should commit)
        response = client.post("/test")
        assert response.status_code == 200

        # Verify the data was committed
        session = extension.get_session()
        assert isinstance(session, Session)
        result = session.execute(select(User).where(User.name == "service_test"))
        assert result.scalar_one().name == "service_test"


def test_async_service_jsonify_msgspec(setup_database: Path) -> None:
    app = Flask(__name__)

    with app.test_client() as client:
        config = SQLAlchemyAsyncConfig(
            connection_string=f"sqlite+aiosqlite:///{setup_database}", metadata=metadata, commit_mode="autocommit"
        )
        extension = AdvancedAlchemy(config, app)

        @app.route("/test", methods=["POST"])
        def test_route() -> Response:
            service = AsyncUserService(extension.get_async_session())
            user = extension.portal_provider.portal.call(service.create, {"name": "async_service_test"})
            return service.jsonify(service.to_schema(user, schema_type=UserSchema))

        # Test successful response (should commit)
        response = client.post("/test")
        assert response.status_code == 200

        # Verify the data was committed
        session = extension.get_session()
        assert isinstance(session, AsyncSession)
        result = extension.portal_provider.portal.call(
            session.scalar, select(User).where(User.name == "async_service_test")
        )
        assert result
        assert result.name == "async_service_test"
    extension.portal_provider.stop()


def test_sync_service_jsonify_pydantic(setup_database: Path) -> None:
    app = Flask(__name__)

    with app.test_client() as client:
        config = SQLAlchemySyncConfig(
            connection_string=f"sqlite:///{setup_database}", metadata=metadata, commit_mode="autocommit"
        )
        extension = AdvancedAlchemy(config, app)

        @app.route("/test", methods=["POST"])
        def test_route() -> Response:
            service = UserService(extension.get_sync_session())
            user = service.create({"name": "test_sync_service_jsonify_pydantic"})
            return service.jsonify(service.to_schema(user, schema_type=UserPydantic))

        # Test successful response (should commit)
        response = client.post("/test")
        assert response.status_code == 200

        # Verify the data was committed
        session = extension.get_session()
        assert isinstance(session, Session)
        result = session.execute(select(User).where(User.name == "test_sync_service_jsonify_pydantic"))
        assert result.scalar_one().name == "test_sync_service_jsonify_pydantic"


def test_async_service_jsonify_pydantic(setup_database: Path) -> None:
    app = Flask(__name__)

    with app.test_client() as client:
        config = SQLAlchemyAsyncConfig(
            connection_string=f"sqlite+aiosqlite:///{setup_database}", metadata=metadata, commit_mode="autocommit"
        )
        extension = AdvancedAlchemy(config, app)

        @app.route("/test", methods=["POST"])
        def test_route() -> Response:
            service = AsyncUserService(extension.get_async_session())
            user = extension.portal_provider.portal.call(
                service.create, {"name": "test_async_service_jsonify_pydantic"}
            )
            return service.jsonify(service.to_schema(user, schema_type=UserPydantic))

        # Test successful response (should commit)
        response = client.post("/test")
        assert response.status_code == 200

        # Verify the data was committed
        session = extension.get_session()
        assert isinstance(session, AsyncSession)
        result = extension.portal_provider.portal.call(
            session.scalar, select(User).where(User.name == "test_async_service_jsonify_pydantic")
        )
        assert result
        assert result.name == "test_async_service_jsonify_pydantic"
    extension.portal_provider.stop()
python-advanced-alchemy-1.9.3/tests/unit/test_extensions/test_litestar/000077500000000000000000000000001516556515500265135ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/tests/unit/test_extensions/test_litestar/__init__.py000066400000000000000000000000001516556515500306120ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/tests/unit/test_extensions/test_litestar/conftest.py000066400000000000000000000253571516556515500307260ustar00rootroot00000000000000from __future__ import annotations

import importlib.util
import os
import random
import string
import sys
from collections.abc import AsyncGenerator, Generator, Sequence
from dataclasses import replace
from pathlib import Path
from types import ModuleType
from typing import Any, Callable, cast
from unittest.mock import ANY

import pytest
from litestar.app import Litestar
from litestar.dto import AbstractDTO, DTOField, Mark
from litestar.dto._backend import DTOBackend
from litestar.dto.data_structures import DTOFieldDefinition
from litestar.testing import RequestFactory
from litestar.types import (
    ASGIVersion,
    RouteHandlerType,
    Scope,
    ScopeSession,  # type: ignore
)
from litestar.types.empty import Empty
from litestar.typing import FieldDefinition
from pytest import FixtureRequest, MonkeyPatch
from sqlalchemy import Engine, NullPool, create_engine
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import Session, sessionmaker
from typing_extensions import TypeVar

from advanced_alchemy.alembic.commands import AlembicCommands
from advanced_alchemy.config.common import GenericSQLAlchemyConfig
from advanced_alchemy.extensions.litestar import SQLAlchemyAsyncConfig, SQLAlchemyPlugin, SQLAlchemySyncConfig


@pytest.fixture(autouse=True)
def reload_package() -> Generator[None, None, None]:
    yield
    GenericSQLAlchemyConfig._SESSION_SCOPE_KEY_REGISTRY = set()  # type: ignore
    GenericSQLAlchemyConfig._ENGINE_APP_STATE_KEY_REGISTRY = set()  # type: ignore
    GenericSQLAlchemyConfig._SESSIONMAKER_APP_STATE_KEY_REGISTRY = set()  # type: ignore


@pytest.fixture(autouse=True)
def reset_cached_dto_backends() -> Generator[None, None, None]:
    DTOBackend._seen_model_names = set()  # pyright: ignore[reportPrivateUsage]
    AbstractDTO._dto_backends = {}  # pyright: ignore[reportPrivateUsage]
    yield
    DTOBackend._seen_model_names = set()  # pyright: ignore[reportPrivateUsage]
    AbstractDTO._dto_backends = {}  # pyright: ignore[reportPrivateUsage]


@pytest.fixture(autouse=True)
async def disable_implicit_sync_warning() -> None:
    os.environ["LITESTAR_WARN_IMPLICIT_SYNC_TO_THREAD"] = "0"


@pytest.fixture
def int_factory() -> Generator[Callable[[], int], None, None]:
    yield lambda: 2


@pytest.fixture
def expected_field_defs(int_factory: Callable[[], int]) -> Generator[list[DTOFieldDefinition], None, None]:
    yield [
        DTOFieldDefinition.from_field_definition(
            field_definition=FieldDefinition.from_kwarg(
                annotation=int,
                name="a",
            ),
            model_name=ANY,
            default_factory=Empty,  # type: ignore[arg-type]
            dto_field=DTOField(),
        ),
        replace(
            DTOFieldDefinition.from_field_definition(
                field_definition=FieldDefinition.from_kwarg(
                    annotation=int,
                    name="b",
                ),
                model_name=ANY,
                default_factory=Empty,  # type: ignore[arg-type]
                dto_field=DTOField(mark=Mark.READ_ONLY),
            ),
            metadata=ANY,
            type_wrappers=ANY,
            raw=ANY,
            kwarg_definition=ANY,
        ),
        replace(
            DTOFieldDefinition.from_field_definition(
                field_definition=FieldDefinition.from_kwarg(
                    annotation=int,
                    name="c",
                ),
                model_name=ANY,
                default_factory=Empty,  # type: ignore[arg-type]
                dto_field=DTOField(),
            ),
            metadata=ANY,
            type_wrappers=ANY,
            raw=ANY,
            kwarg_definition=ANY,
        ),
        replace(
            DTOFieldDefinition.from_field_definition(
                field_definition=FieldDefinition.from_kwarg(
                    annotation=int,
                    name="d",
                    default=1,
                ),
                model_name=ANY,
                default_factory=Empty,  # type: ignore[arg-type]
                dto_field=DTOField(),
            ),
            metadata=ANY,
            type_wrappers=ANY,
            raw=ANY,
            kwarg_definition=ANY,
        ),
        replace(
            DTOFieldDefinition.from_field_definition(
                field_definition=FieldDefinition.from_kwarg(
                    annotation=int,
                    name="e",
                ),
                model_name=ANY,
                default_factory=int_factory,
                dto_field=DTOField(),
            ),
            metadata=ANY,
            type_wrappers=ANY,
            raw=ANY,
            kwarg_definition=ANY,
        ),
    ]


@pytest.fixture
def create_module(tmp_path: Path, monkeypatch: MonkeyPatch) -> Generator[Callable[[str], ModuleType], None, None]:
    """Utility fixture for dynamic module creation."""

    def wrapped(source: str) -> ModuleType:
        """

        Args:
            source: Source code as a string.

        Returns:
            An imported module.
        """
        T = TypeVar("T")

        def not_none(val: T | None) -> T:
            assert val is not None
            return val

        def module_name_generator() -> str:
            letters = string.ascii_lowercase
            return "".join(random.choice(letters) for _ in range(10))

        module_name = module_name_generator()
        path = tmp_path / f"{module_name}.py"
        path.write_text(source)
        # https://docs.python.org/3/library/importlib.html#importing-a-source-file-directly
        spec = not_none(importlib.util.spec_from_file_location(module_name, path))
        module = not_none(importlib.util.module_from_spec(spec))
        monkeypatch.setitem(sys.modules, module_name, module)
        not_none(spec.loader).exec_module(module)
        return module

    yield wrapped


@pytest.fixture
def create_scope() -> Generator[Callable[..., Scope], None, None]:
    def inner(
        *,
        type: str = "http",
        app: Litestar | None = None,
        asgi: ASGIVersion | None = None,
        auth: Any = None,
        client: tuple[str, int] | None = ("testclient", 50000),
        extensions: dict[str, dict[object, object]] | None = None,
        http_version: str = "1.1",
        path: str = "/",
        path_params: dict[str, str] | None = None,
        query_string: str = "",
        root_path: str = "",
        route_handler: RouteHandlerType | None = None,
        scheme: str = "http",
        server: tuple[str, int | None] | None = ("testserver", 80),
        session: ScopeSession | None = None,  # pyright: ignore[reportUnknownParameterType]
        state: dict[str, Any] | None = None,
        user: Any = None,
        **kwargs: dict[str, Any],
    ) -> Scope:
        scope: dict[str, Any] = {
            "app": app,
            "asgi": asgi or {"spec_version": "2.0", "version": "3.0"},
            "auth": auth,
            "type": type,
            "path": path,
            "raw_path": path.encode(),
            "root_path": root_path,
            "scheme": scheme,
            "query_string": query_string.encode(),
            "client": client,
            "server": server,
            "method": "GET",
            "http_version": http_version,
            "extensions": extensions or {"http.response.template": {}},
            "state": state or {},
            "path_params": path_params or {},
            "route_handler": route_handler,
            "user": user,
            "session": session,
            **kwargs,
        }
        return cast("Scope", scope)

    yield inner  # pyright: ignore[reportUnknownVariableType]


@pytest.fixture
def scope(create_scope: Callable[..., Scope]) -> Generator[Scope, None, None]:
    yield create_scope()


@pytest.fixture()
def engine() -> Generator[Engine, None, None]:
    """SQLite engine for end-to-end testing.

    Returns:
        Async SQLAlchemy engine instance.
    """
    engine = create_engine("sqlite:///:memory:", poolclass=NullPool)
    try:
        yield engine
    finally:
        engine.dispose()


@pytest.fixture()
async def sync_sqlalchemy_plugin(
    engine: Engine,
    session_maker: sessionmaker[Session] | None = None,
) -> AsyncGenerator[SQLAlchemyPlugin, None]:
    yield SQLAlchemyPlugin(config=SQLAlchemySyncConfig(engine_instance=engine, session_maker=session_maker))


@pytest.fixture()
async def async_engine() -> AsyncGenerator[AsyncEngine, None]:
    """SQLite engine for end-to-end testing.

    Returns:
        Async SQLAlchemy engine instance.
    """
    engine = create_async_engine("sqlite+aiosqlite:///:memory:", poolclass=NullPool)
    try:
        yield engine
    finally:
        await engine.dispose()


@pytest.fixture()
async def async_sqlalchemy_plugin(
    async_engine: AsyncEngine,
    async_session_maker: async_sessionmaker[AsyncSession] | None = None,
) -> AsyncGenerator[SQLAlchemyPlugin, None]:
    yield SQLAlchemyPlugin(
        config=SQLAlchemyAsyncConfig(engine_instance=async_engine, session_maker=async_session_maker),
    )


@pytest.fixture(params=[pytest.param("sync_sqlalchemy_plugin"), pytest.param("async_sqlalchemy_plugin")])
async def plugin(request: FixtureRequest) -> AsyncGenerator[SQLAlchemyPlugin, None]:
    yield cast(SQLAlchemyPlugin, request.getfixturevalue(request.param))


@pytest.fixture()
async def sync_app(sync_sqlalchemy_plugin: SQLAlchemyPlugin) -> AsyncGenerator[Litestar, None]:
    yield Litestar(plugins=[sync_sqlalchemy_plugin])


@pytest.fixture()
async def async_app(async_sqlalchemy_plugin: SQLAlchemyPlugin) -> AsyncGenerator[Litestar, None]:
    yield Litestar(plugins=[async_sqlalchemy_plugin])


@pytest.fixture()
async def sync_alembic_commands(sync_app: Litestar) -> AsyncGenerator[AlembicCommands, None]:
    plugin = sync_app.plugins.get(SQLAlchemyPlugin)
    config = plugin.config[0] if isinstance(plugin.config, Sequence) else plugin.config  # type: ignore
    yield AlembicCommands(sqlalchemy_config=config)


@pytest.fixture()
async def async_alembic_commands(async_app: Litestar) -> AsyncGenerator[AlembicCommands, None]:
    plugin = async_app.plugins.get(SQLAlchemyPlugin)
    config = plugin.config[0] if isinstance(plugin.config, Sequence) else plugin.config  # type: ignore
    yield AlembicCommands(sqlalchemy_config=config)


@pytest.fixture(params=[pytest.param("sync_alembic_commands"), pytest.param("async_alembic_commands")])
async def alembic_commands(request: FixtureRequest) -> AsyncGenerator[AlembicCommands, None]:
    yield cast(AlembicCommands, request.getfixturevalue(request.param))


@pytest.fixture(params=[pytest.param("sync_app"), pytest.param("async_app")])
async def app(request: FixtureRequest) -> AsyncGenerator[Litestar, None]:
    yield cast(Litestar, request.getfixturevalue(request.param))


@pytest.fixture()
def request_factory() -> Generator[RequestFactory, None, None]:
    yield RequestFactory()
python-advanced-alchemy-1.9.3/tests/unit/test_extensions/test_litestar/test_context.py000066400000000000000000000016321516556515500316120ustar00rootroot00000000000000from __future__ import annotations

from typing import cast

from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session

from advanced_alchemy.extensions.litestar.plugins import SQLAlchemyPlugin
from advanced_alchemy.extensions.litestar.plugins.init.config.asyncio import SQLAlchemyAsyncConfig
from advanced_alchemy.extensions.litestar.plugins.init.config.sync import SQLAlchemySyncConfig


async def test_sync_db_session(sync_sqlalchemy_plugin: SQLAlchemyPlugin) -> None:
    config = cast("SQLAlchemySyncConfig", sync_sqlalchemy_plugin.config[0])

    with config.get_session() as session:
        assert isinstance(session, Session)


async def test_async_db_session(async_sqlalchemy_plugin: SQLAlchemyPlugin) -> None:
    config = cast("SQLAlchemyAsyncConfig", async_sqlalchemy_plugin.config[0])

    async with config.get_session() as session:
        assert isinstance(session, AsyncSession)
python-advanced-alchemy-1.9.3/tests/unit/test_extensions/test_litestar/test_dto.py000066400000000000000000000674151516556515500307270ustar00rootroot00000000000000from __future__ import annotations

import datetime
import sys
from typing import TYPE_CHECKING, Annotated, ClassVar
from uuid import UUID, uuid4

import pytest
import sqlalchemy
from litestar import Request, get
from litestar.dto import DTOField, Mark
from litestar.dto.field import DTO_FIELD_META_KEY
from litestar.enums import MediaType
from litestar.plugins.pydantic import PydanticInitPlugin
from litestar.serialization import encode_json
from litestar.testing import RequestFactory
from litestar.typing import FieldDefinition
from sqlalchemy import ForeignKey, func
from sqlalchemy.orm import DeclarativeBase, Mapped, MappedAsDataclass, declared_attr, mapped_column, relationship
from typing_extensions import TypeVar

from advanced_alchemy.exceptions import ImproperConfigurationError
from advanced_alchemy.extensions.litestar.dto import (
    SQLAlchemyDTO,
    SQLAlchemyDTOConfig,
    parse_type_from_element,  # type: ignore
)

if TYPE_CHECKING:
    from collections.abc import Callable
    from types import ModuleType
    from typing import Any


@pytest.fixture(name="base")
def fx_base() -> type[DeclarativeBase]:
    class Base(DeclarativeBase):
        id: Mapped[UUID] = mapped_column(default=uuid4, primary_key=True)
        created: Mapped[datetime.datetime] = mapped_column(
            default=datetime.datetime.now,
            info={DTO_FIELD_META_KEY: DTOField(mark=Mark.READ_ONLY)},
        )
        updated: Mapped[datetime.datetime] = mapped_column(
            default=datetime.datetime.now,
            info={DTO_FIELD_META_KEY: DTOField(mark=Mark.READ_ONLY)},
        )

        # noinspection PyMethodParameters
        @declared_attr.directive
        def __tablename__(cls) -> str:
            """Infer table name from class name."""
            return cls.__name__.lower()

    return Base


@pytest.fixture(name="author_model")
def fx_author_model(base: DeclarativeBase) -> type[DeclarativeBase]:
    class Author(base):  # type: ignore
        name: Mapped[str]
        dob: Mapped[datetime.date]

    return Author


@pytest.fixture(name="raw_author")
def fx_raw_author() -> bytes:
    return b'{"id":"97108ac1-ffcb-411d-8b1e-d9183399f63b","name":"Agatha Christie","dob":"1890-09-15","created":"0001-01-01T00:00:00","updated":"0001-01-01T00:00:00"}'


@pytest.fixture(name="asgi_connection")
def fx_asgi_connection() -> Request[Any, Any, Any]:
    @get("/", name="handler_id", media_type=MediaType.JSON, type_decoders=PydanticInitPlugin.decoders())
    def _handler() -> None: ...

    return RequestFactory().get(path="/", route_handler=_handler)


T = TypeVar("T")
DataT = TypeVar("DataT", bound=DeclarativeBase)


async def get_model_from_dto(
    dto_type: type[SQLAlchemyDTO[DataT]],
    annotation: Any,
    asgi_connection: Request[Any, Any, Any],
    raw: bytes,
) -> Any:
    dto_type.create_for_field_definition(
        handler_id=asgi_connection.route_handler.handler_id,
        field_definition=FieldDefinition.from_kwarg(annotation, name="data"),
    )
    dto_type.create_for_field_definition(
        handler_id=asgi_connection.route_handler.handler_id,
        field_definition=FieldDefinition.from_kwarg(annotation, name="return"),
    )
    return dto_type(asgi_connection).decode_bytes(raw)


def assert_model_values(model_instance: DeclarativeBase, expected_values: dict[str, Any]) -> None:
    assert {k: v for k, v in model_instance.__dict__.items() if not k.startswith("_")} == expected_values


async def test_model_write_dto(
    author_model: type[DeclarativeBase],
    raw_author: bytes,
    asgi_connection: Request[Any, Any, Any],
) -> None:
    model = await get_model_from_dto(SQLAlchemyDTO[author_model], author_model, asgi_connection, raw_author)  # type: ignore
    assert_model_values(
        model,
        {
            "id": UUID("97108ac1-ffcb-411d-8b1e-d9183399f63b"),
            "name": "Agatha Christie",
            "dob": datetime.date(1890, 9, 15),
        },
    )


async def test_model_read_dto(
    author_model: type[DeclarativeBase],
    raw_author: bytes,
    asgi_connection: Request[Any, Any, Any],
) -> None:
    config = SQLAlchemyDTOConfig()
    dto_type = SQLAlchemyDTO[Annotated[author_model, config]]  # type: ignore
    model = await get_model_from_dto(dto_type, author_model, asgi_connection, raw_author)
    assert_model_values(
        model,
        {
            "id": UUID("97108ac1-ffcb-411d-8b1e-d9183399f63b"),
            "name": "Agatha Christie",
            "dob": datetime.date(1890, 9, 15),
        },
    )


async def test_model_list_dto(author_model: type[DeclarativeBase], asgi_connection: Request[Any, Any, Any]) -> None:
    dto_type = SQLAlchemyDTO[author_model]  # type: ignore
    raw = b'[{"id": "97108ac1-ffcb-411d-8b1e-d9183399f63b","name":"Agatha Christie","dob":"1890-09-15","created":"0001-01-01T00:00:00","updated":"0001-01-01T00:00:00"}]'
    dto_data = await get_model_from_dto(dto_type, list[author_model], asgi_connection, raw)  # type: ignore
    assert isinstance(dto_data, list)
    assert_model_values(
        dto_data[0],  # type: ignore
        {
            "id": UUID("97108ac1-ffcb-411d-8b1e-d9183399f63b"),
            "name": "Agatha Christie",
            "dob": datetime.date(1890, 9, 15),
        },
    )


async def test_dto_exclude(
    author_model: type[DeclarativeBase],
    raw_author: bytes,
    asgi_connection: Request[Any, Any, Any],
) -> None:
    config = SQLAlchemyDTOConfig(exclude={"id"})
    model = await get_model_from_dto(
        SQLAlchemyDTO[Annotated[author_model, config]],  # type: ignore
        author_model,
        asgi_connection,
        raw_author,
    )
    assert "id" not in vars(model)


async def test_write_dto_field_default(base: type[DeclarativeBase], asgi_connection: Request[Any, Any, Any]) -> None:
    class Model(base):  # type: ignore
        field: Mapped[int] = mapped_column(default=3)

    dto_type = SQLAlchemyDTO[Annotated[Model, SQLAlchemyDTOConfig(exclude={"id", "created", "updated"})]]
    model = await get_model_from_dto(dto_type, Model, asgi_connection, b'{"a":"b"}')
    assert_model_values(model, {"field": 3})


async def test_write_dto_for_model_field_factory_default(
    base: type[DeclarativeBase],
    asgi_connection: Request[Any, Any, Any],
) -> None:
    val = uuid4()

    class Model(base):  # type: ignore
        field: Mapped[UUID] = mapped_column(default=lambda: val)

    dto_type = SQLAlchemyDTO[Annotated[Model, SQLAlchemyDTOConfig(exclude={"id", "created", "updated"})]]
    model = await get_model_from_dto(dto_type, Model, asgi_connection, b'{"a":"b"}')
    assert_model_values(model, {"field": val})


async def test_dto_instrumented_attribute_key(
    base: type[DeclarativeBase],
    asgi_connection: Request[Any, Any, Any],
) -> None:
    val = uuid4()

    class Model(base):  # type: ignore
        field: Mapped[UUID] = mapped_column(default=lambda: val)

    dto_type = SQLAlchemyDTO[Annotated[Model, SQLAlchemyDTOConfig(exclude={Model.id, Model.created, Model.updated})]]  # pyright: ignore[reportAttributeAccessIssue,reportUnknownMemberType,reportUnknownArgumentType]
    model = await get_model_from_dto(dto_type, Model, asgi_connection, b'{"a":"b"}')
    assert_model_values(model, {"field": val})


async def test_write_dto_for_model_field_unsupported_default(
    base: type[DeclarativeBase],
    asgi_connection: Request[Any, Any, Any],
) -> None:
    """Test for error condition where we don't know what to do with a default
    type."""

    class Model(base):  # type: ignore
        field: Mapped[datetime.datetime] = mapped_column(default=func.now())

    with pytest.raises(ValueError):
        await get_model_from_dto(SQLAlchemyDTO[Annotated[Model, SQLAlchemyDTOConfig()]], Model, asgi_connection, b"")


async def test_dto_for_private_model_field(
    base: type[DeclarativeBase],
    asgi_connection: Request[Any, Any, Any],
) -> None:
    class Model(base):  # type: ignore
        field: Mapped[datetime.datetime] = mapped_column(
            info={DTO_FIELD_META_KEY: DTOField(mark=Mark.PRIVATE)},
        )

    dto_type = SQLAlchemyDTO[Annotated[Model, SQLAlchemyDTOConfig()]]
    raw = b'{"id":"97108ac1-ffcb-411d-8b1e-d9183399f63b","created":"0001-01-01T00:00:00","updated":"0001-01-01T00:00:00","field":"0001-01-01T00:00:00"}'
    assert "field" not in vars(await get_model_from_dto(dto_type, Model, asgi_connection, raw))

    dto_instance = dto_type(asgi_connection)
    serializable = dto_instance.data_to_encodable_type(  # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType]
        Model(
            id=UUID("0956ca9e-5671-4d7d-a862-b98e6368ed2c"),
            created=datetime.datetime.min,
            updated=datetime.datetime.min,
            field=datetime.datetime.min,
        ),
    )
    assert b"field" not in encode_json(serializable)


async def test_dto_for_non_mapped_model_field(
    base: type[DeclarativeBase],
    asgi_connection: Request[Any, Any, Any],
) -> None:
    class Model(base):  # type: ignore
        field: ClassVar[datetime.datetime]

    dto_type = SQLAlchemyDTO[Annotated[Model, SQLAlchemyDTOConfig()]]
    raw = b'{"id": "97108ac1-ffcb-411d-8b1e-d9183399f63b","created":"0001-01-01T00:00:00","updated":"0001-01-01T00:00:00","field":"0001-01-01T00:00:00"}'
    assert "field" not in vars(await get_model_from_dto(dto_type, Model, asgi_connection, raw))


async def test_dto_mapped_as_dataclass_model_type(
    base: type[DeclarativeBase],
    asgi_connection: Request[Any, Any, Any],
) -> None:
    """Test declare pydantic type on `dto.DTOField`."""

    class Model(base, MappedAsDataclass):  # type: ignore
        clz_var: ClassVar[str]
        field: Mapped[str]

    dto_type = SQLAlchemyDTO[Annotated[Model, SQLAlchemyDTOConfig(exclude={"id"})]]
    model = await get_model_from_dto(dto_type, Model, asgi_connection, b'{"clz_var":"nope","field":"yep"}')
    assert_model_values(model, {"field": "yep"})


async def test_to_mapped_model_with_collection_relationship(
    base: type[DeclarativeBase],
    create_module: Callable[[str], ModuleType],
    asgi_connection: Request[Any, Any, Any],
) -> None:
    """Test building a DTO with collection relationship, and parsing data."""

    module = create_module(
        """
from __future__ import annotations

from typing import Dict, List, Set, Tuple, Type, List

from sqlalchemy import ForeignKey, Integer
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from typing_extensions import Annotated

from advanced_alchemy.extensions.litestar.dto import SQLAlchemyDTO, SQLAlchemyDTOConfig

class Base(DeclarativeBase):
    id: Mapped[int] = mapped_column(primary_key=True)

class A(Base):
    __tablename__ = "a"
    b_id: Mapped[int] = mapped_column(ForeignKey("b.id"))

class B(Base):
    __tablename__ = "b"
    a: Mapped[List[A]] = relationship("A")

dto_type = SQLAlchemyDTO[Annotated[B, SQLAlchemyDTOConfig()]]
""",
    )

    model = await get_model_from_dto(
        module.dto_type,
        module.B,
        asgi_connection,
        b'{"id": 1, "a": [{"id": 2, "b_id": 1}, {"id": 3, "b_id": 1}]}',
    )
    assert isinstance(model, module.B)
    assert len(model.a) == 2
    assert all(isinstance(val, module.A) for val in model.a)


async def test_to_mapped_model_with_relationship_type_hint(
    base: type[DeclarativeBase],
    create_module: Callable[[str], ModuleType],
    asgi_connection: Request[Any, Any, Any],
) -> None:
    """Test building a DTO with collection relationship, and parsing data."""

    module = create_module(
        """
from __future__ import annotations

from typing import Dict, List, Set, Tuple, Type, List

from sqlalchemy import ForeignKey, Integer
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, Relationship
from typing_extensions import Annotated

from advanced_alchemy.extensions.litestar.dto import SQLAlchemyDTO, SQLAlchemyDTOConfig

class Base(DeclarativeBase):
    id: Mapped[int] = mapped_column(primary_key=True)

class A(Base):
    __tablename__ = "a"
    b_id: Mapped[int] = mapped_column(ForeignKey("b.id"))

class B(Base):
    __tablename__ = "b"
    a: Relationship[List[A]] = relationship("A")

dto_type = SQLAlchemyDTO[Annotated[B, SQLAlchemyDTOConfig()]]
""",
    )

    model = await get_model_from_dto(
        module.dto_type,
        module.B,
        asgi_connection,
        b'{"id": 1, "a": [{"id": 2, "b_id": 1}, {"id": 3, "b_id": 1}]}',
    )
    assert isinstance(model, module.B)
    assert len(model.a) == 2
    assert all(isinstance(val, module.A) for val in model.a)


async def test_to_mapped_model_with_scalar_relationship(
    create_module: Callable[[str], ModuleType],
    asgi_connection: Request[Any, Any, Any],
) -> None:
    """Test building DTO with Scalar relationship, and parsing data."""

    module = create_module(
        """
from __future__ import annotations

from sqlalchemy import ForeignKey
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from typing_extensions import Annotated

from advanced_alchemy.extensions.litestar.dto import SQLAlchemyDTO, SQLAlchemyDTOConfig

class Base(DeclarativeBase):
    id: Mapped[int] = mapped_column(primary_key=True)

class A(Base):
    __tablename__ = "a"

class B(Base):
    __tablename__ = "b"
    a_id: Mapped[int] = mapped_column(ForeignKey("a.id"))
    a: Mapped[A] = relationship(A)

dto_type = SQLAlchemyDTO[Annotated[B, SQLAlchemyDTOConfig()]]
""",
    )
    model = await get_model_from_dto(
        module.dto_type,
        module.B,
        asgi_connection,
        b'{"id": 2, "a_id": 1, "a": {"id": 1}}',
    )
    assert isinstance(model, module.B)
    assert isinstance(model.a, module.A)


async def test_dto_mapped_union(
    create_module: Callable[[str], ModuleType],
    asgi_connection: Request[Any, Any, Any],
) -> None:
    """Test where a column type declared as e.g., `Mapped[str | None]`."""

    module = create_module(
        """
from __future__ import annotations

from typing import Dict, List, Set, Tuple, Type, Union

from sqlalchemy import ForeignKey
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from typing_extensions import Annotated

from advanced_alchemy.extensions.litestar.dto import SQLAlchemyDTO

class Base(DeclarativeBase):
    id: Mapped[int] = mapped_column(primary_key=True)

class A(Base):
    __tablename__ = "a"
    a: Mapped[Union[str, None]]

dto_type = SQLAlchemyDTO[A]
    """,
    )
    model = await get_model_from_dto(module.dto_type, module.A, asgi_connection, b'{"id": 1}')
    assert vars(model)["a"] is None


@pytest.mark.skipif(sys.version_info < (3, 10), reason="requires python3.10 or higher")
async def test_dto_mapped_union_type(
    create_module: Callable[[str], ModuleType],
    asgi_connection: Request[Any, Any, Any],
) -> None:
    """Test where a column type declared as e.g., `Mapped[str | None]`."""

    module = create_module(
        """
from __future__ import annotations

from typing import Dict, List, Set, Tuple, Type, Union, Optional

from sqlalchemy import ForeignKey
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from typing_extensions import Annotated

from advanced_alchemy.extensions.litestar.dto import SQLAlchemyDTO

class Base(DeclarativeBase):
    id: Mapped[int] = mapped_column(primary_key=True)

class A(Base):
    __tablename__ = "a"
    a: Mapped[Optional[str]]

dto_type = SQLAlchemyDTO[A]
    """,
    )
    model = await get_model_from_dto(module.dto_type, module.A, asgi_connection, b'{"id": 1}')
    assert vars(model)["a"] is None
    model = await get_model_from_dto(module.dto_type, module.A, asgi_connection, b'{"id": 1, "a": "a"}')
    assert vars(model)["a"] == "a"


async def test_dto_self_referencing_relationships(
    create_module: Callable[[str], ModuleType],
    asgi_connection: Request[Any, Any, Any],
) -> None:
    module = create_module(
        """
from __future__ import annotations

from sqlalchemy import ForeignKey
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship

from advanced_alchemy.extensions.litestar.dto import SQLAlchemyDTO

class Base(DeclarativeBase):
    id: Mapped[int] = mapped_column(primary_key=True)

class A(Base):
    __tablename__ = "a"
    b_id: Mapped[int] = mapped_column(ForeignKey("b.id"))
    b: Mapped[B] = relationship(back_populates="a")

class B(Base):
    __tablename__ = "b"
    a: Mapped[A] = relationship(back_populates="b")

dto_type = SQLAlchemyDTO[A]
""",
    )
    raw = b'{"id": 1, "b_id": 1, "b": {"id": 1, "a": {"id": 1, "b_id": 1}}}'
    model = await get_model_from_dto(module.dto_type, module.A, asgi_connection, raw)
    assert isinstance(model, module.A)
    assert isinstance(model.b, module.B)
    assert isinstance(model.b.a, module.A)

    encodable_type = module.dto_type(asgi_connection).data_to_encodable_type(model)
    assert encodable_type.id == 1
    assert encodable_type.b_id == 1
    assert encodable_type.b.id == 1


async def test_dto_optional_relationship_with_none_value(
    create_module: Callable[[str], ModuleType],
    asgi_connection: Request[Any, Any, Any],
) -> None:
    module = create_module(
        """
from __future__ import annotations

from typing import Dict, List, Set, Tuple, Type, Optional

from sqlalchemy import ForeignKey
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from typing_extensions import Annotated

from advanced_alchemy.extensions.litestar.dto import SQLAlchemyDTO, SQLAlchemyDTOConfig

class Base(DeclarativeBase):
    id: Mapped[int] = mapped_column(primary_key=True)

class A(Base):
    __tablename__ = "a"

class B(Base):
    __tablename__ = "b"
    a_id: Mapped[Optional[int]] = mapped_column(ForeignKey("a.id"))
    a: Mapped[Optional[A]] = relationship(A)

dto_type = SQLAlchemyDTO[Annotated[B, SQLAlchemyDTOConfig()]]
""",
    )
    model = await get_model_from_dto(module.dto_type, module.B, asgi_connection, b'{"id": 2, "a_id": null, "a": null}')
    assert isinstance(model, module.B)
    assert model.a is None


async def test_dto_nullable_one_to_one_inverse_relationship(
    create_module: Callable[[str], ModuleType],
    asgi_connection: Request[Any, Any, Any],
) -> None:
    """Test that nullable one-to-one relationships on the inverse side (no FK) are handled correctly.

    Regression test for https://github.com/jolt-org/advanced-alchemy/issues/227.
    When the FK is on the other model and uselist=False, the DTO should allow None.
    """
    module = create_module(
        """
from __future__ import annotations

from typing import Optional

from sqlalchemy import ForeignKey
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from typing_extensions import Annotated

from advanced_alchemy.extensions.litestar.dto import SQLAlchemyDTO, SQLAlchemyDTOConfig

class Base(DeclarativeBase):
    id: Mapped[int] = mapped_column(primary_key=True)

class B(Base):
    __tablename__ = "b"
    a_id: Mapped[int] = mapped_column(ForeignKey("a.id"))
    a: Mapped["A"] = relationship(back_populates="b")

class A(Base):
    __tablename__ = "a"
    b: Mapped[Optional[B]] = relationship(back_populates="a")

dto_type = SQLAlchemyDTO[Annotated[A, SQLAlchemyDTOConfig()]]
""",
    )
    model = await get_model_from_dto(module.dto_type, module.A, asgi_connection, b'{"id": 1, "b": null}')
    assert isinstance(model, module.A)
    assert model.b is None


async def test_forward_ref_relationship_resolution(
    create_module: Callable[[str], ModuleType],
    asgi_connection: Request[Any, Any, Any],
) -> None:
    """Testing that classes related to the mapped class for the dto are considered for forward-ref resolution.

    The key part of this test is that the `B` type is only imported inside an `if TYPE_CHECKING:` block
    in `a_module`, so it should not be available for forward-ref resolution when `a_module` is imported. This
    works due to related mapped classes (via `mapper.registry.mappers`) being added to forward-ref resolution
    namespace.
    """
    base_module = create_module(
        """
from __future__ import annotations
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column

class Base(DeclarativeBase):
    id: Mapped[int] = mapped_column(primary_key=True)
""",
    )

    b_module = create_module(
        f"""
from __future__ import annotations

from {base_module.__name__} import Base

class B(Base):
    __tablename__ = "b"
""",
    )

    a_module = create_module(
        f"""
from __future__ import annotations

from typing import Dict, List, Set, Tuple, Type, TYPE_CHECKING

from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from typing_extensions import Annotated

from advanced_alchemy.extensions.litestar.dto import SQLAlchemyDTO, SQLAlchemyDTOConfig

from {base_module.__name__} import Base

if TYPE_CHECKING:
    from {b_module.__name__} import B

class A(Base):
    __tablename__ = "a"
    b_id: Mapped[int] = mapped_column(ForeignKey("b.id"))
    b: Mapped[B] = relationship()

dto_type = SQLAlchemyDTO[Annotated[A, SQLAlchemyDTOConfig()]]
""",
    )

    model = await get_model_from_dto(
        a_module.dto_type,
        a_module.A,
        asgi_connection,
        b'{"id": 1, "b_id": 2, "b": {"id": 2}}',
    )
    assert isinstance(model, a_module.A)
    assert isinstance(model.b, b_module.B)


async def test_dto_mapped_builtin_collection(
    create_module: Callable[[str], ModuleType],
    asgi_connection: Request[Any, Any, Any],
) -> None:
    """Test where a column type declared as e.g., `Mapped[dict]`."""

    module = create_module(
        """
from __future__ import annotations

from typing import Dict, List, Set, Tuple, Type, Union

from sqlalchemy import ForeignKey, Integer
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy.types import JSON, ARRAY
from typing_extensions import Annotated

from advanced_alchemy.extensions.litestar.dto import SQLAlchemyDTO

class Base(DeclarativeBase):
    id: Mapped[int] = mapped_column(primary_key=True)

class A(Base):
    __tablename__ = "a"
    a: Mapped[dict] = mapped_column(JSON)
    c: Mapped[list] = mapped_column(ARRAY(Integer))

dto_type = SQLAlchemyDTO[A]
    """,
    )
    model = await get_model_from_dto(
        module.dto_type,
        module.A,
        asgi_connection,
        b'{"id": 1, "a": {"b": 1}, "c": [1, 2, 3]}',
    )
    assert vars(model)["a"] == {"b": 1}
    assert vars(model)["c"] == [1, 2, 3]


async def test_no_type_hint_column(base: type[DeclarativeBase], asgi_connection: Request[Any, Any, Any]) -> None:
    class Model(base):  # type: ignore
        nullable_field = mapped_column(sqlalchemy.String)
        not_nullable_field = mapped_column(sqlalchemy.String, nullable=False, default="")

    dto_type = SQLAlchemyDTO[Annotated[Model, SQLAlchemyDTOConfig()]]
    model = await get_model_from_dto(dto_type, Model, asgi_connection, b"{}")
    assert model.nullable_field is None
    assert model.not_nullable_field == ""


async def test_no_type_hint_scalar_relationship_with_nullable_fk(
    base: type[DeclarativeBase],
    asgi_connection: Request[Any, Any, Any],
) -> None:
    class Child(base):  # type: ignore
        ...

    class Model(base):  # type: ignore
        child_id = mapped_column(ForeignKey("child.id"))
        child = relationship(Child)

    dto_type = SQLAlchemyDTO[Annotated[Model, SQLAlchemyDTOConfig(exclude={"child_id"})]]
    model = await get_model_from_dto(dto_type, Model, asgi_connection, b"{}")
    assert model.child is None


async def test_no_type_hint_scalar_relationship_with_not_nullable_fk(
    base: type[DeclarativeBase],
    asgi_connection: Request[Any, Any, Any],
) -> None:
    class Child(base):  # type: ignore
        ...

    class Model(base):  # type: ignore
        child_id = mapped_column(ForeignKey("child.id"), nullable=False)
        child = relationship(Child)

    dto_type = SQLAlchemyDTO[Annotated[Model, SQLAlchemyDTOConfig(exclude={"child_id"})]]
    model = await get_model_from_dto(dto_type, Model, asgi_connection, b'{"child": {}}')
    assert isinstance(model.child, Child)


async def test_no_type_hint_collection_relationship(
    base: type[DeclarativeBase],
    asgi_connection: Request[Any, Any, Any],
) -> None:
    class Child(base):  # type: ignore
        model_id = mapped_column(ForeignKey("model.id"))

    class Model(base):  # type: ignore
        children = relationship(Child)

    dto_type = SQLAlchemyDTO[Annotated[Model, SQLAlchemyDTOConfig()]]
    model = await get_model_from_dto(dto_type, Model, asgi_connection, b'{"children": []}')
    assert model.children == []


async def test_no_type_hint_collection_relationship_alt_collection_class(
    base: type[DeclarativeBase],
    asgi_connection: Request[Any, Any, Any],
) -> None:
    class Child(base):  # type: ignore
        model_id = mapped_column(ForeignKey("model.id"))

    class Model(base):  # type: ignore
        children = relationship(Child, collection_class=set)

    dto_type = SQLAlchemyDTO[Annotated[Model, SQLAlchemyDTOConfig()]]
    model = await get_model_from_dto(dto_type, Model, asgi_connection, b'{"children": []}')
    assert model.children == set()


def test_parse_type_from_element_failure() -> None:
    with pytest.raises(ImproperConfigurationError) as exc:
        parse_type_from_element(1, None)  # type: ignore
    assert str(exc.value) == "Unable to parse type from element '1'. Consider adding a type hint."


async def test_to_mapped_model_with_dynamic_mapped(
    create_module: Callable[[str], ModuleType],
    asgi_connection: Request[Any, Any, Any],
) -> None:
    """Test building DTO with DynamicMapped relationship, and parsing data."""

    module = create_module(
        """
from __future__ import annotations

from sqlalchemy import ForeignKey
from sqlalchemy.orm import DeclarativeBase, DynamicMapped, Mapped, mapped_column, relationship, WriteOnlyMapped
from typing import List
from typing_extensions import Annotated

from advanced_alchemy.extensions.litestar.dto import SQLAlchemyDTO, SQLAlchemyDTOConfig

class Base(DeclarativeBase):
    id: Mapped[int] = mapped_column(primary_key=True)

class Child(Base):
    __tablename__ = "child"
    test_model_id: Mapped[int] = mapped_column(ForeignKey("test_model.id"))

class TestModel(Base):
    __tablename__ = "test_model"
    children: DynamicMapped[List[Child]] = relationship(Child, lazy="joined")

dto_type = SQLAlchemyDTO[Annotated[TestModel, SQLAlchemyDTOConfig()]]
""",
    )
    model = await get_model_from_dto(
        module.dto_type,
        module.TestModel,
        asgi_connection,
        b'{"id": 2, "children": [{"id": 1, "test_model_id": 2}]}',
    )
    assert isinstance(model, module.TestModel)
    # For DynamicMapped, we should check the query result
    child = model.children[0]  # Access first item from the dynamic query
    assert isinstance(child, module.Child)


async def test_to_mapped_model_with_writeonly_mapped(
    create_module: Callable[[str], ModuleType],
    asgi_connection: Request[Any, Any, Any],
) -> None:
    """Test building DTO with WriteOnlyMapped relationship, and parsing data."""

    module = create_module(
        """
from __future__ import annotations

from sqlalchemy import ForeignKey
from sqlalchemy.orm import DeclarativeBase, Mapped, WriteOnlyMapped, mapped_column, relationship
from typing import List
from typing_extensions import Annotated

from litestar.dto.field import Mark, dto_field
from advanced_alchemy.extensions.litestar.dto import SQLAlchemyDTO, SQLAlchemyDTOConfig

class Base(DeclarativeBase):
    id: Mapped[int] = mapped_column(primary_key=True)

class Child(Base):
    __tablename__ = "child"
    test_model_id: Mapped[int] = mapped_column(ForeignKey("test_model.id"))

class TestModel(Base):
    __tablename__ = "test_model"
    children: WriteOnlyMapped[List[Child]] = relationship(Child, info=dto_field(mark=Mark.WRITE_ONLY))

dto_type = SQLAlchemyDTO[Annotated[TestModel, SQLAlchemyDTOConfig()]]
""",
    )
    model = await get_model_from_dto(
        module.dto_type,
        module.TestModel,
        asgi_connection,
        b'{"id": 2, "children": [{"id": 1, "test_model_id": 2}]}',
    )
    assert isinstance(model, module.TestModel)
    # WriteOnlyMapped relationships can only be written to, not read from
    # So we can only verify the model was created successfully
python-advanced-alchemy-1.9.3/tests/unit/test_extensions/test_litestar/test_dto_integration.py000066400000000000000000001001511516556515500333130ustar00rootroot00000000000000from __future__ import annotations

from dataclasses import dataclass
from functools import cached_property
from types import ModuleType
from typing import Annotated, Any, Callable, Optional
from uuid import UUID

import pytest
from litestar import get, post
from litestar.di import Provide
from litestar.dto import DTOField, Mark
from litestar.dto._backend import _camelize  # type: ignore
from litestar.dto.field import DTO_FIELD_META_KEY
from litestar.dto.types import RenameStrategy
from litestar.testing import create_test_client  # type: ignore
from sqlalchemy import Column, ForeignKey, Integer, String, Table, func, select
from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import (
    DeclarativeBase,
    Mapped,
    column_property,
    composite,
    declared_attr,
    mapped_column,
    relationship,
)

from advanced_alchemy.extensions.litestar.dto import SQLAlchemyDTO, SQLAlchemyDTOConfig


class Base(DeclarativeBase):
    id: Mapped[str] = mapped_column(primary_key=True, default=UUID)

    # noinspection PyMethodParameters
    @declared_attr.directive
    def __tablename__(cls) -> str:
        """Infer table name from class name."""
        return cls.__name__.lower()


class Tag(Base):
    name: Mapped[str] = mapped_column(default="best seller")


class TaggableMixin:
    @classmethod
    @declared_attr.directive
    def tag_association_table(cls) -> Table:
        return Table(
            f"{cls.__tablename__}_tag_association",  # type: ignore
            cls.metadata,  # type: ignore
            Column("base_id", ForeignKey(f"{cls.__tablename__}.id", ondelete="CASCADE"), primary_key=True),  # type: ignore
            Column("tag_id", ForeignKey("tag.id", ondelete="CASCADE"), primary_key=True),  # type: ignore
        )

    @declared_attr
    def assigned_tags(cls) -> Mapped[list[Tag]]:
        return relationship(
            "Tag",
            secondary=lambda: cls.tag_association_table,
            lazy="immediate",
            cascade="all, delete",
            passive_deletes=True,
        )

    @declared_attr
    def tags(cls) -> AssociationProxy[list[str]]:
        return association_proxy(
            "assigned_tags",
            "name",
            creator=lambda name: Tag(name=name),  # pyright: ignore[reportUnknownArgumentType,reportUnknownLambdaType]
            info={"__dto__": DTOField()},
        )


class Author(Base):
    name: Mapped[str] = mapped_column(default="Arthur")
    date_of_birth: Mapped[str] = mapped_column(nullable=True)


class BookReview(Base):
    review: Mapped[str]
    book_id: Mapped[str] = mapped_column(ForeignKey("book.id"), default="000")


class Book(Base):
    title: Mapped[str] = mapped_column(String(length=250), default="Hi")
    author_id: Mapped[str] = mapped_column(ForeignKey("author.id"), default="123")
    first_author: Mapped[Author] = relationship(lazy="joined", innerjoin=True)
    reviews: Mapped[list[BookReview]] = relationship(lazy="joined", innerjoin=True)
    bar: Mapped[str] = mapped_column(default="Hello")
    SPAM: Mapped[str] = mapped_column(default="Bye")
    spam_bar: Mapped[str] = mapped_column(default="Goodbye")
    number_of_reviews: Mapped[Optional[int]] = column_property(
        select(func.count(BookReview.id)).where(BookReview.book_id == id).scalar_subquery(),  # type: ignore
    )


def _rename_field(name: str, strategy: RenameStrategy) -> str:
    if callable(strategy):
        return strategy(name)

    if strategy == "camel":
        return _camelize(value=name, capitalize_first_letter=False)

    if strategy == "pascal":
        return _camelize(value=name, capitalize_first_letter=True)

    return name.lower() if strategy == "lower" else name.upper()


@dataclass
class BookAuthorTestData:
    book_id: str = "000"
    book_title: str = "TDD Python"
    book_author_id: str = "123"
    book_author_name: str = "Harry Percival"
    book_author_date_of_birth: str = "01/01/1900"
    book_bar: str = "Hi"
    book_SPAM: str = "Bye"
    book_spam_bar: str = "GoodBye"
    book_review_id: str = "23432"
    book_review: str = "Excellent!"
    number_of_reviews: int | None = None


@pytest.fixture
def book_json_data() -> Callable[[RenameStrategy, BookAuthorTestData], tuple[dict[str, Any], Book]]:
    def _generate(rename_strategy: RenameStrategy, test_data: BookAuthorTestData) -> tuple[dict[str, Any], Book]:
        data: dict[str, Any] = {
            _rename_field(name="id", strategy=rename_strategy): test_data.book_id,
            _rename_field(name="title", strategy=rename_strategy): test_data.book_title,
            _rename_field(name="author_id", strategy=rename_strategy): test_data.book_author_id,
            _rename_field(name="bar", strategy=rename_strategy): test_data.book_bar,
            _rename_field(name="SPAM", strategy=rename_strategy): test_data.book_SPAM,
            _rename_field(name="spam_bar", strategy=rename_strategy): test_data.book_spam_bar,
            _rename_field(name="first_author", strategy=rename_strategy): {
                _rename_field(name="id", strategy=rename_strategy): test_data.book_author_id,
                _rename_field(name="name", strategy=rename_strategy): test_data.book_author_name,
                _rename_field(name="date_of_birth", strategy=rename_strategy): test_data.book_author_date_of_birth,
            },
            _rename_field(name="reviews", strategy=rename_strategy): [
                {
                    _rename_field(name="book_id", strategy=rename_strategy): test_data.book_id,
                    _rename_field(name="id", strategy=rename_strategy): test_data.book_review_id,
                    _rename_field(name="review", strategy=rename_strategy): test_data.book_review,
                },
            ],
            _rename_field(name="number_of_reviews", strategy=rename_strategy): test_data.number_of_reviews,
        }
        book = Book(
            id=test_data.book_id,
            title=test_data.book_title,
            author_id=test_data.book_author_id,
            bar=test_data.book_bar,
            SPAM=test_data.book_SPAM,
            spam_bar=test_data.book_spam_bar,
            first_author=Author(
                id=test_data.book_author_id,
                name=test_data.book_author_name,
                date_of_birth=test_data.book_author_date_of_birth,
            ),
            reviews=[
                BookReview(id=test_data.book_review_id, review=test_data.book_review, book_id=test_data.book_id),
            ],
        )
        return data, book

    return _generate


@pytest.mark.parametrize(
    "rename_strategy",
    ("camel",),
)
def test_fields_alias_generator_sqlalchemy(
    rename_strategy: RenameStrategy,
    book_json_data: Callable[[RenameStrategy, BookAuthorTestData], tuple[dict[str, Any], Book]],
) -> None:
    test_data = BookAuthorTestData()
    json_data, instance = book_json_data(rename_strategy, test_data)
    config = SQLAlchemyDTOConfig(rename_strategy=rename_strategy)
    dto = SQLAlchemyDTO[Annotated[Book, config]]

    @post(dto=dto, signature_namespace={"Book": Book})
    def post_handler(data: Book) -> Book:
        return data

    @get(dto=dto, signature_namespace={"Book": Book})
    def get_handler() -> Book:
        return instance

    with create_test_client(
        route_handlers=[post_handler, get_handler],
    ) as client:
        response_callback = client.get("/")
        assert response_callback.json() == json_data

        response_callback = client.post("/", json=json_data)
        assert response_callback.json() == json_data


class ConcreteBase(Base):
    pass


func_result_query = select(func.count()).scalar_subquery()
model_with_func_query = select(ConcreteBase, func_result_query.label("func_result")).subquery()


class ModelWithFunc(Base):
    __table__ = model_with_func_query
    func_result: Mapped[Optional[int]] = column_property(model_with_func_query.c.func_result)


def test_model_using_func() -> None:
    instance = ModelWithFunc(id="hi")
    config = SQLAlchemyDTOConfig()
    dto = SQLAlchemyDTO[Annotated[ModelWithFunc, config]]

    @get(dto=dto, signature_namespace={"ModelWithFunc": ModelWithFunc})
    async def get_handler() -> ModelWithFunc:
        return instance

    with create_test_client(
        route_handlers=[get_handler],
    ) as client:
        response_callback = client.get("/")
        assert response_callback


def test_dto_includes_simple_property() -> None:
    """Test that @property decorated methods appear in DTO as read-only fields."""

    class ModelWithProperty(Base):
        __tablename__ = "model_with_property"

        first_name: Mapped[str] = mapped_column()
        last_name: Mapped[str] = mapped_column()

        @property
        def full_name(self) -> str:
            return f"{self.first_name} {self.last_name}"

    config = SQLAlchemyDTOConfig()
    dto = SQLAlchemyDTO[Annotated[ModelWithProperty, config]]
    field_defs = list(dto.generate_field_definitions(ModelWithProperty))
    field_names = {f.name for f in field_defs}

    assert "full_name" in field_names

    full_name_field = next(f for f in field_defs if f.name == "full_name")
    assert full_name_field.dto_field.mark == Mark.READ_ONLY


def test_dto_includes_cached_property() -> None:
    """Test that @cached_property decorated methods appear in DTO as read-only fields."""

    class ModelWithCachedProperty(Base):
        __tablename__ = "model_with_cached_property"

        value: Mapped[int] = mapped_column()

        @cached_property
        def expensive_calculation(self) -> int:
            return self.value * 2

    config = SQLAlchemyDTOConfig()
    dto = SQLAlchemyDTO[Annotated[ModelWithCachedProperty, config]]
    field_defs = list(dto.generate_field_definitions(ModelWithCachedProperty))
    field_names = {f.name for f in field_defs}

    assert "expensive_calculation" in field_names

    field = next(f for f in field_defs if f.name == "expensive_calculation")
    assert field.dto_field.mark == Mark.READ_ONLY


def test_dto_property_with_setter_is_read_only() -> None:
    """Test that properties with setters are marked READ_ONLY (setter support not implemented)."""

    class ModelWithPropertySetter(Base):
        __tablename__ = "model_with_property_setter"

        _internal_value: Mapped[int] = mapped_column(default=0)

        @property
        def value(self) -> int:
            return self._internal_value

        @value.setter
        def value(self, new_value: int) -> None:
            self._internal_value = new_value

    config = SQLAlchemyDTOConfig()
    dto = SQLAlchemyDTO[Annotated[ModelWithPropertySetter, config]]
    field_defs = list(dto.generate_field_definitions(ModelWithPropertySetter))
    field_names = {f.name for f in field_defs}

    assert "value" in field_names

    field = next(f for f in field_defs if f.name == "value")
    assert field.dto_field.mark == Mark.READ_ONLY


def test_dto_skips_private_properties() -> None:
    """Test that properties starting with _ are excluded from DTO."""

    class ModelWithPrivateProperty(Base):
        __tablename__ = "model_with_private_property"

        @property
        def public_prop(self) -> str:
            return "public"

        @property
        def _private_prop(self) -> str:
            return "private"

    config = SQLAlchemyDTOConfig()
    dto = SQLAlchemyDTO[Annotated[ModelWithPrivateProperty, config]]
    field_defs = list(dto.generate_field_definitions(ModelWithPrivateProperty))
    field_names = {f.name for f in field_defs}

    assert "public_prop" in field_names
    assert "_private_prop" not in field_names


def test_dto_handles_untyped_property() -> None:
    """Test that properties without type hints are included with Any type."""

    class ModelWithUntypedProperty(Base):
        __tablename__ = "model_with_untyped_property"

        @property
        def untyped_prop(self):  # type: ignore[no-untyped-def]
            return "value"

    config = SQLAlchemyDTOConfig()
    dto = SQLAlchemyDTO[Annotated[ModelWithUntypedProperty, config]]
    field_defs = list(dto.generate_field_definitions(ModelWithUntypedProperty))
    field_names = {f.name for f in field_defs}

    assert "untyped_prop" in field_names

    field = next(f for f in field_defs if f.name == "untyped_prop")
    assert field is not None


def test_dto_with_association_proxy(create_module: Callable[[str], ModuleType]) -> None:
    module = create_module(
        """
from __future__ import annotations

from typing import Dict, List, Set, Tuple, Type, Final, List, Generator

from sqlalchemy import Column
from sqlalchemy import ForeignKey
from sqlalchemy import Integer
from sqlalchemy import String
from sqlalchemy import Table
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm import Mapped
from sqlalchemy.orm import mapped_column
from sqlalchemy.orm import relationship
from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.ext.associationproxy import AssociationProxy

from litestar import get
from advanced_alchemy.extensions.litestar.dto import SQLAlchemyDTO
from litestar.dto import dto_field

class Base(DeclarativeBase):
    pass

class User(Base):
    __tablename__ = "user_table"
    id: Mapped[int] = mapped_column(primary_key=True)
    kw: Mapped[List[Keyword]] = relationship(secondary=lambda: user_keyword_table, info=dto_field("private"))
    # proxy the 'keyword' attribute from the 'kw' relationship
    keywords: AssociationProxy[List[str]] = association_proxy("kw", "keyword")

class Keyword(Base):
    __tablename__ = "keyword"
    id: Mapped[int] = mapped_column(primary_key=True)
    keyword: Mapped[str] = mapped_column(String(64))

user_keyword_table: Final[Table] = Table(
    "user_keyword",
    Base.metadata,
    Column("user_id", Integer, ForeignKey("user_table.id"), primary_key=True),
    Column("keyword_id", Integer, ForeignKey("keyword.id"), primary_key=True),
)

dto = SQLAlchemyDTO[User]

@get("/", return_dto=dto)
async def get_handler() -> User:
    return User(id=1, kw=[Keyword(keyword="bar"), Keyword(keyword="baz")])
""",
    )

    with create_test_client(route_handlers=[module.get_handler]) as client:
        response = client.get("/")
        assert response.json() == {"id": 1, "keywords": ["bar", "baz"]}


def test_dto_with_hybrid_property(create_module: Callable[[str], ModuleType]) -> None:
    module = create_module(
        """
from __future__ import annotations

from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm import Mapped
from sqlalchemy.orm import mapped_column
from unittest.mock import MagicMock

from litestar import get
from advanced_alchemy.extensions.litestar.dto import SQLAlchemyDTO

method_call_mock = MagicMock()

class Base(DeclarativeBase):
    pass

class Interval(Base):
    __tablename__ = 'interval'

    id: Mapped[int] = mapped_column(primary_key=True)
    start: Mapped[int]
    end: Mapped[int]

    @classmethod
    def something(cls) -> None:
        method_call_mock()

    @hybrid_property
    def length(self) -> int:
        self.something()
        return self.end - self.start

dto = SQLAlchemyDTO[Interval]

@get("/", return_dto=dto)
async def get_handler() -> Interval:
    return Interval(id=1, start=1, end=3)
""",
    )

    with create_test_client(route_handlers=[module.get_handler]) as client:
        response = client.get("/")
        assert response.json() == {"id": 1, "start": 1, "end": 3, "length": 2}

    # property should have only been accessed once - during serialisation.
    # this is to prevent implicit descriptor access, as noted in
    # https://github.com/litestar-org/advanced-alchemy/issues/646
    assert module.method_call_mock.call_count == 1


def test_dto_with_hybrid_property_expression(create_module: Callable[[str], ModuleType]) -> None:
    module = create_module(
        """
from __future__ import annotations

from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm import Mapped
from sqlalchemy.orm import mapped_column
from sqlalchemy.sql import SQLColumnExpression

from litestar import get
from advanced_alchemy.extensions.litestar.dto import SQLAlchemyDTO

class Base(DeclarativeBase):
    pass

class Interval(Base):
    __tablename__ = 'interval'

    id: Mapped[int] = mapped_column(primary_key=True)
    start: Mapped[int]
    end: Mapped[int]

    @hybrid_property
    def length(self) -> int:
        return self.end - self.start

    @length.inplace.expression
    def _length_expression(cls) -> SQLColumnExpression[int]:
        return cls.end - cls.start

dto = SQLAlchemyDTO[Interval]

@get("/", return_dto=dto)
async def get_handler() -> Interval:
    return Interval(id=1, start=1, end=3)
""",
    )

    with create_test_client(route_handlers=[module.get_handler]) as client:
        response = client.get("/")
        assert response.json() == {"id": 1, "start": 1, "end": 3, "length": 2}


def test_dto_with_hybrid_property_setter(create_module: Callable[[str], ModuleType]) -> None:
    module = create_module(
        """
from __future__ import annotations

from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm import Mapped
from sqlalchemy.orm import mapped_column
from sqlalchemy.sql import SQLColumnExpression

from litestar import post
from advanced_alchemy.extensions.litestar.dto import SQLAlchemyDTO
from litestar.dto import dto_field

class Base(DeclarativeBase):
    pass

class Circle(Base):
    __tablename__ = 'circle'

    id: Mapped[int] = mapped_column(primary_key=True, info=dto_field("read-only"))
    diameter: Mapped[float] = mapped_column(info=dto_field("private"))

    @hybrid_property
    def radius(self) -> float:
        return self.diameter / 2

    @radius.inplace.setter
    def _radius_setter(self, value: float) -> None:
        self.diameter = value * 2

dto = SQLAlchemyDTO[Circle]

DIAMETER: float = 0

@post("/", dto=dto, sync_to_thread=False)
def get_handler(data: Circle) -> Circle:
    global DIAMETER
    DIAMETER = data.diameter
    data.id = 1
    return data
""",
    )

    with create_test_client(route_handlers=[module.get_handler]) as client:
        response = client.post("/", json={"radius": 5})
        assert response.json() == {"id": 1, "radius": 5}
        assert module.DIAMETER == 10


@pytest.mark.skip(reason="Debug me!")
async def test_dto_with_composite_map() -> None:
    @dataclass
    class Point:
        x: int
        y: int

    class Vertex1(Base):
        start: Mapped[Point] = composite(mapped_column("x1"), mapped_column("y1"))
        end: Mapped[Point] = composite(mapped_column("x2"), mapped_column("y2"))

    dto = SQLAlchemyDTO[Vertex1]

    @post(dto=dto, signature_namespace={"Vertex": Vertex1})
    async def post_handler(data: Vertex1) -> Vertex1:
        return data

    with create_test_client(route_handlers=[post_handler]) as client:
        response = client.post(
            "/",
            json={
                "id": "1",
                "start": {"x": 10, "y": 20},
                "end": {"x": 1, "y": 2},
            },
        )
        assert response.json() == {
            "id": "1",
            "start": {"x": 10, "y": 20},
            "end": {"x": 1, "y": 2},
        }


@pytest.mark.skip(reason="Debug me!")
async def test_dto_with_composite_map_using_explicit_columns() -> None:
    @dataclass
    class Point:
        x: int
        y: int

    class Vertex2(Base):
        x1: Mapped[int]
        y1: Mapped[int]
        x2: Mapped[int]
        y2: Mapped[int]

        start: Mapped[Point] = composite("x1", "y1")
        end: Mapped[Point] = composite("x2", "y2")

    dto = SQLAlchemyDTO[Vertex2]

    @post(dto=dto, signature_namespace={"Vertex": Vertex2})
    async def post_handler(data: Vertex2) -> Vertex2:
        return data

    with create_test_client(route_handlers=[post_handler]) as client:
        response = client.post(
            "/",
            json={
                "id": "1",
                "start": {"x": 10, "y": 20},
                "end": {"x": 1, "y": 2},
            },
        )
        assert response.json() == {
            "id": "1",
            "start": {"x": 10, "y": 20},
            "end": {"x": 1, "y": 2},
        }


@pytest.mark.skip(reason="Debug me!")
async def test_dto_with_composite_map_using_hybrid_imperative_mapping() -> None:
    @dataclass
    class Point:
        x: int
        y: int

    table = Table(
        "vertices2",
        Base.metadata,
        Column("id", String, primary_key=True),
        Column("x1", Integer),
        Column("y1", Integer),
        Column("x2", Integer),
        Column("y2", Integer),
    )

    class Vertex3(Base):
        __table__ = table

        id: Mapped[str]

        start = composite(Point, table.c.x1, table.c.y1)
        end = composite(Point, table.c.x2, table.c.y2)

    dto = SQLAlchemyDTO[Vertex3]

    @post(dto=dto, signature_namespace={"Vertex": Vertex3})
    async def post_handler(data: Vertex3) -> Vertex3:
        return data

    with create_test_client(route_handlers=[post_handler]) as client:
        response = client.post(
            "/",
            json={
                "id": "1",
                "start": {"x": 10, "y": 20},
                "end": {"x": 1, "y": 2},
            },
        )
        assert response.json() == {
            "id": "1",
            "start": {"x": 10, "y": 20},
            "end": {"x": 1, "y": 2},
        }


async def test_field_with_sequence_default(create_module: Callable[[str], ModuleType]) -> None:
    module = create_module(
        """
from sqlalchemy import create_engine, Column, Integer, Sequence
from sqlalchemy.orm import DeclarativeBase, Mapped, sessionmaker

from litestar import Litestar, post
from advanced_alchemy.extensions.litestar.dto import SQLAlchemyDTO, SQLAlchemyDTOConfig

engine = create_engine('sqlite:///:memory:', echo=True)
Session = sessionmaker(bind=engine, expire_on_commit=False)

class Base(DeclarativeBase):
    pass

class Model(Base):
    __tablename__ = "model"
    id: Mapped[int] = Column(Integer, Sequence('model_id_seq', optional=False), primary_key=True)
    val: Mapped[str]

class ModelCreateDTO(SQLAlchemyDTO[Model]):
    config = SQLAlchemyDTOConfig(exclude={"id"})

ModelReturnDTO = SQLAlchemyDTO[Model]

@post("/", dto=ModelCreateDTO, return_dto=ModelReturnDTO)
def post_handler(data: Model) -> Model:
    Base.metadata.create_all(engine)

    with Session() as session:
        session.add(data)
        session.commit()

    return data
    """,
    )
    with create_test_client(route_handlers=[module.post_handler]) as client:
        response = client.post("/", json={"id": 1, "val": "value"})
        assert response.json() == {"id": 1, "val": "value"}


async def test_disable_implicitly_mapped_columns_using_annotated_notation() -> None:
    class Base(DeclarativeBase):
        id: Mapped[int] = mapped_column(default=int, primary_key=True)

    table = Table(
        "vertices2",
        Base.metadata,
        Column("id", Integer, primary_key=True),
        Column("field", String, nullable=True),
    )

    class Model(Base):
        __table__ = table
        id: Mapped[int]

        @hybrid_property
        def id_multiplied(self) -> int:
            return self.id * 10

    dto_type = SQLAlchemyDTO[Annotated[Model, SQLAlchemyDTOConfig(include_implicit_fields=False)]]

    @get(
        dto=None,
        return_dto=dto_type,
        signature_namespace={"Model": Model},
        dependencies={"model": Provide(lambda: Model(id=123, field="hi"), sync_to_thread=False)},
    )
    async def post_handler(model: Model) -> Model:
        return model

    with create_test_client(route_handlers=[post_handler]) as client:
        response = client.get(
            "/",
        )

        json = response.json()
        assert json.get("field") is None
        assert json.get("id_multiplied") is None


async def test_disable_implicitly_mapped_columns_special() -> None:
    class Base(DeclarativeBase):
        id: Mapped[int] = mapped_column(default=int, primary_key=True)

    table = Table(
        "vertices2",
        Base.metadata,
        Column("id", Integer, primary_key=True),
        Column("field", String, nullable=True),
    )

    class Model(Base):
        __table__ = table
        id: Mapped[int]

    class dto_type(SQLAlchemyDTO[Model]):
        config = SQLAlchemyDTOConfig(include_implicit_fields=False)

    @get(
        dto=None,
        return_dto=dto_type,
        signature_namespace={"Model": Model},
        dependencies={"model": Provide(lambda: Model(id=123, field="hi"), sync_to_thread=False)},
    )
    async def post_handler(model: Model) -> Model:
        return model

    with create_test_client(route_handlers=[post_handler]) as client:
        response = client.get(
            "/",
        )

        json = response.json()
        assert json.get("field") is None


async def test_disable_implicitly_mapped_columns_with_hybrid_properties_and_Mark_overrides() -> None:
    class Base(DeclarativeBase):
        id: Mapped[int] = mapped_column(default=int, primary_key=True)

    table = Table(
        "vertices2",
        Base.metadata,
        Column("id", Integer, primary_key=True),
        Column("field", String, nullable=True),
        Column("field2", String),
        Column("field3", String),
        Column("field4", String),
    )

    class Model(Base):
        __table__ = table
        id: Mapped[int]
        field2 = column_property(table.c.field2, info={DTO_FIELD_META_KEY: DTOField(mark=Mark.READ_ONLY)})  # type: ignore
        field3 = column_property(table.c.field3, info={DTO_FIELD_META_KEY: DTOField(mark=Mark.WRITE_ONLY)})  # type: ignore
        field4 = column_property(table.c.field4, info={DTO_FIELD_META_KEY: DTOField(mark=Mark.PRIVATE)})  # type: ignore

        @hybrid_property
        def id_multiplied(self) -> int:
            return self.id * 10

    dto_type = SQLAlchemyDTO[
        Annotated[
            Model,
            SQLAlchemyDTOConfig(include_implicit_fields="hybrid-only"),
        ]
    ]

    @get(
        dto=None,
        return_dto=dto_type,
        signature_namespace={"Model": Model},
        dependencies={
            "model": Provide(
                lambda: Model(id=12, field="hi", field2="bye2", field3="bye3", field4="bye4"),
                sync_to_thread=False,
            ),
        },
    )
    async def post_handler(model: Model) -> Model:
        return model

    with create_test_client(route_handlers=[post_handler]) as client:
        response = client.get(
            "/",
        )

        json = response.json()
        assert json.get("id_multiplied") == 120
        assert json.get("field") is None
        assert json.get("field2") is not None
        assert json.get("field3") is not None
        assert json.get("field4") is None


def test_dto_to_sync_service(create_module: Callable[[str], ModuleType]) -> None:
    module = create_module(
        """
from __future__ import annotations

from typing import Dict, List, Set, Tuple, Type, Generator

from litestar import post
from litestar.di import Provide
from litestar.dto import DTOData
from sqlalchemy import create_engine
from sqlalchemy.orm import Mapped, sessionmaker

from advanced_alchemy.extensions.litestar import SQLAlchemyDTO, SQLAlchemyDTOConfig, base, repository, service

engine = create_engine("sqlite:///:memory:", echo=True, connect_args={"check_same_thread": False})
Session = sessionmaker(bind=engine, expire_on_commit=False)

class Model(base.BigIntBase):
    val: Mapped[str]

class ModelCreateDTO(SQLAlchemyDTO[Model]):
    config = SQLAlchemyDTOConfig(exclude={"id"})

ModelReturnDTO = SQLAlchemyDTO[Model]

class ModelRepository(repository.SQLAlchemySyncRepository[Model]):
    model_type=Model

class ModelService(service.SQLAlchemySyncRepositoryService[Model]):
    repository_type = ModelRepository

def provide_service( ) -> Generator[ModelService, None, None]:
    Model.metadata.create_all(engine)
    with Session() as db_session, ModelService.new(session=db_session) as service:
        yield service
    Model.metadata.drop_all(engine)


@post("/", dependencies={"service": Provide(provide_service)}, dto=ModelCreateDTO, return_dto=ModelReturnDTO)
def post_handler(data: DTOData[Model], service: ModelService) -> Model:
    return service.create(data, auto_commit=True)

    """,
    )
    with create_test_client(route_handlers=[module.post_handler]) as client:
        response = client.post("/", json={"id": 1, "val": "value"})
        assert response.json() == {"id": 1, "val": "value"}


async def test_dto_to_async_service(create_module: Callable[[str], ModuleType]) -> None:
    module = create_module(
        """
from __future__ import annotations

from typing import Dict, List, Set, Tuple, Type, AsyncGenerator

from litestar import post
from litestar.di import Provide
from litestar.dto import DTOData  # noqa: TCH002
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.orm import Mapped  # noqa: TCH002

from advanced_alchemy.extensions.litestar import SQLAlchemyDTO, SQLAlchemyDTOConfig, base, repository, service

engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=True, connect_args={"check_same_thread": False})
Session = async_sessionmaker(bind=engine, expire_on_commit=False)

class AModel(base.BigIntBase):
    val: Mapped[str]

class ModelCreateDTO(SQLAlchemyDTO[AModel]):
    config = SQLAlchemyDTOConfig(exclude={"id"})

ModelReturnDTO = SQLAlchemyDTO[AModel]

class ModelRepository(repository.SQLAlchemyAsyncRepository[AModel]):
    model_type=AModel

class ModelService(service.SQLAlchemyAsyncRepositoryService[AModel]):
    repository_type = ModelRepository

async def provide_service( ) -> AsyncGenerator[ModelService, None]:
    async with engine.begin() as conn:
        await conn.run_sync(AModel.metadata.create_all)
    async with Session() as db_session, ModelService.new(session=db_session) as service:
        yield service
    async with engine.begin() as conn:
        await conn.run_sync(AModel.metadata.create_all)

@post("/", dependencies={"service": Provide(provide_service)}, dto=ModelCreateDTO, return_dto=ModelReturnDTO)
async def post_handler(data: DTOData[AModel], service: ModelService) -> AModel:
    return await service.create(data, auto_commit=True)

    """,
    )
    with create_test_client(route_handlers=[module.post_handler]) as client:
        response = client.post("/", json={"id": 1, "val": "value"})
        assert response.json() == {"id": 1, "val": "value"}


def test_dto_with_declared_attr(create_module: Callable[[str], ModuleType]) -> None:
    module = create_module(
        """
from __future__ import annotations

from typing import Dict, List, Set, Tuple, Type, Union

from litestar import post
from litestar.di import Provide
from litestar.dto import DTOData, DTOField, Mark
from litestar.dto.field import DTO_FIELD_META_KEY
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, column_property, declared_attr, mapped_column, sessionmaker

from advanced_alchemy.extensions.litestar import SQLAlchemyDTO, SQLAlchemyDTOConfig, base, repository, service

engine = create_engine("sqlite:///:memory:", echo=True, connect_args={"check_same_thread": False})
Session = sessionmaker(bind=engine, expire_on_commit=False)

class Model(base.BigIntBase):
    __tablename__ = "a"
    a: Mapped[int] = mapped_column()

    @declared_attr
    def a_doubled(cls) -> Mapped[int]:
        return column_property(cls.a * 2, info={DTO_FIELD_META_KEY: DTOField(mark=Mark.READ_ONLY)})

class ModelCreateDTO(SQLAlchemyDTO[Model]):
    config = SQLAlchemyDTOConfig(exclude={"id"})

ModelReturnDTO = SQLAlchemyDTO[Model]

class ModelRepository(repository.SQLAlchemySyncRepository[Model]):
    model_type=Model

class ModelService(service.SQLAlchemySyncRepositoryService[Model]):
    repository_type = ModelRepository

def provide_service( ) -> Generator[ModelService, None, None]:
    Model.metadata.create_all(engine)
    with Session() as db_session, ModelService.new(session=db_session) as service:
        yield service
    Model.metadata.drop_all(engine)

@post("/", dependencies={"service": Provide(provide_service)}, dto=ModelCreateDTO, return_dto=ModelReturnDTO)
def post_handler(data: DTOData[Model], service: ModelService) -> Model:
    return service.create(data, auto_commit=True)

""",
    )
    with create_test_client(route_handlers=[module.post_handler]) as client:
        response = client.post("/", json={"id": 1, "a": 21})
        assert response.json() == {"id": 1, "a": 21, "a_doubled": 42}
python-advanced-alchemy-1.9.3/tests/unit/test_extensions/test_litestar/test_init_plugin/000077500000000000000000000000001516556515500320735ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/tests/unit/test_extensions/test_litestar/test_init_plugin/__init__.py000066400000000000000000000000001516556515500341720ustar00rootroot00000000000000test_asyncio.py000066400000000000000000000465211516556515500351020ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/tests/unit/test_extensions/test_litestar/test_init_pluginfrom __future__ import annotations

import random
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch

import pytest
from asgi_lifespan import LifespanManager
from litestar import Litestar, Request, Response, get
from litestar.status_codes import (
    HTTP_404_NOT_FOUND,
    HTTP_409_CONFLICT,
    HTTP_500_INTERNAL_SERVER_ERROR,
)
from litestar.testing import RequestFactory, create_test_client
from litestar.types.asgi_types import HTTPResponseStartEvent
from pytest import MonkeyPatch
from sqlalchemy.ext.asyncio import AsyncSession

from advanced_alchemy.exceptions import (
    DuplicateKeyError,
    ForeignKeyError,
    ImproperConfigurationError,
    IntegrityError,
    InvalidRequestError,
    NotFoundError,
    RepositoryError,
)
from advanced_alchemy.extensions.litestar._utils import set_aa_scope_state
from advanced_alchemy.extensions.litestar.exception_handler import exception_to_http_response
from advanced_alchemy.extensions.litestar.plugins import SQLAlchemyAsyncConfig, SQLAlchemyInitPlugin
from advanced_alchemy.extensions.litestar.plugins.init.config.asyncio import (
    autocommit_before_send_handler,
    autocommit_handler_maker,
)

if TYPE_CHECKING:
    from typing import Any, Callable

    from litestar.types import Scope


def test_default_before_send_handler() -> None:
    """Test default_before_send_handler."""

    captured_scope_state: dict[str, Any] | None = None
    config = SQLAlchemyAsyncConfig(connection_string="sqlite+aiosqlite://")
    plugin = SQLAlchemyInitPlugin(config=config)

    @get()
    async def test_handler(db_session: AsyncSession, scope: Scope) -> None:
        nonlocal captured_scope_state
        captured_scope_state = scope["state"]
        assert db_session is captured_scope_state[config.session_dependency_key]

    with create_test_client(route_handlers=[test_handler], plugins=[plugin]) as client:
        client.get("/")
        assert captured_scope_state is not None
        assert config.session_dependency_key not in captured_scope_state  # pyright: ignore


def test_default_before_send_handle_multi() -> None:
    """Test default_before_send_handler."""

    captured_scope_state: dict[str, Any] | None = None
    config1 = SQLAlchemyAsyncConfig(connection_string="sqlite+aiosqlite://")
    config2 = SQLAlchemyAsyncConfig(
        connection_string="sqlite+aiosqlite://",
        session_dependency_key="other_session",
        session_scope_key="_sqlalchemy_state_2",
        engine_dependency_key="other_engine",
    )
    plugin = SQLAlchemyInitPlugin(config=[config1, config2])

    @get()
    async def test_handler(db_session: AsyncSession, scope: Scope) -> None:
        nonlocal captured_scope_state
        captured_scope_state = scope["state"]
        assert db_session is captured_scope_state[config1.session_dependency_key]

    with create_test_client(route_handlers=[test_handler], plugins=[plugin]) as client:
        client.get("/")
        assert captured_scope_state is not None
        assert config1.session_dependency_key not in captured_scope_state


async def test_create_all_default(monkeypatch: MonkeyPatch) -> None:
    """Test default_before_send_handler."""

    config = SQLAlchemyAsyncConfig(connection_string="sqlite+aiosqlite://")
    plugin = SQLAlchemyInitPlugin(config=config)
    app = Litestar(route_handlers=[], plugins=[plugin])
    with patch.object(
        config,
        "create_all_metadata",
    ) as create_all_metadata_mock:
        async with LifespanManager(app):  # type: ignore[arg-type]  # pyright: ignore[reportArgumentType]
            create_all_metadata_mock.assert_not_called()


async def test_create_all(monkeypatch: MonkeyPatch) -> None:
    """Test default_before_send_handler."""
    config = SQLAlchemyAsyncConfig(connection_string="sqlite+aiosqlite://", create_all=True)
    plugin = SQLAlchemyInitPlugin(config=config)
    app = Litestar(route_handlers=[], plugins=[plugin])
    with patch.object(
        config,
        "create_all_metadata",
    ) as create_all_metadata_mock:
        async with LifespanManager(app):  # type: ignore[arg-type]   # pyright: ignore[reportArgumentType]
            create_all_metadata_mock.assert_called_once()


async def test_before_send_handler_success_response(create_scope: Callable[..., Scope]) -> None:
    """Test that the session is committed given a success response."""
    config = SQLAlchemyAsyncConfig(
        connection_string="sqlite+aiosqlite://",
        before_send_handler=autocommit_before_send_handler,
    )
    app = Litestar(route_handlers=[], plugins=[SQLAlchemyInitPlugin(config)])
    mock_session = MagicMock(spec=AsyncSession)
    http_scope = create_scope(app=app)
    set_aa_scope_state(http_scope, config.session_scope_key, mock_session)
    http_response_start: HTTPResponseStartEvent = {
        "type": "http.response.start",
        "status": random.randint(200, 299),
        "headers": {},
    }
    await autocommit_before_send_handler(http_response_start, http_scope)
    mock_session.commit.assert_awaited_once()


async def test_before_send_handler_success_response_autocommit(create_scope: Callable[..., Scope]) -> None:
    """Test that the session is committed given a success response."""
    config = SQLAlchemyAsyncConfig(
        connection_string="sqlite+aiosqlite://",
        before_send_handler="autocommit",
    )
    app = Litestar(route_handlers=[], plugins=[SQLAlchemyInitPlugin(config)])
    mock_session = MagicMock(spec=AsyncSession)
    http_scope = create_scope(app=app)
    set_aa_scope_state(http_scope, config.session_scope_key, mock_session)
    http_response_start: HTTPResponseStartEvent = {
        "type": "http.response.start",
        "status": random.randint(200, 299),
        "headers": {},
    }
    await autocommit_before_send_handler(http_response_start, http_scope)
    mock_session.commit.assert_awaited_once()


async def test_before_send_handler_error_response(create_scope: Callable[..., Scope]) -> None:
    """Test that the session is rolled back given an error response."""
    config = SQLAlchemyAsyncConfig(
        connection_string="sqlite+aiosqlite://",
        before_send_handler=autocommit_before_send_handler,
    )
    app = Litestar(route_handlers=[], plugins=[SQLAlchemyInitPlugin(config)])
    mock_session = MagicMock(spec=AsyncSession)
    http_scope = create_scope(app=app)
    set_aa_scope_state(http_scope, config.session_scope_key, mock_session)
    http_response_start: HTTPResponseStartEvent = {
        "type": "http.response.start",
        "status": random.randint(300, 599),
        "headers": {},
    }
    await autocommit_before_send_handler(http_response_start, http_scope)
    mock_session.rollback.assert_awaited_once()


async def test_autocommit_handler_maker_redirect_response(create_scope: Callable[..., Scope]) -> None:
    """Test that the handler created by the handler maker commits on redirect"""
    autocommit_redirect_handler = autocommit_handler_maker(commit_on_redirect=True)
    config = SQLAlchemyAsyncConfig(
        connection_string="sqlite+aiosqlite://",
        before_send_handler=autocommit_redirect_handler,
    )
    app = Litestar(route_handlers=[], plugins=[SQLAlchemyInitPlugin(config)])
    mock_session = MagicMock(spec=AsyncSession)
    http_scope = create_scope(app=app)
    set_aa_scope_state(http_scope, config.session_scope_key, mock_session)
    http_response_start: HTTPResponseStartEvent = {
        "type": "http.response.start",
        "status": random.randint(300, 399),
        "headers": {},
    }
    await autocommit_redirect_handler(http_response_start, http_scope)
    mock_session.commit.assert_awaited_once()


async def test_autocommit_handler_maker_commit_statuses(create_scope: Callable[..., Scope]) -> None:
    """Test that the handler created by the handler maker commits on explicit statuses"""
    custom_autocommit_handler = autocommit_handler_maker(extra_commit_statuses={302, 303})
    config = SQLAlchemyAsyncConfig(
        connection_string="sqlite+aiosqlite://",
        before_send_handler=custom_autocommit_handler,
    )
    app = Litestar(route_handlers=[], plugins=[SQLAlchemyInitPlugin(config)])
    mock_session = MagicMock(spec=AsyncSession)
    http_scope = create_scope(app=app)
    set_aa_scope_state(http_scope, config.session_scope_key, mock_session)
    http_response_start: HTTPResponseStartEvent = {
        "type": "http.response.start",
        "status": random.randint(302, 303),
        "headers": {},
    }
    await custom_autocommit_handler(http_response_start, http_scope)
    mock_session.commit.assert_awaited_once()


async def test_autocommit_handler_maker_rollback_statuses(create_scope: Callable[..., Scope]) -> None:
    """Test that the handler created by the handler maker rolls back on explicit statuses"""
    custom_autocommit_handler = autocommit_handler_maker(commit_on_redirect=True, extra_rollback_statuses={307, 308})
    config = SQLAlchemyAsyncConfig(
        connection_string="sqlite+aiosqlite://",
        before_send_handler=custom_autocommit_handler,
    )
    app = Litestar(route_handlers=[], plugins=[SQLAlchemyInitPlugin(config)])
    mock_session = MagicMock(spec=AsyncSession)
    http_scope = create_scope(app=app)
    set_aa_scope_state(http_scope, config.session_scope_key, mock_session)
    http_response_start: HTTPResponseStartEvent = {
        "type": "http.response.start",
        "status": random.randint(307, 308),
        "headers": {},
    }
    await custom_autocommit_handler(http_response_start, http_scope)
    mock_session.rollback.assert_awaited_once()


async def test_autocommit_handler_maker_rollback_statuses_multi(create_scope: Callable[..., Scope]) -> None:
    """Test that the handler created by the handler maker rolls back on explicit statuses"""
    custom_autocommit_handler = autocommit_handler_maker(
        session_scope_key="_sqlalchemy_state_2",
        commit_on_redirect=True,
        extra_rollback_statuses={307, 308},
    )
    config1 = SQLAlchemyAsyncConfig(
        connection_string="sqlite+aiosqlite://",
    )
    config2 = SQLAlchemyAsyncConfig(
        connection_string="sqlite+aiosqlite://",
        before_send_handler=custom_autocommit_handler,
        session_dependency_key="other_session",
        engine_dependency_key="other_engine",
        session_scope_key="_sqlalchemy_state_2",
    )
    app = Litestar(route_handlers=[], plugins=[SQLAlchemyInitPlugin(config=[config1, config2])])
    mock_session1 = MagicMock(spec=AsyncSession)
    mock_session2 = MagicMock(spec=AsyncSession)
    http_scope = create_scope(app=app)
    set_aa_scope_state(http_scope, config1.session_scope_key, mock_session1)
    set_aa_scope_state(http_scope, config2.session_scope_key, mock_session2)
    http_response_start: HTTPResponseStartEvent = {
        "type": "http.response.start",
        "status": random.randint(307, 308),
        "headers": {},
    }
    await custom_autocommit_handler(http_response_start, http_scope)

    mock_session2.rollback.assert_called_once()
    mock_session1.rollback.assert_not_called()


async def test_autocommit_handler_maker_rollback_statuses_multi_bad_config(create_scope: Callable[..., Scope]) -> None:
    """Test that the handler created by the handler maker rolls back on explicit statuses"""
    with pytest.raises(ImproperConfigurationError):
        custom_autocommit_handler = autocommit_handler_maker(
            session_scope_key="_sqlalchemy_state_2",
            commit_on_redirect=True,
            extra_rollback_statuses={307, 308},
        )
        config1 = SQLAlchemyAsyncConfig(
            connection_string="sqlite+aiosqlite://",
        )
        config2 = SQLAlchemyAsyncConfig(
            connection_string="sqlite+aiosqlite://",
            before_send_handler=custom_autocommit_handler,
            session_dependency_key="other_session",
            session_scope_key="_sqlalchemy_state_2",
        )
        app = Litestar(route_handlers=[], plugins=[SQLAlchemyInitPlugin(config=[config1, config2])])
        mock_session1 = MagicMock(spec=AsyncSession)
        mock_session2 = MagicMock(spec=AsyncSession)
        http_scope = create_scope(app=app)
        set_aa_scope_state(http_scope, config1.session_scope_key, mock_session1)
        set_aa_scope_state(http_scope, config2.session_scope_key, mock_session2)
        http_response_start: HTTPResponseStartEvent = {
            "type": "http.response.start",
            "status": random.randint(307, 308),
            "headers": {},
        }
        await custom_autocommit_handler(http_response_start, http_scope)

        mock_session2.rollback.assert_called_once()
        mock_session1.rollback.assert_not_called()


async def test_autocommit_handler_maker_multi(create_scope: Callable[..., Scope]) -> None:
    """Test that the handler created by the handler maker rolls back on explicit statuses"""

    config1 = SQLAlchemyAsyncConfig(
        connection_string="sqlite+aiosqlite://",
        before_send_handler="autocommit",
    )
    config2 = SQLAlchemyAsyncConfig(
        connection_string="sqlite+aiosqlite://",
        before_send_handler="autocommit",
        session_dependency_key="other_session",
        engine_dependency_key="other_engine",
    )
    app = Litestar(route_handlers=[], plugins=[SQLAlchemyInitPlugin(config=[config1, config2])])
    mock_session1 = MagicMock(spec=AsyncSession)
    mock_session2 = MagicMock(spec=AsyncSession)
    http_scope = create_scope(app=app)
    set_aa_scope_state(http_scope, config1.session_scope_key, mock_session1)
    set_aa_scope_state(http_scope, config2.session_scope_key, mock_session2)
    http_response_start: HTTPResponseStartEvent = {
        "type": "http.response.start",
        "status": random.randint(307, 308),
        "headers": {},
    }
    await config2.before_send_handler(http_response_start, http_scope)  # type: ignore
    mock_session2.rollback.assert_called_once()
    mock_session1.rollback.assert_not_called()


@pytest.mark.parametrize(
    ("exc", "status"),
    [
        (IntegrityError, HTTP_409_CONFLICT),
        (ForeignKeyError, HTTP_409_CONFLICT),
        (DuplicateKeyError, HTTP_409_CONFLICT),
        (InvalidRequestError, HTTP_500_INTERNAL_SERVER_ERROR),
        (NotFoundError, HTTP_404_NOT_FOUND),
    ],
)
def test_repository_exception_to_http_response(exc: type[RepositoryError], status: int) -> None:
    """Test default exception handler."""

    config1 = SQLAlchemyAsyncConfig(connection_string="sqlite+aiosqlite://")
    config2 = SQLAlchemyAsyncConfig(
        connection_string="sqlite+aiosqlite://",
        session_dependency_key="other_session",
        session_scope_key="_sqlalchemy_state_2",
        engine_dependency_key="other_engine",
    )
    plugin = SQLAlchemyInitPlugin(config=[config1, config2])
    app = Litestar(route_handlers=[], plugins=[plugin])
    request = RequestFactory(app=app, server="testserver").get("/wherever")
    response = exception_to_http_response(request, exc())
    assert app.exception_handlers.get(exc) is None
    assert app.exception_handlers.get(RepositoryError) is not None
    assert response.status_code == status


@pytest.mark.parametrize(
    ("exc", "status"),
    [
        (IntegrityError, HTTP_409_CONFLICT),
        (ForeignKeyError, HTTP_409_CONFLICT),
        (DuplicateKeyError, HTTP_409_CONFLICT),
        (InvalidRequestError, HTTP_500_INTERNAL_SERVER_ERROR),
        (NotFoundError, HTTP_404_NOT_FOUND),
    ],
)
def test_existing_repository_exception_to_http_response(exc: type[RepositoryError], status: int) -> None:
    """Test default exception handler."""

    def handler(request: Request[Any, Any, Any], exc: RepositoryError) -> Response[Any]:
        return Response(status_code=200, content="OK")

    config1 = SQLAlchemyAsyncConfig(connection_string="sqlite+aiosqlite://")
    config2 = SQLAlchemyAsyncConfig(
        connection_string="sqlite+aiosqlite://",
        session_dependency_key="other_session",
        session_scope_key="_sqlalchemy_state_2",
        engine_dependency_key="other_engine",
    )
    plugin = SQLAlchemyInitPlugin(config=[config1, config2])
    app = Litestar(route_handlers=[], plugins=[plugin], exception_handlers={RepositoryError: handler})
    request = RequestFactory(app=app, server="testserver").get("/wherever")
    response = handler(request, exc())
    assert app.exception_handlers.get(exc) is None
    assert app.exception_handlers.get(RepositoryError) is not None
    assert app.exception_handlers.get(RepositoryError) == handler
    assert response.status_code == 200


@pytest.mark.parametrize(
    ("exc", "status"),
    [
        (IntegrityError, HTTP_409_CONFLICT),
        (ForeignKeyError, HTTP_409_CONFLICT),
        (DuplicateKeyError, HTTP_409_CONFLICT),
        (InvalidRequestError, HTTP_500_INTERNAL_SERVER_ERROR),
        (NotFoundError, HTTP_404_NOT_FOUND),
    ],
)
def test_repository_disabled_exception_to_http_response(exc: type[RepositoryError], status: int) -> None:
    """Test default exception handler."""

    config1 = SQLAlchemyAsyncConfig(connection_string="sqlite+aiosqlite://", set_default_exception_handler=False)
    config2 = SQLAlchemyAsyncConfig(
        connection_string="sqlite+aiosqlite://",
        session_dependency_key="other_session",
        session_scope_key="_sqlalchemy_state_2",
        engine_dependency_key="other_engine",
        set_default_exception_handler=False,
    )
    plugin = SQLAlchemyInitPlugin(config=[config1, config2])
    app = Litestar(route_handlers=[], plugins=[plugin])
    assert app.exception_handlers.get(exc) is None
    assert app.exception_handlers.get(RepositoryError) is None


async def test_autocommit_handler_rollback_failure_still_closes(create_scope: Callable[..., Scope]) -> None:
    """Verify session is closed even when rollback raises in async autocommit handler."""
    config = SQLAlchemyAsyncConfig(
        connection_string="sqlite+aiosqlite://",
        before_send_handler=autocommit_before_send_handler,
    )
    app = Litestar(route_handlers=[], plugins=[SQLAlchemyInitPlugin(config)])
    mock_session = MagicMock(spec=AsyncSession)
    mock_session.rollback.side_effect = RuntimeError("rollback failed")
    http_scope = create_scope(app=app)
    set_aa_scope_state(http_scope, config.session_scope_key, mock_session)
    http_response_start: HTTPResponseStartEvent = {
        "type": "http.response.start",
        "status": 500,
        "headers": {},
    }
    await autocommit_before_send_handler(http_response_start, http_scope)
    mock_session.rollback.assert_awaited_once()
    # http.response.start is a SESSION_TERMINUS event, so close runs too
    mock_session.close.assert_awaited_once()


async def test_autocommit_handler_commit_failure_does_not_crash(create_scope: Callable[..., Scope]) -> None:
    """Verify commit failure is caught and does not crash the handler."""
    config = SQLAlchemyAsyncConfig(
        connection_string="sqlite+aiosqlite://",
        before_send_handler=autocommit_before_send_handler,
    )
    app = Litestar(route_handlers=[], plugins=[SQLAlchemyInitPlugin(config)])
    mock_session = MagicMock(spec=AsyncSession)
    mock_session.commit.side_effect = RuntimeError("commit failed")
    http_scope = create_scope(app=app)
    set_aa_scope_state(http_scope, config.session_scope_key, mock_session)
    http_response_start: HTTPResponseStartEvent = {
        "type": "http.response.start",
        "status": 200,
        "headers": {},
    }
    # Should not raise - the exception is caught
    await autocommit_before_send_handler(http_response_start, http_scope)
    mock_session.commit.assert_awaited_once()
    mock_session.close.assert_awaited_once()
    mock_session.commit.assert_awaited_once()
test_common.py000066400000000000000000000137071516556515500347250ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/tests/unit/test_extensions/test_litestar/test_init_pluginfrom __future__ import annotations

import datetime
import uuid
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch

import pytest
from litestar.datastructures import State
from sqlalchemy import create_engine

from advanced_alchemy.exceptions import ImproperConfigurationError
from advanced_alchemy.extensions.litestar._utils import _SCOPE_NAMESPACE  # pyright: ignore[reportPrivateUsage]
from advanced_alchemy.extensions.litestar.plugins import SQLAlchemyAsyncConfig, SQLAlchemySyncConfig
from advanced_alchemy.extensions.litestar.plugins.init.config.common import SESSION_SCOPE_KEY

if TYPE_CHECKING:
    from typing import Any

    from litestar.types import Scope
    from pytest import MonkeyPatch


@pytest.fixture(name="config_cls", params=[SQLAlchemySyncConfig, SQLAlchemyAsyncConfig])
def _config_cls(request: Any) -> type[SQLAlchemySyncConfig | SQLAlchemyAsyncConfig]:
    """Return SQLAlchemy config class."""
    return request.param  # type:ignore[no-any-return]


def test_raise_improperly_configured_exception(config_cls: type[SQLAlchemySyncConfig]) -> None:
    """Test raise ImproperlyConfiguredException if both engine and connection string are provided."""
    with pytest.raises(ImproperConfigurationError):
        config_cls(connection_string="sqlite://", engine_instance=create_engine("sqlite://"))


def test_engine_config_dict_with_no_provided_config(
    config_cls: type[SQLAlchemySyncConfig],
) -> None:
    """Test engine_config_dict with no provided config."""
    config = config_cls()
    assert config.engine_config_dict.keys() == {"json_deserializer", "json_serializer"}


def test_session_config_dict_with_no_provided_config(
    config_cls: type[SQLAlchemySyncConfig],
) -> None:
    """Test session_config_dict with no provided config."""
    config = config_cls()
    # Config now includes file_object_raise_on_error in session info by default
    assert config.session_config_dict == {"info": {"file_object_raise_on_error": True}}


def test_config_create_engine_if_engine_instance_provided(
    config_cls: type[SQLAlchemySyncConfig],
) -> None:
    """Test create_engine if engine instance provided."""
    engine = create_engine("sqlite://")
    config = config_cls(engine_instance=engine)
    assert config.get_engine() == engine


def test_create_engine_if_no_engine_instance_or_connection_string_provided(
    config_cls: type[SQLAlchemySyncConfig],
) -> None:
    """Test create_engine if no engine instance or connection string provided."""
    config = config_cls()
    with pytest.raises(ImproperConfigurationError):
        config.get_engine()


def test_call_create_engine_callable_type_error_handling(
    config_cls: type[SQLAlchemySyncConfig],
    monkeypatch: MonkeyPatch,
) -> None:
    """If the dialect doesn't support JSON types, we get a ValueError.
    This should be handled by removing the JSON serializer/deserializer kwargs.
    """
    call_count = 0

    def side_effect(*args: Any, **kwargs: Any) -> None:
        nonlocal call_count
        call_count += 1
        if call_count == 1:
            raise TypeError()

    config = config_cls(connection_string="sqlite://")
    create_engine_callable_mock = MagicMock(side_effect=side_effect)
    monkeypatch.setattr(config, "create_engine_callable", create_engine_callable_mock)

    config.get_engine()

    assert create_engine_callable_mock.call_count == 2
    first_call, second_call = create_engine_callable_mock.mock_calls
    assert first_call.kwargs.keys() == {"json_deserializer", "json_serializer"}
    assert second_call.kwargs.keys() == set()


def test_create_session_maker_if_session_maker_provided(
    config_cls: type[SQLAlchemySyncConfig],
) -> None:
    """Test create_session_maker if session maker provided to config."""
    session_maker = MagicMock()
    config = config_cls(session_maker=session_maker)
    assert config.create_session_maker() == session_maker


def test_create_session_maker_if_no_session_maker_or_bind_provided(
    config_cls: type[SQLAlchemySyncConfig],
    monkeypatch: MonkeyPatch,
) -> None:
    """Test create_session_maker if no session maker or bind provided to config."""
    config = config_cls()
    create_engine_mock = MagicMock(return_value=create_engine("sqlite://"))
    monkeypatch.setattr(config, "get_engine", create_engine_mock)
    assert config.session_maker is None
    assert isinstance(config.create_session_maker(), config.session_maker_class)
    create_engine_mock.assert_called_once()


def test_create_session_instance_if_session_not_in_scope_state(
    config_cls: type[SQLAlchemySyncConfig],
) -> None:
    """Test provide_session if session not in scope state."""
    with patch(
        "advanced_alchemy.extensions.litestar._utils.get_aa_scope_state",
    ) as get_scope_state_mock:
        get_scope_state_mock.return_value = None
        config = config_cls()
        state = State()
        state[config.session_maker_app_state_key] = MagicMock()
        scope: Scope = {}  # type:ignore[assignment]
        assert isinstance(config.provide_session(state, scope), MagicMock)
        assert SESSION_SCOPE_KEY in scope[_SCOPE_NAMESPACE]  # type: ignore[literal-required]


def test_app_state(config_cls: type[SQLAlchemySyncConfig], monkeypatch: MonkeyPatch) -> None:
    """Test app_state."""
    config = config_cls(connection_string="sqlite://")
    with (
        patch.object(config, "create_session_maker") as create_session_maker_mock,
        patch.object(config, "get_engine") as create_engine_mock,
    ):
        assert config.create_app_state_items().keys() == {
            config.engine_app_state_key,
            config.session_maker_app_state_key,
        }
        create_session_maker_mock.assert_called_once()
        create_engine_mock.assert_called_once()


def test_namespace_resolution() -> None:
    # https://github.com/litestar-org/advanced-alchemy/issues/256

    from litestar import Litestar, get

    @get("/")
    async def handler(param: datetime.datetime, other_param: uuid.UUID) -> None:
        return None

    Litestar([handler])
test_engine.py000066400000000000000000000004171516556515500346740ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/tests/unit/test_extensions/test_litestar/test_init_pluginfrom __future__ import annotations

from advanced_alchemy.extensions.litestar.plugins.init.config.engine import serializer


def test_serializer_returns_string() -> None:
    """Test that serializer returns a string."""
    assert isinstance(serializer({"a": "b"}), str)
python-advanced-alchemy-1.9.3/tests/unit/test_extensions/test_litestar/test_init_plugin/test_sync.py000066400000000000000000000522011516556515500344600ustar00rootroot00000000000000from __future__ import annotations

import random
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch

import pytest
from asgi_lifespan import LifespanManager
from litestar import Litestar, Request, Response, get
from litestar.status_codes import (
    HTTP_404_NOT_FOUND,
    HTTP_409_CONFLICT,
    HTTP_500_INTERNAL_SERVER_ERROR,
)
from litestar.testing import (
    RequestFactory,
    create_test_client,  # type: ignore
)
from litestar.types.asgi_types import HTTPResponseStartEvent
from pytest import MonkeyPatch
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session

from advanced_alchemy.exceptions import (
    DuplicateKeyError,
    ForeignKeyError,
    ImproperConfigurationError,
    IntegrityError,
    InvalidRequestError,
    NotFoundError,
    RepositoryError,
)
from advanced_alchemy.extensions.litestar._utils import set_aa_scope_state
from advanced_alchemy.extensions.litestar.exception_handler import exception_to_http_response
from advanced_alchemy.extensions.litestar.plugins import (
    SQLAlchemyAsyncConfig,
    SQLAlchemyInitPlugin,
    SQLAlchemySyncConfig,
)
from advanced_alchemy.extensions.litestar.plugins.init.config.sync import (
    autocommit_before_send_handler,
    autocommit_handler_maker,
)

if TYPE_CHECKING:
    from typing import Any, Callable

    from litestar.types import Scope


def test_default_before_send_handler() -> None:
    """Test default_before_send_handler."""

    captured_scope_state: dict[str, Any] | None = None
    config = SQLAlchemySyncConfig(connection_string="sqlite://")
    plugin = SQLAlchemyInitPlugin(config=config)

    @get()
    def test_handler(db_session: Session, scope: Scope) -> None:
        nonlocal captured_scope_state
        captured_scope_state = scope["state"]
        assert db_session is captured_scope_state[config.session_dependency_key]

    with create_test_client(route_handlers=[test_handler], plugins=[plugin]) as client:
        client.get("/")
        assert captured_scope_state is not None
        assert config.session_dependency_key not in captured_scope_state


def test_default_before_send_handle_multi() -> None:
    """Test default_before_send_handler."""

    captured_scope_state: dict[str, Any] | None = None
    config1 = SQLAlchemySyncConfig(connection_string="sqlite://")
    config2 = SQLAlchemySyncConfig(
        connection_string="sqlite://",
        session_dependency_key="other_session",
        session_scope_key="_sqlalchemy_state_2",
        engine_dependency_key="other_engine",
    )
    plugin = SQLAlchemyInitPlugin(config=[config1, config2])

    @get()
    def test_handler(db_session: Session, scope: Scope) -> None:
        nonlocal captured_scope_state
        captured_scope_state = scope["state"]
        assert db_session is captured_scope_state[config1.session_dependency_key]

    with create_test_client(route_handlers=[test_handler], plugins=[plugin]) as client:
        client.get("/")
        assert captured_scope_state is not None
        assert config1.session_dependency_key not in captured_scope_state


async def test_create_all_default(monkeypatch: MonkeyPatch) -> None:
    """Test default_before_send_handler."""

    config = SQLAlchemySyncConfig(connection_string="sqlite+aiosqlite://")
    plugin = SQLAlchemyInitPlugin(config=config)
    app = Litestar(route_handlers=[], plugins=[plugin])
    with patch.object(
        config,
        "create_all_metadata",
    ) as create_all_metadata_mock:
        async with LifespanManager(app) as _client:  # type: ignore[arg-type] # pyright: ignore[reportArgumentType]
            create_all_metadata_mock.assert_not_called()


async def test_create_all(monkeypatch: MonkeyPatch) -> None:
    """Test default_before_send_handler."""

    config = SQLAlchemySyncConfig(connection_string="sqlite+aiosqlite://", create_all=True)
    plugin = SQLAlchemyInitPlugin(config=config)
    app = Litestar(route_handlers=[], plugins=[plugin])
    with patch.object(
        config,
        "create_all_metadata",
    ) as create_all_metadata_mock:
        async with LifespanManager(app) as _client:  # type: ignore[arg-type]  # pyright: ignore[reportArgumentType]
            create_all_metadata_mock.assert_called_once()


def test_before_send_handler_success_response(create_scope: Callable[..., Scope]) -> None:
    """Test that the session is committed given a success response."""
    config = SQLAlchemySyncConfig(connection_string="sqlite://", before_send_handler=autocommit_before_send_handler)
    app = Litestar(route_handlers=[], plugins=[SQLAlchemyInitPlugin(config)])
    mock_session = MagicMock(spec=Session)
    http_scope = create_scope(app=app)
    set_aa_scope_state(http_scope, config.session_scope_key, mock_session)
    http_response_start: HTTPResponseStartEvent = {
        "type": "http.response.start",
        "status": random.randint(200, 299),
        "headers": {},
    }
    autocommit_before_send_handler(http_response_start, http_scope)
    mock_session.commit.assert_called_once()


def test_before_send_handler_success_response_autocommit(create_scope: Callable[..., Scope]) -> None:
    """Test that the session is committed given a success response."""
    config = SQLAlchemySyncConfig(connection_string="sqlite://", before_send_handler="autocommit")
    app = Litestar(route_handlers=[], plugins=[SQLAlchemyInitPlugin(config)])
    mock_session = MagicMock(spec=Session)
    http_scope = create_scope(app=app)
    set_aa_scope_state(http_scope, config.session_scope_key, mock_session)
    http_response_start: HTTPResponseStartEvent = {
        "type": "http.response.start",
        "status": random.randint(200, 299),
        "headers": {},
    }
    autocommit_before_send_handler(http_response_start, http_scope)
    mock_session.commit.assert_called_once()


def test_before_send_handler_error_response(create_scope: Callable[..., Scope]) -> None:
    """Test that the session is committed given a success response."""
    config = SQLAlchemySyncConfig(connection_string="sqlite://", before_send_handler=autocommit_before_send_handler)
    app = Litestar(route_handlers=[], plugins=[SQLAlchemyInitPlugin(config)])
    mock_session = MagicMock(spec=Session)
    http_scope = create_scope(app=app)
    set_aa_scope_state(http_scope, config.session_scope_key, mock_session)
    http_response_start: HTTPResponseStartEvent = {
        "type": "http.response.start",
        "status": random.randint(300, 599),
        "headers": {},
    }
    autocommit_before_send_handler(http_response_start, http_scope)
    mock_session.rollback.assert_called_once()


def test_autocommit_handler_maker_redirect_response(create_scope: Callable[..., Scope]) -> None:
    """Test that the handler created by the handler maker commits on redirect"""
    autocommit_redirect_handler = autocommit_handler_maker(commit_on_redirect=True)
    config = SQLAlchemySyncConfig(
        connection_string="sqlite://",
        before_send_handler=autocommit_redirect_handler,
    )
    app = Litestar(route_handlers=[], plugins=[SQLAlchemyInitPlugin(config)])
    mock_session = MagicMock(spec=Session)
    http_scope = create_scope(app=app)
    set_aa_scope_state(http_scope, config.session_scope_key, mock_session)
    http_response_start: HTTPResponseStartEvent = {
        "type": "http.response.start",
        "status": random.randint(300, 399),
        "headers": {},
    }
    autocommit_redirect_handler(http_response_start, http_scope)
    mock_session.commit.assert_called_once()


def test_autocommit_handler_maker_commit_statuses(create_scope: Callable[..., Scope]) -> None:
    """Test that the handler created by the handler maker commits on explicit statuses"""
    custom_autocommit_handler = autocommit_handler_maker(extra_commit_statuses={302, 303})
    config = SQLAlchemySyncConfig(
        connection_string="sqlite://",
        before_send_handler=custom_autocommit_handler,
    )
    app = Litestar(route_handlers=[], plugins=[SQLAlchemyInitPlugin(config)])
    mock_session = MagicMock(spec=Session)
    http_scope = create_scope(app=app)
    set_aa_scope_state(http_scope, config.session_scope_key, mock_session)
    http_response_start: HTTPResponseStartEvent = {
        "type": "http.response.start",
        "status": random.randint(302, 303),
        "headers": {},
    }
    custom_autocommit_handler(http_response_start, http_scope)
    mock_session.commit.assert_called_once()


def test_autocommit_handler_maker_rollback_statuses(create_scope: Callable[..., Scope]) -> None:
    """Test that the handler created by the handler maker rolls back on explicit statuses"""
    custom_autocommit_handler = autocommit_handler_maker(commit_on_redirect=True, extra_rollback_statuses={307, 308})
    config = SQLAlchemySyncConfig(
        connection_string="sqlite://",
        before_send_handler=custom_autocommit_handler,
    )
    app = Litestar(route_handlers=[], plugins=[SQLAlchemyInitPlugin(config)])
    mock_session = MagicMock(spec=Session)
    http_scope = create_scope(app=app)
    set_aa_scope_state(http_scope, config.session_scope_key, mock_session)
    http_response_start: HTTPResponseStartEvent = {
        "type": "http.response.start",
        "status": random.randint(307, 308),
        "headers": {},
    }
    custom_autocommit_handler(http_response_start, http_scope)
    mock_session.rollback.assert_called_once()


def test_autocommit_handler_maker_rollback_statuses_multi(create_scope: Callable[..., Scope]) -> None:
    """Test that the handler created by the handler maker rolls back on explicit statuses"""
    custom_autocommit_handler = autocommit_handler_maker(
        session_scope_key="_sqlalchemy_state_2",
        commit_on_redirect=True,
        extra_rollback_statuses={307, 308},
    )
    config1 = SQLAlchemySyncConfig(
        connection_string="sqlite://",
    )
    config2 = SQLAlchemySyncConfig(
        connection_string="sqlite://",
        before_send_handler=custom_autocommit_handler,
        session_dependency_key="other_session",
        engine_dependency_key="other_engine",
        session_scope_key="_sqlalchemy_state_2",
    )
    app = Litestar(route_handlers=[], plugins=[SQLAlchemyInitPlugin(config=[config1, config2])])
    mock_session1 = MagicMock(spec=Session)
    mock_session2 = MagicMock(spec=Session)
    http_scope = create_scope(app=app)
    set_aa_scope_state(http_scope, config1.session_scope_key, mock_session1)
    set_aa_scope_state(http_scope, config2.session_scope_key, mock_session2)
    http_response_start: HTTPResponseStartEvent = {
        "type": "http.response.start",
        "status": random.randint(307, 308),
        "headers": {},
    }
    custom_autocommit_handler(http_response_start, http_scope)
    mock_session2.rollback.assert_called_once()
    mock_session1.rollback.assert_not_called()


def test_autocommit_handler_maker_rollback_statuses_multi_bad_config(create_scope: Callable[..., Scope]) -> None:
    """Test that the handler created by the handler maker rolls back on explicit statuses"""
    with pytest.raises(ImproperConfigurationError):
        custom_autocommit_handler = autocommit_handler_maker(
            session_scope_key="_sqlalchemy_state_2",
            commit_on_redirect=True,
            extra_rollback_statuses={307, 308},
        )
        config1 = SQLAlchemySyncConfig(
            connection_string="sqlite://",
        )
        config2 = SQLAlchemySyncConfig(
            connection_string="sqlite://",
            before_send_handler=custom_autocommit_handler,
            session_dependency_key="other_session",
            session_scope_key="_sqlalchemy_state_2",
        )
        app = Litestar(route_handlers=[], plugins=[SQLAlchemyInitPlugin(config=[config1, config2])])
        mock_session1 = MagicMock(spec=Session)
        mock_session2 = MagicMock(spec=Session)
        http_scope = create_scope(app=app)
        set_aa_scope_state(http_scope, config1.session_scope_key, mock_session1)
        set_aa_scope_state(http_scope, config2.session_scope_key, mock_session2)
        http_response_start: HTTPResponseStartEvent = {
            "type": "http.response.start",
            "status": random.randint(307, 308),
            "headers": {},
        }
        custom_autocommit_handler(http_response_start, http_scope)
        mock_session2.rollback.assert_called_once()
        mock_session1.rollback.assert_not_called()


def test_autocommit_handler_maker_multi(create_scope: Callable[..., Scope]) -> None:
    """Test that the handler created by the handler maker rolls back on explicit statuses"""

    config1 = SQLAlchemySyncConfig(
        connection_string="sqlite://",
        before_send_handler="autocommit",
    )
    config2 = SQLAlchemySyncConfig(
        connection_string="sqlite://",
        before_send_handler="autocommit",
        session_dependency_key="other_session",
        engine_dependency_key="other_engine",
    )
    app = Litestar(route_handlers=[], plugins=[SQLAlchemyInitPlugin(config=[config1, config2])])
    mock_session1 = MagicMock(spec=Session)
    mock_session2 = MagicMock(spec=Session)
    http_scope = create_scope(app=app)
    set_aa_scope_state(http_scope, config1.session_scope_key, mock_session1)
    set_aa_scope_state(http_scope, config2.session_scope_key, mock_session2)
    http_response_start: HTTPResponseStartEvent = {
        "type": "http.response.start",
        "status": random.randint(307, 308),
        "headers": {},
    }
    config2.before_send_handler(http_response_start, http_scope)  # type: ignore
    mock_session2.rollback.assert_called_once()
    mock_session1.rollback.assert_not_called()


def test_autocommit_handler_maker_multi_async_and_sync(create_scope: Callable[..., Scope]) -> None:
    """Test that the handler created by the handler maker rolls back on explicit statuses"""

    config1 = SQLAlchemySyncConfig(
        connection_string="sqlite://",
        before_send_handler="autocommit",
    )
    config2 = SQLAlchemySyncConfig(
        connection_string="sqlite://",
        before_send_handler="autocommit",
        session_dependency_key="other_session",
        engine_dependency_key="other_engine",
    )
    config3 = SQLAlchemyAsyncConfig(
        connection_string="sqlite+aiosqlite://",
        before_send_handler="autocommit",
        session_dependency_key="the_session",
        engine_dependency_key="the_engine",
    )
    config4 = SQLAlchemyAsyncConfig(
        connection_string="sqlite+aiosqlite://",
        before_send_handler="autocommit",
        session_dependency_key="other_other_session",
        engine_dependency_key="other_other_engine",
    )
    app = Litestar(route_handlers=[], plugins=[SQLAlchemyInitPlugin(config=[config1, config2, config3, config4])])
    mock_session1 = MagicMock(spec=Session)
    mock_session2 = MagicMock(spec=Session)
    mock_session3 = MagicMock(spec=AsyncSession)
    mock_session4 = MagicMock(spec=AsyncSession)
    http_scope = create_scope(app=app)
    set_aa_scope_state(http_scope, config1.session_scope_key, mock_session1)
    set_aa_scope_state(http_scope, config2.session_scope_key, mock_session2)
    set_aa_scope_state(http_scope, config3.session_scope_key, mock_session3)
    set_aa_scope_state(http_scope, config4.session_scope_key, mock_session4)
    http_response_start: HTTPResponseStartEvent = {
        "type": "http.response.start",
        "status": random.randint(307, 308),
        "headers": {},
    }
    config2.before_send_handler(http_response_start, http_scope)  # type: ignore
    mock_session2.rollback.assert_called_once()
    mock_session1.rollback.assert_not_called()
    mock_session3.rollback.assert_not_called()
    mock_session4.rollback.assert_not_called()


@pytest.mark.parametrize(
    ("exc", "status"),
    [
        (IntegrityError, HTTP_409_CONFLICT),
        (ForeignKeyError, HTTP_409_CONFLICT),
        (DuplicateKeyError, HTTP_409_CONFLICT),
        (InvalidRequestError, HTTP_500_INTERNAL_SERVER_ERROR),
        (NotFoundError, HTTP_404_NOT_FOUND),
    ],
)
def test_repository_exception_to_http_response(exc: type[RepositoryError], status: int) -> None:
    """Test default exception handler."""

    config1 = SQLAlchemyAsyncConfig(connection_string="sqlite+aiosqlite://")
    config2 = SQLAlchemyAsyncConfig(
        connection_string="sqlite+aiosqlite://",
        session_dependency_key="other_session",
        session_scope_key="_sqlalchemy_state_2",
        engine_dependency_key="other_engine",
    )
    plugin = SQLAlchemyInitPlugin(config=[config1, config2])
    app = Litestar(route_handlers=[], plugins=[plugin])
    request = RequestFactory(app=app, server="testserver").get("/wherever")
    response = exception_to_http_response(request, exc())
    assert app.exception_handlers.get(exc) is None
    assert app.exception_handlers.get(RepositoryError) is not None
    assert response.status_code == status


@pytest.mark.parametrize(
    ("exc", "status"),
    [
        (IntegrityError, HTTP_409_CONFLICT),
        (ForeignKeyError, HTTP_409_CONFLICT),
        (DuplicateKeyError, HTTP_409_CONFLICT),
        (InvalidRequestError, HTTP_500_INTERNAL_SERVER_ERROR),
        (NotFoundError, HTTP_404_NOT_FOUND),
    ],
)
def test_existing_repository_exception_to_http_response(exc: type[RepositoryError], status: int) -> None:
    """Test default exception handler."""

    def handler(request: Request[Any, Any, Any], exc: RepositoryError) -> Response[Any]:
        return Response(status_code=200, content="OK")

    config1 = SQLAlchemyAsyncConfig(connection_string="sqlite+aiosqlite://")
    config2 = SQLAlchemyAsyncConfig(
        connection_string="sqlite+aiosqlite://",
        session_dependency_key="other_session",
        session_scope_key="_sqlalchemy_state_2",
        engine_dependency_key="other_engine",
    )
    plugin = SQLAlchemyInitPlugin(config=[config1, config2])
    app = Litestar(route_handlers=[], plugins=[plugin], exception_handlers={RepositoryError: handler})
    request = RequestFactory(app=app, server="testserver").get("/wherever")
    response = handler(request, exc())
    assert app.exception_handlers.get(exc) is None
    assert app.exception_handlers.get(RepositoryError) is not None
    assert app.exception_handlers.get(RepositoryError) == handler
    assert response.status_code == 200


@pytest.mark.parametrize(
    ("exc", "status"),
    [
        (IntegrityError, HTTP_409_CONFLICT),
        (ForeignKeyError, HTTP_409_CONFLICT),
        (DuplicateKeyError, HTTP_409_CONFLICT),
        (InvalidRequestError, HTTP_500_INTERNAL_SERVER_ERROR),
        (NotFoundError, HTTP_404_NOT_FOUND),
    ],
)
def test_repository_disabled_exception_to_http_response(exc: type[RepositoryError], status: int) -> None:
    """Test default exception handler."""

    config1 = SQLAlchemySyncConfig(connection_string="sqlite://", set_default_exception_handler=False)
    config2 = SQLAlchemySyncConfig(
        connection_string="sqlite://",
        session_dependency_key="other_session",
        session_scope_key="_sqlalchemy_state_2",
        engine_dependency_key="other_engine",
        set_default_exception_handler=False,
    )
    plugin = SQLAlchemyInitPlugin(config=[config1, config2])
    app = Litestar(route_handlers=[], plugins=[plugin])
    assert app.exception_handlers.get(exc) is None
    assert app.exception_handlers.get(RepositoryError) is None


def test_autocommit_handler_rollback_failure_still_closes(create_scope: Callable[..., Scope]) -> None:
    """Verify session is closed even when rollback raises in sync autocommit handler."""
    from advanced_alchemy.extensions.litestar.plugins.init.config.sync import autocommit_before_send_handler

    config = SQLAlchemySyncConfig(
        connection_string="sqlite://",
        before_send_handler=autocommit_before_send_handler,
    )
    app = Litestar(route_handlers=[], plugins=[SQLAlchemyInitPlugin(config)])
    mock_session = MagicMock(spec=Session)
    mock_session.rollback.side_effect = RuntimeError("rollback failed")
    http_scope = create_scope(app=app)
    set_aa_scope_state(http_scope, config.session_scope_key, mock_session)
    http_response_start: HTTPResponseStartEvent = {
        "type": "http.response.start",
        "status": 500,
        "headers": {},
    }
    autocommit_before_send_handler(http_response_start, http_scope)
    mock_session.rollback.assert_called_once()
    # http.response.start is a SESSION_TERMINUS event, so close runs too
    mock_session.close.assert_called_once()


def test_autocommit_handler_commit_failure_does_not_crash(create_scope: Callable[..., Scope]) -> None:
    """Verify commit failure is caught and does not crash the handler."""
    from advanced_alchemy.extensions.litestar.plugins.init.config.sync import autocommit_before_send_handler

    config = SQLAlchemySyncConfig(
        connection_string="sqlite://",
        before_send_handler=autocommit_before_send_handler,
    )
    app = Litestar(route_handlers=[], plugins=[SQLAlchemyInitPlugin(config)])
    mock_session = MagicMock(spec=Session)
    mock_session.commit.side_effect = RuntimeError("commit failed")
    http_scope = create_scope(app=app)
    set_aa_scope_state(http_scope, config.session_scope_key, mock_session)
    http_response_start: HTTPResponseStartEvent = {
        "type": "http.response.start",
        "status": 200,
        "headers": {},
    }
    # Should not raise - the exception is caught
    autocommit_before_send_handler(http_response_start, http_scope)
    mock_session.commit.assert_called_once()
    mock_session.close.assert_called_once()
python-advanced-alchemy-1.9.3/tests/unit/test_extensions/test_litestar/test_litestar_re_exports.py000066400000000000000000000006521516556515500342300ustar00rootroot00000000000000# ruff: noqa: F401

import pytest


def test_repository_re_exports() -> None:
    with pytest.warns(DeprecationWarning):
        from litestar.contrib.sqlalchemy import types  # type: ignore
        from litestar.contrib.sqlalchemy.repository import (
            SQLAlchemyAsyncRepository,  # type: ignore
            SQLAlchemySyncRepository,  # type: ignore
            wrap_sqlalchemy_exception,  # type: ignore
        )
python-advanced-alchemy-1.9.3/tests/unit/test_extensions/test_litestar/test_plugin.py000066400000000000000000000152541516556515500314310ustar00rootroot00000000000000from pathlib import Path
from typing import Any

from litestar import Litestar, get
from litestar.testing import TestClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import DeclarativeBase, Mapped, Session

from advanced_alchemy._listeners import is_async_context
from advanced_alchemy.base import BigIntPrimaryKey
from advanced_alchemy.extensions.litestar import (
    SQLAlchemyAsyncConfig,
    SQLAlchemyInitPlugin,
    SQLAlchemySyncConfig,
)


# Test Function
def test_litestar_is_async_context(tmp_path: Path) -> None:
    """Test that is_async_context is set correctly in Litestar dependency injection."""
    db_path = tmp_path / "litestar_context_test.db"

    class Base(DeclarativeBase):
        pass

    class SyncModel(BigIntPrimaryKey, Base):  # type: ignore
        __tablename__ = "sync_model_litestar_test"
        name: Mapped[str]

    class AsyncModel(BigIntPrimaryKey, Base):  # type: ignore
        __tablename__ = "async_model_litestar_test"
        name: Mapped[str]

    @get("/sync")
    def sync_route(db_session: Session) -> dict[str, Any]:
        instance = db_session.execute(select(SyncModel).where(SyncModel.id == 1)).scalar_one()
        return {"id": instance.id, "name": instance.name, "is_async_context": is_async_context()}

    @get("/async")
    async def async_route(db_session: AsyncSession) -> dict[str, Any]:
        instance = await db_session.execute(select(AsyncModel).where(AsyncModel.id == 1))
        scalar_instance = instance.scalar_one()
        return {"id": scalar_instance.id, "name": scalar_instance.name, "is_async_context": is_async_context()}

    # Sync App
    sync_config = SQLAlchemySyncConfig(connection_string=f"sqlite:///{db_path}")
    sync_plugin = SQLAlchemyInitPlugin(config=sync_config)

    @get("/test_sync")
    def sync_handler(db_session: Session) -> dict[str, Any]:
        # Perform a dummy operation if needed (e.g., db_session.execute(select(1)))
        return {"is_async": is_async_context()}

    sync_app = Litestar(route_handlers=[sync_handler], plugins=[sync_plugin])

    # Create tables for sync app
    with sync_config.get_engine().begin() as conn:
        Base.metadata.create_all(conn)

    with TestClient(app=sync_app) as sync_client:
        response = sync_client.get("/test_sync")
        assert response.status_code == 200
        # Note: In ASGI context, event loop is always running, so is_async_context()
        # returns True even for sync handlers. This is expected behavior since v1.9.0.
        # The sync/async distinction is now handled by explicit listener classes,
        # not by the deprecated is_async_context() function.
        assert response.json() == {"is_async": True}

    # Async App
    async_config = SQLAlchemyAsyncConfig(connection_string=f"sqlite+aiosqlite:///{db_path}")
    async_plugin = SQLAlchemyInitPlugin(config=async_config)

    @get("/test_async")
    async def async_handler(db_session: AsyncSession) -> dict[str, Any]:
        # Perform a dummy operation if needed (e.g., await db_session.execute(select(1)))
        return {"is_async": is_async_context()}

    async_app = Litestar(route_handlers=[async_handler], plugins=[async_plugin])

    # Create tables for async app (needs async context)
    async def create_async_tables() -> None:
        async with async_config.get_engine().begin() as conn:
            await conn.run_sync(Base.metadata.create_all)

    import asyncio

    asyncio.run(create_async_tables())

    with TestClient(app=async_app) as async_client:
        response = async_client.get("/test_async")
        assert response.status_code == 200
        assert response.json() == {"is_async": True}


def test_plugin_is_async_context(tmp_path: Path) -> None:
    """Test that is_async_context is set correctly via plugin dependency injection."""
    db_path = tmp_path / "litestar_plugin_context.db"

    class Base(DeclarativeBase):
        pass

    class SyncModel(BigIntPrimaryKey, Base):  # type: ignore
        __tablename__ = "sync_model_litestar_test"
        name: Mapped[str]

    class AsyncModel(BigIntPrimaryKey, Base):  # type: ignore
        __tablename__ = "async_model_litestar_test"
        name: Mapped[str]

    @get("/sync")
    def sync_route(db_session: Session) -> dict[str, Any]:
        instance = db_session.execute(select(SyncModel).where(SyncModel.id == 1)).scalar_one()
        return {"id": instance.id, "name": instance.name, "is_async_context": is_async_context()}

    @get("/async")
    async def async_route(db_session: AsyncSession) -> dict[str, Any]:
        instance = await db_session.execute(select(AsyncModel).where(AsyncModel.id == 1))
        scalar_instance = instance.scalar_one()
        return {"id": scalar_instance.id, "name": scalar_instance.name, "is_async_context": is_async_context()}

    # Sync App
    sync_config = SQLAlchemySyncConfig(connection_string=f"sqlite:///{db_path}")
    sync_plugin = SQLAlchemyInitPlugin(config=sync_config)

    @get("/test_sync_plugin")
    def sync_plugin_handler(db_session: Session) -> dict[str, Any]:  # type: ignore[arg-type]
        return {"is_async": is_async_context()}

    sync_app = Litestar(route_handlers=[sync_plugin_handler], plugins=[sync_plugin])

    # Create tables for sync app
    with sync_config.get_engine().begin() as conn:
        Base.metadata.create_all(conn)

    with TestClient(app=sync_app) as sync_client:
        response = sync_client.get("/test_sync_plugin")
        assert response.status_code == 200
        # Note: In ASGI context, event loop is always running, so is_async_context()
        # returns True even for sync handlers. This is expected behavior since v1.9.0.
        # The sync/async distinction is now handled by explicit listener classes,
        # not by the deprecated is_async_context() function.
        assert response.json() == {"is_async": True}

    # Async App
    async_config = SQLAlchemyAsyncConfig(connection_string=f"sqlite+aiosqlite:///{db_path}")
    async_plugin = SQLAlchemyInitPlugin(config=async_config)

    @get("/test_async_plugin")
    async def async_plugin_handler(db_session: AsyncSession) -> dict[str, Any]:  # type: ignore[arg-type]
        return {"is_async": is_async_context()}

    async_app = Litestar(route_handlers=[async_plugin_handler], plugins=[async_plugin])

    # Create tables for async app
    async def create_async_tables() -> None:
        async with async_config.get_engine().begin() as conn:
            await conn.run_sync(Base.metadata.create_all)

    import asyncio

    asyncio.run(create_async_tables())

    with TestClient(app=async_app) as async_client:
        response = async_client.get("/test_async_plugin")
        assert response.status_code == 200
        assert response.json() == {"is_async": True}
python-advanced-alchemy-1.9.3/tests/unit/test_extensions/test_litestar/test_providers.py000066400000000000000000000734411516556515500321520ustar00rootroot00000000000000"""Tests for the DI module."""

from __future__ import annotations

import inspect
import unittest.mock
import uuid
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any, cast
from unittest.mock import MagicMock, patch

from litestar import Litestar, get
from litestar.di import Provide
from litestar.openapi.config import OpenAPIConfig
from litestar.params import Dependency
from litestar.testing import TestClient
from sqlalchemy import FromClause, String, select
from sqlalchemy.orm import DeclarativeBase, Mapped, Mapper, mapped_column

from advanced_alchemy.extensions.litestar.plugins.init.plugin import signature_namespace_values
from advanced_alchemy.extensions.litestar.providers import (
    DEPENDENCY_DEFAULTS,
    DependencyCache,
    DependencyDefaults,
    FilterConfig,
    SingletonMeta,
    _create_filter_aggregate_function,  # pyright: ignore[reportPrivateUsage]
    _create_statement_filters,  # pyright: ignore[reportPrivateUsage]
    create_filter_dependencies,
    create_service_dependencies,
    create_service_provider,
    dep_cache,
)
from advanced_alchemy.filters import (
    BeforeAfter,
    CollectionFilter,
    FilterTypes,
    LimitOffset,
    NotInCollectionFilter,
    OrderBy,
    SearchFilter,
)
from advanced_alchemy.repository import SQLAlchemyAsyncRepository, SQLAlchemySyncRepository
from advanced_alchemy.service import (
    SQLAlchemyAsyncRepositoryService,
    SQLAlchemySyncRepositoryService,
)
from advanced_alchemy.types.identity import BigIntIdentity
from tests.helpers import anext_


class Base(DeclarativeBase):
    """Base class for models."""

    if TYPE_CHECKING:
        __name__: str  # type: ignore
        __table__: FromClause  # type: ignore
        __mapper__: Mapper[Any]  # type: ignore

    id: Mapped[int] = mapped_column(BigIntIdentity, primary_key=True)

    def to_dict(self, exclude: set[str] | None = None) -> dict[str, Any]:
        """Convert model to dictionary.

        Returns:
            Dict[str, Any]: A dict representation of the model
        """
        exclude = {"sa_orm_sentinel", "_sentinel"}.union(self._sa_instance_state.unloaded).union(exclude or [])  # type: ignore[attr-defined]
        return {field: getattr(self, field) for field in self.__mapper__.columns.keys() if field not in exclude}


class DITestModel(Base):
    """Test model for use in tests."""

    __tablename__ = "di_test_model"

    name: Mapped[str] = mapped_column(String)


class MockSyncService(SQLAlchemySyncRepositoryService[DITestModel]):
    """Mock sync service class for testing."""

    class Repo(SQLAlchemySyncRepository[DITestModel]):
        """Mock repo class."""

        model_type = DITestModel

    repository_type = Repo


class MockAsyncService(SQLAlchemyAsyncRepositoryService[DITestModel]):
    """Mock async service class for testing."""

    class Repo(SQLAlchemyAsyncRepository[DITestModel]):
        """Mock repo class."""

        model_type = DITestModel

    repository_type = Repo


def test_singleton_pattern() -> None:
    """Test that the SingletonMeta creates singletons."""

    class TestClass(metaclass=SingletonMeta):
        """Test class using SingletonMeta."""

        def __init__(self) -> None:
            self.value = uuid.uuid4().hex

    # Instances should be the same
    instance1 = TestClass()
    instance2 = TestClass()

    assert instance1 is instance2
    assert instance1.value == instance2.value


def test_multiple_classes() -> None:
    """Test that different classes using SingletonMeta have different instances."""

    class TestClass1(metaclass=SingletonMeta):
        """First test class using SingletonMeta."""

        def __init__(self) -> None:
            self.value = 1

    class TestClass2(metaclass=SingletonMeta):
        """Second test class using SingletonMeta."""

        def __init__(self) -> None:
            self.value = 2

    instance1 = TestClass1()
    instance2 = TestClass2()

    assert instance1 is not instance2  # type: ignore
    assert instance1.value != instance2.value


def test_add_get_dependencies() -> None:
    """Test adding and retrieving dependencies from cache."""
    # Create a new instance to avoid test interference
    with patch.dict(SingletonMeta._instances, {}, clear=True):  # pyright: ignore[reportPrivateUsage]
        cache = DependencyCache()

        # Test with string key
        deps1 = {"service": Provide(lambda: "service")}
        cache.add_dependencies("key1", deps1)
        assert cache.get_dependencies("key1") == deps1

        # Test with integer key
        deps2 = {"filter": Provide(lambda: "filter")}
        cache.add_dependencies(123, deps2)
        assert cache.get_dependencies(123) == deps2

        # Test retrieving non-existent key
        assert cache.get_dependencies("nonexistent") is None


def test_global_instance() -> None:
    """Test that the global dep_cache instance is a singleton."""
    # Do not clear SingletonMeta._instances, so that dep_cache remains the global singleton
    new_cache = DependencyCache()
    assert new_cache is dep_cache


def test_create_sync_service_provider() -> None:
    """Test creating a sync service provider."""
    provider = create_service_provider(MockSyncService)

    # Ensure the provider is callable
    assert callable(provider)
    svc = next(provider(db_session=MagicMock()))
    assert isinstance(svc, MockSyncService)


def test_create_sync_service_provider_custom() -> None:
    """Test creating a sync service provider."""
    provider = create_service_provider(MockSyncService, config=MagicMock(session_dependency_key="testing_session"))

    # Ensure the provider is callable
    assert callable(provider)
    svc = next(provider(testing_session=MagicMock()))
    assert isinstance(svc, MockSyncService)


def test_create_sync_service_provider_positional() -> None:
    """Test creating an async service provider."""
    provider = create_service_provider(MockSyncService, config=MagicMock(session_dependency_key="testing_session"))

    # Ensure the provider is callable
    assert callable(provider)
    svc = next(provider(MagicMock()))
    assert isinstance(svc, MockSyncService)


async def test_create_async_service_provider() -> None:
    """Test creating an async service provider."""
    provider = create_service_provider(MockAsyncService)

    # Ensure the provider is callable
    assert callable(provider)
    svc = await anext_(provider(db_session=MagicMock()))
    assert isinstance(svc, MockAsyncService)


async def test_create_async_service_provider_custom() -> None:
    """Test creating an async service provider."""
    provider = create_service_provider(MockAsyncService, config=MagicMock(session_dependency_key="testing_session"))

    # Ensure the provider is callable
    assert callable(provider)
    svc = await anext_(provider(testing_session=MagicMock()))
    assert isinstance(svc, MockAsyncService)


async def test_create_async_service_provider_positional() -> None:
    """Test creating an async service provider."""
    provider = create_service_provider(MockAsyncService, config=MagicMock(session_dependency_key="testing_session"))

    # Ensure the provider is callable
    assert callable(provider)
    svc = await anext_(provider(MagicMock()))
    assert isinstance(svc, MockAsyncService)


def test_create_async_service_dependencies() -> None:
    """Test creating async service dependencies."""
    with patch("advanced_alchemy.extensions.litestar.providers.create_service_provider") as mock_create_provider:
        mock_create_provider.return_value = lambda: "async_service"

        deps = create_service_dependencies(
            MockAsyncService,
            key="service",
            statement=select(DITestModel),
            config=MagicMock(),
        )

        assert "service" in deps
        assert isinstance(deps["service"], Provide)

        # Check provider function
        assert deps["service"].dependency() == "async_service"

        # Verify create_service_provider was called correctly
        mock_create_provider.assert_called_once()


def test_create_sync_service_dependencies() -> None:
    """Test creating sync service dependencies."""
    with patch("advanced_alchemy.extensions.litestar.providers.create_service_provider") as mock_create_provider:
        mock_create_provider.return_value = lambda: "sync_service"

        deps = create_service_dependencies(
            MockSyncService,
            key="service",
            statement=select(DITestModel),
            config=MagicMock(),
        )

        assert "service" in deps
        assert isinstance(deps["service"], Provide)

        # Check provider function
        assert deps["service"].dependency() == "sync_service"

        # Verify create_service_provider was called correctly
        mock_create_provider.assert_called_once()

        # Verify sync_to_thread is False for sync services
        assert deps["service"].sync_to_thread is False


def test_create_service_dependencies_with_filters() -> None:
    """Test creating service dependencies with filters."""
    with patch("advanced_alchemy.extensions.litestar.providers.create_service_provider") as mock_create_provider:
        with patch("advanced_alchemy.extensions.litestar.providers.create_filter_dependencies") as mock_create_filters:
            mock_create_provider.return_value = lambda: "service"
            mock_create_filters.return_value = {"filter1": Provide(lambda: "filter1")}

            deps = create_service_dependencies(
                MockSyncService,
                key="service",
                filters={"id_filter": int},
            )

            assert "service" in deps
            assert "filter1" in deps

            # Verify create_filter_dependencies was called
            mock_create_filters.assert_called_once_with({"id_filter": int}, DEPENDENCY_DEFAULTS)


def test_create_filter_dependencies_cache_hit() -> None:
    """Test create_filter_dependencies with cache hit."""
    # Setup cache with a pre-existing entry
    mock_deps = {"test": Provide(lambda: "test")}

    with patch.object(dep_cache, "get_dependencies", return_value=mock_deps) as mock_get:
        with patch.object(dep_cache, "add_dependencies") as mock_add:
            config = cast(FilterConfig, {"key1": 1, "key2": 2})
            deps = create_filter_dependencies(config)

            # Verify cache was checked
            mock_get.assert_called_once()

            # Verify result is from cache
            assert deps == mock_deps

            # Verify cache wasn't updated
            mock_add.assert_not_called()


def test_create_filter_dependencies_cache_miss() -> None:
    """Test create_filter_dependencies with cache miss."""
    # Setup cache to return None (cache miss)
    mock_deps = {"test": Provide(lambda: "test")}

    with patch.object(dep_cache, "get_dependencies", return_value=None) as mock_get:
        with patch.object(dep_cache, "add_dependencies") as mock_add:
            with patch(
                "advanced_alchemy.extensions.litestar.providers._create_statement_filters", return_value=mock_deps
            ) as mock_create:
                config = cast(FilterConfig, {"key1": 1, "key2": 2})
                deps = create_filter_dependencies(config)

                # Verify cache was checked
                mock_get.assert_called_once()

                # Verify _create_statement_filters was called
                mock_create.assert_called_once_with(config, DEPENDENCY_DEFAULTS)

                # Verify cache was updated
                mock_add.assert_called_once()

                # Verify return value
                assert deps == mock_deps


def test_id_filter() -> None:
    """Test creating ID filter dependency."""
    config = cast(FilterConfig, {"id_filter": int})
    deps = _create_statement_filters(config)

    assert "id_filter" in deps
    assert "filters" in deps

    # Test the provider function
    provider_func = deps["id_filter"].dependency
    f = provider_func(ids=["1", "2", "3"])
    assert isinstance(f, CollectionFilter)
    assert f.field_name == "id"
    assert f.values is not None  # type: ignore
    assert f.values == ["1", "2", "3"]  # type: ignore


def test_created_at_filter() -> None:
    """Test creating created_at filter dependency."""
    config = cast(FilterConfig, {"created_at": "created_at"})
    deps = _create_statement_filters(config)

    assert "created_filter" in deps
    assert "filters" in deps

    # Test the provider function
    provider_func = deps["created_filter"].dependency
    before = datetime.now()
    later = datetime.now() + timedelta(days=1)
    f = provider_func(before=before, after=later)
    assert isinstance(f, BeforeAfter)
    assert f.field_name == "created_at"
    assert f.before == before
    assert f.after == later


def test_updated_at_filter() -> None:
    """Test creating updated_at filter dependency."""
    config = cast(FilterConfig, {"updated_at": "updated_at"})
    deps = _create_statement_filters(config)

    assert "updated_filter" in deps
    assert "filters" in deps

    # Test the provider function
    provider_func = deps["updated_filter"].dependency
    f = provider_func(before=datetime.now(), after=datetime.now())
    assert isinstance(f, BeforeAfter)
    assert f.field_name == "updated_at"


def test_search_filter() -> None:
    """Test creating search filter dependency."""
    config = cast(FilterConfig, {"search": "name", "search_ignore_case": True})
    deps = _create_statement_filters(config)

    assert "search_filter" in deps
    assert "filters" in deps

    # Test the provider function
    provider_func = deps["search_filter"].dependency
    f = provider_func(search_string="test", ignore_case=True)
    assert isinstance(f, SearchFilter)
    assert f.field_name == "name" or f.field_name == {"name"}
    assert f.value == "test"
    assert f.ignore_case is True


def test_limit_offset_filter() -> None:
    """Test creating limit_offset filter dependency."""
    config = cast(FilterConfig, {"pagination_type": "limit_offset", "default_limit": 10, "max_limit": 100})
    deps = _create_statement_filters(config)

    assert "limit_offset_filter" in deps
    assert "filters" in deps
    # Test the provider function
    provider_func = deps["limit_offset_filter"].dependency

    f = provider_func(current_page=2, page_size=5)
    assert isinstance(f, LimitOffset)
    assert f.limit == 5
    assert f.offset == 5


def test_order_by_filter() -> None:
    """Test creating order_by filter dependency."""
    config = cast(FilterConfig, {"sort_field": "name"})
    deps = _create_statement_filters(config)

    assert "order_by_filter" in deps
    assert "filters" in deps

    # Test the provider function
    provider_func = deps["order_by_filter"].dependency
    f = provider_func(field_name="name", sort_order="desc")
    assert isinstance(f, OrderBy)
    assert f.field_name == "name"
    assert f.sort_order == "desc"


def test_not_in_filter() -> None:
    """Test creating not_in filter dependency."""
    deps = _create_statement_filters({"not_in_fields": ["status"]})

    assert "status_not_in_filter" in deps
    assert "filters" in deps

    # Test the provider function
    provider_func = deps["status_not_in_filter"].dependency
    f = provider_func(status_not_in=["pending", "failed"])
    assert isinstance(f, NotInCollectionFilter)
    assert f.field_name == "status"
    assert f.values == ["pending", "failed"]

    # Test with None
    f_none = provider_func(status_not_in=None)
    assert f_none is None


def test_in_filter() -> None:
    """Test creating in filter dependency."""
    deps = _create_statement_filters({"in_fields": ["tag"]})

    assert "tag_in_filter" in deps
    assert "filters" in deps

    # Test the provider function
    provider_func = deps["tag_in_filter"].dependency
    f = provider_func(tag_in=["python", "litestar"])
    assert isinstance(f, CollectionFilter)
    assert f.field_name == "tag"
    assert f.values == ["python", "litestar"]

    # Test with None
    f_none = provider_func(tag_in=None)
    assert f_none is None


def test_litestar_in_filter_values_are_isolated() -> None:
    """Ensure in-filter query params do not overwrite each other."""
    filter_config: FilterConfig = {"in_fields": ["first_name", "last_name"]}
    filter_dependencies = create_filter_dependencies(filter_config)

    @get("/test")
    def handler(filters: list[FilterTypes] = Dependency(skip_validation=True)) -> dict[str, list[str]]:
        response: dict[str, list[str]] = {}
        for filter_ in filters:
            if isinstance(filter_, CollectionFilter):
                field_name = str(filter_.field_name)
                response[field_name] = list(filter_.values or [])
        return response

    app = Litestar(route_handlers=[handler], dependencies=filter_dependencies)
    client = TestClient(app)

    response = client.get("/test?firstNameIn=Sezer&lastNameIn=Tasan")
    assert response.status_code == 200
    payload = response.json()
    assert payload["first_name"] == ["Sezer"]
    assert payload["last_name"] == ["Tasan"]


def test_litestar_not_in_filter_values_are_isolated() -> None:
    """Ensure not-in-filter query params do not overwrite each other."""
    filter_config: FilterConfig = {"not_in_fields": ["first_name", "last_name"]}
    filter_dependencies = create_filter_dependencies(filter_config)

    @get("/test")
    def handler(filters: list[FilterTypes] = Dependency(skip_validation=True)) -> dict[str, list[str]]:
        response: dict[str, list[str]] = {}
        for filter_ in filters:
            if isinstance(filter_, NotInCollectionFilter):
                field_name = str(filter_.field_name)
                response[field_name] = list(filter_.values or [])
        return response

    app = Litestar(route_handlers=[handler], dependencies=filter_dependencies)
    client = TestClient(app)

    response = client.get("/test?firstNameNotIn=Sezer&lastNameNotIn=Tasan")
    assert response.status_code == 200
    payload = response.json()
    assert payload["first_name"] == ["Sezer"]
    assert payload["last_name"] == ["Tasan"]


def test_custom_dependency_defaults() -> None:
    """Test using custom dependency defaults."""

    class CustomDefaults(DependencyDefaults):
        """Custom dependency defaults."""

        LIMIT_OFFSET_FILTER_DEPENDENCY_KEY = "page"
        ID_FILTER_DEPENDENCY_KEY = "ids"
        DEFAULT_PAGINATION_SIZE = 100

    custom_defaults = CustomDefaults()
    config = cast(FilterConfig, {"id_filter": int, "id_field": "custom_id", "pagination_type": "limit_offset"})
    deps = _create_statement_filters(config, custom_defaults)
    assert "ids" in deps
    assert "page" in deps
    assert "filters" in deps
    ids_func = deps["ids"].dependency
    f = ids_func(ids=["1", "2", "3"])
    assert isinstance(f, CollectionFilter)  # type: ignore
    assert f.field_name == "custom_id"
    assert f.values is not None  # type: ignore
    assert f.values == ["1", "2", "3"]  # type: ignore
    page_func = deps["page"].dependency
    f: LimitOffset = page_func(current_page=2, page_size=5)  # type: ignore
    assert isinstance(f, LimitOffset)
    assert f.limit == 5
    assert f.offset == 5


def test_id_filter_aggregation() -> None:
    """Test aggregation with ID filter."""
    config = cast(FilterConfig, {"id_filter": str})
    aggregate_func = _create_filter_aggregate_function(config)

    # Check signature
    sig = inspect.signature(aggregate_func)
    assert "id_filter" in sig.parameters

    # Simulate calling with filter
    mock_filter = MagicMock(spec=CollectionFilter)
    result = aggregate_func(id_filter=mock_filter)

    assert isinstance(result, list)
    assert mock_filter in result


def test_created_at_filter_aggregation() -> None:
    """Test aggregation with created_at filter."""
    aggregate_func = _create_filter_aggregate_function({"created_at": True})

    # Check signature
    sig = inspect.signature(aggregate_func)
    assert "created_filter" in sig.parameters

    # Simulate calling with filter
    mock_filter = MagicMock(spec=BeforeAfter)
    result = aggregate_func(created_filter=mock_filter)

    assert isinstance(result, list)
    assert mock_filter in result


def test_updated_at_filter_aggregation() -> None:
    """Test aggregation with updated_at filter."""
    aggregate_func = _create_filter_aggregate_function({"updated_at": True})

    # Check signature
    sig = inspect.signature(aggregate_func)
    assert "updated_filter" in sig.parameters

    # Simulate calling with filter
    mock_filter = MagicMock(spec=BeforeAfter)
    result = aggregate_func(updated_filter=mock_filter)

    assert isinstance(result, list)
    assert mock_filter in result


def test_search_filter_aggregation() -> None:
    """Test aggregation with search filter."""
    aggregate_func = _create_filter_aggregate_function({"search": ["name"]})

    # Check signature
    sig = inspect.signature(aggregate_func)
    assert "search_filter" in sig.parameters

    # Mock search filter with valid attributes
    mock_filter = MagicMock(spec=SearchFilter)
    mock_filter.field_name = "name"
    mock_filter.value = "test"

    result = aggregate_func(search_filter=mock_filter)

    assert isinstance(result, list)
    assert mock_filter in result

    # Test with invalid search filter (None value)
    mock_filter.value = None
    result = aggregate_func(search_filter=mock_filter)
    assert mock_filter not in result


def test_limit_offset_filter_aggregation() -> None:
    """Test aggregation with limit_offset filter."""
    aggregate_func = _create_filter_aggregate_function({"pagination_type": "limit_offset"})

    # Check signature
    sig = inspect.signature(aggregate_func)
    assert "limit_offset_filter" in sig.parameters

    # Simulate calling with filter
    mock_filter = MagicMock(spec=LimitOffset)
    result = aggregate_func(limit_offset_filter=mock_filter)

    assert isinstance(result, list)
    assert mock_filter in result


def test_order_by_filter_aggregation() -> None:
    """Test aggregation with order_by filter."""
    aggregate_func = _create_filter_aggregate_function({"sort_field": "name"})

    # Check signature
    sig = inspect.signature(aggregate_func)
    assert "order_by_filter" in sig.parameters

    # Mock order_by filter with valid field_name
    mock_filter = MagicMock(spec=OrderBy)
    mock_filter.field_name = "name"

    result = aggregate_func(order_by_filter=mock_filter)

    assert isinstance(result, list)
    assert mock_filter in result

    # Test with invalid order_by filter (None field_name)
    mock_filter.field_name = None
    result = aggregate_func(order_by_filter=mock_filter)
    assert mock_filter not in result


def test_not_in_filter_aggregation() -> None:
    """Test aggregation with not_in filter."""
    aggregate_func = _create_filter_aggregate_function({"not_in_fields": ["status"]})
    # Check signature
    sig = inspect.signature(aggregate_func)
    assert "status_not_in_filter" in sig.parameters

    # Simulate calling with filter
    mock_filter = MagicMock(spec=NotInCollectionFilter)
    result = aggregate_func(status_not_in_filter=mock_filter)

    assert isinstance(result, list)
    assert mock_filter in result

    # Simulate calling without filter (value is None)
    result_none = aggregate_func(status_not_in_filter=None)
    assert mock_filter not in result_none


def test_in_filter_aggregation() -> None:
    """Test aggregation with in filter."""
    config = cast(FilterConfig, {"in_fields": ["tag"]})
    aggregate_func = _create_filter_aggregate_function(config)

    # Check signature
    sig = inspect.signature(aggregate_func)
    assert "tag_in_filter" in sig.parameters

    # Simulate calling with filter
    mock_filter = MagicMock(spec=CollectionFilter)
    result = aggregate_func(tag_in_filter=mock_filter)

    assert isinstance(result, list)
    assert mock_filter in result

    # Simulate calling without filter (value is None)
    result_none = aggregate_func(tag_in_filter=None)
    assert mock_filter not in result_none


def test_multiple_filters_aggregation() -> None:
    """Test aggregation with multiple filters."""

    aggregate_func = _create_filter_aggregate_function(
        {
            "id_filter": int,
            "created_at": True,
            "updated_at": True,
            "search": "name",
            "pagination_type": "limit_offset",
            "sort_field": "name",
            "not_in_fields": ["status"],
            "in_fields": ["tag"],
        }
    )

    # Check signature has all parameters
    sig = inspect.signature(aggregate_func)
    assert "id_filter" in sig.parameters
    assert "created_filter" in sig.parameters
    assert "updated_filter" in sig.parameters
    assert "search_filter" in sig.parameters
    assert "limit_offset_filter" in sig.parameters
    assert "order_by_filter" in sig.parameters
    assert "status_not_in_filter" in sig.parameters
    assert "tag_in_filter" in sig.parameters

    # Simulate calling with multiple filters
    mock_id_filter = MagicMock(spec=CollectionFilter)
    mock_created_filter = MagicMock(spec=BeforeAfter)
    mock_updated_filter = MagicMock(spec=BeforeAfter)
    mock_search_filter = MagicMock(spec=SearchFilter)
    mock_search_filter.field_name = "name"
    mock_search_filter.value = "test"
    mock_limit_offset = MagicMock(spec=LimitOffset)
    mock_order_by_filter = MagicMock(spec=OrderBy)
    mock_order_by_filter.field_name = "name"
    mock_not_in_filter = MagicMock(spec=NotInCollectionFilter)
    mock_in_filter = MagicMock(spec=CollectionFilter)

    result = aggregate_func(
        id_filter=mock_id_filter,
        created_filter=mock_created_filter,
        updated_filter=mock_updated_filter,
        search_filter=mock_search_filter,
        limit_offset_filter=mock_limit_offset,
        order_by_filter=mock_order_by_filter,
        status_not_in_filter=mock_not_in_filter,
        tag_in_filter=mock_in_filter,
    )

    # Verify all filters are included
    assert len(result) == 8
    assert mock_id_filter in result
    assert mock_created_filter in result
    assert mock_updated_filter in result
    assert mock_search_filter in result
    assert mock_limit_offset in result
    assert mock_order_by_filter in result
    assert mock_not_in_filter in result
    assert mock_in_filter in result


@unittest.mock.patch(
    "advanced_alchemy.extensions.litestar.providers.create_filter_dependencies",
    wraps=create_filter_dependencies,  # Call the original function logic
)
def test_litestar_openapi_schema(mock_create_filters: unittest.mock.MagicMock) -> None:
    """Test OpenAPI schema generation for filter dependencies."""

    filter_config = {
        "id_filter": uuid.UUID,  # Example using UUID
        "id_field": "guid",
        "created_at": True,
        "updated_at": True,
        "pagination_type": "limit_offset",
        "pagination_size": 25,
        "search": "name,description",
        "search_ignore_case": True,
        "sort_field": "name",
        "sort_order": "asc",
        "not_in_fields": ["status", "category"],
        "in_fields": ["tag", "region"],
    }
    # Call the mocked function, which wraps the original
    filter_dependencies = mock_create_filters(filter_config)

    @get("/test")
    async def test_handler(filters: list[FilterTypes]) -> list[str]:
        """Dummy handler to test schema generation."""
        return [type(f).__name__ for f in filters]

    app = Litestar(
        route_handlers=[test_handler],
        signature_namespace=signature_namespace_values,
        dependencies=filter_dependencies,  # Provide all dependencies to the app
        openapi_config=OpenAPIConfig(
            title="Test API", version="1.0.0", use_handler_docstrings=True, path="/schema"
        ),  # Explicitly enable OpenAPI with specific path
    )
    client = TestClient(app)

    # Fetch the OpenAPI schema (using the exact path from Litestar's default config)
    response = client.get("/schema/openapi.json")
    assert response.status_code == 200, (
        f"Failed to get schema, status: {response.status_code}, content: {response.text[:200]}"
    )
    schema = response.json()

    # Find the parameters for the /test endpoint
    path_item = schema.get("paths", {}).get("/test", {}).get("get", {})
    parameters = path_item.get("parameters", [])

    # Assert all expected parameter names are present
    param_names = {p["name"] for p in parameters}
    expected_params = {
        "ids",  # from id_filter, alias based on id_field 'guid' might not be reflected here, Litestar uses parameter name
        "createdBefore",
        "createdAfter",
        "updatedBefore",
        "updatedAfter",
        "currentPage",
        "pageSize",
        "searchString",
        "searchIgnoreCase",
        "orderBy",
        "sortOrder",
        "statusNotIn",
        "categoryNotIn",
        "tagIn",
        "regionIn",
    }
    assert param_names == expected_params

    # Check details of specific parameters

    # ids (based on id_filter: uuid.UUID)
    ids_param = next(p for p in parameters if p["name"] == "ids")
    assert ids_param["in"] == "query"
    assert ids_param["required"] is False
    assert "oneOf" in ids_param["schema"]
    assert len(ids_param["schema"]["oneOf"]) == 2
    array_schema = ids_param["schema"]["oneOf"][0]
    assert array_schema["type"] == "array"
    assert array_schema["items"]["type"] == "string"

    # createdBefore (datetime parameter)
    created_before_param = next(p for p in parameters if p["name"] == "createdBefore")
    assert created_before_param["in"] == "query"
    assert created_before_param["required"] is False
    assert "oneOf" in created_before_param["schema"]
    date_schema = created_before_param["schema"]["oneOf"][0]
    assert date_schema["type"] == "string"
    assert date_schema["format"] == "date-time"

    # Check in and not_in parameters
    status_not_in_param = next(p for p in parameters if p["name"] == "statusNotIn")
    assert status_not_in_param["in"] == "query"
    assert status_not_in_param["required"] is False
    assert "oneOf" in status_not_in_param["schema"]

    tag_in_param = next(p for p in parameters if p["name"] == "tagIn")
    assert tag_in_param["in"] == "query"
    assert tag_in_param["required"] is False
    assert "oneOf" in tag_in_param["schema"]
python-advanced-alchemy-1.9.3/tests/unit/test_extensions/test_litestar/test_serialization_plugin.py000066400000000000000000000045401516556515500343620ustar00rootroot00000000000000from types import ModuleType
from typing import Callable

from litestar import get
from litestar.status_codes import HTTP_200_OK
from litestar.testing import RequestFactory, create_test_client
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column

from advanced_alchemy.base import UUIDAuditBase
from advanced_alchemy.extensions.litestar import SQLAlchemySerializationPlugin
from advanced_alchemy.service.pagination import OffsetPagination


async def test_serialization_plugin(
    create_module: Callable[[str], ModuleType],
    request_factory: RequestFactory,
) -> None:
    module = create_module(
        """
from __future__ import annotations

from typing import Dict, List, Set, Tuple, Type, List

from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column

from litestar import Litestar, get, post
from advanced_alchemy.extensions.litestar import SQLAlchemySerializationPlugin

class Base(DeclarativeBase):
    id: Mapped[int] = mapped_column(primary_key=True)

class A(Base):
    __tablename__ = "a"
    a: Mapped[str]

@post("/a")
def post_handler(data: A) -> A:
    return data

@get("/a")
def get_handler() -> List[A]:
    return [A(id=1, a="test"), A(id=2, a="test2")]

@get("/a/1")
def get_a() -> A:
    return A(id=1, a="test")
""",
    )
    with create_test_client(
        route_handlers=[module.post_handler, module.get_handler, module.get_a],
        plugins=[SQLAlchemySerializationPlugin()],
    ) as client:
        response = client.post("/a", json={"id": 1, "a": "test"})
        assert response.status_code == 201
        assert response.json() == {"id": 1, "a": "test"}
        response = client.get("/a")
        assert response.json() == [{"id": 1, "a": "test"}, {"id": 2, "a": "test2"}]
        response = client.get("/a/1")
        assert response.json() == {"id": 1, "a": "test"}


class User(UUIDAuditBase):
    first_name: Mapped[str] = mapped_column(String(200))


def test_pagination_serialization() -> None:
    users = [User(first_name="ASD"), User(first_name="qwe")]

    @get("/paginated")
    async def paginated_handler() -> OffsetPagination[User]:
        return OffsetPagination[User](items=users, limit=2, offset=0, total=2)

    with create_test_client(paginated_handler, plugins=[SQLAlchemySerializationPlugin()]) as client:
        response = client.get("/paginated")
        assert response.status_code == HTTP_200_OK
python-advanced-alchemy-1.9.3/tests/unit/test_extensions/test_litestar/test_session.py000066400000000000000000001355071516556515500316220ustar00rootroot00000000000000import datetime
from collections.abc import Awaitable, Generator
from typing import Any, Callable, TypeVar
from unittest.mock import AsyncMock, MagicMock, Mock, patch

import pytest
from litestar.middleware.session.server_side import ServerSideSessionConfig
from pytest import MonkeyPatch
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session as SyncSession

from advanced_alchemy.extensions.litestar.plugins.init.config.asyncio import SQLAlchemyAsyncConfig
from advanced_alchemy.extensions.litestar.plugins.init.config.sync import SQLAlchemySyncConfig
from advanced_alchemy.extensions.litestar.session import (
    SessionModelMixin,
    SQLAlchemyAsyncSessionBackend,
    SQLAlchemySyncSessionBackend,
)

# Type variable for the callable in the patch
F = TypeVar("F", bound=Callable[..., Any])


class MockDateTime:
    """Mock datetime class for testing time-dependent behavior."""

    def __init__(self, fixed_time: datetime.datetime) -> None:
        self.fixed_time = fixed_time

    def now(self, tz: datetime.timezone = datetime.timezone.utc) -> datetime.datetime:
        return self.fixed_time


class MockSessionModel(SessionModelMixin):
    """Mock session model for testing."""

    __tablename__ = "mock_session"


@pytest.fixture()
def mock_session_model() -> type[SessionModelMixin]:
    return MockSessionModel


@pytest.fixture()
def mock_async_session() -> MagicMock:
    session = MagicMock(spec=AsyncSession)
    session.__aenter__.return_value = session  # Simulate async context manager
    session.__aexit__.return_value = None

    # Add mock bind and dialect to avoid AttributeError in session backend
    mock_dialect = MagicMock()
    mock_dialect.name = "mssql"  # Use dialect that forces fallback path in tests
    mock_dialect.server_version_info = None  # Ensure no merge support
    mock_bind = MagicMock()
    mock_bind.dialect = mock_dialect
    session.bind = mock_bind

    return session


@pytest.fixture()
def mock_async_config(mock_session_model: type[SessionModelMixin], mock_async_session: MagicMock) -> MagicMock:
    config = MagicMock(spec=SQLAlchemyAsyncConfig)
    config.get_session.return_value = mock_async_session  # Configure the mock method
    return config


@pytest.fixture()
def mock_sync_session() -> MagicMock:
    session = MagicMock(spec=SyncSession)
    session.__enter__.return_value = session  # Simulate sync context manager
    session.__exit__.return_value = None

    # Add mock bind and dialect to avoid AttributeError in session backend
    mock_dialect = MagicMock()
    mock_dialect.name = "mssql"  # Use dialect that forces fallback path in tests
    mock_dialect.server_version_info = None  # Ensure no merge support
    mock_bind = MagicMock()
    mock_bind.dialect = mock_dialect
    session.bind = mock_bind

    return session


@pytest.fixture()
def mock_sync_config(mock_session_model: type[SessionModelMixin], mock_sync_session: MagicMock) -> MagicMock:
    config = MagicMock(spec=SQLAlchemySyncConfig)
    config.get_session.return_value = mock_sync_session  # Configure the mock method
    return config


@pytest.fixture()
def async_backend_config(
    mock_session_model: type[SessionModelMixin],
    mock_async_config: MagicMock,  # Use MagicMock type hint
) -> SQLAlchemyAsyncSessionBackend:
    return SQLAlchemyAsyncSessionBackend(
        model=mock_session_model,
        alchemy_config=mock_async_config,
        config=ServerSideSessionConfig(max_age=1000),
    )


def test_backend_config_post_init_valid(
    mock_session_model: type[SessionModelMixin], mock_async_config: MagicMock
) -> None:
    """Test SQLAlchemyBackendConfig initialization with valid parameters."""
    config = SQLAlchemyAsyncSessionBackend(
        model=mock_session_model,
        alchemy_config=mock_async_config,
        config=ServerSideSessionConfig(max_age=1000),
    )
    assert config.model is mock_session_model
    assert config.alchemy is mock_async_config


def test_backend_config_backend_class_async(
    mock_session_model: type[SessionModelMixin], mock_async_config: MagicMock
) -> None:
    """Test _backend_class property returns async backend for async config."""
    config = SQLAlchemyAsyncSessionBackend(
        model=mock_session_model,
        alchemy_config=mock_async_config,
        config=ServerSideSessionConfig(max_age=1000),
    )
    assert config._backend_class is SQLAlchemyAsyncSessionBackend  # pyright: ignore [reportPrivateUsage]


def test_backend_config_backend_class_sync(
    mock_session_model: type[SessionModelMixin], mock_sync_config: MagicMock
) -> None:
    """Test _backend_class property returns sync backend for sync config."""
    config = SQLAlchemySyncSessionBackend(
        model=mock_session_model,
        alchemy_config=mock_sync_config,
        config=ServerSideSessionConfig(max_age=1000),
    )
    assert config._backend_class is SQLAlchemySyncSessionBackend  # pyright: ignore [reportPrivateUsage]


# --- SessionModelMixin Tests ---


def test_session_model_mixin_is_expired_property() -> None:
    """Test the is_expired hybrid property."""
    now = datetime.datetime.now(datetime.timezone.utc)
    expired_session = MockSessionModel(expires_at=now - datetime.timedelta(seconds=1))
    active_session = MockSessionModel(expires_at=now + datetime.timedelta(seconds=10))

    assert expired_session.is_expired is True
    assert active_session.is_expired is False


def test_create_session_model_default_table_name() -> None:
    """Test creating a session model with the default table name."""

    class DefaultSessionModel(SessionModelMixin):
        """Default session model for testing."""

        __tablename__ = "session"

    assert issubclass(DefaultSessionModel, SessionModelMixin)
    assert DefaultSessionModel.__tablename__ == "session"


def test_create_session_model_custom_table_name() -> None:
    """Test creating a session model with a custom table name."""
    custom_name = "custom_user_sessions"

    class CustomSessionModel(SessionModelMixin):
        """Custom session model for testing."""

        __tablename__ = custom_name

    assert issubclass(CustomSessionModel, SessionModelMixin)
    assert CustomSessionModel.__tablename__ == custom_name


# --- SQLAlchemyAsyncSessionBackend Tests ---


@pytest.fixture()
def async_backend(async_backend_config: SQLAlchemyAsyncSessionBackend) -> SQLAlchemyAsyncSessionBackend:
    return async_backend_config


async def test_async_backend_get_session_obj_found(
    async_backend: SQLAlchemyAsyncSessionBackend,
    mock_session_model: type[SessionModelMixin],
    mock_async_session: MagicMock,
) -> None:
    """Test _get_session_obj finds an existing session."""
    mock_scalar_result = MagicMock()
    expected_session = mock_session_model(
        session_id="test_id", data=b"data", expires_at=datetime.datetime.now(datetime.timezone.utc)
    )
    mock_scalar_result.one_or_none.return_value = expected_session
    mock_async_session.scalars.return_value = mock_scalar_result

    session_obj = await async_backend._get_session_obj(db_session=mock_async_session, session_id="test_id")  # pyright: ignore [reportPrivateUsage]

    assert session_obj is expected_session
    mock_async_session.scalars.assert_called_once()
    call_args, _ = mock_async_session.scalars.call_args
    select_stmt = call_args[0]
    assert str(select_stmt).startswith(f"SELECT {mock_session_model.__tablename__}")
    assert "WHERE" in str(select_stmt) and "session_id = :session_id_1" in str(select_stmt)


async def test_async_backend_get_session_obj_not_found(
    async_backend: SQLAlchemyAsyncSessionBackend,
    mock_async_session: MagicMock,
) -> None:
    """Test _get_session_obj returns None when session not found."""
    mock_scalar_result = MagicMock()
    mock_scalar_result.one_or_none.return_value = None
    mock_async_session.scalars.return_value = mock_scalar_result

    session_obj = await async_backend._get_session_obj(db_session=mock_async_session, session_id="test_id")  # pyright: ignore [reportPrivateUsage]

    assert session_obj is None
    mock_async_session.scalars.assert_called_once()


@pytest.mark.asyncio()
async def test_async_backend_get_existing_not_expired(
    async_backend: SQLAlchemyAsyncSessionBackend,
    mock_async_config: MagicMock,
    mock_session_model: type[SessionModelMixin],
    mock_async_session: MagicMock,
) -> None:
    """Test getting an existing, non-expired session."""
    now = datetime.datetime.now(datetime.timezone.utc)
    expires_at = now + datetime.timedelta(seconds=async_backend.config.max_age)
    session_data = b"session_data"
    session_id = "existing_session"

    mock_session_obj = mock_session_model(session_id=session_id, data=session_data, expires_at=expires_at)

    with patch.object(async_backend, "_get_session_obj", return_value=mock_session_obj) as mock_get_obj:
        result = await async_backend.get(session_id, store=Mock())

        assert result == session_data
        mock_get_obj.assert_awaited_once_with(db_session=mock_async_session, session_id=session_id)
        # Check expiry was updated
        assert mock_session_obj.expires_at > now
        mock_async_session.commit.assert_awaited_once()
        mock_async_session.delete.assert_not_called()


@pytest.mark.asyncio()
async def test_async_backend_get_existing_expired(
    async_backend: SQLAlchemyAsyncSessionBackend,
    mock_async_config: MagicMock,
    mock_session_model: type[SessionModelMixin],
    mock_async_session: MagicMock,
) -> None:
    """Test getting an expired session returns None and deletes it."""
    now = datetime.datetime.now(datetime.timezone.utc)
    expires_at = now - datetime.timedelta(seconds=1)  # Expired
    session_data = b"expired_data"
    session_id = "expired_session"

    mock_session_obj = mock_session_model(session_id=session_id, data=session_data, expires_at=expires_at)

    with patch.object(async_backend, "_get_session_obj", return_value=mock_session_obj) as mock_get_obj:
        result = await async_backend.get(session_id, store=Mock())

        assert result is None
        mock_get_obj.assert_awaited_once_with(db_session=mock_async_session, session_id=session_id)
        mock_async_session.delete.assert_awaited_once_with(mock_session_obj)
        mock_async_session.commit.assert_awaited_once()


async def test_async_backend_get_non_existent(
    async_backend: SQLAlchemyAsyncSessionBackend,
    mock_async_config: MagicMock,
    mock_async_session: MagicMock,
) -> None:
    """Test getting a non-existent session returns None."""
    session_id = "non_existent_session"
    with patch.object(async_backend, "_get_session_obj", return_value=None) as mock_get_obj:
        result = await async_backend.get(session_id, store=Mock())

        assert result is None
        mock_get_obj.assert_awaited_once_with(db_session=mock_async_session, session_id=session_id)
        mock_async_session.delete.assert_not_called()
        mock_async_session.commit.assert_not_called()  # Nothing to commit


@pytest.mark.asyncio()
async def test_async_backend_set_new_session(
    async_backend: SQLAlchemyAsyncSessionBackend,
    mock_async_config: MagicMock,
    mock_session_model: type[SessionModelMixin],
    mock_async_session: MagicMock,
    monkeypatch: MonkeyPatch,
) -> None:
    """Test setting a new session."""
    now = datetime.datetime.now(datetime.timezone.utc)
    monkeypatch.setattr("advanced_alchemy.extensions.litestar.session.datetime.datetime", MockDateTime(now))

    with patch.object(async_backend, "_get_session_obj", return_value=None) as mock_get_obj:
        await async_backend.set("new_session", b"new_data", store=Mock())

        mock_get_obj.assert_awaited_once_with(db_session=mock_async_session, session_id="new_session")
        mock_async_session.add.assert_called_once()
        added_obj = mock_async_session.add.call_args[0][0]
        assert isinstance(added_obj, mock_session_model)
        assert added_obj.session_id == "new_session"
        assert added_obj.data == b"new_data"
        assert added_obj.expires_at == now + datetime.timedelta(seconds=async_backend.config.max_age)
        mock_async_session.commit.assert_awaited_once()


async def test_async_backend_set_update_existing_session(
    async_backend: SQLAlchemyAsyncSessionBackend,
    mock_async_config: MagicMock,
    mock_session_model: type[SessionModelMixin],
    mock_async_session: MagicMock,
    monkeypatch: MonkeyPatch,
) -> None:
    """Test updating an existing session's data and expiry."""
    now = datetime.datetime.now(datetime.timezone.utc)
    monkeypatch.setattr("advanced_alchemy.extensions.litestar.session.datetime.datetime", MockDateTime(now))

    mock_existing_session = mock_session_model(
        session_id="existing_session_update", data=b"old_data", expires_at=now - datetime.timedelta(seconds=10)
    )

    with patch.object(async_backend, "_get_session_obj", return_value=mock_existing_session) as mock_get_obj:
        await async_backend.set("existing_session_update", b"new_data", store=Mock())

        mock_get_obj.assert_awaited_once_with(db_session=mock_async_session, session_id="existing_session_update")
        mock_async_session.add.assert_not_called()
        assert mock_existing_session.data == b"new_data"
        assert mock_existing_session.expires_at == now + datetime.timedelta(seconds=async_backend.config.max_age)
        mock_async_session.commit.assert_awaited_once()


async def test_async_backend_delete_existing(
    async_backend: SQLAlchemyAsyncSessionBackend,
    mock_async_config: MagicMock,
    mock_session_model: type[SessionModelMixin],
    mock_async_session: MagicMock,
) -> None:
    """Test deleting an existing session."""
    session_id = "delete_me"
    await async_backend.delete(session_id, store=Mock())

    mock_async_session.execute.assert_awaited_once()
    call_args, _ = mock_async_session.execute.call_args
    delete_stmt = call_args[0]
    assert str(delete_stmt).startswith(f"DELETE FROM {mock_session_model.__tablename__}")
    assert "WHERE" in str(delete_stmt) and "session_id = :session_id_1" in str(delete_stmt)
    mock_async_session.commit.assert_awaited_once()


@pytest.mark.asyncio()
async def test_async_backend_delete_non_existent(
    async_backend: SQLAlchemyAsyncSessionBackend,
    mock_async_config: MagicMock,
    mock_async_session: MagicMock,
) -> None:
    """Test deleting a non-existent session (fails silently)."""
    session_id = "i_dont_exist"
    await async_backend.delete(session_id, store=Mock())

    mock_async_session.execute.assert_awaited_once()  # Execute is still called
    mock_async_session.commit.assert_awaited_once()  # Commit happens regardless


@pytest.mark.asyncio()
async def test_async_backend_delete_all(
    async_backend: SQLAlchemyAsyncSessionBackend,
    mock_async_config: MagicMock,
    mock_session_model: type[SessionModelMixin],
    mock_async_session: MagicMock,
) -> None:
    """Test deleting all sessions."""
    await async_backend.delete_all(store=Mock())

    mock_async_session.execute.assert_awaited_once()
    call_args, _ = mock_async_session.execute.call_args
    delete_stmt = call_args[0]
    assert str(delete_stmt) == f"DELETE FROM {mock_session_model.__tablename__}"
    mock_async_session.commit.assert_awaited_once()


@pytest.mark.asyncio()
async def test_async_backend_delete_expired(
    async_backend: SQLAlchemyAsyncSessionBackend,
    mock_async_config: MagicMock,
    mock_session_model: type[SessionModelMixin],
    monkeypatch: MonkeyPatch,
    mock_async_session: MagicMock,
) -> None:
    """Test deleting expired sessions."""
    mock_async_session = mock_async_config.get_session.return_value
    mock_async_session.__aenter__.return_value = mock_async_session
    mock_async_session.__aexit__.return_value = None

    fixed_time = datetime.datetime.now(datetime.timezone.utc)
    mock_datetime = MockDateTime(fixed_time)
    monkeypatch.setattr("advanced_alchemy.extensions.litestar.session.datetime.datetime", mock_datetime)

    await async_backend.delete_expired()

    mock_async_session.execute.assert_awaited_once()
    call_args, _ = mock_async_session.execute.call_args
    delete_stmt = call_args[0]
    assert str(delete_stmt).startswith(f"DELETE FROM {mock_session_model.__tablename__}")
    assert "WHERE" in str(delete_stmt) and "now() >" in str(delete_stmt)
    mock_async_session.commit.assert_awaited_once()


# --- SQLAlchemySyncSessionBackend Tests ---

# Use async fixtures and tests because the public methods are async,
# wrapping the internal sync calls.


@pytest.fixture()
def sync_backend(mock_sync_config: MagicMock) -> Generator[SQLAlchemySyncSessionBackend, None, None]:
    mock_sync_config.get_session.return_value = MagicMock()
    mock_sync_config.get_session.return_value.__enter__.return_value = MagicMock()
    mock_sync_config.get_session.return_value.__exit__.return_value = None

    # Create a proper async wrapper for sync functions
    def mock_async_(fn: Callable[..., Any], **_: Any) -> Callable[..., Awaitable[Any]]:
        async def wrapper(*args: Any, **kwargs: Any) -> Any:
            return fn(*args, **kwargs)

        return wrapper

    with patch("advanced_alchemy.extensions.litestar.session.async_", side_effect=mock_async_):
        yield SQLAlchemySyncSessionBackend(
            config=ServerSideSessionConfig(max_age=1000),
            alchemy_config=mock_sync_config,
            model=MockSessionModel,
        )


@pytest.mark.asyncio()
async def test_sync_backend_get_wraps_sync_call(
    sync_backend: SQLAlchemySyncSessionBackend,
    mock_sync_config: MagicMock,
    mock_sync_session: MagicMock,
) -> None:
    """Test that sync backend's async get() calls the internal _get_sync."""
    session_id = "sync_test_get"
    mock_sync_session = mock_sync_config.get_session.return_value
    mock_sync_session.__enter__.return_value = mock_sync_session
    mock_sync_session.__exit__.return_value = None

    mock_get_sync = MagicMock(return_value=b"data")
    with patch.object(sync_backend, "_get_sync", mock_get_sync):
        result = await sync_backend.get(session_id, store=Mock())
        assert result == b"data"
        mock_get_sync.assert_called_once_with(session_id)


@pytest.mark.asyncio()
async def test_sync_backend_set_wraps_sync_call(
    sync_backend: SQLAlchemySyncSessionBackend,
    mock_sync_config: MagicMock,
    mock_sync_session: MagicMock,
) -> None:
    """Test that sync backend's async set() calls the internal _set_sync."""
    session_id = "sync_test_set"
    data = b"set_data"
    mock_sync_session = mock_sync_config.get_session.return_value
    mock_sync_session.__enter__.return_value = mock_sync_session
    mock_sync_session.__exit__.return_value = None

    mock_set_sync = MagicMock()
    with patch.object(sync_backend, "_set_sync", mock_set_sync):
        await sync_backend.set(session_id, data, store=Mock())
        mock_set_sync.assert_called_once_with(session_id, data)


@pytest.mark.asyncio()
async def test_sync_backend_delete_wraps_sync_call(
    sync_backend: SQLAlchemySyncSessionBackend,
    mock_sync_config: MagicMock,
    mock_sync_session: MagicMock,
) -> None:
    """Test that sync backend's async delete() calls the internal _delete_sync."""
    session_id = "sync_test_delete"
    mock_sync_session = mock_sync_config.get_session.return_value
    mock_sync_session.__enter__.return_value = mock_sync_session
    mock_sync_session.__exit__.return_value = None

    mock_delete_sync = MagicMock()
    with patch.object(sync_backend, "_delete_sync", mock_delete_sync):
        await sync_backend.delete(session_id, store=Mock())
        mock_delete_sync.assert_called_once_with(session_id)


@pytest.mark.asyncio()
async def test_sync_backend_delete_all_wraps_sync_call(
    sync_backend: SQLAlchemySyncSessionBackend,
    mock_sync_config: MagicMock,
    mock_sync_session: MagicMock,
) -> None:
    """Test that sync backend's async delete_all() calls the internal _delete_all_sync."""
    mock_sync_session = mock_sync_config.get_session.return_value
    mock_sync_session.__enter__.return_value = mock_sync_session
    mock_sync_session.__exit__.return_value = None

    mock_delete_all_sync = MagicMock()
    with patch.object(sync_backend, "_delete_all_sync", mock_delete_all_sync):
        await sync_backend.delete_all()
        mock_delete_all_sync.assert_called_once()


@pytest.mark.asyncio()
async def test_sync_backend_delete_expired_wraps_sync_call(
    sync_backend: SQLAlchemySyncSessionBackend,
    mock_sync_config: MagicMock,
    mock_sync_session: MagicMock,
) -> None:
    """Test that sync backend's async delete_expired() calls the internal _delete_expired_sync."""
    mock_sync_session = mock_sync_config.get_session.return_value
    mock_sync_session.__enter__.return_value = mock_sync_session
    mock_sync_session.__exit__.return_value = None

    mock_delete_expired_sync = MagicMock()
    with patch.object(sync_backend, "_delete_expired_sync", mock_delete_expired_sync):
        await sync_backend.delete_expired()
        mock_delete_expired_sync.assert_called_once()


# --- Internal Sync Method Tests (_get_sync, _set_sync etc.) ---
# These run within the sync backend's methods, so we test them directly (not async)


def test_sync_backend_internal_get_sync_existing_not_expired(
    sync_backend: SQLAlchemySyncSessionBackend,
    mock_sync_config: MagicMock,
    mock_session_model: type[SessionModelMixin],
    monkeypatch: MonkeyPatch,
    mock_sync_session: MagicMock,
) -> None:
    """Test the internal _get_sync method for existing, non-expired session."""
    now = datetime.datetime.now(datetime.timezone.utc)
    mock_datetime = MockDateTime(now)
    monkeypatch.setattr("advanced_alchemy.extensions.litestar.session.datetime.datetime", mock_datetime)

    expires_at = now + datetime.timedelta(seconds=sync_backend.config.max_age)
    session_data = b"sync_session_data"
    session_id = "sync_existing_session"

    mock_session_obj = mock_session_model(session_id=session_id, data=session_data, expires_at=expires_at)

    mock_sync_session = mock_sync_config.get_session.return_value
    mock_sync_session.__enter__.return_value = mock_sync_session
    mock_sync_session.__exit__.return_value = None

    with patch.object(sync_backend, "_get_session_obj", return_value=mock_session_obj) as mock_get_obj:
        result = sync_backend._get_sync(session_id)  # pyright: ignore [reportPrivateUsage]

        assert result == session_data
        mock_get_obj.assert_called_once_with(db_session=mock_sync_session, session_id=session_id)
        assert mock_session_obj.expires_at > now
        mock_sync_session.commit.assert_called_once()
        mock_sync_session.delete.assert_not_called()


def test_sync_backend_internal_get_sync_existing_expired(
    sync_backend: SQLAlchemySyncSessionBackend,
    mock_sync_config: MagicMock,
    mock_session_model: type[SessionModelMixin],
    monkeypatch: MonkeyPatch,
    mock_sync_session: MagicMock,
) -> None:
    """Test the internal _get_sync method for an expired session."""
    now = datetime.datetime.now(datetime.timezone.utc)
    mock_datetime = MockDateTime(now)
    monkeypatch.setattr("advanced_alchemy.extensions.litestar.session.datetime.datetime", mock_datetime)

    expires_at = now - datetime.timedelta(seconds=1)  # Expired
    session_data = b"sync_expired_data"
    session_id = "sync_expired_session"

    mock_session_obj = mock_session_model(session_id=session_id, data=session_data, expires_at=expires_at)

    mock_sync_session = mock_sync_config.get_session.return_value
    mock_sync_session.__enter__.return_value = mock_sync_session
    mock_sync_session.__exit__.return_value = None

    with patch.object(sync_backend, "_get_session_obj", return_value=mock_session_obj) as mock_get_obj:
        result = sync_backend._get_sync(session_id)  # pyright: ignore [reportPrivateUsage]

        assert result is None
        mock_get_obj.assert_called_once_with(db_session=mock_sync_session, session_id=session_id)
        mock_sync_session.delete.assert_called_once_with(mock_session_obj)
        mock_sync_session.commit.assert_called_once()


def test_sync_backend_internal_get_sync_non_existent(
    sync_backend: SQLAlchemySyncSessionBackend,
    mock_sync_config: MagicMock,
    mock_sync_session: MagicMock,
) -> None:
    """Test the internal _get_sync method for a non-existent session."""
    session_id = "sync_non_existent"
    mock_sync_session = mock_sync_config.get_session.return_value
    mock_sync_session.__enter__.return_value = mock_sync_session
    mock_sync_session.__exit__.return_value = None

    with patch.object(sync_backend, "_get_session_obj", return_value=None) as mock_get_obj:
        result = sync_backend._get_sync(session_id)  # pyright: ignore [reportPrivateUsage]

        assert result is None
        mock_get_obj.assert_called_once_with(db_session=mock_sync_session, session_id=session_id)
        mock_sync_session.delete.assert_not_called()
        mock_sync_session.commit.assert_not_called()


def test_sync_backend_internal_set_sync_new(
    sync_backend: SQLAlchemySyncSessionBackend,
    mock_sync_config: MagicMock,
    mock_session_model: type[SessionModelMixin],
    monkeypatch: MonkeyPatch,
    mock_sync_session: MagicMock,
) -> None:
    """Test the internal _set_sync method for a new session."""
    now = datetime.datetime.now(datetime.timezone.utc)
    mock_datetime = MockDateTime(now)
    monkeypatch.setattr("advanced_alchemy.extensions.litestar.session.datetime.datetime", mock_datetime)

    session_id = "sync_new_set"
    data = b"sync_new_data"

    mock_sync_session = MagicMock(spec=SyncSession)
    mock_sync_session.__enter__.return_value = mock_sync_session
    mock_sync_session.__exit__.return_value = None

    # Add mock bind and dialect to avoid AttributeError in session backend
    mock_dialect = MagicMock()
    mock_dialect.name = "mssql"  # Use dialect that forces fallback path in tests
    mock_dialect.server_version_info = None  # Ensure no merge support
    mock_bind = MagicMock()
    mock_bind.dialect = mock_dialect
    mock_sync_session.bind = mock_bind

    mock_sync_config.get_session.return_value = mock_sync_session

    with patch.object(sync_backend, "_get_session_obj", return_value=None) as mock_get_obj:
        sync_backend._set_sync(session_id, data)  # pyright: ignore [reportPrivateUsage]

        mock_get_obj.assert_called_once_with(db_session=mock_sync_session, session_id=session_id)
        # Check add was called with a new model instance
        mock_sync_session.add.assert_called_once()
        added_obj = mock_sync_session.add.call_args[0][0]
        assert isinstance(added_obj, mock_session_model)
        assert added_obj.session_id == session_id
        assert added_obj.data == data
        assert added_obj.expires_at == now + datetime.timedelta(seconds=sync_backend.config.max_age)
        mock_sync_session.commit.assert_called_once()


def test_sync_backend_internal_set_sync_update(
    sync_backend: SQLAlchemySyncSessionBackend,
    mock_sync_config: MagicMock,
    mock_session_model: type[SessionModelMixin],
    monkeypatch: MonkeyPatch,
    mock_sync_session: MagicMock,
) -> None:
    """Test the internal _set_sync method updating an existing session."""
    now = datetime.datetime.now(datetime.timezone.utc)
    mock_datetime = MockDateTime(now)
    monkeypatch.setattr("advanced_alchemy.extensions.litestar.session.datetime.datetime", mock_datetime)

    session_id = "sync_update_set"
    old_data = b"sync_old_data"
    new_data = b"sync_new_data"
    old_expires_at = now - datetime.timedelta(seconds=20)

    mock_existing_session = mock_session_model(session_id=session_id, data=old_data, expires_at=old_expires_at)

    mock_sync_session = MagicMock(spec=SyncSession)
    mock_sync_session.__enter__.return_value = mock_sync_session
    mock_sync_session.__exit__.return_value = None

    # Add mock bind and dialect to avoid AttributeError in session backend
    mock_dialect = MagicMock()
    mock_dialect.name = "mssql"  # Use dialect that forces fallback path in tests
    mock_dialect.server_version_info = None  # Ensure no merge support
    mock_bind = MagicMock()
    mock_bind.dialect = mock_dialect
    mock_sync_session.bind = mock_bind

    mock_sync_config.get_session.return_value = mock_sync_session

    with patch.object(sync_backend, "_get_session_obj", return_value=mock_existing_session) as mock_get_obj:
        sync_backend._set_sync(session_id, new_data)  # pyright: ignore [reportPrivateUsage]

        mock_get_obj.assert_called_once_with(db_session=mock_sync_session, session_id=session_id)
        mock_sync_session.add.assert_not_called()  # Should not add new
        assert mock_existing_session.data == new_data
        assert mock_existing_session.expires_at == now + datetime.timedelta(seconds=sync_backend.config.max_age)
        mock_sync_session.commit.assert_called_once()


def test_sync_backend_internal_delete_sync(
    sync_backend: SQLAlchemySyncSessionBackend,
    mock_sync_config: MagicMock,
    mock_session_model: type[SessionModelMixin],
    mock_sync_session: MagicMock,
) -> None:
    """Test the internal _delete_sync method."""
    session_id = "sync_delete_me"
    mock_sync_session = MagicMock(spec=SyncSession)
    mock_sync_session.__enter__.return_value = mock_sync_session
    mock_sync_session.__exit__.return_value = None

    # Add mock bind and dialect to avoid AttributeError in session backend
    mock_dialect = MagicMock()
    mock_dialect.name = "sqlite"
    mock_bind = MagicMock()
    mock_bind.dialect = mock_dialect
    mock_sync_session.bind = mock_bind

    mock_sync_config.get_session.return_value = mock_sync_session

    sync_backend._delete_sync(session_id)  # pyright: ignore [reportPrivateUsage]

    mock_sync_session.execute.assert_called_once()
    call_args, _ = mock_sync_session.execute.call_args
    delete_stmt = call_args[0]
    assert str(delete_stmt).startswith(f"DELETE FROM {mock_session_model.__tablename__}")
    assert "WHERE" in str(delete_stmt) and "session_id = :session_id_1" in str(delete_stmt)
    mock_sync_session.commit.assert_called_once()


def test_sync_backend_internal_delete_all_sync(
    sync_backend: SQLAlchemySyncSessionBackend,
    mock_sync_config: MagicMock,
    mock_session_model: type[SessionModelMixin],
    mock_sync_session: MagicMock,
) -> None:
    """Test the internal _delete_all_sync method."""
    mock_sync_session = MagicMock(spec=SyncSession)
    mock_sync_session.__enter__.return_value = mock_sync_session
    mock_sync_session.__exit__.return_value = None

    # Add mock bind and dialect to avoid AttributeError in session backend
    mock_dialect = MagicMock()
    mock_dialect.name = "sqlite"
    mock_bind = MagicMock()
    mock_bind.dialect = mock_dialect
    mock_sync_session.bind = mock_bind

    mock_sync_config.get_session.return_value = mock_sync_session

    sync_backend._delete_all_sync()  # pyright: ignore [reportPrivateUsage]

    mock_sync_session.execute.assert_called_once()
    call_args, _ = mock_sync_session.execute.call_args
    delete_stmt = call_args[0]
    assert str(delete_stmt) == f"DELETE FROM {mock_session_model.__tablename__}"
    mock_sync_session.commit.assert_called_once()


def test_sync_backend_internal_delete_expired_sync(
    sync_backend: SQLAlchemySyncSessionBackend,
    mock_sync_config: MagicMock,
    mock_session_model: type[SessionModelMixin],
    monkeypatch: MonkeyPatch,
    mock_sync_session: MagicMock,
) -> None:
    """Test the internal _delete_expired_sync method."""
    mock_sync_session = MagicMock(spec=SyncSession)
    mock_sync_session.__enter__.return_value = mock_sync_session
    mock_sync_session.__exit__.return_value = None

    # Add mock bind and dialect to avoid AttributeError in session backend
    mock_dialect = MagicMock()
    mock_dialect.name = "sqlite"
    mock_bind = MagicMock()
    mock_bind.dialect = mock_dialect
    mock_sync_session.bind = mock_bind

    mock_sync_config.get_session.return_value = mock_sync_session

    fixed_time = datetime.datetime.now(datetime.timezone.utc)
    mock_datetime = MockDateTime(fixed_time)
    monkeypatch.setattr("advanced_alchemy.extensions.litestar.session.datetime.datetime", mock_datetime)

    sync_backend._delete_expired_sync()  # pyright: ignore [reportPrivateUsage]

    mock_sync_session.execute.assert_called_once()
    call_args, _ = mock_sync_session.execute.call_args
    delete_stmt = call_args[0]
    assert str(delete_stmt).startswith(f"DELETE FROM {mock_session_model.__tablename__}")
    assert "WHERE" in str(delete_stmt) and "now() >" in str(delete_stmt)
    mock_sync_session.commit.assert_called_once()


# --- SessionModelMixin Edge Cases ---


def test_table_args_with_spanner_dialect() -> None:
    """Test table args generation for Spanner dialect."""
    dialect_mock = Mock()
    dialect_mock.name = "spanner+spanner"

    result = MockSessionModel._create_unique_session_id_constraint(dialect=dialect_mock)
    assert result is False

    result = MockSessionModel._create_unique_session_id_index(dialect=dialect_mock)
    assert result is True


def test_table_args_with_postgresql_dialect() -> None:
    """Test table args generation for PostgreSQL dialect."""
    dialect_mock = Mock()
    dialect_mock.name = "postgresql"

    result = MockSessionModel._create_unique_session_id_constraint(dialect=dialect_mock)
    assert result is True

    result = MockSessionModel._create_unique_session_id_index(dialect=dialect_mock)
    assert result is False


def test_is_expired_expression() -> None:
    """Test the SQL expression for is_expired."""
    expr = MockSessionModel.is_expired
    assert expr is not None
    assert hasattr(expr, "expression")


def test_session_model_fields() -> None:
    """Test that all required fields are present."""
    now = datetime.datetime.now(datetime.timezone.utc)
    session = MockSessionModel(
        session_id="test_123",
        data=b"test_data",
        expires_at=now + datetime.timedelta(hours=1),
    )

    assert session.session_id == "test_123"
    assert session.data == b"test_data"
    assert session.expires_at > now
    assert hasattr(session, "id")


# --- Async Backend Error Tests ---


@pytest.fixture()
def mock_async_config_errors() -> MagicMock:
    """Create mock async config."""
    config = MagicMock(spec=SQLAlchemyAsyncConfig)
    session = AsyncMock(spec=AsyncSession)
    session.__aenter__.return_value = session
    session.__aexit__.return_value = None

    # Add mock bind and dialect to avoid AttributeError in session backend
    mock_dialect = MagicMock()
    mock_dialect.name = "mssql"  # Use dialect that forces fallback path in tests
    mock_dialect.server_version_info = None  # Ensure no merge support
    mock_bind = MagicMock()
    mock_bind.dialect = mock_dialect
    session.bind = mock_bind

    config.get_session.return_value = session
    return config


@pytest.fixture()
def async_backend_errors(mock_async_config_errors: MagicMock) -> SQLAlchemyAsyncSessionBackend:
    """Create async backend with mock config."""
    return SQLAlchemyAsyncSessionBackend(
        config=ServerSideSessionConfig(max_age=3600),
        alchemy_config=mock_async_config_errors,
        model=MockSessionModel,
    )


async def test_async_get_database_error(
    async_backend_errors: SQLAlchemyAsyncSessionBackend,
    mock_async_config_errors: MagicMock,
) -> None:
    """Test handling of database errors during get operation."""
    session = mock_async_config_errors.get_session.return_value

    session.scalars.side_effect = Exception("Database connection lost")

    with pytest.raises(Exception, match="Database connection lost"):
        await async_backend_errors.get("session_123", Mock())


async def test_async_set_database_error_on_commit(
    async_backend_errors: SQLAlchemyAsyncSessionBackend,
    mock_async_config_errors: MagicMock,
) -> None:
    """Test handling of database errors during set operation commit."""
    session = mock_async_config_errors.get_session.return_value

    mock_result = MagicMock()
    mock_result.one_or_none.return_value = None

    async def mock_scalars(*args: Any, **kwargs: Any) -> MagicMock:
        return mock_result

    session.scalars = mock_scalars
    session.commit.side_effect = Exception("Commit failed")

    with pytest.raises(Exception, match="Commit failed"):
        await async_backend_errors.set("session_123", b"data", Mock())


async def test_async_delete_database_error(
    async_backend_errors: SQLAlchemyAsyncSessionBackend,
    mock_async_config_errors: MagicMock,
) -> None:
    """Test handling of database errors during delete operation."""
    session = mock_async_config_errors.get_session.return_value

    session.execute.side_effect = Exception("Delete operation failed")

    with pytest.raises(Exception, match="Delete operation failed"):
        await async_backend_errors.delete("session_123", Mock())


async def test_async_concurrent_session_modification(
    async_backend_errors: SQLAlchemyAsyncSessionBackend,
    mock_async_config_errors: MagicMock,
) -> None:
    """Test behavior with concurrent session modifications."""
    session = mock_async_config_errors.get_session.return_value

    mock_result1 = MagicMock()
    mock_result1.one_or_none.return_value = MockSessionModel(
        session_id="test",
        data=b"data",
        expires_at=datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=1),
    )

    mock_result2 = MagicMock()
    mock_result2.one_or_none.return_value = None

    async def mock_scalars_1(*args: Any, **kwargs: Any) -> MagicMock:
        return mock_result1

    async def mock_scalars_2(*args: Any, **kwargs: Any) -> MagicMock:
        return mock_result2

    scalars_calls = iter([mock_scalars_1, mock_scalars_2])

    async def mock_scalars_dispatcher(*args: Any, **kwargs: Any) -> MagicMock:
        return await next(scalars_calls)(*args, **kwargs)

    session.scalars = mock_scalars_dispatcher

    result1 = await async_backend_errors.get("test", Mock())
    assert result1 == b"data"

    result2 = await async_backend_errors.get("test", Mock())
    assert result2 is None


# --- Sync Backend Error Tests ---


@pytest.fixture()
def mock_sync_config_errors() -> MagicMock:
    """Create mock sync config."""
    config = MagicMock(spec=SQLAlchemySyncConfig)
    session = MagicMock(spec=SyncSession)
    session.__enter__.return_value = session
    session.__exit__.return_value = None

    # Add mock bind and dialect to avoid AttributeError in session backend
    mock_dialect = MagicMock()
    mock_dialect.name = "mssql"  # Use dialect that forces fallback path in tests
    mock_dialect.server_version_info = None  # Ensure no merge support
    mock_bind = MagicMock()
    mock_bind.dialect = mock_dialect
    session.bind = mock_bind

    config.get_session.return_value = session
    return config


@pytest.fixture()
def sync_backend_errors(mock_sync_config_errors: MagicMock) -> SQLAlchemySyncSessionBackend:
    """Create sync backend with mock config."""
    return SQLAlchemySyncSessionBackend(
        config=ServerSideSessionConfig(max_age=3600),
        alchemy_config=mock_sync_config_errors,
        model=MockSessionModel,
    )


def test_get_sync_database_error(
    sync_backend_errors: SQLAlchemySyncSessionBackend,
    mock_sync_config_errors: MagicMock,
) -> None:
    """Test handling of database errors during sync get operation."""
    session = mock_sync_config_errors.get_session.return_value
    session.scalars.side_effect = Exception("Database error")

    with pytest.raises(Exception, match="Database error"):
        sync_backend_errors._get_sync("session_123")


def test_set_sync_database_error(
    sync_backend_errors: SQLAlchemySyncSessionBackend,
    mock_sync_config_errors: MagicMock,
) -> None:
    """Test handling of database errors during sync set operation."""
    session = mock_sync_config_errors.get_session.return_value
    mock_result = MagicMock()
    mock_result.one_or_none.return_value = None
    session.scalars.return_value = mock_result
    session.commit.side_effect = Exception("Commit failed")

    with pytest.raises(Exception, match="Commit failed"):
        sync_backend_errors._set_sync("session_123", b"data")


def test_delete_sync_constraint_violation(
    sync_backend_errors: SQLAlchemySyncSessionBackend,
    mock_sync_config_errors: MagicMock,
) -> None:
    """Test handling of constraint violations during delete."""
    session = mock_sync_config_errors.get_session.return_value
    session.execute.side_effect = Exception("Foreign key constraint violation")

    with pytest.raises(Exception, match="Foreign key constraint violation"):
        sync_backend_errors._delete_sync("session_123")


# --- Configuration Edge Cases ---


def test_backend_with_minimal_max_age() -> None:
    """Test backend behavior with minimal max_age."""
    config = MagicMock(spec=SQLAlchemyAsyncConfig)
    session = MagicMock(spec=AsyncSession)
    session.__aenter__.return_value = session
    session.__aexit__.return_value = None

    # Add mock bind and dialect to avoid AttributeError in session backend
    mock_dialect = MagicMock()
    mock_dialect.name = "mssql"  # Use dialect that forces fallback path in tests
    mock_dialect.server_version_info = None  # Ensure no merge support
    mock_bind = MagicMock()
    mock_bind.dialect = mock_dialect
    session.bind = mock_bind

    config.get_session.return_value = session

    backend = SQLAlchemyAsyncSessionBackend(
        config=ServerSideSessionConfig(max_age=1),
        alchemy_config=config,
        model=MockSessionModel,
    )

    assert backend.config.max_age == 1


def test_backend_config_property_setter() -> None:
    """Test that config property can be updated."""
    config = MagicMock(spec=SQLAlchemyAsyncConfig)

    backend = SQLAlchemyAsyncSessionBackend(
        config=ServerSideSessionConfig(max_age=3600),
        alchemy_config=config,
        model=MockSessionModel,
    )

    new_config = ServerSideSessionConfig(max_age=7200)
    backend.config = new_config
    assert backend.config.max_age == 7200


def test_select_session_obj_query_generation() -> None:
    """Test the SQL query generation for selecting session objects."""
    config = MagicMock(spec=SQLAlchemyAsyncConfig)

    backend = SQLAlchemyAsyncSessionBackend(
        config=ServerSideSessionConfig(max_age=3600),
        alchemy_config=config,
        model=MockSessionModel,
    )

    query = backend._select_session_obj("test_session_id")
    query_str = str(query)

    assert "mock_session" in query_str
    assert "session_id" in query_str
    assert "WHERE" in query_str


# --- Timezone Handling Tests ---


def test_session_expiry_timezone_aware() -> None:
    """Test that session expiry is always timezone-aware."""

    now = datetime.datetime.now(datetime.timezone.utc)
    future = now + datetime.timedelta(hours=1)
    past = now - datetime.timedelta(hours=1)

    active_session = MockSessionModel(
        session_id="active",
        data=b"data",
        expires_at=future,
    )
    expired_session = MockSessionModel(
        session_id="expired",
        data=b"data",
        expires_at=past,
    )

    assert active_session.expires_at.tzinfo is not None
    assert expired_session.expires_at.tzinfo is not None

    assert not active_session.is_expired
    assert expired_session.is_expired


# --- Large Data Handling Tests ---


@pytest.fixture()
def large_data() -> bytes:
    """Generate large data for testing."""
    return b"x" * (1024 * 1024)


async def test_async_backend_large_data(large_data: bytes) -> None:
    """Test async backend with large data."""
    config = MagicMock(spec=SQLAlchemyAsyncConfig)
    session = MagicMock(spec=AsyncSession)
    session.__aenter__.return_value = session
    session.__aexit__.return_value = None

    # Add mock bind and dialect to avoid AttributeError in session backend
    mock_dialect = MagicMock()
    mock_dialect.name = "mssql"  # Use dialect that forces fallback path in tests
    mock_dialect.server_version_info = None  # Ensure no merge support
    mock_bind = MagicMock()
    mock_bind.dialect = mock_dialect
    session.bind = mock_bind

    config.get_session.return_value = session

    backend = SQLAlchemyAsyncSessionBackend(
        config=ServerSideSessionConfig(max_age=3600),
        alchemy_config=config,
        model=MockSessionModel,
    )

    mock_result = MagicMock()
    mock_result.one_or_none.return_value = None

    async def mock_scalars(*args: Any, **kwargs: Any) -> MagicMock:
        return mock_result

    session.scalars = mock_scalars
    session.commit = AsyncMock(return_value=None)

    await backend.set("large_session", large_data, Mock())

    session.add.assert_called_once()
    added_obj = session.add.call_args[0][0]
    assert added_obj.data == large_data


def test_sync_backend_large_data(large_data: bytes) -> None:
    """Test sync backend with large data."""
    config = MagicMock(spec=SQLAlchemySyncConfig)
    session = MagicMock(spec=SyncSession)
    session.__enter__.return_value = session
    session.__exit__.return_value = None

    # Add mock bind and dialect to avoid AttributeError in session backend
    mock_dialect = MagicMock()
    mock_dialect.name = "mssql"  # Use dialect that forces fallback path in tests
    mock_dialect.server_version_info = None  # Ensure no merge support
    mock_bind = MagicMock()
    mock_bind.dialect = mock_dialect
    session.bind = mock_bind

    config.get_session.return_value = session

    backend = SQLAlchemySyncSessionBackend(
        config=ServerSideSessionConfig(max_age=3600),
        alchemy_config=config,
        model=MockSessionModel,
    )

    mock_result = MagicMock()
    mock_result.one_or_none.return_value = None
    session.scalars.return_value = mock_result

    backend._set_sync("large_session", large_data)

    session.add.assert_called_once()
    added_obj = session.add.call_args[0][0]
    assert added_obj.data == large_data


# --- Session ID Validation Tests ---


@pytest.mark.parametrize(
    "session_id",
    [
        "",
        "a" * 256,
        "session with spaces",
        "session/with/slashes",
        "session?with=query",
        "session#with#hash",
        "๐Ÿ˜€emoji-session",
        "\nsession\nwith\nnewlines",
    ],
)
async def test_various_session_ids(session_id: str) -> None:
    """Test handling of various session ID formats."""
    config = MagicMock(spec=SQLAlchemyAsyncConfig)
    session = MagicMock(spec=AsyncSession)
    session.__aenter__.return_value = session
    session.__aexit__.return_value = None

    # Add mock bind and dialect to avoid AttributeError in session backend
    mock_dialect = MagicMock()
    mock_dialect.name = "mssql"  # Use dialect that forces fallback path in tests
    mock_dialect.server_version_info = None  # Ensure no merge support
    mock_bind = MagicMock()
    mock_bind.dialect = mock_dialect
    session.bind = mock_bind

    config.get_session.return_value = session

    backend = SQLAlchemyAsyncSessionBackend(
        config=ServerSideSessionConfig(max_age=3600),
        alchemy_config=config,
        model=MockSessionModel,
    )

    mock_result = MagicMock()
    mock_result.one_or_none.return_value = None

    async def mock_scalars(*args: Any, **kwargs: Any) -> MagicMock:
        return mock_result

    session.scalars = mock_scalars
    session.commit = AsyncMock(return_value=None)

    await backend.set(session_id, b"data", Mock())

    session.add.assert_called_once()
    added_obj = session.add.call_args[0][0]

    if len(session_id) > 255:
        assert len(added_obj.session_id) <= 255
    else:
        assert added_obj.session_id == session_id
python-advanced-alchemy-1.9.3/tests/unit/test_extensions/test_litestar/test_store.py000066400000000000000000000407731516556515500312730ustar00rootroot00000000000000import datetime
from collections.abc import Awaitable, Generator
from typing import Any, Callable, TypeVar
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from litestar.exceptions import ImproperlyConfiguredException
from litestar.types import Empty
from pytest import MonkeyPatch
from sqlalchemy import Dialect
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session as SyncSession

from advanced_alchemy.extensions.litestar.plugins.init.config.asyncio import SQLAlchemyAsyncConfig
from advanced_alchemy.extensions.litestar.plugins.init.config.sync import SQLAlchemySyncConfig
from advanced_alchemy.extensions.litestar.store import SQLAlchemyStore, StoreModelMixin

# Type variable for the callable in the patch
F = TypeVar("F", bound=Callable[..., Any])


class MockDateTime:
    """Mock datetime class for testing time-dependent behavior."""

    def __init__(self, fixed_time: datetime.datetime) -> None:
        self.fixed_time = fixed_time

    def now(self, tz: datetime.timezone = datetime.timezone.utc) -> datetime.datetime:
        return self.fixed_time


class MockStoreModel(StoreModelMixin):
    """Mock store model for testing."""

    __tablename__ = "mock_store"


@pytest.fixture()
def mock_store_model() -> type[StoreModelMixin]:
    return MockStoreModel


@pytest.fixture()
def mock_async_session() -> AsyncMock:
    session = AsyncMock(spec=AsyncSession)
    session.__aenter__.return_value = session  # Simulate async context manager
    session.__aexit__.return_value = None

    # Set up the dialect for merge/upsert support
    dialect = MagicMock(spec=Dialect)
    dialect.name = "postgresql"
    dialect.server_version_info = (15, 0)
    session.bind = MagicMock()
    session.bind.dialect = dialect

    # Configure execute to return a mock that can be used for scalar_one_or_none
    execute_result = AsyncMock()
    execute_result.scalar_one_or_none = AsyncMock()  # Create an async mock for the method
    session.execute = AsyncMock(return_value=execute_result)  # Create an async mock for execute

    return session


@pytest.fixture()
def mock_async_config(mock_store_model: type[StoreModelMixin], mock_async_session: AsyncMock) -> MagicMock:
    config = MagicMock(spec=SQLAlchemyAsyncConfig)
    config.get_session.return_value = mock_async_session  # Configure the mock method
    return config


@pytest.fixture()
def mock_sync_session() -> MagicMock:
    session = MagicMock(spec=SyncSession)
    session.__enter__.return_value = session  # Simulate sync context manager
    session.__exit__.return_value = None

    # Set up the dialect for merge/upsert support
    dialect = MagicMock(spec=Dialect)
    dialect.name = "postgresql"
    dialect.server_version_info = (15, 0)
    session.bind = MagicMock()
    session.bind.dialect = dialect

    # Configure execute to return a mock that can be used for scalar_one_or_none
    execute_result = MagicMock()
    execute_result.scalar_one_or_none.return_value = None
    session.execute.return_value = execute_result

    return session


@pytest.fixture()
def mock_sync_config(mock_store_model: type[StoreModelMixin], mock_sync_session: MagicMock) -> MagicMock:
    config = MagicMock(spec=SQLAlchemySyncConfig)
    config.get_session.return_value = mock_sync_session  # Configure the mock method
    return config


@pytest.fixture()
def async_store(mock_async_config: MagicMock) -> SQLAlchemyStore[SQLAlchemyAsyncConfig]:
    """Create an async store instance."""
    return SQLAlchemyStore(config=mock_async_config, model=MockStoreModel)


@pytest.fixture()
def sync_store(mock_sync_config: MagicMock) -> SQLAlchemyStore[SQLAlchemySyncConfig]:
    """Create a sync store instance."""
    return SQLAlchemyStore(config=mock_sync_config, model=MockStoreModel)


def test_store_model_mixin_is_expired_property() -> None:
    """Test the is_expired hybrid property."""
    now = datetime.datetime.now(datetime.timezone.utc)
    expired_store = MockStoreModel(expires_at=now - datetime.timedelta(seconds=1))
    active_store = MockStoreModel(expires_at=now + datetime.timedelta(seconds=10))

    assert expired_store.is_expired is True
    assert active_store.is_expired is False


def test_store_init_default_namespace() -> None:
    """Test store initialization with default namespace."""
    store = SQLAlchemyStore(config=MagicMock(spec=SQLAlchemyAsyncConfig), model=MockStoreModel)
    assert store.namespace == "LITESTAR"


def test_store_init_custom_namespace() -> None:
    """Test store initialization with custom namespace."""
    store = SQLAlchemyStore(config=MagicMock(spec=SQLAlchemyAsyncConfig), model=MockStoreModel, namespace="CUSTOM")
    assert store.namespace == "CUSTOM"


def test_store_init_no_namespace() -> None:
    """Test store initialization with no namespace."""
    store = SQLAlchemyStore(config=MagicMock(spec=SQLAlchemyAsyncConfig), model=MockStoreModel, namespace=None)
    assert store.namespace is None


def test_store_init_empty_namespace() -> None:
    """Test store initialization with Empty namespace."""
    store = SQLAlchemyStore(config=MagicMock(spec=SQLAlchemyAsyncConfig), model=MockStoreModel, namespace=Empty)
    assert store.namespace == "LITESTAR"


def test_supports_merge() -> None:
    """Test merge support detection for different dialects."""
    store = SQLAlchemyStore(config=MagicMock(spec=SQLAlchemyAsyncConfig), model=MockStoreModel)

    # PostgreSQL >= 15 (currently disabled via _DISABLE_POSTGRES_MERGE)
    postgres_dialect = MagicMock(spec=Dialect)
    postgres_dialect.name = "postgresql"
    postgres_dialect.server_version_info = (15, 0)
    assert store.supports_merge(postgres_dialect) is False  # Disabled due to _DISABLE_POSTGRES_MERGE = True

    # PostgreSQL < 15
    postgres_dialect.server_version_info = (14, 0)
    assert store.supports_merge(postgres_dialect) is False

    # Oracle
    oracle_dialect = MagicMock(spec=Dialect)
    oracle_dialect.name = "oracle"
    oracle_dialect.server_version_info = (19, 0)  # Add server_version_info for Oracle
    assert store.supports_merge(oracle_dialect) is True

    # Other dialects
    other_dialect = MagicMock(spec=Dialect)
    other_dialect.name = "mysql"
    other_dialect.server_version_info = (8, 0)  # Add server_version_info for MySQL
    assert store.supports_merge(other_dialect) is False


def test_supports_upsert() -> None:
    """Test upsert support detection for different dialects."""
    store = SQLAlchemyStore(config=MagicMock(spec=SQLAlchemyAsyncConfig), model=MockStoreModel)

    # PostgreSQL
    postgres_dialect = MagicMock(spec=Dialect)
    postgres_dialect.name = "postgresql"
    assert store.supports_upsert(postgres_dialect) is True

    # CockroachDB
    cockroach_dialect = MagicMock(spec=Dialect)
    cockroach_dialect.name = "cockroachdb"
    assert store.supports_upsert(cockroach_dialect) is True

    # SQLite
    sqlite_dialect = MagicMock(spec=Dialect)
    sqlite_dialect.name = "sqlite"
    assert store.supports_upsert(sqlite_dialect) is True

    # MySQL
    mysql_dialect = MagicMock(spec=Dialect)
    mysql_dialect.name = "mysql"
    assert store.supports_upsert(mysql_dialect) is True

    # Other dialects
    other_dialect = MagicMock(spec=Dialect)
    other_dialect.name = "other"
    assert store.supports_upsert(other_dialect) is False


def test_make_key_with_namespace() -> None:
    """Test key generation with namespace."""
    store = SQLAlchemyStore(config=MagicMock(spec=SQLAlchemyAsyncConfig), model=MockStoreModel, namespace="test")

    # Instead of using make_key, we'll test the key format directly
    # by checking the namespace and key format
    assert store.namespace == "test"


def test_make_key_without_namespace() -> None:
    """Test key generation without namespace."""
    store = SQLAlchemyStore(config=MagicMock(spec=SQLAlchemyAsyncConfig), model=MockStoreModel, namespace=None)

    # Instead of using make_key, we'll test the key format directly
    # by checking the namespace and key format
    assert store.namespace is None


@pytest.fixture()
def sync_store_with_mock_async(
    mock_sync_config: MagicMock,
) -> Generator[SQLAlchemyStore[SQLAlchemySyncConfig], None, None]:
    def mock_async_(fn: Callable[..., Any], **_: Any) -> Callable[..., Awaitable[Any]]:
        async def wrapper(*args: Any, **kwargs: Any) -> Any:
            return fn(*args, **kwargs)

        return wrapper

    with patch("advanced_alchemy.extensions.litestar.store.async_", side_effect=mock_async_):
        yield SQLAlchemyStore(config=mock_sync_config, model=MockStoreModel)


@pytest.mark.asyncio()
async def test_store_set_async(
    async_store: SQLAlchemyStore[SQLAlchemyAsyncConfig], mock_async_session: AsyncMock
) -> None:
    """Test setting a value using async store."""
    key = "test_key"
    value = b"test_value"
    expires_in = 3600

    await async_store.set(key, value, expires_in)

    mock_async_session.execute.assert_called_once()
    await mock_async_session.commit()


@pytest.mark.asyncio()
async def test_store_set_sync(
    sync_store_with_mock_async: SQLAlchemyStore[SQLAlchemySyncConfig], mock_sync_session: MagicMock
) -> None:
    """Test setting a value using sync store."""
    key = "test_key"
    value = b"test_value"
    expires_in = 3600

    # Reset the mock before the test
    mock_sync_session.execute.reset_mock()
    mock_sync_session.commit.reset_mock()

    await sync_store_with_mock_async.set(key, value, expires_in)

    # Verify that execute was called at least once
    assert mock_sync_session.execute.call_count > 0
    assert mock_sync_session.commit.call_count > 0


@pytest.mark.asyncio()
async def test_store_get_async(
    async_store: SQLAlchemyStore[SQLAlchemyAsyncConfig], mock_async_session: AsyncMock
) -> None:
    """Test getting a value using async store."""
    key = "test_key"
    expected_value = b"test_value"

    # Set up the mock to return the expected value directly
    mock_result = MagicMock()
    mock_result.scalar_one_or_none.return_value = expected_value
    mock_async_session.execute.return_value = mock_result

    # Use the async context manager to get the session
    async with async_store:
        result = await async_store.get(key)
        assert result == expected_value
        assert mock_async_session.execute.called
        await mock_async_session.commit()


@pytest.mark.asyncio()
async def test_store_get_sync(
    sync_store_with_mock_async: SQLAlchemyStore[SQLAlchemySyncConfig], mock_sync_session: MagicMock
) -> None:
    """Test getting a value using sync store."""
    key = "test_key"
    expected_value = b"test_value"
    mock_sync_session.execute.return_value.scalar_one_or_none.return_value = expected_value

    result = await sync_store_with_mock_async.get(key)

    assert result == expected_value
    mock_sync_session.execute.assert_called_once()
    mock_sync_session.commit.assert_called_once()


@pytest.mark.asyncio()
async def test_store_delete_async(
    async_store: SQLAlchemyStore[SQLAlchemyAsyncConfig], mock_async_session: AsyncMock
) -> None:
    """Test deleting a value using async store."""
    key = "test_key"

    await async_store.delete(key)

    mock_async_session.execute.assert_called_once()
    await mock_async_session.commit()


@pytest.mark.asyncio()
async def test_store_delete_sync(
    sync_store_with_mock_async: SQLAlchemyStore[SQLAlchemySyncConfig], mock_sync_session: MagicMock
) -> None:
    """Test deleting a value using sync store."""
    key = "test_key"

    await sync_store_with_mock_async.delete(key)

    mock_sync_session.execute.assert_called_once()
    mock_sync_session.commit.assert_called_once()


@pytest.mark.asyncio()
async def test_store_delete_all_async(
    async_store: SQLAlchemyStore[SQLAlchemyAsyncConfig], mock_async_session: AsyncMock
) -> None:
    """Test deleting all values using async store."""
    await async_store.delete_all()

    mock_async_session.execute.assert_called_once()
    await mock_async_session.commit()


@pytest.mark.asyncio()
async def test_store_delete_all_sync(
    sync_store_with_mock_async: SQLAlchemyStore[SQLAlchemySyncConfig], mock_sync_session: MagicMock
) -> None:
    """Test deleting all values using sync store."""
    await sync_store_with_mock_async.delete_all()

    mock_sync_session.execute.assert_called_once()
    mock_sync_session.commit.assert_called_once()


@pytest.mark.asyncio()
async def test_store_delete_all_no_namespace_async(mock_async_config: MagicMock) -> None:
    """Test deleting all values with no namespace raises error."""
    store = SQLAlchemyStore(config=mock_async_config, model=MockStoreModel, namespace=None)
    with pytest.raises(ImproperlyConfiguredException, match="Cannot perform delete operation: No namespace configured"):
        await store.delete_all()


@pytest.mark.asyncio()
async def test_store_delete_all_no_namespace_sync(mock_sync_config: MagicMock) -> None:
    """Test deleting all values with no namespace raises error."""
    store = SQLAlchemyStore(config=mock_sync_config, model=MockStoreModel, namespace=None)
    with pytest.raises(ImproperlyConfiguredException, match="Cannot perform delete operation: No namespace configured"):
        await store.delete_all()


@pytest.mark.asyncio()
async def test_store_exists_async(
    async_store: SQLAlchemyStore[SQLAlchemyAsyncConfig], mock_async_session: AsyncMock
) -> None:
    """Test checking existence using async store."""
    key = "test_key"

    # Set up the mock to return a value directly
    mock_result = MagicMock()
    mock_result.scalar_one_or_none.return_value = "exists"
    mock_async_session.execute.return_value = mock_result

    # Use the async context manager to get the session
    async with async_store:
        result = await async_store.exists(key)
        assert result is True
        assert mock_async_session.execute.called


@pytest.mark.asyncio()
async def test_store_exists_sync(
    sync_store_with_mock_async: SQLAlchemyStore[SQLAlchemySyncConfig], mock_sync_session: MagicMock
) -> None:
    """Test checking existence using sync store."""
    key = "test_key"
    mock_sync_session.execute.return_value.scalar_one_or_none.return_value = "exists"

    result = await sync_store_with_mock_async.exists(key)

    assert result is True
    mock_sync_session.execute.assert_called_once()


@pytest.mark.asyncio()
async def test_store_expires_in_async(
    async_store: SQLAlchemyStore[SQLAlchemyAsyncConfig], mock_async_session: AsyncMock, monkeypatch: MonkeyPatch
) -> None:
    """Test getting expiration time using async store."""
    now = datetime.datetime.now(datetime.timezone.utc)
    expires_at = now + datetime.timedelta(seconds=3600)
    monkeypatch.setattr("advanced_alchemy.extensions.litestar.store.datetime.datetime", MockDateTime(now))

    # Set up the mock to return the expiration time directly
    mock_result = MagicMock()
    mock_result.scalar_one_or_none.return_value = expires_at
    mock_async_session.execute.return_value = mock_result

    # Use the async context manager to get the session
    async with async_store:
        result = await async_store.expires_in("test_key")
        assert result == 3600
        assert mock_async_session.execute.called


@pytest.mark.asyncio()
async def test_store_expires_in_sync(
    sync_store_with_mock_async: SQLAlchemyStore[SQLAlchemySyncConfig],
    mock_sync_session: MagicMock,
    monkeypatch: MonkeyPatch,
) -> None:
    """Test getting expiration time using sync store."""
    key = "test_key"
    now = datetime.datetime.now(datetime.timezone.utc)
    expires_at = now + datetime.timedelta(seconds=3600)
    mock_datetime = MockDateTime(now)
    monkeypatch.setattr("advanced_alchemy.extensions.litestar.store.datetime.datetime", mock_datetime)
    mock_sync_session.execute.return_value.scalar_one_or_none.return_value = expires_at

    result = await sync_store_with_mock_async.expires_in(key)

    assert result == 3600
    mock_sync_session.execute.assert_called_once()


def test_store_with_namespace() -> None:
    """Test creating a new store with nested namespace."""
    store = SQLAlchemyStore(config=MagicMock(spec=SQLAlchemyAsyncConfig), model=MockStoreModel, namespace="base")
    nested_store = store.with_namespace("nested")
    assert nested_store.namespace == "base_nested"

    # Test with no base namespace
    store = SQLAlchemyStore(config=MagicMock(spec=SQLAlchemyAsyncConfig), model=MockStoreModel, namespace=None)
    nested_store = store.with_namespace("nested")
    assert nested_store.namespace == "nested"


@pytest.mark.asyncio()
async def test_store_context_manager() -> None:
    """Test store as async context manager."""
    store = SQLAlchemyStore(config=MagicMock(spec=SQLAlchemyAsyncConfig), model=MockStoreModel)
    async with store:
        pass  # Context manager should not raise any errors
python-advanced-alchemy-1.9.3/tests/unit/test_extensions/test_sanic.py000066400000000000000000000204451516556515500263400ustar00rootroot00000000000000from __future__ import annotations

from typing import TYPE_CHECKING, Any, Union, cast
from unittest.mock import MagicMock

import pytest
from pytest import FixtureRequest
from pytest_mock import MockerFixture
from sanic import HTTPResponse, Request, Sanic
from sanic_testing.testing import SanicTestClient  # type: ignore[import-untyped]
from sqlalchemy import Engine
from sqlalchemy.ext.asyncio import AsyncEngine
from typing_extensions import assert_type

from advanced_alchemy.extensions.sanic import AdvancedAlchemy, SQLAlchemyAsyncConfig, SQLAlchemySyncConfig

AnyConfig = Union[SQLAlchemyAsyncConfig, SQLAlchemySyncConfig]


@pytest.fixture()
def app() -> Sanic[Any, Any]:
    return Sanic("TestSanic")


@pytest.fixture()
def client(app: Sanic[Any, Any]) -> SanicTestClient:
    return SanicTestClient(app=app)


@pytest.fixture()
def sync_config() -> SQLAlchemySyncConfig:
    return SQLAlchemySyncConfig(connection_string="sqlite+pysqlite://")


@pytest.fixture()
def async_config() -> SQLAlchemyAsyncConfig:
    return SQLAlchemyAsyncConfig(connection_string="sqlite+aiosqlite://")


@pytest.fixture(params=["sync_config", "async_config"])
def config(request: FixtureRequest) -> AnyConfig:
    return cast(AnyConfig, request.getfixturevalue(request.param))


@pytest.fixture()
def alchemy(config: AnyConfig, app: Sanic[Any, Any]) -> AdvancedAlchemy:
    alchemy = AdvancedAlchemy(sqlalchemy_config=config)
    alchemy.register(app)
    return alchemy


@pytest.fixture()
def mock_close(mocker: MockerFixture, config: AnyConfig) -> MagicMock:
    if isinstance(config, SQLAlchemySyncConfig):
        return mocker.patch("sqlalchemy.orm.Session.close")
    return mocker.patch("sqlalchemy.ext.asyncio.AsyncSession.close")


@pytest.fixture()
def mock_commit(mocker: MockerFixture, config: AnyConfig) -> MagicMock:
    if isinstance(config, SQLAlchemySyncConfig):
        return mocker.patch("sqlalchemy.orm.Session.commit")
    return mocker.patch("sqlalchemy.ext.asyncio.AsyncSession.commit")


@pytest.fixture()
def mock_rollback(mocker: MockerFixture, config: AnyConfig) -> MagicMock:
    if isinstance(config, SQLAlchemySyncConfig):
        return mocker.patch("sqlalchemy.orm.Session.rollback")
    return mocker.patch("sqlalchemy.ext.asyncio.AsyncSession.rollback")


def test_infer_types_from_config(async_config: SQLAlchemyAsyncConfig, sync_config: SQLAlchemySyncConfig) -> None:
    if TYPE_CHECKING:
        sync_alchemy = AdvancedAlchemy(sqlalchemy_config=sync_config)
        async_alchemy = AdvancedAlchemy(sqlalchemy_config=async_config)

        assert_type(sync_alchemy.get_sync_engine(), Engine)
        assert_type(async_alchemy.get_async_engine(), AsyncEngine)


def test_inject_engine(app: Sanic[Any, Any], alchemy: AdvancedAlchemy) -> None:
    @app.get("/")  # type: ignore[misc]
    async def handler(request: Request) -> HTTPResponse:
        assert isinstance(getattr(request.app.ctx, alchemy.get_config().engine_key), (Engine, AsyncEngine))
        return HTTPResponse(status=200)

    client = SanicTestClient(app=app)
    assert client.get("/")[1].status == 200  # pyright: ignore[reportOptionalMemberAccess,reportUnknownMemberType]


"""
def test_inject_session(app: Sanic, alchemy: AdvancedAlchemy, client: SanicTestClient) -> None:
    if isinstance(alchemy.sqlalchemy_config, SQLAlchemyAsyncConfig):
        app.ext.add_dependency(AsyncSession, alchemy.get_session_from_request)

        @app.get("/")
        async def handler(request: Request) -> HTTPResponse:
            assert isinstance(getattr(request.ctx, alchemy.session_key), AsyncSession)
            return HTTPResponse(status=200)

        assert client.get("/")[1].status == 200
    else:
        app.ext.add_dependency(Session, alchemy.get_session_from_request)

        @app.get("/")
        async def handler(request: Request) -> HTTPResponse:
            assert isinstance(getattr(request.ctx, alchemy.session_key), Session)
            return HTTPResponse(status=200)

        assert client.get("/")[1].status == 200
"""

"""
def test_session_no_autocommit(
    app: Sanic,
    alchemy: AdvancedAlchemy,
    client: SanicTestClient,
    mock_commit: MagicMock,
    mock_close: MagicMock,
) -> None:
    alchemy.autocommit_strategy = None
    app.ext.add_dependency(Session, alchemy.get_session)

    @app.get("/")
    def handler(session: Session) -> None:
        pass

    assert client.get("/")[1].status == 200
    mock_commit.assert_not_called()
    mock_close.assert_called_once()
"""

"""
def test_session_autocommit_always(
    app: Sanic,
    alchemy: AdvancedAlchemy,
    client: SanicTestClient,
    mock_commit: MagicMock,
    mock_close: MagicMock,
) -> None:
    alchemy.autocommit_strategy = "always"
    app.ext.add_dependency(Session, alchemy.get_session)

    @app.get("/")
    def handler(session: Session) -> None:
        pass

    assert client.get("/")[1].status == 200
    mock_commit.assert_called_once()
    mock_close.assert_called_once()
"""

"""
@pytest.mark.parametrize("status", [200, 201, 202, 204, 206])
def test_session_autocommit_match_status(
    app: Sanic,
    alchemy: AdvancedAlchemy,
    client: SanicTestClient,
    mock_commit: MagicMock,
    mock_close: MagicMock,
    mock_rollback: MagicMock,
    status: int,
) -> None:
    alchemy.autocommit_strategy = "match_status"
    app.ext.add_dependency(Session, alchemy.get_session)

    @app.get("/")
    def handler(session: Session) -> HTTPResponse:
        return HTTPResponse(status=status)

    client.get("/")
    mock_commit.assert_called_once()
    mock_close.assert_called_once()
    mock_rollback.assert_not_called()
"""

"""
@pytest.mark.parametrize("status", [300, 301, 305, 307, 308, 400, 401, 404, 450, 500, 900])
def test_session_autocommit_rollback_for_status(
    app: Sanic,
    alchemy: AdvancedAlchemy,
    client: SanicTestClient,
    mock_commit: MagicMock,
    mock_close: MagicMock,
    mock_rollback: MagicMock,
    status: int,
) -> None:
    alchemy.autocommit_strategy = "match_status"
    app.ext.add_dependency(Session, alchemy.get_session)

    @app.get("/")
    def handler(session: Session) -> HTTPResponse:
        return HTTPResponse(status=status)

    client.get("/")
    mock_commit.assert_not_called()
    mock_close.assert_called_once()
    mock_rollback.assert_called_once()
"""

"""
@pytest.mark.parametrize("autocommit_strategy", ["always", "match_status"])
def test_session_autocommit_close_on_exception(
    app: Sanic,
    alchemy: AdvancedAlchemy,
    client: SanicTestClient,
    mock_commit: MagicMock,
    mock_close: MagicMock,
    autocommit_strategy: CommitStrategy,
) -> None:
    alchemy.autocommit_strategy = autocommit_strategy
    mock_commit.side_effect = ValueError
    app.ext.add_dependency(Session, alchemy.get_session)

    @app.get("/")
    def handler(session: Session) -> None:
        pass

    client.get("/")
    mock_commit.assert_called_once()
    mock_close.assert_called_once()
"""

"""
def test_multiple_instances(app: Sanic) -> None:
    mock = MagicMock()
    config_1 = SQLAlchemySyncConfig(connection_string="sqlite+aiosqlite://")
    config_2 = SQLAlchemySyncConfig(connection_string="sqlite+aiosqlite:///test.db")

    alchemy_1 = AdvancedAlchemy(sqlalchemy_config=config_1)

    alchemy_2 = AdvancedAlchemy(
        sqlalchemy_config=config_2,
        engine_key="other_engine",
        session_key="other_session",
        session_maker_key="other_sessionmaker",
    )
    Extend.register(alchemy_1)
    Extend.register(alchemy_2)
    app.ext.add_dependency(Session, alchemy_1.get_session)
    app.ext.add_dependency(Session, alchemy_2.get_session)
    app.ext.add_dependency(Engine, alchemy_1.get_engine)
    app.ext.add_dependency(Engine, alchemy_2.get_engine)

    @app.get("/")
    async def handler(
        session_1: Session,
        session_2: Session,
        engine_1: Engine,
        engine_2: Engine,
    ) -> None:
        assert session_1 != session_2
        assert engine_1 != engine_2
        mock(session=session_1, engine=engine_1)
        mock(session=session_2, engine=engine_2)

    client = SanicTestClient(app=app)
    _response = client.get("/")

    assert alchemy_1.engine_key != alchemy_2.engine_key
    assert alchemy_1.session_maker_key != alchemy_2.session_maker_key
    assert alchemy_1.session_key != alchemy_2.session_key

    assert alchemy_1.get_engine() is not alchemy_2.get_engine()
    assert alchemy_1.get_sessionmaker() is not alchemy_2.get_sessionmaker()
"""
python-advanced-alchemy-1.9.3/tests/unit/test_extensions/test_starlette.py000066400000000000000000000553621516556515500272600ustar00rootroot00000000000000from __future__ import annotations

import sys
from collections.abc import AsyncGenerator, Generator
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, Callable, Union, cast
from unittest.mock import MagicMock

import pytest
from pytest import FixtureRequest
from sqlalchemy import Engine, create_engine
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
from sqlalchemy.orm import Session
from starlette.applications import Starlette
from starlette.exceptions import HTTPException
from starlette.requests import Request
from starlette.responses import Response
from starlette.routing import Route
from starlette.testclient import TestClient
from typing_extensions import Literal, assert_type

from advanced_alchemy.exceptions import ImproperConfigurationError
from advanced_alchemy.extensions.starlette import AdvancedAlchemy, SQLAlchemyAsyncConfig, SQLAlchemySyncConfig

if TYPE_CHECKING:
    from pytest import FixtureRequest
    from pytest_mock import MockerFixture


AnyConfig = Union[SQLAlchemyAsyncConfig, SQLAlchemySyncConfig]
pytestmark = pytest.mark.xfail(
    condition=sys.version_info < (3, 9),
    reason="Certain versions of Starlette and FastAPI are stated to still support 3.8, but there are documented incompatibilities on various versions that have not been yanked.  Marking 3.8 as an acceptable failure for now.",
)


@pytest.fixture()
def app() -> Starlette:
    return Starlette()


@pytest.fixture()
def client(app: Starlette) -> Generator[TestClient, None, None]:
    with TestClient(app=app, raise_server_exceptions=False) as client:
        yield client


@pytest.fixture()
def sync_config() -> SQLAlchemySyncConfig:
    return SQLAlchemySyncConfig(connection_string="sqlite+pysqlite:///:memory:")


@pytest.fixture()
def async_config() -> SQLAlchemyAsyncConfig:
    return SQLAlchemyAsyncConfig(connection_string="sqlite+aiosqlite:///:memory:")


@pytest.fixture(params=["sync_config", "async_config"])
def config(request: FixtureRequest) -> AnyConfig:
    return cast(AnyConfig, request.getfixturevalue(request.param))


@pytest.fixture()
def alchemy(config: AnyConfig, app: Starlette) -> Generator[AdvancedAlchemy, None, None]:
    alchemy = AdvancedAlchemy(config, app=app)
    yield alchemy


@pytest.fixture()
def multi_alchemy(app: Starlette) -> Generator[AdvancedAlchemy, None, None]:
    alchemy = AdvancedAlchemy(
        [
            SQLAlchemySyncConfig(connection_string="sqlite+pysqlite:///:memory:", bind_key="sync"),
            SQLAlchemyAsyncConfig(connection_string="sqlite+aiosqlite:///:memory:"),
        ],
        app=app,
    )
    yield alchemy


async def test_infer_types_from_config(async_config: SQLAlchemyAsyncConfig, sync_config: SQLAlchemySyncConfig) -> None:
    if TYPE_CHECKING:
        sync_alchemy = AdvancedAlchemy([sync_config])
        async_alchemy = AdvancedAlchemy([async_config])

        assert_type(sync_alchemy.get_sync_engine(), Engine)
        assert_type(async_alchemy.get_async_engine(), AsyncEngine)

        assert_type(sync_alchemy.get_sync_config().create_session_maker(), Callable[[], Session])
        assert_type(async_alchemy.get_async_config().create_session_maker(), Callable[[], AsyncSession])

        with sync_alchemy.with_sync_session() as session:
            assert_type(session, Session)
        async with async_alchemy.with_async_session() as session:
            assert_type(session, AsyncSession)


def test_init_app_not_called_raises(client: TestClient, config: SQLAlchemySyncConfig) -> None:
    alchemy = AdvancedAlchemy(config)
    with pytest.raises(ImproperConfigurationError):
        alchemy.app


def test_inject_engine(app: Starlette) -> None:
    mock = MagicMock()
    config = SQLAlchemySyncConfig(engine_instance=create_engine("sqlite+aiosqlite://"))
    alchemy = AdvancedAlchemy(config=config, app=app)

    async def handler(request: Request) -> Response:
        engine = alchemy.get_engine()
        mock(engine)
        return Response(status_code=200)

    app.router.routes.append(Route("/", endpoint=handler))

    with TestClient(app=app) as client:
        assert client.get("/").status_code == 200
        assert mock.call_args[0][0] is config.engine_instance


def test_inject_session(app: Starlette, alchemy: AdvancedAlchemy, client: TestClient) -> None:
    mock = MagicMock()

    async def handler(request: Request) -> Response:
        session = alchemy.get_session(request)
        mock(session)
        return Response(status_code=200)

    app.router.routes.append(Route("/", endpoint=handler))

    call = client.get("/")
    assert call.status_code == 200
    assert mock.call_count == 1
    call_1_session = mock.call_args_list[0].args[0]
    assert isinstance(
        call_1_session,
        AsyncSession if isinstance(alchemy.config[0], SQLAlchemyAsyncConfig) else Session,
    )


def test_session_no_autocommit(
    app: Starlette,
    alchemy: AdvancedAlchemy,
    client: TestClient,
    mocker: MockerFixture,
) -> None:
    if isinstance(alchemy.config[0], SQLAlchemyAsyncConfig):
        mock_commit = mocker.patch("sqlalchemy.ext.asyncio.AsyncSession.commit")
        mock_close = mocker.patch("sqlalchemy.ext.asyncio.AsyncSession.close")
    else:
        mock_commit = mocker.patch("sqlalchemy.orm.Session.commit")
        mock_close = mocker.patch("sqlalchemy.orm.Session.close")

    app.middleware_stack = app.build_middleware_stack()

    async def handler(request: Request) -> Response:
        _session = alchemy.get_session(request)
        return Response(status_code=200)

    app.router.routes.append(Route("/", endpoint=handler))

    assert client.get("/").status_code == 200
    mock_commit.assert_not_called()
    mock_close.assert_called_once()


@pytest.mark.parametrize("status_code", [200, 201, 202, 204, 206])
def test_sync_session_autocommit_success_status(
    mocker: MockerFixture,
    status_code: int,
) -> None:
    mock_commit = mocker.patch("sqlalchemy.orm.Session.commit")
    mock_close = mocker.patch("sqlalchemy.orm.Session.close")
    mock_rollback = mocker.patch("sqlalchemy.orm.Session.rollback")
    app = Starlette()
    config = SQLAlchemySyncConfig(connection_string="sqlite+pysqlite:///:memory:", commit_mode="autocommit")
    alchemy = AdvancedAlchemy(config, app=app)

    async def handler(request: Request) -> Response:
        _session = alchemy.get_session(request)
        return Response(status_code=status_code)

    app.router.routes.append(Route("/", endpoint=handler))

    with TestClient(app=app) as client:
        _ = client.get("/")
        mock_commit.assert_called_once()
        mock_close.assert_called_once()
        mock_rollback.assert_not_called()


@pytest.mark.parametrize("status_code", [200, 201, 202, 204, 206])
def test_sync_session_autocommit_include_redirect_success_status(
    mocker: MockerFixture,
    status_code: int,
) -> None:
    mock_commit = mocker.patch("sqlalchemy.orm.Session.commit")
    mock_close = mocker.patch("sqlalchemy.orm.Session.close")
    mock_rollback = mocker.patch("sqlalchemy.orm.Session.rollback")
    app = Starlette()
    config = SQLAlchemySyncConfig(
        connection_string="sqlite+pysqlite:///:memory:", commit_mode="autocommit_include_redirect"
    )
    alchemy = AdvancedAlchemy(config, app=app)

    async def handler(request: Request) -> Response:
        _session = alchemy.get_session(request)
        return Response(status_code=status_code)

    app.router.routes.append(Route("/", endpoint=handler))

    with TestClient(app=app) as client:
        _ = client.get("/")
        mock_commit.assert_called_once()
        mock_close.assert_called_once()
        mock_rollback.assert_not_called()


@pytest.mark.parametrize("status_code", [200, 201, 202, 204, 206])
def test_async_session_autocommit_success_status(
    mocker: MockerFixture,
    status_code: int,
) -> None:
    mock_commit = mocker.patch("sqlalchemy.ext.asyncio.AsyncSession.commit")
    mock_close = mocker.patch("sqlalchemy.ext.asyncio.AsyncSession.close")
    mock_rollback = mocker.patch("sqlalchemy.ext.asyncio.AsyncSession.rollback")
    app = Starlette()
    config = SQLAlchemyAsyncConfig(connection_string="sqlite+aiosqlite:///:memory:", commit_mode="autocommit")
    alchemy = AdvancedAlchemy(config, app=app)

    async def handler(request: Request) -> Response:
        _session = alchemy.get_session(request)
        return Response(status_code=status_code)

    app.router.routes.append(Route("/", endpoint=handler))

    with TestClient(app=app) as client:
        _ = client.get("/")
        mock_commit.assert_called_once()
        mock_close.assert_called_once()
        mock_rollback.assert_not_called()


@pytest.mark.parametrize("status_code", [200, 201, 202, 204, 206])
def test_async_session_autocommit_include_redirect_success_status(
    mocker: MockerFixture,
    status_code: int,
) -> None:
    mock_commit = mocker.patch("sqlalchemy.ext.asyncio.AsyncSession.commit")
    mock_close = mocker.patch("sqlalchemy.ext.asyncio.AsyncSession.close")
    mock_rollback = mocker.patch("sqlalchemy.ext.asyncio.AsyncSession.rollback")
    app = Starlette()
    config = SQLAlchemyAsyncConfig(
        connection_string="sqlite+aiosqlite:///:memory:", commit_mode="autocommit_include_redirect"
    )
    alchemy = AdvancedAlchemy(config, app=app)

    async def handler(request: Request) -> Response:
        _session = alchemy.get_session(request)
        return Response(status_code=status_code)

    app.router.routes.append(Route("/", endpoint=handler))

    with TestClient(app=app) as client:
        _ = client.get("/")
        mock_commit.assert_called_once()
        mock_close.assert_called_once()
        mock_rollback.assert_not_called()


@pytest.mark.parametrize("status_code", [300, 301, 305, 307, 308, 400, 401, 404, 450, 500, 900])
def test_sync_session_autocommit_rollback_for_status(
    status_code: int,
    mocker: MockerFixture,
) -> None:
    mock_commit = mocker.patch("sqlalchemy.orm.Session.commit")
    mock_close = mocker.patch("sqlalchemy.orm.Session.close")
    mock_rollback = mocker.patch("sqlalchemy.orm.Session.rollback")
    app = Starlette()
    config = SQLAlchemySyncConfig(connection_string="sqlite+pysqlite:///:memory:", commit_mode="autocommit")
    alchemy = AdvancedAlchemy(config, app=app)

    async def handler(request: Request) -> Response:
        _session = alchemy.get_session(request)
        return Response(status_code=status_code)

    app.router.routes.append(Route("/", endpoint=handler))

    with TestClient(app=app) as client:
        response = client.get("/")
        assert response.status_code == status_code
        if status_code >= 300:
            assert mock_commit.call_count == 0
            assert mock_rollback.call_count == 1
            assert mock_close.call_count == 1
        else:
            assert mock_commit.call_count == 1
            assert mock_close.call_count == 1
            assert mock_rollback.call_count == 0


@pytest.mark.parametrize("status_code", [300, 301, 305, 307, 308, 400, 401, 404, 450, 500, 900])
def test_sync_session_autocommit_include_redirect_rollback_for_status(
    status_code: int,
    mocker: MockerFixture,
) -> None:
    mock_commit = mocker.patch("sqlalchemy.orm.Session.commit")
    mock_close = mocker.patch("sqlalchemy.orm.Session.close")
    mock_rollback = mocker.patch("sqlalchemy.orm.Session.rollback")
    app = Starlette()
    config = SQLAlchemySyncConfig(
        connection_string="sqlite+pysqlite:///:memory:", commit_mode="autocommit_include_redirect"
    )
    alchemy = AdvancedAlchemy(config, app=app)

    async def handler(request: Request) -> Response:
        _session = alchemy.get_session(request)
        return Response(status_code=status_code)

    app.router.routes.append(Route("/", endpoint=handler))

    with TestClient(app=app) as client:
        response = client.get("/")
        assert response.status_code == status_code
        if status_code < 400:
            assert mock_commit.call_count == 1
            assert mock_rollback.call_count == 0
            assert mock_close.call_count == 1
        else:
            assert mock_commit.call_count == 0
            assert mock_rollback.call_count == 1
            assert mock_close.call_count == 1


@pytest.mark.parametrize("status_code", [300, 301, 305, 307, 308, 400, 401, 404, 450, 500, 900])
def test_async_session_autocommit_rollback_for_status(
    status_code: int,
    mocker: MockerFixture,
) -> None:
    mock_commit = mocker.patch("sqlalchemy.ext.asyncio.AsyncSession.commit")
    mock_close = mocker.patch("sqlalchemy.ext.asyncio.AsyncSession.close")
    mock_rollback = mocker.patch("sqlalchemy.ext.asyncio.AsyncSession.rollback")
    app = Starlette()
    config = SQLAlchemyAsyncConfig(connection_string="sqlite+aiosqlite:///:memory:", commit_mode="autocommit")
    alchemy = AdvancedAlchemy(config, app=app)

    async def handler(request: Request) -> Response:
        _session = alchemy.get_session(request)
        return Response(status_code=status_code)

    app.router.routes.append(Route("/", endpoint=handler))

    with TestClient(app=app) as client:
        response = client.get("/")
        assert response.status_code == status_code
        if status_code >= 300:
            assert mock_commit.call_count == 0
            assert mock_rollback.call_count == 1
            assert mock_close.call_count == 1
        else:
            assert mock_commit.call_count == 1
            assert mock_close.call_count == 1
            assert mock_rollback.call_count == 0


@pytest.mark.parametrize("status_code", [300, 301, 305, 307, 308, 400, 401, 404, 450, 500, 900])
def test_async_session_autocommit_include_redirect_rollback_for_status(
    status_code: int,
    mocker: MockerFixture,
) -> None:
    mock_commit = mocker.patch("sqlalchemy.ext.asyncio.AsyncSession.commit")
    mock_close = mocker.patch("sqlalchemy.ext.asyncio.AsyncSession.close")
    mock_rollback = mocker.patch("sqlalchemy.ext.asyncio.AsyncSession.rollback")
    app = Starlette()
    config = SQLAlchemyAsyncConfig(
        connection_string="sqlite+aiosqlite:///:memory:", commit_mode="autocommit_include_redirect"
    )
    alchemy = AdvancedAlchemy(config, app=app)

    async def handler(request: Request) -> Response:
        _session = alchemy.get_session(request)
        return Response(status_code=status_code)

    app.router.routes.append(Route("/", endpoint=handler))

    with TestClient(app=app) as client:
        response = client.get("/")
        assert response.status_code == status_code
        if status_code >= 400:
            assert mock_commit.call_count == 0
            assert mock_rollback.call_count == 1
            assert mock_close.call_count == 1
        else:
            assert mock_commit.call_count == 1
            assert mock_rollback.call_count == 0
            assert mock_close.call_count == 1


@pytest.mark.parametrize("autocommit_strategy", ["autocommit", "autocommit_include_redirect"])
def test_sync_session_autocommit_close_on_exception(
    mocker: MockerFixture,
    autocommit_strategy: Literal["autocommit", "autocommit_include_redirect"],
) -> None:
    mock_commit = mocker.patch("sqlalchemy.orm.Session.commit")
    mock_rollback = mocker.patch("sqlalchemy.orm.Session.rollback")
    mock_close = mocker.patch("sqlalchemy.orm.Session.close")

    async def http_exception(request: Request, exc: HTTPException) -> Response:
        return Response(status_code=exc.status_code)

    app = Starlette(exception_handlers={HTTPException: http_exception})  # type: ignore
    config = SQLAlchemySyncConfig(connection_string="sqlite+pysqlite:///:memory:", commit_mode=autocommit_strategy)
    alchemy = AdvancedAlchemy(config, app=app)

    async def handler(request: Request) -> None:
        _session = alchemy.get_session(request)
        raise HTTPException(status_code=500, detail="Intentional error for testing")

    app.router.routes.append(Route("/", endpoint=handler))

    with TestClient(app=app) as client:
        client.get("/")
        mock_commit.assert_not_called()
        mock_rollback.assert_called_once()
        mock_close.assert_called_once()


@pytest.mark.parametrize("autocommit_strategy", ["autocommit", "autocommit_include_redirect"])
async def test_async_session_autocommit_close_on_exception(
    mocker: MockerFixture,
    autocommit_strategy: Literal["autocommit", "autocommit_include_redirect"],
) -> None:
    mock_commit = mocker.patch("sqlalchemy.ext.asyncio.AsyncSession.commit")
    mock_rollback = mocker.patch("sqlalchemy.ext.asyncio.AsyncSession.rollback")
    mock_close = mocker.patch("sqlalchemy.ext.asyncio.AsyncSession.close")

    async def http_exception(request: Request, exc: HTTPException) -> Response:
        return Response(status_code=exc.status_code)

    app = Starlette(exception_handlers={HTTPException: http_exception})  # type: ignore
    config = SQLAlchemyAsyncConfig(connection_string="sqlite+aiosqlite:///:memory:", commit_mode=autocommit_strategy)
    alchemy = AdvancedAlchemy(config, app=app)

    async def handler(request: Request) -> None:
        _session = alchemy.get_session(request)
        raise HTTPException(status_code=500, detail="Intentional error for testing")

    app.router.routes.append(Route("/", endpoint=handler))

    with TestClient(app=app) as client:
        client.get("/")
        mock_commit.assert_not_called()
        mock_rollback.assert_called_once()
        mock_close.assert_called_once()


def test_multiple_instances(app: Starlette) -> None:
    mock = MagicMock()
    config_1 = SQLAlchemySyncConfig(connection_string="sqlite:///other.db")
    config_2 = SQLAlchemyAsyncConfig(connection_string="sqlite+aiosqlite:///test.db", bind_key="other")

    alchemy_1 = AdvancedAlchemy([config_1, config_2], app=app)

    async def handler(request: Request) -> Response:
        session_1 = alchemy_1.get_sync_session(request)
        engine_1 = alchemy_1.get_sync_engine()
        session_2 = alchemy_1.get_async_session(request, key="other")
        engine_2 = alchemy_1.get_async_engine(key="other")
        assert session_1 is not session_2  # type: ignore
        assert engine_1 is not engine_2  # type: ignore
        mock(session=session_1, engine=engine_1)
        mock(session=session_2, engine=engine_2)
        return Response(status_code=200)

    app.router.routes.append(Route("/", endpoint=handler))

    with TestClient(app=app) as client:
        client.get("/")

        assert alchemy_1.get_sync_engine() is not alchemy_1.get_async_engine("other")  # type: ignore


async def test_lifespan_startup_shutdown_called_starlette(
    mocker: MockerFixture, app: Starlette, config: AnyConfig
) -> None:
    mock_startup = mocker.patch.object(AdvancedAlchemy, "on_startup")
    mock_shutdown = mocker.patch.object(AdvancedAlchemy, "on_shutdown")
    _alchemy = AdvancedAlchemy(config, app=app)

    with TestClient(app=app) as _client:  # TestClient context manager triggers lifespan events
        pass  # App starts up and shuts down within this context

    mock_startup.assert_called_once()
    mock_shutdown.assert_called_once()


async def test_lifespan_with_custom_lifespan_starlette(
    mocker: MockerFixture, app: Starlette, config: AnyConfig
) -> None:
    mock_aa_startup = mocker.patch.object(AdvancedAlchemy, "on_startup")
    mock_aa_shutdown = mocker.patch.object(AdvancedAlchemy, "on_shutdown")
    mock_custom_startup = mocker.MagicMock()
    mock_custom_shutdown = mocker.MagicMock()

    @asynccontextmanager
    async def custom_lifespan(app_in: Starlette) -> AsyncGenerator[None, None]:
        mock_custom_startup()
        yield
        mock_custom_shutdown()

    app.router.lifespan_context = custom_lifespan  # type: ignore[assignment] # Set a custom lifespan on the app
    _alchemy = AdvancedAlchemy(config, app=app)

    with TestClient(app=app) as _client:  # TestClient context manager triggers lifespan events
        pass  # App starts up and shuts down within this context

    mock_aa_startup.assert_called_once()
    mock_aa_shutdown.assert_called_once()
    mock_custom_startup.assert_called_once()
    mock_custom_shutdown.assert_called_once()


def test_async_session_handler_rollback_failure_still_closes(
    mocker: MockerFixture,
) -> None:
    """Verify session is closed even when rollback raises in async session_handler."""
    mocker.patch("sqlalchemy.ext.asyncio.AsyncSession.commit")
    mock_close = mocker.patch("sqlalchemy.ext.asyncio.AsyncSession.close")
    mocker.patch(
        "sqlalchemy.ext.asyncio.AsyncSession.rollback",
        side_effect=RuntimeError("rollback failed"),
    )
    app = Starlette()
    config = SQLAlchemyAsyncConfig(connection_string="sqlite+aiosqlite:///:memory:", commit_mode="autocommit")
    alchemy = AdvancedAlchemy(config, app=app)

    async def handler(request: Request) -> Response:
        _session = alchemy.get_session(request)
        return Response(status_code=500)

    app.router.routes.append(Route("/", endpoint=handler))

    with TestClient(app=app, raise_server_exceptions=False) as client:
        response = client.get("/")
        assert response.status_code == 500
        mock_close.assert_called_once()


def test_sync_session_handler_rollback_failure_still_closes(
    mocker: MockerFixture,
) -> None:
    """Verify session is closed even when rollback raises in sync session_handler."""
    mocker.patch("sqlalchemy.orm.Session.commit")
    mock_close = mocker.patch("sqlalchemy.orm.Session.close")
    mocker.patch(
        "sqlalchemy.orm.Session.rollback",
        side_effect=RuntimeError("rollback failed"),
    )
    app = Starlette()
    config = SQLAlchemySyncConfig(connection_string="sqlite+pysqlite:///:memory:", commit_mode="autocommit")
    alchemy = AdvancedAlchemy(config, app=app)

    async def handler(request: Request) -> Response:
        _session = alchemy.get_session(request)
        return Response(status_code=500)

    app.router.routes.append(Route("/", endpoint=handler))

    with TestClient(app=app, raise_server_exceptions=False) as client:
        response = client.get("/")
        assert response.status_code == 500
        mock_close.assert_called_once()


def test_async_session_handler_commit_failure_still_closes(
    mocker: MockerFixture,
) -> None:
    """Verify session is closed even when commit raises in async session_handler."""
    mocker.patch(
        "sqlalchemy.ext.asyncio.AsyncSession.commit",
        side_effect=RuntimeError("commit failed"),
    )
    mock_close = mocker.patch("sqlalchemy.ext.asyncio.AsyncSession.close")
    mocker.patch("sqlalchemy.ext.asyncio.AsyncSession.rollback")
    app = Starlette()
    config = SQLAlchemyAsyncConfig(connection_string="sqlite+aiosqlite:///:memory:", commit_mode="autocommit")
    alchemy = AdvancedAlchemy(config, app=app)

    async def handler(request: Request) -> Response:
        _session = alchemy.get_session(request)
        return Response(status_code=200)

    app.router.routes.append(Route("/", endpoint=handler))

    with TestClient(app=app, raise_server_exceptions=False) as client:
        response = client.get("/")
        assert response.status_code == 200
        mock_close.assert_called_once()
python-advanced-alchemy-1.9.3/tests/unit/test_file_object.py000066400000000000000000002105131516556515500242470ustar00rootroot00000000000000"""Unit tests for FileObject class."""

import asyncio
import logging
import sys
from pathlib import Path
from typing import Callable, Optional
from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch

import pytest
from sqlalchemy.orm import Session

from advanced_alchemy.service.typing import PYDANTIC_INSTALLED, BaseModel
from advanced_alchemy.types.file_object import FileObject
from advanced_alchemy.types.file_object.base import StorageBackend
from advanced_alchemy.types.file_object.session_tracker import FileObjectSessionTracker

if sys.version_info >= (3, 11):
    from builtins import ExceptionGroup
else:
    from exceptiongroup import ExceptionGroup  # type: ignore[import-not-found,unused-ignore]


def test_file_object_delete_no_backend() -> None:
    """Test delete method when backend is None."""
    # Create a FileObject
    obj = FileObject(backend="mock", filename="test.txt")

    # Directly patch the backend property to specifically return None for this test
    with patch.object(FileObject, "backend", new_callable=PropertyMock, return_value=None):
        # Deleting with no backend should raise a RuntimeError
        with pytest.raises(RuntimeError, match="No storage backend configured"):
            obj.delete()


def test_sign_empty_result_list() -> None:
    """Test sign method when backend returns an empty list."""
    # Create a FileObject
    obj = FileObject(backend="mock", filename="test.txt")

    # Create a mock backend
    mock_backend = Mock(spec=StorageBackend)
    mock_backend.key = "mock"
    mock_backend.sign.return_value = []  # Empty list

    # Patch the storages.get_backend method to return our mock backend
    with patch("advanced_alchemy.types.file_object.storages.get_backend", return_value=mock_backend):
        # Test sign method - should raise RuntimeError when list is empty
        with pytest.raises(RuntimeError, match="No signed URL generated"):
            obj.sign(expires_in=3600)


@pytest.mark.asyncio
async def test_sign_async_empty_result_list() -> None:
    """Test sign_async method when backend returns an empty list."""
    # Create a FileObject
    obj = FileObject(backend="mock", filename="test.txt")

    # Create a mock backend
    mock_backend = Mock(spec=StorageBackend)
    mock_backend.key = "mock"
    mock_backend.sign_async = AsyncMock(return_value=[])  # Empty list

    # Patch the storages.get_backend method to return our mock backend
    with patch("advanced_alchemy.types.file_object.storages.get_backend", return_value=mock_backend):
        # Test sign_async method - should raise RuntimeError when list is empty
        with pytest.raises(RuntimeError, match="No signed URL generated"):
            await obj.sign_async(expires_in=3600)


def test_file_object_initialization() -> None:
    """Test FileObject initialization with different parameters."""
    # Test with minimal parameters
    obj = FileObject(backend="mock", filename="test.txt")
    assert obj.filename == "test.txt"
    assert obj.path == "test.txt"  # When to_filename is not provided, path defaults to filename

    # Test with to_filename
    obj = FileObject(backend="mock", filename="original.txt", to_filename="destination.txt")
    assert obj.filename == "destination.txt"  # filename property returns path
    assert obj.path == "destination.txt"

    # Test with content_type
    obj = FileObject(backend="mock", filename="test.txt", content_type="text/plain")
    assert obj.content_type == "text/plain"

    # Test with metadata
    obj = FileObject(backend="mock", filename="test.txt", metadata={"category": "test"})
    assert obj.metadata == {"category": "test"}


def test_file_object_to_dict() -> None:
    """Test to_dict method of FileObject."""
    # Create a FileObject with metadata
    obj = FileObject(
        backend="mock",
        filename="test.txt",
        size=100,
        content_type="text/plain",
        last_modified=1234567890.0,
        checksum="abc123",
        etag="xyz789",
        version_id="v1",
        metadata={"category": "test"},
    )

    # Create a mock backend
    mock_backend = Mock(spec=StorageBackend)
    mock_backend.key = "mock_backend"  # Set as an attribute, not a property

    # Patch the storages.get_backend method to return our mock backend
    with patch("advanced_alchemy.types.file_object.storages.get_backend", return_value=mock_backend):
        # Convert to dict
        obj_dict = obj.to_dict()

        # Verify the dict contains the expected values
        assert obj_dict == {
            "filename": "test.txt",
            "content_type": "text/plain",
            "size": 100,
            "last_modified": 1234567890.0,
            "checksum": "abc123",
            "etag": "xyz789",
            "version_id": "v1",
            "metadata": {"category": "test"},
            "backend": "mock_backend",
        }


def test_file_object_update_metadata() -> None:
    """Test update_metadata method of FileObject."""
    # Create a FileObject with initial metadata
    obj = FileObject(backend="mock", filename="test.txt", metadata={"category": "document", "tags": ["important"]})

    # Update metadata
    obj.update_metadata({"priority": "high", "tags": ["urgent"]})

    # Verify metadata was updated correctly
    assert obj.metadata == {
        "category": "document",
        "tags": ["urgent"],  # Tags should be replaced, not appended
        "priority": "high",  # New field should be added
    }


def test_file_object_repr() -> None:
    """Test __repr__ method of FileObject."""
    # Create a FileObject with all attributes
    obj = FileObject(
        backend="mock",
        filename="test.txt",
        size=100,
        content_type="text/plain",
        last_modified=1234567890.0,
        etag="etag123",
        version_id="v1",
    )

    # Mock the backend to have a key attribute
    mock_backend = Mock()
    mock_backend.key = "mock_backend"

    # Patch the storages.get_backend method to return our mock backend
    with patch("advanced_alchemy.types.file_object.storages.get_backend", return_value=mock_backend):
        # Get the string representation
        repr_str = repr(obj)

        # Verify it contains expected information
        assert "FileObject" in repr_str
        assert "filename=test.txt" in repr_str
        assert "backend=mock_backend" in repr_str
        assert "size=100" in repr_str
        assert "content_type=text/plain" in repr_str
        assert "etag=etag123" in repr_str
        assert "last_modified=1234567890.0" in repr_str
        assert "version_id=v1" in repr_str


def test_file_object_equality() -> None:
    """Test __eq__ and __hash__ methods of FileObject."""
    # Create a basic equality test by using direct instances
    obj1 = FileObject(backend="mock", filename="test.txt")

    # Create a different object with same path
    obj2 = FileObject(backend="mock", filename="test.txt")

    # Create an object with different path
    obj3 = FileObject(backend="mock", filename="other.txt")

    # Test basic __eq__ behavior (two instances with same values)
    with patch("advanced_alchemy.types.file_object.storages.get_backend") as mock_get_backend:
        # The first two instances should be equal with same backend and path
        mock_get_backend.return_value = Mock(spec=StorageBackend, key="same_key")
        assert obj1 == obj2

        # Test inequality with different paths (same backend)
        assert obj1 != obj3

        # Compare with a non-FileObject type
        assert obj1 != "not a file object"


def test_file_object_equality_different_backends() -> None:
    """Test equality with different backends."""
    # Create two FileObjects with the same path but different backends
    obj1 = FileObject(backend="mock1", filename="test.txt")
    obj2 = FileObject(backend="mock2", filename="test.txt")

    # Create mocks for backends with different keys
    mock_backend1 = Mock(spec=StorageBackend)
    mock_backend1.key = "backend1"

    mock_backend2 = Mock(spec=StorageBackend)
    mock_backend2.key = "backend2"

    # Use a mock dictionary for get_backend
    mock_backends = {"mock1": mock_backend1, "mock2": mock_backend2}

    # Patch the get_backend method to return the appropriate mock
    with patch("advanced_alchemy.types.file_object.storages.get_backend", side_effect=lambda key: mock_backends[key]):  # type: ignore
        # Files with same path but different backends should not be equal
        assert obj1 != obj2


def test_file_object_content_type_guessing() -> None:
    """Test content_type guessing from filename."""
    # Test common file extensions
    file_types = {
        "test.txt": "text/plain",
        "image.jpg": "image/jpeg",
        "doc.pdf": "application/pdf",
        "data.json": "application/json",
        "script.py": "text/x-python",
        "unknown": "application/octet-stream",  # No extension
    }

    for filename, expected_type in file_types.items():
        obj = FileObject(backend="mock", filename=filename)
        assert obj.content_type == expected_type


def test_file_object_save_no_data() -> None:
    """Test save method with no data."""
    # Create a FileObject with no content or source_path
    obj = FileObject(backend="mock", filename="test.txt")

    # Saving with no data should raise a TypeError
    with pytest.raises(TypeError, match=r"No data provided and no pending content/path found to save."):
        obj.save()


@pytest.mark.asyncio
async def test_file_object_save_async_no_data() -> None:
    """Test save_async method with no data."""
    # Create a FileObject with no content or source_path
    obj = FileObject(backend="mock", filename="test.txt")

    # Saving with no data should raise a TypeError
    with pytest.raises(TypeError, match=r"No data provided and no pending content/path found to save."):
        await obj.save_async()


def test_file_object_save_with_content() -> None:
    """Test save method with content provided in constructor."""
    # Create content
    test_content = b"Test content"

    # Create a FileObject with content
    obj = FileObject(backend="mock", filename="test.txt", content=test_content)

    # Verify object has pending data before save
    assert obj.has_pending_data

    # Mock the backend's save_object method
    mock_backend = Mock(spec=StorageBackend)
    mock_backend.key = "mock"
    mock_backend.save_object.return_value = obj  # Return the same object

    # Patch the storages.get_backend method to return our mock backend
    with patch("advanced_alchemy.types.file_object.storages.get_backend", return_value=mock_backend):
        # Call save method
        result = obj.save()

        # Verify the backend's save_object method was called
        assert mock_backend.save_object.called

        # Verify the method returns the updated object
        assert result is obj

        # Verify the pending content was cleared
        assert not obj.has_pending_data


@pytest.mark.asyncio
async def test_file_object_save_async_with_content() -> None:
    """Test save_async method with content provided in constructor."""
    # Create content
    test_content = b"Test async content"

    # Create a FileObject with content
    obj = FileObject(backend="mock", filename="test.txt", content=test_content)

    # Verify object has pending data before save
    assert obj.has_pending_data

    # Mock the backend's save_object_async method
    mock_backend = Mock(spec=StorageBackend)
    mock_backend.key = "mock"
    mock_backend.save_object_async = AsyncMock(return_value=obj)  # Return the same object

    # Patch the storages.get_backend method to return our mock backend
    with patch("advanced_alchemy.types.file_object.storages.get_backend", return_value=mock_backend):
        # Call save_async method
        result = await obj.save_async()

        # Verify the backend's save_object_async method was called
        assert mock_backend.save_object_async.called

        # Verify the method returns the updated object
        assert result is obj

        # Verify the pending content was cleared
        assert not obj.has_pending_data


def test_file_object_get_content() -> None:
    """Test get_content method."""
    # Create expected content
    test_content = b"Test content for get_content"

    # Create a FileObject
    obj = FileObject(backend="mock", filename="test.txt")

    # Mock the backend's get_content method
    mock_backend = Mock(spec=StorageBackend)
    mock_backend.key = "mock"
    mock_backend.get_content.return_value = test_content

    # Patch the storages.get_backend method to return our mock backend
    with patch("advanced_alchemy.types.file_object.storages.get_backend", return_value=mock_backend):
        # Call get_content method
        content = obj.get_content()

        # Verify the backend's get_content method was called with the right parameters
        mock_backend.get_content.assert_called_once_with(obj.path, options=None)

        # Verify the content returned matches the expected content
        assert content == test_content

        # Test with options
        options = {"option1": "value1"}
        content = obj.get_content(options=options)
        mock_backend.get_content.assert_called_with(obj.path, options=options)


@pytest.mark.asyncio
async def test_file_object_get_content_async() -> None:
    """Test get_content_async method."""
    # Create expected content
    test_content = b"Test content for get_content_async"

    # Create a FileObject
    obj = FileObject(backend="mock", filename="test.txt")

    # Mock the backend's get_content_async method
    mock_backend = Mock(spec=StorageBackend)
    mock_backend.key = "mock"
    mock_backend.get_content_async = AsyncMock(return_value=test_content)

    # Patch the storages.get_backend method to return our mock backend
    with patch("advanced_alchemy.types.file_object.storages.get_backend", return_value=mock_backend):
        # Call get_content_async method
        content = await obj.get_content_async()

        # Verify the backend's get_content_async method was called with the right parameters
        mock_backend.get_content_async.assert_called_once_with(obj.path, options=None)

        # Verify the content returned matches the expected content
        assert content == test_content

        # Test with options
        options = {"option1": "value1"}
        content = await obj.get_content_async(options=options)
        mock_backend.get_content_async.assert_called_with(obj.path, options=options)


def test_file_object_delete() -> None:
    """Test delete method."""
    # Create a FileObject
    obj = FileObject(backend="mock", filename="test.txt")

    # Mock the backend's delete_object method
    mock_backend = Mock(spec=StorageBackend)
    mock_backend.key = "mock"

    # Patch the storages.get_backend method to return our mock backend
    with patch("advanced_alchemy.types.file_object.storages.get_backend", return_value=mock_backend):
        # Call delete method
        obj.delete()

        # Verify the backend's delete_object method was called with the right parameters
        mock_backend.delete_object.assert_called_once_with(obj.path)


@pytest.mark.asyncio
async def test_file_object_delete_async() -> None:
    """Test delete_async method."""
    # Create a FileObject
    obj = FileObject(backend="mock", filename="test.txt")

    # Mock the backend's delete_object_async method
    mock_backend = Mock(spec=StorageBackend)
    mock_backend.key = "mock"
    mock_backend.delete_object_async = AsyncMock()

    # Patch the storages.get_backend method to return our mock backend
    with patch("advanced_alchemy.types.file_object.storages.get_backend", return_value=mock_backend):
        # Call delete_async method
        await obj.delete_async()

        # Verify the backend's delete_object_async method was called with the right parameters
        mock_backend.delete_object_async.assert_called_once_with(obj.path)


def test_file_object_sign() -> None:
    """Test sign method with non-list return value."""
    # Create a FileObject
    obj = FileObject(backend="mock", filename="test.txt")

    # Mock the backend's sign method to return a string
    mock_backend = Mock(spec=StorageBackend)
    mock_backend.key = "mock"
    mock_backend.sign.return_value = "signed-url"

    # Patch the storages.get_backend method to return our mock backend
    with patch("advanced_alchemy.types.file_object.storages.get_backend", return_value=mock_backend):
        # Call sign method
        url = obj.sign(expires_in=3600)

        # Verify the backend's sign method was called with the right parameters
        mock_backend.sign.assert_called_once_with(obj.path, expires_in=3600, for_upload=False)

        # Verify the url returned matches the expected url
        assert url == "signed-url"

        # Test with for_upload=True
        url = obj.sign(for_upload=True)
        mock_backend.sign.assert_called_with(obj.path, expires_in=None, for_upload=True)


@pytest.mark.asyncio
async def test_file_object_sign_async() -> None:
    """Test sign_async method with non-list return value."""
    # Create a FileObject
    obj = FileObject(backend="mock", filename="test.txt")

    # Mock the backend's sign_async method to return a string
    mock_backend = Mock(spec=StorageBackend)
    mock_backend.key = "mock"
    mock_backend.sign_async = AsyncMock(return_value="signed-url-async")

    # Patch the storages.get_backend method to return our mock backend
    with patch("advanced_alchemy.types.file_object.storages.get_backend", return_value=mock_backend):
        # Call sign_async method
        url = await obj.sign_async(expires_in=3600)

        # Verify the backend's sign_async method was called with the right parameters
        mock_backend.sign_async.assert_called_once_with(obj.path, expires_in=3600, for_upload=False)

        # Verify the url returned matches the expected url
        assert url == "signed-url-async"

        # Test with for_upload=True
        url = await obj.sign_async(for_upload=True)
        mock_backend.sign_async.assert_called_with(obj.path, expires_in=None, for_upload=True)


def test_file_object_has_pending_data() -> None:
    """Test has_pending_data property."""
    # Create a FileObject with no pending data
    obj1 = FileObject(backend="mock", filename="test1.txt")
    assert not obj1.has_pending_data

    # Create a FileObject with pending content
    obj2 = FileObject(backend="mock", filename="test2.txt", content=b"pending content")
    assert obj2.has_pending_data

    # Create a FileObject with pending source_path
    obj3 = FileObject(backend="mock", filename="test3.txt", source_path=Path("source.txt"))
    assert obj3.has_pending_data


def test_file_object_incompatible_init() -> None:
    """Test incompatible initialization parameters."""
    # Cannot provide both content and source_path
    with pytest.raises(ValueError, match="Cannot provide both 'source_content' and 'source_path'"):
        FileObject(backend="mock", filename="test.txt", content=b"Test content", source_path=Path("source.txt"))


@pytest.mark.asyncio
async def test_file_object_sign_async_empty_result_list() -> None:
    """Test sign_async method when backend returns an empty list."""
    # Create a FileObject
    obj = FileObject(backend="mock", filename="test.txt")

    # Create a mock backend
    mock_backend = Mock(spec=StorageBackend)
    mock_backend.key = "mock"
    mock_backend.sign_async = AsyncMock(return_value=[])  # Empty list

    # Patch the storages.get_backend method to return our mock backend
    with patch("advanced_alchemy.types.file_object.storages.get_backend", return_value=mock_backend):
        # Test sign_async method - should raise RuntimeError when list is empty
        with pytest.raises(RuntimeError, match="No signed URL generated"):
            await obj.sign_async(expires_in=3600)


@pytest.mark.skipif(not PYDANTIC_INSTALLED, reason="Pydantic v2 not installed")
def test_pydantic_serialization() -> None:
    """Test serialization of FileObject within a Pydantic model."""

    class FileModel(BaseModel):  # type: ignore[valid-type, misc]
        file: FileObject

    file_obj = FileObject(
        backend="mock",
        filename="test.txt",
        size=100,
        content_type="text/plain",
        last_modified=1234567890.0,
        etag="etag123",
        version_id="v1",
        metadata={"key": "value"},
    )
    model = FileModel(file=file_obj)

    # Mock backend for serialization
    mock_backend = Mock(spec=StorageBackend)
    mock_backend.key = "mock_backend_key"
    with patch("advanced_alchemy.types.file_object.storages.get_backend", return_value=mock_backend):
        serialized_data = model.model_dump()

        # Check if the serialized data matches the output of to_dict()
        expected_dict = {
            "filename": "test.txt",
            "content_type": "text/plain",
            "size": 100,
            "last_modified": 1234567890.0,
            "checksum": None,
            "etag": "etag123",
            "version_id": "v1",
            "metadata": {"key": "value"},
            "backend": "mock_backend_key",  # Expect backend key here
        }
        assert serialized_data == {"file": expected_dict}


@pytest.mark.skipif(not PYDANTIC_INSTALLED, reason="Pydantic v2 not installed")
def test_pydantic_validation_from_dict() -> None:
    """Test validation of a dictionary into FileObject via Pydantic."""

    class FileModel(BaseModel):  # type: ignore[valid-type, misc]
        file: FileObject

    input_dict = {
        "filename": "validated.txt",
        "backend": "mock_validate",
        "size": 200,
        "content_type": "image/png",
        "etag": "etag_validate",
        "metadata": {"source": "validation"},
    }

    # Mock backend for validation lookup
    mock_backend = Mock(spec=StorageBackend)
    mock_backend.key = "mock_validate"
    with patch("advanced_alchemy.types.file_object.storages.get_backend", return_value=mock_backend):
        model = FileModel(file=input_dict)  # type: ignore[arg-type]

        # Verify the created FileObject instance
        assert isinstance(model.file, FileObject)
        assert model.file.filename == "validated.txt"
        assert model.file.backend == mock_backend  # Backend should be resolved instance
        assert model.file.size == 200
        assert model.file.content_type == "image/png"
        assert model.file.etag == "etag_validate"
        assert model.file.metadata == {"source": "validation"}


@pytest.mark.skipif(not PYDANTIC_INSTALLED, reason="Pydantic v2 not installed")
def test_pydantic_validation_from_instance() -> None:
    """Test validation when providing an existing FileObject instance."""

    class FileModel(BaseModel):  # type: ignore[valid-type, misc]
        file: FileObject

    file_obj = FileObject(backend="instance_mock", filename="instance.txt")
    mock_backend = Mock(spec=StorageBackend)
    mock_backend.key = "instance_mock"

    with patch("advanced_alchemy.types.file_object.storages.get_backend", return_value=mock_backend):
        model = FileModel(file=file_obj)
        assert model.file is file_obj  # Should accept the instance directly


@pytest.mark.skipif(not PYDANTIC_INSTALLED, reason="Pydantic v2 not installed")
def test_pydantic_validation_error_missing_filename() -> None:
    """Test Pydantic validation error for missing filename."""
    from pydantic import ValidationError

    class FileModel(BaseModel):  # type: ignore[valid-type, misc]
        file: FileObject

    input_dict = {
        "backend": "mock_error",
        "size": 100,
    }
    with pytest.raises(ValidationError, match="filename"):  # type: ignore[call-arg]
        FileModel(file=input_dict)  # type: ignore[arg-type]


@pytest.mark.skipif(not PYDANTIC_INSTALLED, reason="Pydantic v2 not installed")
def test_pydantic_validation_error_missing_backend() -> None:
    """Test Pydantic validation error for missing backend."""
    from pydantic import ValidationError

    class FileModel(BaseModel):  # type: ignore[valid-type, misc]
        file: FileObject

    input_dict = {
        "filename": "no_backend.txt",
        "size": 100,
    }
    with pytest.raises(ValidationError, match="backend"):  # type: ignore
        FileModel(file=input_dict)  # type: ignore[arg-type]


@pytest.mark.asyncio
async def test_session_tracker_commit_async_reraises_base_exceptions() -> None:
    """Ensure async commit re-raises non-Exception BaseException instances."""
    tracker = FileObjectSessionTracker()
    file_obj = Mock(spec=FileObject)
    file_obj.path = "tmp"
    file_obj.save_async = AsyncMock(side_effect=asyncio.CancelledError())

    tracker.add_pending_save(file_obj, b"payload")

    with pytest.raises(asyncio.CancelledError):
        await tracker.commit_async()

    file_obj.save_async.assert_awaited_once_with(b"payload")


def test_session_tracker_commit_ignores_file_not_found_on_delete_sync() -> None:
    """Sync commit should ignore FileNotFoundError from delete."""
    tracker = FileObjectSessionTracker()
    file_obj = Mock(spec=FileObject)
    file_obj.path = "tmp"
    file_obj.delete.side_effect = FileNotFoundError()

    tracker.add_pending_delete(file_obj)

    tracker.commit()  # should not raise
    file_obj.delete.assert_called_once_with()


def test_session_tracker_add_pending_delete_ignores_none_path() -> None:
    """Objects with no path are not added to pending deletes."""
    tracker = FileObjectSessionTracker()
    file_obj = Mock(spec=FileObject)
    file_obj.path = None

    tracker.add_pending_delete(file_obj)

    assert file_obj not in tracker.pending_deletes


def test_session_tracker_rollback_ignores_file_not_found_sync() -> None:
    """Rollback ignores FileNotFoundError from delete in sync path."""
    tracker = FileObjectSessionTracker()
    obj = Mock(spec=FileObject)
    obj.path = "tmp"
    obj.delete.side_effect = FileNotFoundError()

    tracker._saved_in_transaction.add(obj)

    tracker.rollback()  # should not raise
    obj.delete.assert_called_once_with()


@pytest.mark.asyncio
async def test_session_tracker_rollback_async_ignores_file_not_found() -> None:
    """Rollback ignores FileNotFoundError from delete in async path."""
    tracker = FileObjectSessionTracker()
    obj = Mock(spec=FileObject)
    obj.path = "tmp"
    obj.delete_async = AsyncMock(side_effect=FileNotFoundError())

    tracker._saved_in_transaction.add(obj)

    await tracker.rollback_async()  # should not raise
    obj.delete_async.assert_awaited_once_with()


def test_session_tracker_commit_multiple_saves_then_rollback_deletes_successful_ones() -> None:
    """Sync commit failure after some saves should allow rollback to delete saved ones."""
    tracker = FileObjectSessionTracker(raise_on_error=True)
    obj1 = Mock(spec=FileObject)
    obj1.path = "p1"
    obj1.save.return_value = None
    obj2 = Mock(spec=FileObject)
    obj2.path = "p2"
    obj2.save.side_effect = RuntimeError("boom2")

    tracker.add_pending_save(obj1, b"a")
    tracker.add_pending_save(obj2, b"b")

    with pytest.raises(RuntimeError, match="boom2"):
        tracker.commit()

    # first save recorded, second failed
    assert obj1 in tracker._saved_in_transaction
    assert obj2 not in tracker._saved_in_transaction

    # rollback deletes the saved file
    tracker.rollback()
    obj1.delete.assert_called_once_with()


@pytest.mark.asyncio
async def test_session_tracker_commit_async_multiple_saves_raises_first_and_rollback_deletes_success() -> None:
    """Async commit raises first failure; rollback deletes successful saves."""
    tracker = FileObjectSessionTracker(raise_on_error=True)
    ok = Mock(spec=FileObject)
    ok.path = "ok"
    ok.save_async = AsyncMock(return_value=None)
    bad = Mock(spec=FileObject)
    bad.path = "bad"
    bad.save_async = AsyncMock(side_effect=RuntimeError("first failure"))

    tracker.add_pending_save(ok, b"ok")
    tracker.add_pending_save(bad, b"bad")

    with pytest.raises(RuntimeError, match="first failure"):
        await tracker.commit_async()

    # success recorded despite overall failure
    assert ok in tracker._saved_in_transaction
    # rollback deletes the successful one
    await tracker.rollback_async()
    ok.delete_async.assert_awaited_once_with()


@pytest.mark.asyncio
async def test_session_tracker_commit_async_raises_first_save_exception_in_order() -> None:
    """When multiple saves fail, an ExceptionGroup is raised with all failures."""
    tracker = FileObjectSessionTracker(raise_on_error=True)
    a = Mock(spec=FileObject)
    a.path = "a"
    a.save_async = AsyncMock(side_effect=RuntimeError("first"))
    b = Mock(spec=FileObject)
    b.path = "b"
    b.save_async = AsyncMock(side_effect=RuntimeError("second"))

    tracker.add_pending_save(a, b"x")
    tracker.add_pending_save(b, b"y")

    # Multiple errors raise ExceptionGroup
    with pytest.raises(ExceptionGroup, match="multiple FileObject operation failures"):
        await tracker.commit_async()


@pytest.mark.asyncio
async def test_session_tracker_commit_async_logs_exc_info_on_save_error(caplog: "pytest.LogCaptureFixture") -> None:
    """Async save errors are logged with exc_info for stack traces."""
    tracker = FileObjectSessionTracker(raise_on_error=True)
    obj = Mock(spec=FileObject)
    obj.path = "tmp"
    obj.save_async = AsyncMock(side_effect=RuntimeError("fail"))

    tracker.add_pending_save(obj, b"data")

    with caplog.at_level(logging.ERROR):
        with pytest.raises(RuntimeError):
            await tracker.commit_async()

    # Find our error record and assert exc_info present
    err_records = [r for r in caplog.records if "error saving file" in r.message]
    assert err_records and any(rec.exc_info for rec in err_records)


def test_session_tracker_commit_delete_exceptions_sync(
    caplog: "pytest.LogCaptureFixture",
) -> None:
    """Parametrized-like table: sync delete exceptions behavior."""
    tracker = FileObjectSessionTracker(raise_on_error=True)

    cases = [
        (FileNotFoundError(), None, False),
        (RuntimeError("boom"), RuntimeError, True),
        (asyncio.CancelledError(), asyncio.CancelledError, False),
    ]

    for exc, expected_exc, expect_log in cases:
        file_obj = Mock(spec=FileObject)
        file_obj.path = "tmp"
        file_obj.delete.side_effect = exc
        tracker.add_pending_delete(file_obj)

        with caplog.at_level(logging.ERROR):
            if expected_exc is None:
                tracker.commit()
            else:
                with pytest.raises(expected_exc):
                    tracker.commit()

        file_obj.delete.assert_called_once_with()
        if expect_log:
            assert any("error deleting file" in r.message for r in caplog.records)
        else:
            assert not any("error deleting file" in r.message for r in caplog.records)

        # reset tracker for next case
        tracker.clear()
        caplog.clear()


def test_session_tracker_commit_save_exceptions_sync(caplog: "pytest.LogCaptureFixture") -> None:
    """Sync save exceptions: RuntimeError logs+raises; BaseException bubbles without log."""
    tracker = FileObjectSessionTracker(raise_on_error=True)

    cases = [
        (RuntimeError("sync failure"), RuntimeError, True),
        (asyncio.CancelledError(), asyncio.CancelledError, False),
    ]

    for exc, expected_exc, expect_log in cases:
        file_obj = Mock(spec=FileObject)
        file_obj.path = "tmp"
        file_obj.save.side_effect = exc
        tracker.add_pending_save(file_obj, b"payload")

        with caplog.at_level(logging.ERROR):
            with pytest.raises(expected_exc):
                tracker.commit()

        file_obj.save.assert_called_once_with(b"payload")
        if expect_log:
            assert any("error saving file" in r.message for r in caplog.records)
        else:
            assert not any("error saving file" in r.message for r in caplog.records)

        tracker.clear()
        caplog.clear()


@pytest.mark.asyncio
@pytest.mark.parametrize("mode", ["sync", "async"])
async def test_session_tracker_commit_does_not_clear_state_on_error(mode: str) -> None:
    """Commit keeps pending items when a save fails (sync and async)."""
    tracker = FileObjectSessionTracker(raise_on_error=True)
    obj = Mock(spec=FileObject)
    obj.path = "tmp"
    if mode == "sync":
        obj.save.side_effect = RuntimeError("boom")
        tracker.add_pending_save(obj, b"data")
        with pytest.raises(RuntimeError):
            tracker.commit()
    else:
        obj.save_async = AsyncMock(side_effect=RuntimeError("boom"))
        tracker.add_pending_save(obj, b"data")
        with pytest.raises(RuntimeError):
            await tracker.commit_async()

    assert obj in tracker.pending_saves


@pytest.mark.asyncio
@pytest.mark.parametrize("mode", ["sync", "async"])
async def test_session_tracker_commit_clears_state_on_success(mode: str) -> None:
    """Commit clears state after successful operations (sync and async)."""
    tracker = FileObjectSessionTracker()
    save_obj = Mock(spec=FileObject)
    save_obj.path = "tmp1"
    del_obj = Mock(spec=FileObject)
    del_obj.path = "tmp2"

    if mode == "sync":
        save_obj.save.return_value = None
        del_obj.delete.return_value = None
        tracker.add_pending_save(save_obj, b"data")
        tracker.add_pending_delete(del_obj)
        tracker.commit()
    else:
        save_obj.save_async = AsyncMock(return_value=None)
        del_obj.delete_async = AsyncMock(return_value=None)
        tracker.add_pending_save(save_obj, b"data")
        tracker.add_pending_delete(del_obj)
        await tracker.commit_async()

    assert not tracker.pending_saves
    assert not tracker.pending_deletes
    assert not tracker._saved_in_transaction


@pytest.mark.asyncio
@pytest.mark.parametrize(
    "mode, expect_delete_called",
    [("sync", False), ("async", True)],
)
async def test_session_tracker_commit_delete_attempt_when_save_fails(mode: str, expect_delete_called: bool) -> None:
    """Sync commit aborts before deletes; async still attempts deletes on gather."""
    tracker = FileObjectSessionTracker(raise_on_error=True)
    save_obj = Mock(spec=FileObject)
    save_obj.path = "tmp1"
    del_obj = Mock(spec=FileObject)
    del_obj.path = "tmp2"

    tracker.add_pending_save(save_obj, b"data")
    tracker.add_pending_delete(del_obj)

    if mode == "sync":
        save_obj.save.side_effect = RuntimeError("save failed")
        with pytest.raises(RuntimeError):
            tracker.commit()
        assert del_obj.delete.call_count == (1 if expect_delete_called else 0)
    else:
        save_obj.save_async = AsyncMock(side_effect=RuntimeError("save failed"))
        del_obj.delete_async = AsyncMock(return_value=None)
        with pytest.raises(RuntimeError):
            await tracker.commit_async()
        assert del_obj.delete_async.await_count == (1 if expect_delete_called else 0)


@pytest.mark.asyncio
@pytest.mark.parametrize("mode", ["sync", "async"])
async def test_session_tracker_rollback_reraises_delete_errors_param(
    mode: str, caplog: "pytest.LogCaptureFixture"
) -> None:
    """Rollback re-raises delete errors in both modes."""
    tracker = FileObjectSessionTracker()
    obj = Mock(spec=FileObject)
    obj.path = "tmp"
    tracker._saved_in_transaction.add(obj)

    if mode == "sync":
        obj.delete.side_effect = RuntimeError("rollback delete failure")
        with caplog.at_level(logging.ERROR):
            with pytest.raises(RuntimeError, match="rollback delete failure"):
                tracker.rollback()
        obj.delete.assert_called_once_with()
    else:
        obj.delete_async = AsyncMock(side_effect=RuntimeError("rollback async delete failure"))
        with caplog.at_level(logging.ERROR):
            with pytest.raises(RuntimeError, match="rollback async delete failure"):
                await tracker.rollback_async()
        obj.delete_async.assert_awaited_once_with()


@pytest.mark.parametrize(
    "ops, expected_in_saves, expected_in_deletes",
    [
        (("save", "delete"), False, True),
        (("delete", "save"), True, False),
    ],
)
def test_session_tracker_override_semantics(
    ops: "tuple[str, str]",
    expected_in_saves: bool,
    expected_in_deletes: bool,
) -> None:
    """Parametrized check that save/delete override each other appropriately."""
    tracker = FileObjectSessionTracker()
    file_obj = Mock(spec=FileObject)
    file_obj.path = "tmp"

    first, second = ops
    if first == "save":
        tracker.add_pending_save(file_obj, b"data")
    else:
        tracker.add_pending_delete(file_obj)

    if second == "save":
        tracker.add_pending_save(file_obj, b"data")
    else:
        tracker.add_pending_delete(file_obj)

    assert (file_obj in tracker.pending_saves) is expected_in_saves
    assert (file_obj in tracker.pending_deletes) is expected_in_deletes


@pytest.mark.asyncio
@pytest.mark.parametrize(
    "exc_factory, expected_exception, expect_log",
    [
        (lambda: RuntimeError("delete boom"), RuntimeError, True),
        (lambda: FileNotFoundError(), None, False),
        (lambda: asyncio.CancelledError(), asyncio.CancelledError, False),
    ],
)
async def test_session_tracker_commit_async_delete_exceptions(
    caplog: "pytest.LogCaptureFixture",
    exc_factory: "Callable[[], BaseException]",
    expected_exception: "Optional[type[BaseException]]",
    expect_log: bool,
) -> None:
    """Parametrized verification of async delete exception handling semantics."""
    tracker = FileObjectSessionTracker(raise_on_error=True)
    file_obj = Mock(spec=FileObject)
    file_obj.path = "tmp"
    file_obj.delete_async = AsyncMock(side_effect=exc_factory())

    tracker.add_pending_delete(file_obj)

    with caplog.at_level(logging.ERROR):
        if expected_exception is None:
            await tracker.commit_async()
        else:
            with pytest.raises(expected_exception):
                await tracker.commit_async()

    file_obj.delete_async.assert_awaited_once_with()
    if expect_log:
        assert any("error deleting file" in r.message for r in caplog.records)
    else:
        assert not any("error deleting file" in r.message for r in caplog.records)


# --- FileObject Listener Tests (from _listeners module) ---


def test_file_object_inspector_inspect_instance_no_state() -> None:
    """Test FileObjectInspector when inspect returns None."""
    from advanced_alchemy._listeners import FileObjectInspector

    instance = MagicMock()
    tracker = MagicMock()
    # Mock inspect to return None
    with patch("advanced_alchemy._listeners.inspect", return_value=None):
        FileObjectInspector.inspect_instance(instance, tracker)
        # Should return early, no exception


def test_file_object_inspector_inspect_instance_no_mapper() -> None:
    """Test FileObjectInspector when state has no mapper."""
    from advanced_alchemy._listeners import FileObjectInspector

    instance = MagicMock()
    tracker = MagicMock()
    mock_state = MagicMock()
    mock_state.mapper = None

    with patch("advanced_alchemy._listeners.inspect", return_value=mock_state):
        FileObjectInspector.inspect_instance(instance, tracker)
        # Should return early


def test_file_object_inspector_inspect_instance_key_error() -> None:
    """Test FileObjectInspector handles KeyError gracefully."""
    from advanced_alchemy._listeners import FileObjectInspector
    from advanced_alchemy.types.file_object import StoredObject

    instance = MagicMock()
    tracker = MagicMock()
    mock_state = MagicMock()
    mock_mapper = MagicMock()
    mock_state.mapper = mock_mapper

    mock_attr = MagicMock()
    mock_attr.expression.type = MagicMock(spec=StoredObject)

    mock_mapper.column_attrs = {"file_col": mock_attr}
    mock_state.attrs = {}  # Empty attrs, will trigger KeyError

    with patch("advanced_alchemy._listeners.inspect", return_value=mock_state):
        FileObjectInspector.inspect_instance(instance, tracker)
        # Should handle KeyError gracefully


def test_handle_single_attribute_added() -> None:
    """Test handling single attribute when file is added."""
    from advanced_alchemy._listeners import FileObjectInspector

    tracker = MagicMock(spec=FileObjectSessionTracker)
    attr_state = MagicMock()

    mock_file = MagicMock(spec=FileObject)
    mock_file._pending_source_content = b"content"
    mock_file._pending_source_path = None

    attr_state.history.added = [mock_file]
    attr_state.history.deleted = []

    FileObjectInspector.handle_single_attribute(attr_state, tracker)

    tracker.add_pending_save.assert_called_with(mock_file, b"content")


def test_handle_single_attribute_deleted() -> None:
    """Test handling single attribute when file is deleted."""
    from advanced_alchemy._listeners import FileObjectInspector

    tracker = MagicMock(spec=FileObjectSessionTracker)
    attr_state = MagicMock()

    mock_file = MagicMock(spec=FileObject)
    mock_file.path = "some/path"

    attr_state.history.added = []
    attr_state.history.deleted = [mock_file]

    FileObjectInspector.handle_single_attribute(attr_state, tracker)

    tracker.add_pending_delete.assert_called_with(mock_file)


def test_handle_multiple_attribute() -> None:
    """Test handling multiple attribute with deletion from pending removed."""
    from advanced_alchemy._listeners import FileObjectInspector
    from advanced_alchemy.types.mutables import MutableList

    tracker = MagicMock(spec=FileObjectSessionTracker)
    instance = MagicMock()
    attr_name = "files"
    attr_state = MagicMock()

    # Case: Deletion from pending removed
    current_list = MagicMock(spec=MutableList)
    deleted_item = MagicMock(spec=FileObject)
    deleted_item.path = "path/to/delete"
    current_list._pending_removed = {deleted_item}
    current_list._pending_append = []

    setattr(instance, attr_name, current_list)
    attr_state.history.deleted = []
    attr_state.history.added = []

    FileObjectInspector.handle_multiple_attribute(instance, attr_name, attr_state, tracker)

    tracker.add_pending_delete.assert_called_with(deleted_item)


def test_handle_multiple_attribute_replacement() -> None:
    """Test handling multiple attribute with list replacement."""
    from advanced_alchemy._listeners import FileObjectInspector

    tracker = MagicMock(spec=FileObjectSessionTracker)
    instance = MagicMock()
    attr_name = "files"
    attr_state = MagicMock()

    # Case: List replacement
    # Original list had item1, item2
    # New list has item2 (so item1 removed)
    item1 = MagicMock(spec=FileObject)
    item1.path = "p1"
    item2 = MagicMock(spec=FileObject)
    item2.path = "p2"

    # SQLAlchemy history.deleted contains the *value* that was deleted.
    # For a list assignment replacing the whole list, it might be [old_list].
    # The code expects history.deleted[0] to be the list.
    attr_state.history.deleted = [[item1, item2]]  # original list wrapped in list
    attr_state.history.added = [[item2]]  # new list wrapped in list

    # We must mock getattr(instance, attr_name) to return None or regular list
    setattr(instance, attr_name, [item2])

    FileObjectInspector.handle_multiple_attribute(instance, attr_name, attr_state, tracker)

    tracker.add_pending_delete.assert_called_with(item1)


def test_handle_multiple_attribute_append_and_new() -> None:
    """Test handling multiple attribute with appends and new items."""
    from advanced_alchemy._listeners import FileObjectInspector
    from advanced_alchemy.types.mutables import MutableList

    tracker = MagicMock(spec=FileObjectSessionTracker)
    instance = MagicMock()
    attr_name = "files"
    attr_state = MagicMock()

    current_list = MagicMock(spec=MutableList)
    current_list._pending_removed = set()

    item1 = MagicMock(spec=FileObject)
    item1.path = None
    item1._pending_content = b"d1"
    item1._pending_source_path = None
    # For item2, we want to test pending_source_path
    item2 = MagicMock(spec=FileObject)
    item2.path = None
    item2._pending_source_path = "p2"
    item2._pending_source_content = None

    current_list._pending_append = [item1]

    setattr(instance, attr_name, current_list)

    # Case: New items in history.added
    attr_state.history.deleted = []
    attr_state.history.added = [[item2]]

    FileObjectInspector.handle_multiple_attribute(instance, attr_name, attr_state, tracker)

    tracker.add_pending_save.assert_any_call(item1, b"d1")
    tracker.add_pending_save.assert_any_call(item2, "p2")


def test_process_deleted_instance() -> None:
    """Test processing a deleted instance for file cleanup."""
    from advanced_alchemy._listeners import FileObjectInspector
    from advanced_alchemy.types.file_object import StoredObject

    instance = MagicMock()
    tracker = MagicMock()
    mapper = MagicMock()

    mock_attr = MagicMock()
    mock_attr.expression.type = MagicMock(spec=StoredObject)
    mock_attr.expression.type.multiple = False

    mapper.column_attrs = {"file_col": mock_attr}

    file_obj = MagicMock()
    file_obj.path = "path"
    setattr(instance, "file_col", file_obj)

    FileObjectInspector.process_deleted_instance(instance, mapper, tracker)

    tracker.add_pending_delete.assert_called_with(file_obj)


def test_get_file_tracker_create() -> None:
    """Test get_file_tracker creates a new tracker."""
    from advanced_alchemy._listeners import get_file_tracker

    session = MagicMock(spec=Session)
    session.info = {}

    result = get_file_tracker(session, create=True)
    assert result is not None
    assert session.info["_aa_file_tracker"] is result


# --- BaseFileObjectListener Tests ---


def test_base_file_object_listener_is_listener_enabled_default() -> None:
    """Test BaseFileObjectListener enabled by default."""
    from advanced_alchemy._listeners import BaseFileObjectListener

    class TestBaseFileObjectListener(BaseFileObjectListener):
        pass

    session = MagicMock(spec=Session)
    session.info = {}
    session.bind = None
    session.execution_options = None

    assert TestBaseFileObjectListener._is_listener_enabled(session) is True


def test_base_file_object_listener_is_listener_enabled_session_info() -> None:
    """Test BaseFileObjectListener can be disabled via session info."""
    from advanced_alchemy._listeners import BaseFileObjectListener

    class TestBaseFileObjectListener(BaseFileObjectListener):
        pass

    session = MagicMock(spec=Session)
    session.info = {"enable_file_object_listener": False}

    assert TestBaseFileObjectListener._is_listener_enabled(session) is False


def test_base_file_object_listener_before_flush_disabled() -> None:
    """Test before_flush returns early when listener is disabled."""
    from advanced_alchemy._listeners import BaseFileObjectListener

    class TestBaseFileObjectListener(BaseFileObjectListener):
        pass

    session = MagicMock(spec=Session)
    session.info = {"enable_file_object_listener": False}

    TestBaseFileObjectListener.before_flush(session, MagicMock(), None)
    # Should return early
    assert "_aa_file_tracker" not in session.info


def test_base_file_object_listener_before_flush() -> None:
    """Test before_flush processes session objects."""
    from advanced_alchemy._listeners import BaseFileObjectListener

    class TestBaseFileObjectListener(BaseFileObjectListener):
        pass

    session = MagicMock(spec=Session)
    session.bind = MagicMock()
    session.info = {}
    session.new = []
    session.dirty = []
    session.deleted = []

    # Mock get_file_tracker to return a tracker
    with patch("advanced_alchemy._listeners.get_file_tracker") as mock_get_tracker:
        mock_tracker = MagicMock()
        mock_get_tracker.return_value = mock_tracker
        TestBaseFileObjectListener.before_flush(session, MagicMock(), None)


# --- SyncFileObjectListener Tests ---


def test_sync_file_object_listener_after_commit() -> None:
    """Test SyncFileObjectListener commits tracker on session commit."""
    from advanced_alchemy._listeners import SyncFileObjectListener

    session = MagicMock(spec=Session)
    tracker = MagicMock()
    session.info = {"_aa_file_tracker": tracker}

    SyncFileObjectListener.after_commit(session)

    tracker.commit.assert_called_once()
    assert "_aa_file_tracker" not in session.info


def test_sync_file_object_listener_after_rollback() -> None:
    """Test SyncFileObjectListener rolls back tracker on session rollback."""
    from advanced_alchemy._listeners import SyncFileObjectListener

    session = MagicMock(spec=Session)
    tracker = MagicMock()
    session.info = {"_aa_file_tracker": tracker}

    SyncFileObjectListener.after_rollback(session)

    tracker.rollback.assert_called_once()
    assert "_aa_file_tracker" not in session.info


# --- AsyncFileObjectListener Tests ---


@pytest.mark.asyncio
async def test_async_file_object_listener_after_commit() -> None:
    """Test AsyncFileObjectListener commits tracker asynchronously."""
    from advanced_alchemy._listeners import AsyncFileObjectListener, _active_file_operations

    session = MagicMock(spec=Session)
    tracker = MagicMock()
    tracker.commit_async = AsyncMock()
    session.info = {"_aa_file_tracker": tracker}

    AsyncFileObjectListener.after_commit(session)

    # Find the task and await it
    assert len(_active_file_operations) > 0
    task = next(iter(_active_file_operations))
    await task

    assert "_aa_file_tracker" not in session.info
    tracker.commit_async.assert_called_once()


@pytest.mark.asyncio
async def test_async_file_object_listener_after_rollback() -> None:
    """Test AsyncFileObjectListener rolls back tracker asynchronously."""
    from advanced_alchemy._listeners import AsyncFileObjectListener, _active_file_operations

    session = MagicMock(spec=Session)
    tracker = MagicMock()
    tracker.rollback_async = AsyncMock()
    session.info = {"_aa_file_tracker": tracker}

    AsyncFileObjectListener.after_rollback(session)

    # Find the task and await it
    assert len(_active_file_operations) > 0
    task = next(iter(_active_file_operations))
    await task

    assert "_aa_file_tracker" not in session.info
    tracker.rollback_async.assert_called_once()


# --- FileObjectListener (Legacy) Tests ---


def test_file_object_listener_legacy_sync() -> None:
    """Test legacy FileObjectListener in sync context."""
    from advanced_alchemy._listeners import FileObjectListener

    session = MagicMock(spec=Session)
    tracker = MagicMock()
    session.info = {"_aa_file_tracker": tracker}

    # patch is_async_context to return False
    with patch("advanced_alchemy._listeners.is_async_context", return_value=False):
        FileObjectListener.after_commit(session)
        tracker.commit.assert_called_once()

        session.info = {"_aa_file_tracker": tracker}  # put it back
        FileObjectListener.after_rollback(session)
        tracker.rollback.assert_called_once()


@pytest.mark.asyncio
async def test_file_object_listener_legacy_async() -> None:
    """Test legacy FileObjectListener in async context."""
    from advanced_alchemy._listeners import FileObjectListener, _active_file_operations

    session = MagicMock(spec=Session)
    tracker = MagicMock()
    tracker.commit_async = AsyncMock()
    tracker.rollback_async = AsyncMock()
    session.info = {"_aa_file_tracker": tracker}

    # patch is_async_context to return True
    with patch("advanced_alchemy._listeners.is_async_context", return_value=True):
        FileObjectListener.after_commit(session)

        # Await task
        if _active_file_operations:
            await next(iter(_active_file_operations))

        session.info = {"_aa_file_tracker": tracker}
        FileObjectListener.after_rollback(session)

        # Await task
        if _active_file_operations:
            for t in list(_active_file_operations):
                if not t.done():
                    await t


# --- Setup Tests ---


def test_setup_file_object_listeners() -> None:
    """Test setup_file_object_listeners registers event listeners."""
    from advanced_alchemy._listeners import setup_file_object_listeners

    with (
        patch("advanced_alchemy._listeners.event.listen") as mock_listen,
        patch("sqlalchemy.event.contains", return_value=False),
    ):
        setup_file_object_listeners()
        assert mock_listen.called


# --- Utility/Shared Tests ---


def test_touch_updated_timestamp() -> None:
    """Test touch_updated_timestamp updates timestamps on dirty instances."""
    import datetime

    from advanced_alchemy._listeners import touch_updated_timestamp

    session = MagicMock(spec=Session)
    instance = MagicMock()
    session.dirty = [instance]
    session.new = []

    with patch("advanced_alchemy._listeners.inspect") as mock_inspect:
        state = MagicMock()
        state.mapper.class_ = MagicMock()
        state.mapper.class_.updated_at = "exists"
        state.deleted = False

        # Mock updated_at attribute state
        attr_state = MagicMock()
        attr_state.history.added = []  # No manual update
        state.attrs.get.return_value = attr_state

        mock_inspect.return_value = state

        # Mock _has_persistent_column_changes to return True
        with patch("advanced_alchemy._listeners._has_persistent_column_changes", return_value=True):
            touch_updated_timestamp(session)

            # Verify instance.updated_at was set
            assert isinstance(instance.updated_at, datetime.datetime)


def test_has_persistent_column_changes() -> None:
    """Test _has_persistent_column_changes detects column modifications."""
    from advanced_alchemy._listeners import _has_persistent_column_changes

    state = MagicMock()
    mapper = MagicMock()
    attr = MagicMock()
    attr.key = "some_col"
    mapper.column_attrs = [attr]
    state.mapper = mapper

    attr_state = MagicMock()
    attr_state.history.has_changes.return_value = True
    state.attrs.get.return_value = attr_state

    assert _has_persistent_column_changes(state) is True


# --- Deprecation / Context Tests ---


def test_deprecated_context_functions() -> None:
    """Test deprecated async context functions emit warnings."""
    from advanced_alchemy._listeners import is_async_context, reset_async_context, set_async_context

    with pytest.warns(DeprecationWarning):
        set_async_context(True)

    with pytest.warns(DeprecationWarning):
        reset_async_context(None)

    with pytest.warns(DeprecationWarning):
        is_async_context()


def test_handle_multiple_attribute_finalize() -> None:
    """Test handling multiple attribute calls _finalize_pending."""
    from advanced_alchemy._listeners import FileObjectInspector
    from advanced_alchemy.types.mutables import MutableList

    tracker = MagicMock(spec=FileObjectSessionTracker)
    instance = MagicMock()
    attr_name = "files"
    attr_state = MagicMock()

    current_list = MagicMock(spec=MutableList)
    current_list._pending_removed = set()
    current_list._pending_append = []
    setattr(instance, attr_name, current_list)

    FileObjectInspector.handle_multiple_attribute(instance, attr_name, attr_state, tracker)

    assert current_list._finalize_pending.called


def test_handle_multiple_attribute_pending_save_branches() -> None:
    """Test branches in handle_multiple_attribute for pending saves."""
    from advanced_alchemy._listeners import FileObjectInspector
    from advanced_alchemy.types.mutables import MutableList

    tracker = MagicMock(spec=FileObjectSessionTracker)
    instance = MagicMock()
    attr_name = "files"
    attr_state = MagicMock()

    current_list = MagicMock(spec=MutableList)
    current_list._pending_removed = set()

    item1 = MagicMock(spec=FileObject)
    item1._pending_content = None
    item1._pending_source_path = "p1"

    current_list._pending_append = [item1]
    setattr(instance, attr_name, current_list)

    # item2 already in items_to_save (via history)
    item2 = MagicMock(spec=FileObject)
    item2._pending_source_content = b"d2"
    attr_state.history.added = [[item2]]
    attr_state.history.deleted = []

    FileObjectInspector.handle_multiple_attribute(instance, attr_name, attr_state, tracker)

    tracker.add_pending_save.assert_any_call(item1, "p1")
    tracker.add_pending_save.assert_any_call(item2, b"d2")


def test_process_deleted_instance_multiple() -> None:
    """Test process_deleted_instance with multiple items."""
    from advanced_alchemy._listeners import FileObjectInspector
    from advanced_alchemy.types.file_object import StoredObject
    from advanced_alchemy.types.mutables import MutableList

    instance = MagicMock()
    tracker = MagicMock()
    mapper = MagicMock()

    mock_attr = MagicMock()
    mock_attr.expression.type = MagicMock(spec=StoredObject)
    mock_attr.expression.type.multiple = True

    mapper.column_attrs = {"files_col": mock_attr}

    item1 = MagicMock(spec=FileObject)
    item2 = MagicMock(spec=FileObject)

    # Test with regular list
    setattr(instance, "files_col", [item1, item2])
    FileObjectInspector.process_deleted_instance(instance, mapper, tracker)
    tracker.add_pending_delete.assert_any_call(item1)
    tracker.add_pending_delete.assert_any_call(item2)

    # Test with MutableList
    tracker.reset_mock()
    m_list = MutableList[FileObject]([item1, item2])
    setattr(instance, "files_col", m_list)
    FileObjectInspector.process_deleted_instance(instance, mapper, tracker)
    tracker.add_pending_delete.assert_any_call(item1)
    tracker.add_pending_delete.assert_any_call(item2)


def test_is_listener_enabled_extended() -> None:
    """Test _is_listener_enabled with various option sources."""
    from advanced_alchemy._listeners import BaseFileObjectListener

    class TestListener(BaseFileObjectListener):
        pass

    session = MagicMock(spec=Session)
    session.info = {}

    # 1. Disable via session.bind.execution_options (dict)
    session.bind = MagicMock()
    session.bind.execution_options = {"enable_file_object_listener": False}
    assert TestListener._is_listener_enabled(session) is False

    # 2. Disable via session.bind.execution_options (callable)
    session.bind.execution_options = lambda: {"enable_file_object_listener": False}
    assert TestListener._is_listener_enabled(session) is False

    # 3. Disable via session.bind.sync_engine.execution_options
    session.bind.execution_options = None
    session.bind.sync_engine = MagicMock()
    session.bind.sync_engine.execution_options = {"enable_file_object_listener": False}
    assert TestListener._is_listener_enabled(session) is False

    # 4. Disable via session.execution_options (dict)
    session.bind = None
    session.execution_options = {"enable_file_object_listener": False}
    assert TestListener._is_listener_enabled(session) is False

    # 5. Disable via session.execution_options (callable)
    session.execution_options = lambda: {"enable_file_object_listener": False}
    assert TestListener._is_listener_enabled(session) is False

    # 6. Exception in callable execution_options
    def raising_options() -> None:
        raise ValueError("error")

    session.execution_options = raising_options
    # Should fallback to default True and not crash
    assert TestListener._is_listener_enabled(session) is True


def test_file_object_inspector_skips_non_stored_object() -> None:
    """FileObjectInspector skips columns that are not StoredObject type."""
    from advanced_alchemy._listeners import FileObjectInspector

    instance = MagicMock()
    tracker = MagicMock(spec=FileObjectSessionTracker)
    mock_state = MagicMock()
    mock_mapper = MagicMock()
    mock_state.mapper = mock_mapper

    # Non-StoredObject column
    non_stored_attr = MagicMock()
    non_stored_attr.expression.type = MagicMock()  # Not a StoredObject
    mock_mapper.column_attrs = {"name": non_stored_attr}

    with patch("advanced_alchemy._listeners.inspect", return_value=mock_state):
        FileObjectInspector.inspect_instance(instance, tracker)

    # tracker should not have been called since no StoredObject columns
    tracker.add_pending_save.assert_not_called()
    tracker.add_pending_delete.assert_not_called()


def test_handle_single_attribute_pending_source_path() -> None:
    """Test the elif branch for pending_source_path in handle_single_attribute."""
    from advanced_alchemy._listeners import FileObjectInspector

    tracker = MagicMock(spec=FileObjectSessionTracker)
    attr_state = MagicMock()

    mock_file = MagicMock(spec=FileObject)
    mock_file._pending_source_content = None
    mock_file._pending_source_path = "/path/to/source"

    attr_state.history.added = [mock_file]
    attr_state.history.deleted = []

    FileObjectInspector.handle_single_attribute(attr_state, tracker)

    tracker.add_pending_save.assert_called_with(mock_file, "/path/to/source")


def test_before_flush_processes_new_dirty_deleted() -> None:
    """Test before_flush processes new, dirty, and deleted instances."""
    from advanced_alchemy._listeners import BaseFileObjectListener, FileObjectInspector

    class TestListener(BaseFileObjectListener):
        pass

    session = MagicMock(spec=Session)
    session.info = {}
    session.bind = MagicMock()

    new_instance = MagicMock()
    dirty_instance = MagicMock()
    deleted_instance = MagicMock()

    session.new = [new_instance]
    session.dirty = [dirty_instance]
    session.deleted = [deleted_instance]

    # Mock inspect for deleted instance
    deleted_state = MagicMock()
    deleted_mapper = MagicMock()
    deleted_state.mapper = deleted_mapper
    # No StoredObject columns on deleted instance
    non_stored = MagicMock()
    non_stored.expression.type = MagicMock()
    deleted_mapper.column_attrs = {"name": non_stored}

    with (
        patch("advanced_alchemy._listeners.get_file_tracker") as mock_get_tracker,
        patch.object(FileObjectInspector, "inspect_instance") as mock_inspect,
        patch("advanced_alchemy._listeners.inspect", return_value=deleted_state),
    ):
        mock_tracker = MagicMock()
        mock_get_tracker.return_value = mock_tracker

        TestListener.before_flush(session, MagicMock(), None)

    # inspect_instance called for new and dirty
    assert mock_inspect.call_count == 2
    mock_inspect.assert_any_call(new_instance, mock_tracker)
    mock_inspect.assert_any_call(dirty_instance, mock_tracker)


def test_async_file_object_listener_no_tracker_commit() -> None:
    """AsyncFileObjectListener.after_commit returns early when no tracker exists."""
    from advanced_alchemy._listeners import AsyncFileObjectListener

    session = MagicMock(spec=Session)
    session.info = {}

    AsyncFileObjectListener.after_commit(session)
    # No exception, no task created


def test_async_file_object_listener_no_tracker_rollback() -> None:
    """AsyncFileObjectListener.after_rollback returns early when no tracker exists."""
    from advanced_alchemy._listeners import AsyncFileObjectListener

    session = MagicMock(spec=Session)
    session.info = {}

    AsyncFileObjectListener.after_rollback(session)
    # No exception, no task created


def test_sync_file_object_listener_no_tracker_commit() -> None:
    """SyncFileObjectListener.after_commit is a no-op when no tracker exists."""
    from advanced_alchemy._listeners import SyncFileObjectListener

    session = MagicMock(spec=Session)
    session.info = {}

    SyncFileObjectListener.after_commit(session)
    # No exception


def test_sync_file_object_listener_no_tracker_rollback() -> None:
    """SyncFileObjectListener.after_rollback is a no-op when no tracker exists."""
    from advanced_alchemy._listeners import SyncFileObjectListener

    session = MagicMock(spec=Session)
    session.info = {}

    SyncFileObjectListener.after_rollback(session)
    # No exception


def test_touch_updated_timestamp_with_column_changes() -> None:
    """Test touch_updated_timestamp sets timestamp when persistent column changes detected."""
    import datetime

    from advanced_alchemy._listeners import touch_updated_timestamp

    session = MagicMock(spec=Session)
    instance = MagicMock()
    session.dirty = [instance]
    session.new = []

    with patch("advanced_alchemy._listeners.inspect") as mock_inspect:
        state = MagicMock()
        state.mapper.class_ = MagicMock()
        state.mapper.class_.updated_at = "exists"
        state.deleted = False

        # Mock updated_at attribute: no manual updates
        attr_state = MagicMock()
        attr_state.history.added = []
        state.attrs.get.return_value = attr_state

        # Mock a column with real changes
        col_attr = MagicMock()
        col_attr.key = "name"
        col_attr_state = MagicMock()
        col_attr_state.history.has_changes.return_value = True
        state.mapper.column_attrs = [col_attr]
        state.attrs.get.side_effect = lambda k: attr_state if k == "updated_at" else col_attr_state

        mock_inspect.return_value = state

        touch_updated_timestamp(session)

        assert isinstance(instance.updated_at, datetime.datetime)


def test_touch_updated_timestamp_skips_when_no_changes() -> None:
    """Test touch_updated_timestamp does not set timestamp when no column changes."""
    from advanced_alchemy._listeners import touch_updated_timestamp

    session = MagicMock(spec=Session)
    instance = MagicMock()
    initial_value = instance.updated_at
    session.dirty = [instance]
    session.new = []

    with patch("advanced_alchemy._listeners.inspect") as mock_inspect:
        state = MagicMock()
        state.mapper.class_ = MagicMock()
        state.mapper.class_.updated_at = "exists"
        state.deleted = False

        attr_state = MagicMock()
        attr_state.history.added = []
        state.attrs.get.return_value = attr_state

        # No column changes
        col_attr = MagicMock()
        col_attr.key = "name"
        col_attr_state = MagicMock()
        col_attr_state.history.has_changes.return_value = False
        state.mapper.column_attrs = [col_attr]
        state.attrs.get.side_effect = lambda k: attr_state if k == "updated_at" else col_attr_state

        mock_inspect.return_value = state

        touch_updated_timestamp(session)

        # updated_at should not have been explicitly set to a datetime
        # The MagicMock auto-creates attributes, so we check it wasn't set to a datetime
        assert instance.updated_at is initial_value


def test_touch_updated_timestamp_skips_explicit_user_assignment() -> None:
    """Test touch_updated_timestamp respects explicit user assignments."""
    from advanced_alchemy._listeners import touch_updated_timestamp

    session = MagicMock(spec=Session)
    instance = MagicMock()
    session.dirty = [instance]
    session.new = []

    with patch("advanced_alchemy._listeners.inspect") as mock_inspect:
        state = MagicMock()
        state.mapper.class_ = MagicMock()
        state.mapper.class_.updated_at = "exists"
        state.deleted = False

        # updated_at was explicitly set by user
        attr_state = MagicMock()
        attr_state.history.added = ["2025-01-01"]  # User set a value
        state.attrs.get.return_value = attr_state

        mock_inspect.return_value = state

        touch_updated_timestamp(session)

        # Should not overwrite user's value


def test_get_file_tracker_no_create() -> None:
    """Test get_file_tracker with create=False returns None when no tracker exists."""
    from advanced_alchemy._listeners import get_file_tracker

    session = MagicMock(spec=Session)
    session.info = {}

    result = get_file_tracker(session, create=False)
    assert result is None


def test_process_deleted_instance_none_value() -> None:
    """Test process_deleted_instance skips None-valued FileObject attributes."""
    from advanced_alchemy._listeners import FileObjectInspector
    from advanced_alchemy.types.file_object import StoredObject

    instance = MagicMock()
    tracker = MagicMock()
    mapper = MagicMock()

    mock_attr = MagicMock()
    mock_attr.expression.type = MagicMock(spec=StoredObject)
    mock_attr.expression.type.multiple = False

    mapper.column_attrs = {"file_col": mock_attr}

    # Value is None
    setattr(instance, "file_col", None)

    FileObjectInspector.process_deleted_instance(instance, mapper, tracker)

    tracker.add_pending_delete.assert_not_called()


@pytest.mark.asyncio
async def test_async_file_object_listener_error_handling(caplog: pytest.LogCaptureFixture) -> None:
    """Test error handling in AsyncFileObjectListener."""
    import logging

    from advanced_alchemy._listeners import AsyncFileObjectListener, _active_file_operations

    caplog.set_level(logging.DEBUG)
    session = MagicMock(spec=Session)
    tracker = MagicMock()
    tracker.commit_async = AsyncMock(side_effect=ValueError("commit error"))
    tracker.rollback_async = AsyncMock(side_effect=ValueError("rollback error"))
    session.info = {"_aa_file_tracker": tracker}

    # Test commit error
    caplog.clear()
    AsyncFileObjectListener.after_commit(session)
    while _active_file_operations:
        await asyncio.gather(*_active_file_operations)
    assert "An error occurred while committing a file object" in caplog.text

    # Test rollback error
    caplog.clear()
    session.info = {"_aa_file_tracker": tracker}
    AsyncFileObjectListener.after_rollback(session)
    while _active_file_operations:
        await asyncio.gather(*_active_file_operations)
    assert "An error occurred during async FileObject rollback" in caplog.text
python-advanced-alchemy-1.9.3/tests/unit/test_operations.py000066400000000000000000000276411516556515500241750ustar00rootroot00000000000000"""Tests for advanced_alchemy.operations module."""

from typing import Any

import pytest
from sqlalchemy import Column, Integer, MetaData, String, Table

from advanced_alchemy.operations import MergeStatement, OnConflictUpsert, validate_identifier


@pytest.fixture
def sample_table() -> Table:
    """Create a sample table for testing."""
    metadata = MetaData()
    return Table(
        "test_table",
        metadata,
        Column("id", Integer, primary_key=True),
        Column("key", String(50), nullable=False),
        Column("namespace", String(50), nullable=False),
        Column("value", String(255)),
    )


class TestOnConflictUpsert:
    """Test OnConflictUpsert operations."""

    def test_supports_native_upsert(self) -> None:
        """Test dialect support detection."""
        # Supported dialects
        assert OnConflictUpsert.supports_native_upsert("postgresql") is True
        assert OnConflictUpsert.supports_native_upsert("cockroachdb") is True
        assert OnConflictUpsert.supports_native_upsert("sqlite") is True
        assert OnConflictUpsert.supports_native_upsert("mysql") is True
        assert OnConflictUpsert.supports_native_upsert("mariadb") is True
        assert OnConflictUpsert.supports_native_upsert("duckdb") is True

        # Unsupported dialects
        assert OnConflictUpsert.supports_native_upsert("oracle") is False
        assert OnConflictUpsert.supports_native_upsert("mssql") is False
        assert OnConflictUpsert.supports_native_upsert("unknown") is False

    def test_create_postgresql_upsert(self, sample_table: Table) -> None:
        """Test PostgreSQL ON CONFLICT upsert generation."""
        values = {"key": "test_key", "namespace": "test_ns", "value": "test_value"}
        conflict_columns = ["key", "namespace"]

        upsert_stmt = OnConflictUpsert.create_upsert(
            table=sample_table,
            values=values,
            conflict_columns=conflict_columns,
            dialect_name="postgresql",
        )

        # Should return a PostgreSQL insert statement with ON CONFLICT
        # This is primarily testing that the method doesn't raise an exception
        # and returns the expected type
        assert upsert_stmt is not None
        assert hasattr(upsert_stmt, "on_conflict_do_update")

    def test_create_mysql_upsert(self, sample_table: Table) -> None:
        """Test MySQL ON DUPLICATE KEY UPDATE upsert generation."""
        values = {"key": "test_key", "namespace": "test_ns", "value": "test_value"}
        conflict_columns = ["key", "namespace"]

        upsert_stmt = OnConflictUpsert.create_upsert(
            table=sample_table,
            values=values,
            conflict_columns=conflict_columns,
            dialect_name="mysql",
        )

        # Should return a MySQL insert statement with ON DUPLICATE KEY UPDATE
        assert upsert_stmt is not None
        assert hasattr(upsert_stmt, "on_duplicate_key_update")

    def test_create_duckdb_upsert(self, sample_table: Table) -> None:
        """Test DuckDB ON CONFLICT upsert generation."""
        values = {"key": "test_key", "namespace": "test_ns", "value": "test_value"}
        conflict_columns = ["key", "namespace"]

        upsert_stmt = OnConflictUpsert.create_upsert(
            table=sample_table,
            values=values,
            conflict_columns=conflict_columns,
            dialect_name="duckdb",
        )

        # Should return a PostgreSQL-style insert statement with ON CONFLICT (DuckDB uses PostgreSQL syntax)
        assert upsert_stmt is not None
        assert hasattr(upsert_stmt, "on_conflict_do_update")

    def test_create_upsert_unsupported_dialect(self, sample_table: Table) -> None:
        """Test that unsupported dialects raise NotImplementedError."""
        values = {"key": "test_key", "namespace": "test_ns", "value": "test_value"}
        conflict_columns = ["key", "namespace"]

        with pytest.raises(NotImplementedError, match="Native upsert not supported for dialect 'oracle'"):
            OnConflictUpsert.create_upsert(
                table=sample_table,
                values=values,
                conflict_columns=conflict_columns,
                dialect_name="oracle",
            )

    def test_create_merge_upsert(self, sample_table: Table) -> None:
        """Test MERGE-based upsert generation."""
        values = {"key": "test_key", "namespace": "test_ns", "value": "test_value"}
        conflict_columns = ["key", "namespace"]

        # Test default (non-Oracle) MERGE
        merge_stmt, additional_params = OnConflictUpsert.create_merge_upsert(
            table=sample_table,
            values=values,
            conflict_columns=conflict_columns,
        )

        assert isinstance(merge_stmt, MergeStatement)
        assert merge_stmt.table == sample_table
        # Default dialect (None) uses PostgreSQL format with %(key)s notation
        assert "%(key)s" in str(merge_stmt.source) or ":key" in str(
            merge_stmt.source
        )  # Check for parameter placeholder
        assert "FROM DUAL" not in str(merge_stmt.source)  # Should not have FROM DUAL by default
        assert additional_params == {}  # No additional params for non-Oracle

        # Test Oracle-specific MERGE
        oracle_merge_stmt, oracle_additional_params = OnConflictUpsert.create_merge_upsert(
            table=sample_table,
            values=values,
            conflict_columns=conflict_columns,
            dialect_name="oracle",
        )

        assert isinstance(oracle_merge_stmt, MergeStatement)
        assert oracle_merge_stmt.table == sample_table
        assert ":key" in str(oracle_merge_stmt.source)  # Check for parameter placeholder
        assert "FROM DUAL" in str(oracle_merge_stmt.source)  # Oracle should have FROM DUAL
        # Additional params should be empty for tables without UUID primary keys
        assert isinstance(oracle_additional_params, dict)
        assert "SELECT" in str(merge_stmt.source)


class TestMergeStatement:
    """Test MergeStatement compilation."""

    def test_merge_statement_creation(self, sample_table: Table) -> None:
        """Test basic MergeStatement creation."""
        from sqlalchemy import bindparam, text

        source = "SELECT 'key1' as key, 'ns1' as namespace, 'value1' as value"
        on_condition = text("tgt.key = src.key AND tgt.namespace = src.namespace")
        when_matched_update: dict[str, Any] = {"value": bindparam("value")}
        when_not_matched_insert: dict[str, Any] = {
            "key": bindparam("key"),
            "namespace": bindparam("namespace"),
            "value": bindparam("value"),
        }

        merge_stmt = MergeStatement(
            table=sample_table,
            source=source,
            on_condition=on_condition,
            when_matched_update=when_matched_update,
            when_not_matched_insert=when_not_matched_insert,
        )

        assert merge_stmt.table == sample_table
        assert merge_stmt.source == source
        assert merge_stmt.on_condition == on_condition
        assert merge_stmt.when_matched_update == when_matched_update
        assert merge_stmt.when_not_matched_insert == when_not_matched_insert

    def test_compile_merge_default_raises_error(self, sample_table: Table) -> None:
        """Test that default compiler raises NotImplementedError."""
        from sqlalchemy import text

        from advanced_alchemy.operations import compile_merge_default

        merge_stmt = MergeStatement(
            table=sample_table,
            source="SELECT 1",
            on_condition=text("1=1"),
        )

        # Create a mock compiler for an unsupported dialect
        class MockDialect:
            name = "unsupported"

        class MockCompiler:
            dialect = MockDialect()

        compiler = MockCompiler()

        with pytest.raises(NotImplementedError, match="MERGE statement not supported for dialect 'unsupported'"):
            compile_merge_default(merge_stmt, compiler)  # type: ignore[arg-type]  # pyright: ignore


class TestIdentifierValidation:
    """Test identifier validation security feature."""

    def test_valid_identifiers(self) -> None:
        """Test that valid identifiers pass validation."""
        assert validate_identifier("user_id") == "user_id"
        assert validate_identifier("users_table", "table") == "users_table"
        assert validate_identifier("created_at", "column") == "created_at"
        assert validate_identifier("_private_field") == "_private_field"
        assert validate_identifier("table123") == "table123"

    def test_empty_identifier(self) -> None:
        """Test that empty identifiers are rejected."""
        with pytest.raises(ValueError, match="Empty identifier name"):
            validate_identifier("")

    def test_invalid_characters(self) -> None:
        """Test that identifiers with invalid characters are rejected."""
        invalid_names = [
            "user-id",  # hyphen
            "user.id",  # dot
            "user id",  # space
            "123user",  # starts with number
            "user;",  # semicolon
            "user'",  # quote
            "user`",  # backtick
            "drop table users; --",  # SQL injection attempt
        ]

        for name in invalid_names:
            with pytest.raises(ValueError, match=r"Invalid.*Only alphanumeric"):
                validate_identifier(name)

    def test_sql_keywords_allowed(self) -> None:
        """Test that SQL keywords are allowed as identifiers."""
        # SQL keywords should be allowed since they can be quoted in SQL
        keywords = ["select", "SELECT", "insert", "UPDATE", "delete", "DROP", "create", "ALTER", "truncate"]

        for keyword in keywords:
            # Should not raise an error
            assert validate_identifier(keyword) == keyword
            assert validate_identifier(keyword.lower()) == keyword.lower()
            assert validate_identifier(keyword.upper()) == keyword.upper()

    def test_identifier_type_in_error(self) -> None:
        """Test that identifier type appears in error messages."""
        with pytest.raises(ValueError, match="Empty column name"):
            validate_identifier("", "column")

        with pytest.raises(ValueError, match="Invalid table name"):
            validate_identifier("123invalid", "table")

    def test_upsert_with_validation(self, sample_table: Table) -> None:
        """Test that create_upsert validates identifiers when requested."""
        values = {"key": "test_key", "namespace": "test_ns", "value": "test_value"}

        # Should work with validation enabled for valid identifiers
        upsert_stmt = OnConflictUpsert.create_upsert(
            table=sample_table,
            values=values,
            conflict_columns=["key", "namespace"],
            update_columns=["value"],
            dialect_name="postgresql",
            validate_identifiers=True,
        )
        assert upsert_stmt is not None

    def test_merge_with_validation(self, sample_table: Table) -> None:
        """Test that create_merge_upsert validates identifiers when requested."""
        values = {"key": "test_key", "namespace": "test_ns", "value": "test_value"}

        # Should work with validation enabled for valid identifiers
        merge_stmt, _ = OnConflictUpsert.create_merge_upsert(
            table=sample_table,
            values=values,
            conflict_columns=["key", "namespace"],
            update_columns=["value"],
            dialect_name="oracle",
            validate_identifiers=True,
        )
        assert merge_stmt is not None


class TestStoreIntegration:
    """Test that the store can use the new operations."""

    def test_store_imports_operations(self) -> None:
        """Test that store successfully imports new operations."""
        from advanced_alchemy.extensions.litestar.store import SQLAlchemyStore
        from advanced_alchemy.operations import MergeStatement, OnConflictUpsert

        # This test passes if no import errors occur
        assert OnConflictUpsert is not None
        assert MergeStatement is not None
        assert SQLAlchemyStore is not None
python-advanced-alchemy-1.9.3/tests/unit/test_repository.py000066400000000000000000002746541516556515500242410ustar00rootroot00000000000000"""Unit tests for the SQLAlchemy Repository implementation."""

from __future__ import annotations

import datetime
import decimal
from collections.abc import AsyncGenerator, Collection, Generator
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Union, cast
from unittest.mock import AsyncMock, MagicMock, PropertyMock
from uuid import uuid4

import pytest
from msgspec import Struct
from pydantic import BaseModel
from pytest_lazy_fixtures import lf
from sqlalchemy import Integer, String, column
from sqlalchemy.exc import InvalidRequestError, SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import DeclarativeBase, InstrumentedAttribute, Mapped, Session, mapped_column
from sqlalchemy.sql.selectable import ForUpdateArg
from sqlalchemy.types import TypeEngine

from advanced_alchemy import base
from advanced_alchemy.exceptions import IntegrityError, RepositoryError, wrap_sqlalchemy_exception
from advanced_alchemy.filters import (
    BeforeAfter,
    CollectionFilter,
    LimitOffset,
    NotInCollectionFilter,
    OnBeforeAfter,
)
from advanced_alchemy.repository import (
    SQLAlchemyAsyncRepository,
    SQLAlchemySyncRepository,
)
from advanced_alchemy.repository._util import (
    _build_list_cache_key,
    _normalize_cache_key_value,
    column_has_defaults,
    model_from_dict,
)
from advanced_alchemy.service.typing import (
    is_msgspec_struct,
    is_pydantic_model,
    is_schema,
    is_schema_or_dict,
    is_schema_or_dict_with_field,
    is_schema_or_dict_without_field,
    is_schema_with_field,
    is_schema_without_field,
)
from tests.helpers import maybe_async

if TYPE_CHECKING:
    from _pytest.fixtures import FixtureRequest
    from pytest import MonkeyPatch
    from pytest_mock import MockerFixture


AnyMock = Union[MagicMock, AsyncMock]


class UUIDModel(base.UUIDAuditBase):
    """Inheriting from UUIDAuditBase gives the model 'created_at' and 'updated_at'
    columns.
    """


class BigIntModel(base.BigIntAuditBase):
    """Inheriting from BigIntAuditBase gives the model 'created_at' and 'updated_at'
    columns.
    """


@pytest.fixture()
async def async_mock_repo() -> AsyncGenerator[SQLAlchemyAsyncRepository[MagicMock], None]:
    """SQLAlchemy repository with a mock model type."""

    class Repo(SQLAlchemyAsyncRepository[MagicMock]):
        """Repo with mocked out stuff."""

        model_type = MagicMock(__name__="MagicMock")  # pyright:ignore[reportGeneralTypeIssues,reportAssignmentType]

    session = AsyncMock(spec=AsyncSession, bind=MagicMock())
    yield Repo(session=session, statement=MagicMock())


@pytest.fixture()
def sync_mock_repo() -> Generator[SQLAlchemySyncRepository[MagicMock], None, None]:
    """SQLAlchemy repository with a mock model type."""

    class Repo(SQLAlchemySyncRepository[MagicMock]):
        """Repo with mocked out stuff."""

        model_type = MagicMock(__name__="MagicMock")  # pyright:ignore[reportGeneralTypeIssues,reportAssignmentType]

    yield Repo(session=MagicMock(spec=Session, bind=MagicMock()), statement=MagicMock())


@pytest.fixture(params=[lf("sync_mock_repo"), lf("async_mock_repo")])
def mock_repo(request: FixtureRequest) -> Generator[SQLAlchemyAsyncRepository[MagicMock], None, None]:
    yield cast(SQLAlchemyAsyncRepository[Any], request.param)


@pytest.fixture()
def mock_session_scalars(  # pyright: ignore[reportUnknownParameterType]
    mock_repo: SQLAlchemyAsyncRepository[MagicMock], mocker: MockerFixture
) -> Generator[AnyMock, None, None]:
    yield mocker.patch.object(mock_repo.session, "scalars")


@pytest.fixture()
def mock_session_execute(  # pyright: ignore[reportUnknownParameterType]
    mock_repo: SQLAlchemyAsyncRepository[MagicMock], mocker: MockerFixture
) -> Generator[AnyMock, None, None]:
    yield mocker.patch.object(mock_repo.session, "scalars")


@pytest.fixture()
def mock_repo_list(  # pyright: ignore[reportUnknownParameterType]
    mock_repo: SQLAlchemyAsyncRepository[MagicMock], mocker: MockerFixture
) -> Generator[AnyMock, None, None]:
    yield mocker.patch.object(mock_repo, "list")


@pytest.fixture()
def mock_repo_execute(  # pyright: ignore[reportUnknownParameterType]
    mock_repo: SQLAlchemyAsyncRepository[MagicMock], mocker: MockerFixture
) -> Generator[AnyMock, None, None]:
    yield mocker.patch.object(mock_repo, "_execute")


@pytest.fixture()
def mock_repo_attach_to_session(  # pyright: ignore[reportUnknownParameterType]
    mock_repo: SQLAlchemyAsyncRepository[MagicMock], mocker: MockerFixture
) -> Generator[AnyMock, None, None]:
    yield mocker.patch.object(mock_repo, "_attach_to_session")


@pytest.fixture()
def mock_repo_count(  # pyright: ignore[reportUnknownParameterType]
    mock_repo: SQLAlchemyAsyncRepository[MagicMock], mocker: MockerFixture
) -> Generator[AnyMock, None, None]:
    yield mocker.patch.object(mock_repo, "count")


def test_sqlalchemy_tablename() -> None:
    """Test the snake case conversion for table names."""

    class BigModel(base.UUIDAuditBase):
        """Inheriting from UUIDAuditBase gives the model 'created_at' and 'updated_at'
        columns.
        """

    class TESTModel(base.UUIDAuditBase):
        """Inheriting from UUIDAuditBase gives the model 'created_at' and 'updated_at'
        columns.
        """

    class OtherBigIntModel(base.BigIntAuditBase):
        """Inheriting from BigIntAuditBase gives the model 'created_at' and 'updated_at'
        columns.
        """

    assert BigModel.__tablename__ == "big_model"
    assert TESTModel.__tablename__ == "test_model"
    assert OtherBigIntModel.__tablename__ == "other_big_int_model"


def test_sqlalchemy_sentinel(monkeypatch: MonkeyPatch) -> None:
    """Test the sqlalchemy sentinel column only exists on `UUIDPrimaryKey` models."""

    class AnotherModel(base.UUIDAuditBase):
        """Inheriting from UUIDAuditBase gives the model 'created_at' and 'updated_at'
        columns.
        """

        the_extra_col: Mapped[str] = mapped_column(String(length=100), nullable=True)  # pyright: ignore

    class TheTestModel(base.UUIDBase):
        """Inheriting from DeclarativeBase gives the model 'id'  columns."""

        the_extra_col: Mapped[str] = mapped_column(String(length=100), nullable=True)  # pyright: ignore

    class TheBigIntModel(base.BigIntBase):
        """Inheriting from DeclarativeBase gives the model 'id'  columns."""

        the_extra_col: Mapped[str] = mapped_column(String(length=100), nullable=True)  # pyright: ignore

    unloaded_cols = {"the_extra_col"}
    sa_instance_mock = MagicMock(unloaded=unloaded_cols)

    assert isinstance(AnotherModel._sentinel, InstrumentedAttribute)  # pyright: ignore
    assert isinstance(TheTestModel._sentinel, InstrumentedAttribute)  # pyright: ignore
    assert not hasattr(TheBigIntModel, "_sentinel")

    model1, model2, model3 = AnotherModel(), TheTestModel(), TheBigIntModel()
    monkeypatch.setattr(model1, "_sa_instance_state", sa_instance_mock)
    monkeypatch.setattr(model2, "_sa_instance_state", sa_instance_mock)
    monkeypatch.setattr(model3, "_sa_instance_state", sa_instance_mock)

    assert "created_at" not in model1.to_dict(exclude={"created_at"})
    assert "the_extra_col" not in model1.to_dict(exclude={"created_at"})
    assert "sa_orm_sentinel" not in model1.to_dict()
    assert "sa_orm_sentinel" not in model2.to_dict()
    assert "sa_orm_sentinel" not in model3.to_dict()
    assert "_sentinel" not in model1.to_dict()
    assert "_sentinel" not in model2.to_dict()
    assert "_sentinel" not in model3.to_dict()
    assert "the_extra_col" not in model1.to_dict()


def test_wrap_sqlalchemy_integrity_error() -> None:
    """Test to ensure we wrap IntegrityError."""
    with pytest.raises(IntegrityError), wrap_sqlalchemy_exception():
        raise IntegrityError(None, None, Exception())


def test_wrap_sqlalchemy_generic_error() -> None:
    """Test to ensure we wrap generic SQLAlchemy exceptions."""
    with pytest.raises(RepositoryError), wrap_sqlalchemy_exception():
        raise SQLAlchemyError


async def test_sqlalchemy_repo_add(mock_repo: SQLAlchemyAsyncRepository[Any]) -> None:
    """Test expected method calls for add operation."""
    mock_instance = MagicMock()

    instance = await maybe_async(mock_repo.add(mock_instance))

    assert instance is mock_instance
    mock_repo.session.add.assert_called_once_with(mock_instance)  # pyright: ignore[reportFunctionMemberAccess]
    mock_repo.session.flush.assert_called_once()  # pyright: ignore[reportFunctionMemberAccess]
    mock_repo.session.refresh.assert_called_once_with(  # pyright: ignore[reportFunctionMemberAccess]
        instance=mock_instance,
        attribute_names=None,
        with_for_update=None,
    )
    mock_repo.session.expunge.assert_not_called()  # pyright: ignore[reportFunctionMemberAccess]
    mock_repo.session.commit.assert_not_called()  # pyright: ignore[reportFunctionMemberAccess]


async def test_sqlalchemy_repo_add_many(
    mock_repo: SQLAlchemyAsyncRepository[Any],
    monkeypatch: MonkeyPatch,
    mocker: MockerFixture,
    request: FixtureRequest,
) -> None:
    """Test expected method calls for add many operation."""

    mock_instances = [MagicMock(), MagicMock(), MagicMock()]
    monkeypatch.setattr(mock_repo, "model_type", UUIDModel)
    mocker.patch.object(mock_repo.session, "scalars", return_value=mock_instances)

    instances = await maybe_async(mock_repo.add_many(mock_instances))

    assert len(instances) == 3
    for row in instances:
        assert row.id is not None
    mock_repo.session.expunge.assert_not_called()  # pyright: ignore[reportFunctionMemberAccess]
    mock_repo.session.commit.assert_not_called()  # pyright: ignore[reportFunctionMemberAccess]


async def test_sqlalchemy_repo_update_many(
    mock_repo: SQLAlchemyAsyncRepository[Any],
    monkeypatch: MonkeyPatch,
    mocker: MockerFixture,
) -> None:
    """Test expected method calls for update many operation."""

    mock_instances = [MagicMock(), MagicMock(), MagicMock()]
    monkeypatch.setattr(mock_repo, "model_type", UUIDModel)
    mocker.patch.object(mock_repo.session, "scalars", return_value=mock_instances)

    instances = await maybe_async(mock_repo.update_many(mock_instances))

    assert len(instances) == 3
    for row in instances:
        assert row.id is not None

    mock_repo.session.flush.assert_called_once()  # pyright: ignore[reportFunctionMemberAccess]
    mock_repo.session.commit.assert_not_called()  # pyright: ignore[reportFunctionMemberAccess]


async def test_sqlalchemy_repo_upsert_many(
    mock_repo: SQLAlchemyAsyncRepository[Any],
    monkeypatch: MonkeyPatch,
    mocker: MockerFixture,
) -> None:
    """Test expected method calls for update many operation."""

    mock_instances = [MagicMock(), MagicMock(), MagicMock()]
    monkeypatch.setattr(mock_repo, "model_type", UUIDModel)
    mocker.patch.object(mock_repo.session, "scalars", return_value=mock_instances)
    mocker.patch.object(mock_repo, "list", return_value=mock_instances)
    mocker.patch.object(mock_repo, "add_many", return_value=mock_instances)
    mocker.patch.object(mock_repo, "update_many", return_value=mock_instances)

    instances = await maybe_async(mock_repo.upsert_many(mock_instances))

    assert len(instances) == 3
    for row in instances:
        assert row.id is not None

    mock_repo.session.commit.assert_not_called()  # pyright: ignore[reportFunctionMemberAccess]


async def test_sqlalchemy_repo_delete(mock_repo: SQLAlchemyAsyncRepository[Any], mocker: MockerFixture) -> None:
    """Test expected method calls for delete operation."""
    mock_instance = MagicMock()
    mocker.patch.object(mock_repo, "get", return_value=mock_instance)
    instance = await maybe_async(mock_repo.delete("instance-id"))

    assert instance is mock_instance

    mock_repo.session.delete.assert_called_once_with(mock_instance)  # pyright: ignore[reportFunctionMemberAccess]
    mock_repo.session.flush.assert_called_once()  # pyright: ignore[reportFunctionMemberAccess]
    mock_repo.session.expunge.assert_not_called()  # pyright: ignore[reportFunctionMemberAccess]
    mock_repo.session.commit.assert_not_called()  # pyright: ignore[reportFunctionMemberAccess]


async def test_sqlalchemy_repo_delete_many_uuid(
    mock_repo: SQLAlchemyAsyncRepository[Any],
    monkeypatch: MonkeyPatch,
    mock_session_scalars: AnyMock,
    mock_session_execute: AnyMock,
    mock_repo_list: AnyMock,
) -> None:
    """Test expected method calls for delete operation."""

    mock_instances = [MagicMock(), MagicMock(id=uuid4())]
    mock_session_scalars.return_value = mock_instances
    mock_session_execute.return_value = mock_instances
    mock_repo_list.return_value = mock_instances
    monkeypatch.setattr(mock_repo, "model_type", UUIDModel)
    monkeypatch.setattr(mock_repo.session.bind.dialect, "insertmanyvalues_max_parameters", 2)

    added_instances = await maybe_async(mock_repo.add_many(mock_instances))
    instances = await maybe_async(mock_repo.delete_many([obj.id for obj in added_instances]))

    assert len(instances) == len(mock_instances)
    mock_repo.session.flush.assert_called()  # pyright: ignore[reportFunctionMemberAccess]
    mock_repo.session.commit.assert_not_called()  # pyright: ignore[reportFunctionMemberAccess]


async def test_sqlalchemy_repo_delete_many_bigint(
    mock_repo: SQLAlchemyAsyncRepository[Any],
    monkeypatch: MonkeyPatch,
    mock_session_scalars: AnyMock,
    mock_session_execute: AnyMock,
    mock_repo_list: AnyMock,
    testrun_uid: str,
) -> None:
    """Test expected method calls for delete operation."""

    mock_instances = [MagicMock(), MagicMock(id=uuid4())]
    mock_session_scalars.return_value = mock_instances
    mock_session_execute.return_value = mock_instances
    mock_repo_list.return_value = mock_instances
    monkeypatch.setattr(mock_repo, "model_type", BigIntModel)
    monkeypatch.setattr(mock_repo.session.bind.dialect, "insertmanyvalues_max_parameters", 2)

    added_instances = await maybe_async(mock_repo.add_many(mock_instances))
    instances = await maybe_async(mock_repo.delete_many([obj.id for obj in added_instances]))

    assert len(instances) == len(mock_instances)
    mock_repo.session.flush.assert_called()  # pyright: ignore[reportFunctionMemberAccess]
    mock_repo.session.commit.assert_not_called()  # pyright: ignore[reportFunctionMemberAccess]


async def test_sqlalchemy_repo_get_member(
    mock_repo: SQLAlchemyAsyncRepository[Any],
    monkeypatch: MonkeyPatch,
    mock_repo_execute: AnyMock,
) -> None:
    """Test expected method calls for member get operation."""
    mock_instance = MagicMock()
    mock_repo_execute.return_value = MagicMock(scalar_one_or_none=MagicMock(return_value=mock_instance))

    instance = await maybe_async(mock_repo.get("instance-id"))

    assert instance is mock_instance
    mock_repo.session.expunge.assert_not_called()  # pyright: ignore[reportFunctionMemberAccess]
    mock_repo.session.commit.assert_not_called()  # pyright: ignore[reportFunctionMemberAccess]


async def test_sqlalchemy_repo_get_with_for_update(
    mock_repo: SQLAlchemyAsyncRepository[Any],
    mocker: MockerFixture,
) -> None:
    """Ensure FOR UPDATE options are applied when requested."""

    statement = MagicMock()
    statement.options.return_value = statement
    statement.execution_options.return_value = statement
    statement.with_for_update.return_value = statement
    statement.where.return_value = statement  # Required for composite PK path
    mock_repo.statement = statement

    mocker.patch.object(mock_repo, "_get_loader_options", return_value=([], False))
    mocker.patch.object(mock_repo, "_get_base_stmt", return_value=statement)
    mocker.patch.object(mock_repo, "_apply_filters", return_value=statement)
    mocker.patch.object(mock_repo, "_filter_select_by_kwargs", return_value=statement)
    mocker.patch.object(mock_repo, "_build_pk_filter", return_value=MagicMock())  # Mock the PK filter
    execute_result = MagicMock()
    execute_result.scalar_one_or_none.return_value = MagicMock()
    execute = mocker.patch.object(mock_repo, "_execute", return_value=execute_result)

    instance = await maybe_async(mock_repo.get("instance-id", with_for_update=True))

    assert instance is execute_result.scalar_one_or_none.return_value
    statement.with_for_update.assert_called_once_with()
    execute.assert_called_once_with(statement, uniquify=False)


async def test_sqlalchemy_repo_get_with_for_update_dict(
    mock_repo: SQLAlchemyAsyncRepository[Any],
    mocker: MockerFixture,
) -> None:
    statement = MagicMock()
    statement.options.return_value = statement
    statement.execution_options.return_value = statement
    statement.with_for_update.return_value = statement
    statement.where.return_value = statement  # Required for composite PK path
    mock_repo.statement = statement

    mocker.patch.object(mock_repo, "_get_loader_options", return_value=([], False))
    mocker.patch.object(mock_repo, "_get_base_stmt", return_value=statement)
    mocker.patch.object(mock_repo, "_apply_filters", return_value=statement)
    mocker.patch.object(mock_repo, "_filter_select_by_kwargs", return_value=statement)
    mocker.patch.object(mock_repo, "_build_pk_filter", return_value=MagicMock())  # Mock the PK filter
    execute_result = MagicMock()
    execute_result.scalar_one_or_none.return_value = MagicMock()
    mocker.patch.object(mock_repo, "_execute", return_value=execute_result)

    await maybe_async(
        mock_repo.get(
            "instance-id",
            with_for_update={"nowait": True, "read": False},
        )
    )

    statement.with_for_update.assert_called_once_with(nowait=True, read=False)


async def test_sqlalchemy_repo_get_with_for_update_arg(
    mock_repo: SQLAlchemyAsyncRepository[Any],
    mocker: MockerFixture,
) -> None:
    statement = MagicMock()
    statement.options.return_value = statement
    statement.execution_options.return_value = statement
    statement.with_for_update.return_value = statement
    statement.where.return_value = statement  # Required for composite PK path
    mock_repo.statement = statement

    mocker.patch.object(mock_repo, "_get_loader_options", return_value=([], False))
    mocker.patch.object(mock_repo, "_get_base_stmt", return_value=statement)
    mocker.patch.object(mock_repo, "_apply_filters", return_value=statement)
    mocker.patch.object(mock_repo, "_filter_select_by_kwargs", return_value=statement)
    mocker.patch.object(mock_repo, "_build_pk_filter", return_value=MagicMock())  # Mock the PK filter
    execute_result = MagicMock()
    execute_result.scalar_one_or_none.return_value = MagicMock()
    mocker.patch.object(mock_repo, "_execute", return_value=execute_result)

    await maybe_async(
        mock_repo.get(
            "instance-id",
            with_for_update=ForUpdateArg(nowait=True, key_share=True),
        )
    )

    statement.with_for_update.assert_called_once_with(nowait=True, read=False, skip_locked=False, key_share=True)


async def test_sqlalchemy_repo_get_one_member(
    mock_repo: SQLAlchemyAsyncRepository[Any],
    monkeypatch: MonkeyPatch,
    mock_repo_execute: AnyMock,
) -> None:
    """Test expected method calls for member get one operation."""
    mock_instance = MagicMock()
    mock_repo_execute.return_value = MagicMock(scalar_one_or_none=MagicMock(return_value=mock_instance))

    instance = await maybe_async(mock_repo.get_one(id="instance-id"))

    assert instance is mock_instance
    mock_repo.session.expunge.assert_not_called()  # pyright: ignore[reportFunctionMemberAccess]
    mock_repo.session.commit.assert_not_called()  # pyright: ignore[reportFunctionMemberAccess]


async def test_sqlalchemy_repo_get_or_upsert_member_existing(
    mock_repo: SQLAlchemyAsyncRepository[Any],
    monkeypatch: MonkeyPatch,
    mock_repo_execute: AnyMock,
    mock_repo_attach_to_session: AnyMock,
) -> None:
    """Test expected method calls for member get or create operation (existing)."""
    mock_instance = MagicMock()
    mock_repo_execute.return_value = MagicMock(scalar_one_or_none=MagicMock(return_value=mock_instance))
    mock_repo_attach_to_session.return_value = mock_instance

    instance, created = await maybe_async(mock_repo.get_or_upsert(id="instance-id", upsert=False))

    assert instance is mock_instance
    assert created is False
    mock_repo.session.expunge.assert_not_called()  # pyright: ignore[reportFunctionMemberAccess]
    mock_repo.session.merge.assert_not_called()  # pyright: ignore[reportFunctionMemberAccess]
    mock_repo.session.refresh.assert_not_called()  # pyright: ignore[reportFunctionMemberAccess]


async def test_sqlalchemy_repo_get_or_upsert_member_existing_upsert(
    mock_repo: SQLAlchemyAsyncRepository[Any],
    monkeypatch: MonkeyPatch,
    mock_repo_execute: AnyMock,
    mock_repo_attach_to_session: AnyMock,
) -> None:
    """Test expected method calls for member get or create operation (existing)."""
    mock_instance = MagicMock()
    mock_repo_execute.return_value = MagicMock(scalar_one_or_none=MagicMock(return_value=mock_instance))
    mock_repo_attach_to_session.return_value = mock_instance

    instance, created = await maybe_async(
        mock_repo.get_or_upsert(id="instance-id", upsert=True, an_extra_attribute="yep"),
    )

    assert instance is mock_instance
    assert created is False
    mock_repo.session.expunge.assert_not_called()  # pyright: ignore[reportFunctionMemberAccess]
    mock_repo._attach_to_session.assert_called_once()  # pyright: ignore[reportFunctionMemberAccess,reportPrivateUsage]
    mock_repo.session.flush.assert_called_once()  # pyright: ignore[reportFunctionMemberAccess]
    mock_repo.session.refresh.assert_called_once_with(  # pyright: ignore[reportFunctionMemberAccess]
        instance=mock_instance,
        attribute_names=None,
        with_for_update=None,
    )


async def test_sqlalchemy_repo_get_or_upsert_member_existing_no_upsert(
    mock_repo: SQLAlchemyAsyncRepository[Any],
    monkeypatch: MonkeyPatch,
    mock_repo_execute: AnyMock,
) -> None:
    """Test expected method calls for member get or create operation (existing)."""
    mock_instance = MagicMock()
    mock_repo_execute.return_value = MagicMock(scalar_one_or_none=MagicMock(return_value=mock_instance))

    instance, created = await maybe_async(
        mock_repo.get_or_upsert(id="instance-id", upsert=False, an_extra_attribute="yep"),
    )

    assert instance is mock_instance
    assert created is False
    mock_repo.session.expunge.assert_not_called()  # pyright: ignore[reportFunctionMemberAccess]
    mock_repo.session.add.assert_not_called()  # pyright: ignore[reportFunctionMemberAccess]
    mock_repo.session.refresh.assert_not_called()  # pyright: ignore[reportFunctionMemberAccess]


async def test_sqlalchemy_repo_get_or_upsert_member_created(
    mock_repo: SQLAlchemyAsyncRepository[Any],
    monkeypatch: MonkeyPatch,
    mock_repo_execute: AnyMock,
) -> None:
    """Test expected method calls for member get or create operation (created)."""
    mock_repo_execute.return_value = MagicMock(scalar_one_or_none=MagicMock(return_value=None))

    instance, created = await maybe_async(mock_repo.get_or_upsert(id="new-id"))

    assert instance is not None
    assert created is True
    mock_repo.session.expunge.assert_not_called()  # pyright: ignore[reportFunctionMemberAccess]
    mock_repo.session.add.assert_called_once_with(instance)  # pyright: ignore[reportFunctionMemberAccess]
    mock_repo.session.refresh.assert_called_once_with(instance=instance, attribute_names=None, with_for_update=None)  # pyright: ignore[reportFunctionMemberAccess]


async def test_sqlalchemy_repo_get_one_or_none_member(
    mock_repo: SQLAlchemyAsyncRepository[Any],
    monkeypatch: MonkeyPatch,
    mock_repo_execute: AnyMock,
) -> None:
    """Test expected method calls for member get one or none operation (found)."""
    mock_instance = MagicMock()
    mock_repo_execute.return_value = MagicMock(scalar_one_or_none=MagicMock(return_value=mock_instance))

    instance = await maybe_async(mock_repo.get_one_or_none(id="instance-id"))

    assert instance is mock_instance
    mock_repo.session.expunge.assert_not_called()  # pyright: ignore[reportFunctionMemberAccess]
    mock_repo.session.commit.assert_not_called()  # pyright: ignore[reportFunctionMemberAccess]


async def test_sqlalchemy_repo_get_one_or_none_not_found(
    mock_repo: SQLAlchemyAsyncRepository[Any],
    monkeypatch: MonkeyPatch,
    mock_repo_execute: AnyMock,
) -> None:
    """Test expected method calls for member get one or none operation (Not found)."""

    mock_repo_execute.return_value = MagicMock(scalar_one_or_none=MagicMock(return_value=None))

    instance = await maybe_async(mock_repo.get_one_or_none(id="instance-id"))

    assert instance is None
    mock_repo.session.expunge.assert_not_called()  # pyright: ignore[reportFunctionMemberAccess]
    mock_repo.session.commit.assert_not_called()  # pyright: ignore[reportFunctionMemberAccess]


async def test_sqlalchemy_repo_list(
    mock_repo: SQLAlchemyAsyncRepository[Any],
    monkeypatch: MonkeyPatch,
    mock_repo_execute: AnyMock,
) -> None:
    """Test expected method calls for list operation."""
    mock_instances = [MagicMock(), MagicMock()]
    mock_repo_execute.return_value = MagicMock(scalars=MagicMock(return_value=mock_instances))

    instances = await maybe_async(mock_repo.list())

    assert instances == mock_instances
    mock_repo.session.expunge.assert_not_called()  # pyright: ignore[reportFunctionMemberAccess]
    mock_repo.session.commit.assert_not_called()  # pyright: ignore[reportFunctionMemberAccess]


async def test_sqlalchemy_repo_list_and_count(mock_repo: SQLAlchemyAsyncRepository[Any], mocker: MockerFixture) -> None:
    """Test expected method calls for list operation."""
    mock_instances = [MagicMock(), MagicMock()]
    mock_count = len(mock_instances)
    mocker.patch.object(mock_repo, "_list_and_count_window", return_value=(mock_instances, mock_count))

    instances, instance_count = await maybe_async(mock_repo.list_and_count())

    assert instances == mock_instances
    assert instance_count == mock_count
    mock_repo.session.expunge.assert_not_called()  # pyright: ignore[reportFunctionMemberAccess]
    mock_repo.session.commit.assert_not_called()  # pyright: ignore[reportFunctionMemberAccess]


async def test_sqlalchemy_repo_list_and_count_basic(
    mock_repo: SQLAlchemyAsyncRepository[Any],
    mocker: MockerFixture,
) -> None:
    """Test expected method calls for list operation."""
    mock_instances = [MagicMock(), MagicMock()]
    mock_count = len(mock_instances)
    mocker.patch.object(mock_repo, "_list_and_count_basic", return_value=(mock_instances, mock_count))

    instances, instance_count = await maybe_async(mock_repo.list_and_count(count_with_window_function=False))

    assert instances == mock_instances
    assert instance_count == mock_count
    mock_repo.session.expunge.assert_not_called()  # pyright: ignore[reportFunctionMemberAccess]
    mock_repo.session.commit.assert_not_called()  # pyright: ignore[reportFunctionMemberAccess]


async def test_sqlalchemy_repo_exists(
    mock_repo: SQLAlchemyAsyncRepository[Any],
    monkeypatch: MonkeyPatch,
    mock_repo_execute: AnyMock,
    mock_repo_count: AnyMock,
) -> None:
    """Test expected method calls for exists operation."""
    mock_repo_count.return_value = 1

    exists = await maybe_async(mock_repo.exists(id="my-id"))

    assert exists
    mock_repo.session.commit.assert_not_called()  # pyright: ignore[reportFunctionMemberAccess]


async def test_sqlalchemy_repo_exists_with_filter(
    mock_repo: SQLAlchemyAsyncRepository[Any],
    monkeypatch: MonkeyPatch,
    mock_repo_execute: AnyMock,
    mock_repo_count: AnyMock,
) -> None:
    """Test expected method calls for exists operation. with filter argument"""
    limit_filter = LimitOffset(limit=1, offset=0)
    mock_repo_count.return_value = 1

    exists = await maybe_async(mock_repo.exists(limit_filter, id="my-id"))

    assert exists
    mock_repo.session.commit.assert_not_called()  # pyright: ignore[reportFunctionMemberAccess]


async def test_sqlalchemy_repo_count(
    mock_repo: SQLAlchemyAsyncRepository[Any],
    monkeypatch: MonkeyPatch,
    mock_repo_execute: AnyMock,
    mock_repo_count: AnyMock,
) -> None:
    """Test expected method calls for list operation."""
    mock_repo_count.return_value = 1

    count = await maybe_async(mock_repo.count())

    assert count == 1
    mock_repo.session.commit.assert_not_called()  # pyright: ignore[reportFunctionMemberAccess]


async def test_sqlalchemy_repo_list_with_pagination(
    mock_repo: SQLAlchemyAsyncRepository[Any],
    monkeypatch: MonkeyPatch,
    mock_repo_execute: AnyMock,
    mocker: MockerFixture,
) -> None:
    """Test list operation with pagination."""
    statement = MagicMock()
    mock_repo_execute.return_value = MagicMock()
    mocker.patch.object(LimitOffset, "append_to_statement", return_value=statement)
    mock_repo_execute.return_value = MagicMock()
    await maybe_async(mock_repo.list(LimitOffset(2, 3)))
    mock_repo._execute.assert_called_with(statement, uniquify=False)  # pyright: ignore[reportFunctionMemberAccess,reportPrivateUsage]


async def test_sqlalchemy_repo_list_with_before_after_filter(
    mock_repo: SQLAlchemyAsyncRepository[Any],
    mock_repo_execute: AnyMock,
    mocker: MockerFixture,
) -> None:
    """Test list operation with BeforeAfter filter."""
    statement = MagicMock()
    mocker.patch.object(mock_repo.model_type.updated_at, "__lt__", return_value="lt")
    mocker.patch.object(mock_repo.model_type.updated_at, "__gt__", return_value="gt")
    mocker.patch.object(BeforeAfter, "append_to_statement", return_value=statement)
    mock_repo_execute.return_value = MagicMock()
    await maybe_async(mock_repo.list(BeforeAfter("updated_at", datetime.datetime.max, datetime.datetime.min)))
    mock_repo._execute.assert_called_with(statement, uniquify=False)  # pyright: ignore[reportFunctionMemberAccess,reportPrivateUsage]


async def test_sqlalchemy_repo_list_with_on_before_after_filter(
    mock_repo: SQLAlchemyAsyncRepository[Any],
    monkeypatch: MonkeyPatch,
    mock_repo_execute: AnyMock,
    mocker: MockerFixture,
) -> None:
    """Test list operation with BeforeAfter filter."""
    statement = MagicMock()
    mocker.patch.object(mock_repo.model_type.updated_at, "__le__", return_value="le")
    mocker.patch.object(mock_repo.model_type.updated_at, "__ge__", return_value="ge")
    mocker.patch.object(OnBeforeAfter, "append_to_statement", return_value=statement)

    mock_repo_execute.return_value = MagicMock()
    await maybe_async(mock_repo.list(OnBeforeAfter("updated_at", datetime.datetime.max, datetime.datetime.min)))
    mock_repo._execute.assert_called_with(statement, uniquify=False)  # pyright: ignore[reportFunctionMemberAccess,reportPrivateUsage]


async def test_sqlalchemy_repo_list_with_collection_filter(
    mock_repo: SQLAlchemyAsyncRepository[Any],
    monkeypatch: MonkeyPatch,
    mock_repo_execute: AnyMock,
    mocker: MockerFixture,
) -> None:
    """Test behavior of list operation given CollectionFilter."""
    field_name = "id"
    mock_repo_execute.return_value = MagicMock()
    mock_repo.statement.where.return_value = mock_repo.statement  # pyright: ignore[reportFunctionMemberAccess]
    mocker.patch.object(CollectionFilter, "append_to_statement", return_value=mock_repo.statement)
    values = [1, 2, 3]
    await maybe_async(mock_repo.list(CollectionFilter(field_name, values)))
    mock_repo._execute.assert_called_with(mock_repo.statement, uniquify=False)  # pyright: ignore[reportFunctionMemberAccess,reportPrivateUsage]


async def test_sqlalchemy_repo_list_with_null_collection_filter(
    mock_repo: SQLAlchemyAsyncRepository[Any],
    monkeypatch: MonkeyPatch,
    mock_repo_execute: AnyMock,
    mocker: MockerFixture,
) -> None:
    """Test behavior of list operation given CollectionFilter."""
    field_name = "id"
    mock_repo_execute.return_value = MagicMock()
    mock_repo.statement.where.return_value = mock_repo.statement  # pyright: ignore[reportFunctionMemberAccess]
    monkeypatch.setattr(
        CollectionFilter,
        "append_to_statement",
        MagicMock(return_value=mock_repo.statement),
    )
    await maybe_async(mock_repo.list(CollectionFilter(field_name, None)))  # pyright: ignore[reportFunctionMemberAccess,reportUnknownArgumentType]
    mock_repo._execute.assert_called_with(mock_repo.statement, uniquify=False)  # pyright: ignore[reportFunctionMemberAccess,reportPrivateUsage]


async def test_sqlalchemy_repo_empty_list_with_collection_filter(
    mock_repo: SQLAlchemyAsyncRepository[Any],
    monkeypatch: MonkeyPatch,
    mock_repo_execute: AnyMock,
    mocker: MockerFixture,
) -> None:
    """Test behavior of list operation given CollectionFilter."""
    field_name = "id"
    mock_repo_execute.return_value = MagicMock()
    mock_repo.statement.where.return_value = mock_repo.statement  # pyright: ignore[reportFunctionMemberAccess]
    values: Collection[Any] = []
    await maybe_async(mock_repo.list(CollectionFilter(field_name, values)))
    monkeypatch.setattr(
        CollectionFilter,
        "append_to_statement",
        MagicMock(return_value=mock_repo.statement),
    )
    await maybe_async(mock_repo.list(CollectionFilter(field_name, values)))
    mock_repo._execute.assert_called_with(mock_repo.statement, uniquify=False)  # pyright: ignore[reportFunctionMemberAccess,reportPrivateUsage]


async def test_sqlalchemy_repo_list_with_not_in_collection_filter(
    mock_repo: SQLAlchemyAsyncRepository[Any],
    monkeypatch: MonkeyPatch,
    mock_repo_execute: AnyMock,
    mocker: MockerFixture,
) -> None:
    """Test behavior of list operation given CollectionFilter."""
    field_name = "id"
    mock_repo_execute.return_value = MagicMock()
    mock_repo.statement.where.return_value = mock_repo.statement  # pyright: ignore[reportFunctionMemberAccess]
    monkeypatch.setattr(
        NotInCollectionFilter,
        "append_to_statement",
        MagicMock(return_value=mock_repo.statement),
    )
    values = [1, 2, 3]
    await maybe_async(mock_repo.list(NotInCollectionFilter(field_name, values)))
    mock_repo._execute.assert_called_with(mock_repo.statement, uniquify=False)  # pyright: ignore[reportFunctionMemberAccess,reportPrivateUsage]


async def test_sqlalchemy_repo_list_with_null_not_in_collection_filter(
    mock_repo: SQLAlchemyAsyncRepository[Any],
    monkeypatch: MonkeyPatch,
    mock_repo_execute: AnyMock,
    mocker: MockerFixture,
) -> None:
    """Test behavior of list operation given CollectionFilter."""
    field_name = "id"
    mock_repo_execute.return_value = MagicMock()
    mock_repo.statement.where.return_value = mock_repo.statement  # pyright: ignore[reportFunctionMemberAccess]
    monkeypatch.setattr(
        NotInCollectionFilter,
        "append_to_statement",
        MagicMock(return_value=mock_repo.statement),
    )
    await maybe_async(mock_repo.list(NotInCollectionFilter[str](field_name, None)))  # pyright: ignore[reportFunctionMemberAccess]
    mock_repo._execute.assert_called_with(mock_repo.statement, uniquify=False)  # pyright: ignore[reportFunctionMemberAccess,reportPrivateUsage]


async def test_sqlalchemy_repo_unknown_filter_type_raises(mock_repo: SQLAlchemyAsyncRepository[Any]) -> None:
    """Test that repo raises exception if list receives unknown filter type."""
    with pytest.raises(RepositoryError):
        await maybe_async(mock_repo.list("not a filter"))  # type: ignore


async def test_sqlalchemy_repo_update(
    mock_repo: SQLAlchemyAsyncRepository[Any],
    monkeypatch: MonkeyPatch,
    mocker: MockerFixture,
) -> None:
    """Test the sequence of repo calls for update operation."""
    id_ = 3
    mock_instance = MagicMock()
    existing_instance = MagicMock()
    mocker.patch.object(mock_repo, "get_id_attribute_value", return_value=id_)
    mocker.patch.object(mock_repo, "get", return_value=existing_instance)
    mock_repo.session.merge.return_value = existing_instance  # pyright: ignore[reportFunctionMemberAccess]

    instance = await maybe_async(mock_repo.update(mock_instance))

    assert instance is existing_instance
    mock_repo.session.merge.assert_called_once_with(existing_instance, load=True)  # pyright: ignore[reportFunctionMemberAccess]
    mock_repo.session.flush.assert_called_once()  # pyright: ignore[reportFunctionMemberAccess]
    mock_repo.session.expunge.assert_not_called()  # pyright: ignore[reportFunctionMemberAccess]
    mock_repo.session.commit.assert_not_called()  # pyright: ignore[reportFunctionMemberAccess]
    mock_repo.session.refresh.assert_called_once_with(  # pyright: ignore[reportFunctionMemberAccess]
        instance=existing_instance,
        attribute_names=None,
        with_for_update=None,
    )


async def test_sqlalchemy_repo_upsert(mock_repo: SQLAlchemyAsyncRepository[Any], mocker: MockerFixture) -> None:
    """Test the sequence of repo calls for upsert operation."""
    mock_instance = MagicMock()
    mock_repo.session.merge.return_value = mock_instance  # pyright: ignore[reportFunctionMemberAccess]

    instance = await maybe_async(mock_repo.upsert(mock_instance))
    mocker.patch.object(mock_repo, "exists", return_value=True)
    mocker.patch.object(mock_repo, "count", return_value=1)

    assert instance is mock_instance
    mock_repo.session.flush.assert_called_once()  # pyright: ignore[reportFunctionMemberAccess]
    mock_repo.session.expunge.assert_not_called()  # pyright: ignore[reportFunctionMemberAccess]
    mock_repo.session.commit.assert_not_called()  # pyright: ignore[reportFunctionMemberAccess]
    mock_repo.session.refresh.assert_called_once_with(  # pyright: ignore[reportFunctionMemberAccess]
        instance=mock_instance,
        attribute_names=None,
        with_for_update=None,
    )


async def test_attach_to_session_unexpected_strategy_raises_valueerror(
    mock_repo: SQLAlchemyAsyncRepository[Any],
) -> None:
    """Test to hit the error condition in SQLAlchemy._attach_to_session()."""
    with pytest.raises(ValueError):
        await maybe_async(mock_repo._attach_to_session(MagicMock(), strategy="t-rex"))  # type:ignore[arg-type]


async def test_execute(mock_repo: SQLAlchemyAsyncRepository[Any]) -> None:
    """Simple test of the abstraction over `AsyncSession.execute()`"""
    _ = await maybe_async(mock_repo._execute(mock_repo.statement))  # pyright: ignore[reportFunctionMemberAccess,reportPrivateUsage]
    mock_repo.session.execute.assert_called_once_with(mock_repo.statement)  # pyright: ignore[reportFunctionMemberAccess]


async def test_filter_in_collection_noop_if_collection_empty(mock_repo: SQLAlchemyAsyncRepository[Any]) -> None:
    """Ensures we don't filter on an empty collection."""
    statement = MagicMock()
    filter = CollectionFilter(field_name="id", values=[])  # type:ignore[var-annotated]
    statement = filter.append_to_statement(statement, MagicMock())  # type:ignore[assignment]
    mock_repo.statement.where.assert_not_called()  # pyright: ignore[reportFunctionMemberAccess]


@pytest.mark.parametrize(
    ("before", "after"),
    [
        (datetime.datetime.max, datetime.datetime.min),
        (None, datetime.datetime.min),
        (datetime.datetime.max, None),
    ],
)
async def test_filter_on_datetime_field(
    before: datetime.datetime,
    after: datetime.datetime,
    mock_repo: SQLAlchemyAsyncRepository[Any],
    mocker: MockerFixture,
    monkeypatch: MonkeyPatch,
) -> None:
    """Test through branches of _filter_on_datetime_field()"""
    field_mock = MagicMock(return_value=before or after)
    statement = MagicMock()
    field_mock.__gt__ = field_mock.__lt__ = lambda self, other: True  # pyright: ignore[reportFunctionMemberAccess,reportUnknownLambdaType]
    monkeypatch.setattr(
        BeforeAfter,
        "append_to_statement",
        MagicMock(return_value=mock_repo.statement),
    )
    filter = BeforeAfter(field_name="updated_at", before=before, after=after)
    statement = filter.append_to_statement(statement, MagicMock(return_value=before or after))  # type:ignore[assignment]
    mock_repo.model_type.updated_at = field_mock
    mock_repo.statement.where.assert_not_called()  # pyright: ignore[reportFunctionMemberAccess]


# Type compatibility test fixtures and classes
class MockComplexType:
    """Mock complex type that would have DBAPI serialization issues."""

    def __init__(self, value: Any):
        self.value = value


class MockPostgreSQLRange:
    """Mock PostgreSQL Range type."""

    def __init__(self, lower: Any, upper: Any):
        self.lower = lower
        self.upper = upper


class MockTypeWithoutPythonType(TypeEngine[Any]):
    """Mock SQLAlchemy type that doesn't implement python_type."""

    def __init__(self) -> None:
        super().__init__()

    @property
    def python_type(self) -> type[Any]:
        raise NotImplementedError("This type doesn't have a python_type")


async def test_type_must_use_in_empty_list(mock_repo: SQLAlchemyAsyncRepository[Any]) -> None:
    """Test that empty list returns False."""
    result = mock_repo._type_must_use_in_instead_of_any([])
    assert result is False


async def test_type_must_use_in_standard_python_types(mock_repo: SQLAlchemyAsyncRepository[Any]) -> None:
    """Test that standard Python types can use ANY() operator."""
    # Test integers
    result = mock_repo._type_must_use_in_instead_of_any([1, 2, 3])
    assert result is False

    # Test strings
    result = mock_repo._type_must_use_in_instead_of_any(["a", "b", "c"])
    assert result is False

    # Test booleans
    result = mock_repo._type_must_use_in_instead_of_any([True, False])
    assert result is False

    # Test floats
    result = mock_repo._type_must_use_in_instead_of_any([1.1, 2.2])
    assert result is False

    # Test bytes
    result = mock_repo._type_must_use_in_instead_of_any([b"test", b"data"])
    assert result is False


async def test_type_must_use_in_safe_datetime_decimal_types(mock_repo: SQLAlchemyAsyncRepository[Any]) -> None:
    """Test that datetime and decimal types can use ANY() operator."""
    # Test datetime.date
    result = mock_repo._type_must_use_in_instead_of_any([datetime.date(2024, 1, 1)])
    assert result is False

    # Test datetime.datetime
    result = mock_repo._type_must_use_in_instead_of_any([datetime.datetime.now()])
    assert result is False

    # Test datetime.time
    result = mock_repo._type_must_use_in_instead_of_any([datetime.time(12, 30)])
    assert result is False

    # Test datetime.timedelta
    result = mock_repo._type_must_use_in_instead_of_any([datetime.timedelta(days=1)])
    assert result is False

    # Test decimal.Decimal
    result = mock_repo._type_must_use_in_instead_of_any([decimal.Decimal("10.5")])
    assert result is False


async def test_type_must_use_in_complex_types(mock_repo: SQLAlchemyAsyncRepository[Any]) -> None:
    """Test that complex types must use IN() operator."""
    # Test mock PostgreSQL Range
    ranges = [MockPostgreSQLRange(1, 10), MockPostgreSQLRange(20, 30)]
    result = mock_repo._type_must_use_in_instead_of_any(ranges)
    assert result is True

    # Test custom complex type
    complex_types = [MockComplexType("test")]
    result = mock_repo._type_must_use_in_instead_of_any(complex_types)
    assert result is True


async def test_type_must_use_in_mixed_types_with_complex(mock_repo: SQLAlchemyAsyncRepository[Any]) -> None:
    """Test that mixed types containing complex types use IN() operator."""
    mixed_values = [1, "test", MockComplexType("complex")]
    result = mock_repo._type_must_use_in_instead_of_any(mixed_values)
    assert result is True


async def test_type_must_use_in_none_values_ignored(mock_repo: SQLAlchemyAsyncRepository[Any]) -> None:
    """Test that None values are properly ignored."""
    values_with_none = [1, None, 3]
    result = mock_repo._type_must_use_in_instead_of_any(values_with_none)
    assert result is False

    # Test only None values
    only_none = [None, None]
    result = mock_repo._type_must_use_in_instead_of_any(only_none)
    assert result is False


async def test_type_must_use_in_sqlalchemy_type_matching(mock_repo: SQLAlchemyAsyncRepository[Any]) -> None:
    """Test SQLAlchemy type introspection with matching types."""
    # Test Integer type with integer values
    int_type = Integer()
    result = mock_repo._type_must_use_in_instead_of_any([1, 2, 3], int_type)
    assert result is False

    # Test String type with string values
    str_type = String()
    result = mock_repo._type_must_use_in_instead_of_any(["a", "b"], str_type)
    assert result is False


async def test_type_must_use_in_sqlalchemy_type_mismatched(mock_repo: SQLAlchemyAsyncRepository[Any]) -> None:
    """Test SQLAlchemy type introspection with mismatched types."""
    # Test Integer type with string values (mismatch)
    int_type = Integer()
    result = mock_repo._type_must_use_in_instead_of_any(["not_an_int"], int_type)
    assert result is True

    # Test String type with integer values (mismatch)
    str_type = String()
    result = mock_repo._type_must_use_in_instead_of_any([123], str_type)
    assert result is True


async def test_type_must_use_in_sqlalchemy_type_without_python_type(mock_repo: SQLAlchemyAsyncRepository[Any]) -> None:
    """Test SQLAlchemy type that doesn't implement python_type."""
    mock_type: MockTypeWithoutPythonType = MockTypeWithoutPythonType()
    result = mock_repo._type_must_use_in_instead_of_any([1, 2, 3], mock_type)
    assert result is True  # Should use IN() for safety


async def test_type_must_use_in_sqlalchemy_type_with_none_python_type(
    mock_repo: SQLAlchemyAsyncRepository[Any],
) -> None:
    """Test SQLAlchemy type that has None as python_type."""
    mock_type = MagicMock()
    mock_type.python_type = None

    # Should fall back to Python type checking
    result = mock_repo._type_must_use_in_instead_of_any([1, 2, 3], mock_type)
    assert result is False  # Standard integers should use ANY()

    result = mock_repo._type_must_use_in_instead_of_any([MockComplexType("test")], mock_type)
    assert result is True  # Complex types should use IN()


async def test_type_must_use_in_missing_python_type_attribute(mock_repo: SQLAlchemyAsyncRepository[Any]) -> None:
    """Test fallback when python_type attribute is missing from type."""
    # Create a mock that doesn't have python_type attribute at all
    mock_type = type("MockType", (), {})()  # Empty object with no attributes

    result = mock_repo._type_must_use_in_instead_of_any([1, 2, 3], mock_type)
    assert result is False  # Should fall back to Python type checking for safe types


class MyModel(BaseModel):
    name: str
    age: int


class MyStruct(Struct):
    name: str
    age: int


def test_is_pydantic_model() -> None:
    pydantic_model = MyModel(name="Pydantic John", age=30)
    msgspec_struct = MyStruct(name="Msgspec Joe", age=30)
    old_dict = {"name": "Old Greg", "age": 30}
    int_value = 1

    assert is_pydantic_model(pydantic_model)
    assert not is_pydantic_model(msgspec_struct)
    assert not is_pydantic_model(old_dict)
    assert not is_pydantic_model(int_value)


def test_is_msgspec_struct() -> None:
    pydantic_model = MyModel(name="Pydantic John", age=30)
    msgspec_struct = MyStruct(name="Msgspec Joe", age=30)
    old_dict = {"name": "Old Greg", "age": 30}

    assert not is_msgspec_struct(pydantic_model)
    assert is_msgspec_struct(msgspec_struct)
    assert not is_msgspec_struct(old_dict)


def test_is_schema() -> None:
    pydantic_model = MyModel(name="Pydantic John", age=30)
    msgspec_struct = MyStruct(name="Msgspec Joe", age=30)
    old_dict = {"name": "Old Greg", "age": 30}
    int_value = 1
    assert is_schema(pydantic_model)
    assert is_schema(msgspec_struct)
    assert not is_schema(old_dict)
    assert not is_schema(int_value)
    assert is_schema_with_field(pydantic_model, "name")
    assert not is_schema_with_field(msgspec_struct, "name2")
    assert is_schema_without_field(pydantic_model, "name2")
    assert not is_schema_without_field(msgspec_struct, "name")


def test_is_schema_or_dict() -> None:
    pydantic_model = MyModel(name="Pydantic John", age=30)
    msgspec_struct = MyStruct(name="Msgspec Joe", age=30)
    old_dict = {"name": "Old Greg", "age": 30}
    int_value = 1
    assert is_schema_or_dict(pydantic_model)
    assert is_schema_or_dict(msgspec_struct)
    assert is_schema_or_dict(old_dict)
    assert not is_schema_or_dict(int_value)
    assert is_schema_or_dict_with_field(pydantic_model, "name")
    assert not is_schema_or_dict_with_field(msgspec_struct, "name2")
    assert is_schema_or_dict_without_field(pydantic_model, "name2")
    assert not is_schema_or_dict_without_field(msgspec_struct, "name")


# Tests for new methods added in id-attribute-update branch


def test_async_type_must_use_in_empty_values(mock_repo: SQLAlchemyAsyncRepository[Any]) -> None:
    """Test that empty values return False."""
    assert mock_repo._type_must_use_in_instead_of_any([]) is False


def test_sync_type_must_use_in_empty_values(sync_mock_repo: SQLAlchemySyncRepository[Any]) -> None:
    """Test that empty values return False."""
    assert sync_mock_repo._type_must_use_in_instead_of_any([]) is False


def test_async_safe_types_with_field_type(mock_repo: SQLAlchemyAsyncRepository[Any]) -> None:
    """Test safe types with valid field type."""
    # Mock field type with python_type
    mock_field_type = MagicMock()
    mock_field_type.python_type = str

    values = ["test", "another_string"]
    result = mock_repo._type_must_use_in_instead_of_any(values, mock_field_type)
    assert result is False


def test_sync_type_mismatch_with_field_type(sync_mock_repo: SQLAlchemySyncRepository[Any]) -> None:
    """Test type mismatch triggers IN() usage."""
    # Mock field type expecting strings
    mock_field_type = MagicMock()
    mock_field_type.python_type = str

    # Pass integers when expecting strings
    values = [1, 2, 3]
    result = sync_mock_repo._type_must_use_in_instead_of_any(values, mock_field_type)
    assert result is True


def test_async_field_type_none_python_type(mock_repo: SQLAlchemyAsyncRepository[Any]) -> None:
    """Test behavior when field_type.python_type is None."""
    mock_field_type = MagicMock()
    mock_field_type.python_type = None

    values = [{"complex": "object"}]  # Non-safe type
    result = mock_repo._type_must_use_in_instead_of_any(values, mock_field_type)
    assert result is True  # Should use fallback logic


def test_sync_field_type_no_python_type_attr(sync_mock_repo: SQLAlchemySyncRepository[Any]) -> None:
    """Test behavior when field_type has no python_type attribute."""
    # Create object without python_type attribute
    mock_field_type = object()

    values = [{"complex": "object"}]  # Non-safe type
    result = sync_mock_repo._type_must_use_in_instead_of_any(values, mock_field_type)
    assert result is True  # Should use fallback logic for non-safe types


def test_async_no_field_type_safe_values(mock_repo: SQLAlchemyAsyncRepository[Any]) -> None:
    """Test safe values without field type information."""
    # Test all safe types
    safe_values = [
        42,
        3.14,
        "string",
        True,
        b"bytes",
        decimal.Decimal("10.5"),
        datetime.date.today(),
        datetime.datetime.now(),
        datetime.time(12, 30),
        datetime.timedelta(days=1),
    ]

    result = mock_repo._type_must_use_in_instead_of_any(safe_values)
    assert result is False


def test_sync_no_field_type_unsafe_values(sync_mock_repo: SQLAlchemySyncRepository[Any]) -> None:
    """Test unsafe values without field type information."""
    # Test unsafe types (complex objects)
    unsafe_values = [{"key": "value"}, [1, 2, 3], {"nested": {"data": True}}]

    result = sync_mock_repo._type_must_use_in_instead_of_any(unsafe_values)
    assert result is True


def test_async_mixed_safe_unsafe_values(mock_repo: SQLAlchemyAsyncRepository[Any]) -> None:
    """Test mixed safe and unsafe values."""
    # Mix safe and unsafe types
    mixed_values = ["string", 42, {"unsafe": "dict"}]

    result = mock_repo._type_must_use_in_instead_of_any(mixed_values)
    assert result is True


def test_sync_none_values_handling(sync_mock_repo: SQLAlchemySyncRepository[Any]) -> None:
    """Test handling of None values."""
    # None values should be ignored in type checking
    values_with_none = [None, "string", None, 42]

    result = sync_mock_repo._type_must_use_in_instead_of_any(values_with_none)
    assert result is False


def test_async_empty_values(mock_repo: SQLAlchemyAsyncRepository[Any]) -> None:
    """Test empty list returns empty list."""
    result = mock_repo._get_unique_values([])
    assert result == []


def test_sync_hashable_values(sync_mock_repo: SQLAlchemySyncRepository[Any]) -> None:
    """Test hashable values deduplication."""
    values = [1, 2, 1, 3, 2, 4]
    result = sync_mock_repo._get_unique_values(values)
    assert result == [1, 2, 3, 4]


def test_async_string_values(mock_repo: SQLAlchemyAsyncRepository[Any]) -> None:
    """Test string deduplication."""
    values = ["a", "b", "a", "c", "b"]
    result = mock_repo._get_unique_values(values)
    assert result == ["a", "b", "c"]


def test_sync_unhashable_values(sync_mock_repo: SQLAlchemySyncRepository[Any]) -> None:
    """Test unhashable values (dicts) deduplication."""
    values = [
        {"key": "value1"},
        {"key": "value2"},
        {"key": "value1"},  # duplicate
        {"key": "value3"},
    ]
    result = sync_mock_repo._get_unique_values(values)
    expected = [{"key": "value1"}, {"key": "value2"}, {"key": "value3"}]
    assert result == expected


def test_async_mixed_types(mock_repo: SQLAlchemyAsyncRepository[Any]) -> None:
    """Test mixed hashable and unhashable types."""
    # Mix strings and dicts to trigger TypeError in set operations
    values = ["string", {"dict": "value"}, "string", {"other": "dict"}]
    result = mock_repo._get_unique_values(values)
    expected = ["string", {"dict": "value"}, {"other": "dict"}]
    assert result == expected


def test_sync_preserves_order(sync_mock_repo: SQLAlchemySyncRepository[Any]) -> None:
    """Test that order is preserved in deduplication."""
    values = [3, 1, 4, 1, 5, 9, 2, 6, 5, 3]
    result = sync_mock_repo._get_unique_values(values)
    assert result == [3, 1, 4, 5, 9, 2, 6]


def test_column_with_python_default() -> None:
    """Test column with Python-side default."""
    mock_column = MagicMock()
    mock_column.default = "default_value"
    mock_column.server_default = None
    mock_column.onupdate = None
    mock_column.server_onupdate = None

    assert column_has_defaults(mock_column) is True


def test_column_with_server_default() -> None:
    """Test column with server-side default."""
    mock_column = MagicMock()
    mock_column.default = None
    mock_column.server_default = "DEFAULT VALUE"
    mock_column.onupdate = None
    mock_column.server_onupdate = None

    assert column_has_defaults(mock_column) is True


def test_column_with_python_onupdate() -> None:
    """Test column with Python-side onupdate."""
    mock_column = MagicMock()
    mock_column.default = None
    mock_column.server_default = None
    mock_column.onupdate = "update_function"
    mock_column.server_onupdate = None

    assert column_has_defaults(mock_column) is True


def test_column_with_server_onupdate() -> None:
    """Test column with server-side onupdate."""
    mock_column = MagicMock()
    mock_column.default = None
    mock_column.server_default = None
    mock_column.onupdate = None
    mock_column.server_onupdate = "UPDATE_FUNCTION"

    assert column_has_defaults(mock_column) is True


def test_column_with_no_defaults() -> None:
    """Test column with no defaults."""
    mock_column = MagicMock()
    mock_column.default = None
    mock_column.server_default = None
    mock_column.onupdate = None
    mock_column.server_onupdate = None

    assert column_has_defaults(mock_column) is False


def test_column_with_false_default() -> None:
    """Test column where default is False (falsy but not None)."""
    mock_column = MagicMock()
    mock_column.default = False  # Falsy but not None
    mock_column.server_default = None
    mock_column.onupdate = None
    mock_column.server_onupdate = None

    assert column_has_defaults(mock_column) is True


def test_column_with_zero_default() -> None:
    """Test column where default is 0 (falsy but not None)."""
    mock_column = MagicMock()
    mock_column.default = 0  # Falsy but not None
    mock_column.server_default = None
    mock_column.onupdate = None
    mock_column.server_onupdate = None

    assert column_has_defaults(mock_column) is True


def test_column_with_empty_string_default() -> None:
    """Test column where default is empty string (falsy but not None)."""
    mock_column = MagicMock()
    mock_column.default = ""  # Falsy but not None
    mock_column.server_default = None
    mock_column.onupdate = None
    mock_column.server_onupdate = None

    assert column_has_defaults(mock_column) is True


def test_column_property_label_object() -> None:
    """Test column_property Label objects return False for column_has_defaults."""
    from sqlalchemy.sql.elements import Label

    # Create a Label object similar to what column_property creates
    mock_label = MagicMock(spec=Label)

    # Label objects don't have default/onupdate attributes, but if they did,
    # they would raise AttributeError when accessed
    assert column_has_defaults(mock_label) is False


def test_column_property_with_real_label() -> None:
    """Test column_has_defaults with an actual Label object from SQLAlchemy."""
    from sqlalchemy import literal_column
    from sqlalchemy.sql.elements import Label

    # Create a real Label object like column_property would create
    label_obj = literal_column("test_value").label("test_column")  # type: ignore[var-annotated]
    assert isinstance(label_obj, Label)

    # This should return False and not raise AttributeError
    assert column_has_defaults(label_obj) is False


def test_column_object_without_default_attributes() -> None:
    """Test column_has_defaults with object missing some attributes."""

    # Create an object that only has some of the expected attributes
    class PartialColumn:
        def __init__(self) -> None:
            self.default = "test_default"
            # Missing server_default, onupdate, server_onupdate attributes

    partial_column = PartialColumn()

    # Should return True based on the default attribute, even though others are missing
    assert column_has_defaults(partial_column) is True


def test_column_object_with_no_default_attributes() -> None:
    """Test column_has_defaults with object missing all attributes."""

    # Create an object that has none of the expected attributes
    class MinimalColumn:
        def __init__(self) -> None:
            self.name = "test_column"

    minimal_column = MinimalColumn()

    # Should return False since no default attributes are present
    assert column_has_defaults(minimal_column) is False


def test_normalize_cache_key_value_handles_structures() -> None:
    """Normalize cache key values for common structures."""

    @dataclass
    class Payload:
        name: str
        ids: set[int]

    class CacheBase(DeclarativeBase):
        pass

    class CacheModel(CacheBase):
        __tablename__ = "cache_model"

        id: Mapped[int] = mapped_column(primary_key=True)

    normalized = _normalize_cache_key_value(Payload(name="alpha", ids={2, 1}))
    assert normalized == {"name": "alpha", "ids": [1, 2]}
    assert _normalize_cache_key_value(CacheModel.id) == {"__attr__": "id"}
    assert _normalize_cache_key_value(column("name")) == {"__sql__": "name"}
    assert "__repr__" in _normalize_cache_key_value(object())


def test_build_list_cache_key_stable_for_unordered_inputs() -> None:
    """Cache keys should remain stable for unordered inputs."""
    filters = [CollectionFilter(field_name="id", values={2, 1})]
    key_a = _build_list_cache_key(
        model_name="CacheModel",
        version_token="v1",
        method="list",
        filters=filters,
        kwargs={"meta": {"b": 2, "a": 1}},
        order_by=[("name", False)],
        execution_options={"stream_results": True},
        uniquify=True,
    )
    key_b = _build_list_cache_key(
        model_name="CacheModel",
        version_token="v1",
        method="list",
        filters=[CollectionFilter(field_name="id", values={1, 2})],
        kwargs={"meta": {"a": 1, "b": 2}},
        order_by=[("name", False)],
        execution_options={"stream_results": True},
        uniquify=True,
    )

    assert key_a is not None
    assert key_a == key_b


def test_build_list_cache_key_returns_none_for_raw_filters() -> None:
    """Raw SQLAlchemy expressions should skip caching."""
    key = _build_list_cache_key(
        model_name="CacheModel",
        version_token="v1",
        method="list",
        filters=[column("id") == 1],
        kwargs={},
        order_by=None,
        execution_options={},
        uniquify=False,
    )

    assert key is None


def test_normalize_cache_key_value_complex_types() -> None:
    """Normalize cache key values for complex types (datetime, uuid, etc)."""
    dt = datetime.datetime(2025, 12, 14, 10, 30, 0)
    result = _normalize_cache_key_value(dt)
    assert result == {"__type__": "datetime", "value": "2025-12-14T10:30:00"}

    from uuid import UUID

    u = UUID("12345678-1234-5678-1234-567812345678")
    result = _normalize_cache_key_value(u)
    assert result == {"__type__": "uuid", "value": "12345678-1234-5678-1234-567812345678"}

    # bytes
    result = _normalize_cache_key_value(b"\x01\x02")
    assert result == {"__type__": "bytes", "value": "0102"}


def test_normalize_cache_key_value_list_tuple() -> None:
    """Normalize cache key values for list and tuple types."""
    result = _normalize_cache_key_value([1, "two", 3.0])
    assert result == [1, "two", 3.0]

    result = _normalize_cache_key_value((True, None))
    assert result == [True, None]


def test_normalize_cache_key_value_none_and_primitives() -> None:
    """Normalize cache key values for None and primitive types (early return)."""
    assert _normalize_cache_key_value(None) is None
    assert _normalize_cache_key_value(42) == 42
    assert _normalize_cache_key_value(3.14) == 3.14
    assert _normalize_cache_key_value("hello") == "hello"
    assert _normalize_cache_key_value(True) is True


def test_build_list_cache_key_with_unary_expression() -> None:
    """UnaryExpression in order_by is serialized as string expression."""
    from sqlalchemy import UnaryExpression, desc

    # Create a real UnaryExpression
    unary_expr = desc(column("name"))  # type: ignore[var-annotated]
    assert isinstance(unary_expr, UnaryExpression)

    key = _build_list_cache_key(
        model_name="CacheModel",
        version_token="v1",
        method="list",
        filters=[],
        kwargs={},
        order_by=[unary_expr],  # type: ignore[list-item]
        execution_options={},
        uniquify=False,
    )

    assert key is not None
    assert key.startswith("CacheModel:list:")


def test_build_list_cache_key_with_count_window_function() -> None:
    """count_with_window_function param is included in cache key."""
    key_with = _build_list_cache_key(
        model_name="CacheModel",
        version_token="v1",
        method="list_and_count",
        filters=[],
        kwargs={},
        order_by=None,
        execution_options={},
        uniquify=False,
        count_with_window_function=True,
    )
    key_without = _build_list_cache_key(
        model_name="CacheModel",
        version_token="v1",
        method="list_and_count",
        filters=[],
        kwargs={},
        order_by=None,
        execution_options={},
        uniquify=False,
        count_with_window_function=False,
    )
    key_default = _build_list_cache_key(
        model_name="CacheModel",
        version_token="v1",
        method="list_and_count",
        filters=[],
        kwargs={},
        order_by=None,
        execution_options={},
        uniquify=False,
    )

    assert key_with is not None
    assert key_without is not None
    assert key_default is not None
    # Different values of count_with_window_function produce different keys
    assert key_with != key_without
    # None (omitted) differs from explicit True/False
    assert key_default != key_with
    assert key_default != key_without


def test_model_from_dict_includes_relationship_attributes() -> None:
    """Test that model_from_dict includes relationship attributes from __mapper__.attrs.keys()."""
    from tests.fixtures.uuid.models import UUIDAuthor

    # Verify that attrs.keys() includes relationships while columns.keys() doesn't
    columns_keys = list(UUIDAuthor.__mapper__.columns.keys())
    attrs_keys = list(UUIDAuthor.__mapper__.attrs.keys())

    assert "books" not in columns_keys, "books relationship should NOT be in columns.keys()"
    assert "books" in attrs_keys, "books relationship should be in attrs.keys()"


# Tests for write_only relationship handling in update method (issue #524)


async def test_update_skips_write_only_relationships(
    mock_repo: SQLAlchemyAsyncRepository[Any],
    mocker: MockerFixture,
) -> None:
    """Test that update method skips write_only relationships without error."""
    id_ = 3
    mock_instance = MagicMock()
    existing_instance = MagicMock()

    # Mock the mapper and relationship
    mock_mapper = MagicMock()
    mock_relationship = MagicMock()
    mock_relationship.key = "items"
    mock_relationship.lazy = "write_only"
    mock_relationship.viewonly = False
    mock_mapper.mapper.columns = []
    mock_mapper.mapper.relationships = [mock_relationship]

    # Mock the data object to have the write_only relationship attribute
    mock_instance.items = MagicMock()  # This would be a WriteOnlyCollection in reality

    mocker.patch.object(mock_repo, "get_id_attribute_value", return_value=id_)
    mocker.patch.object(mock_repo, "get", return_value=existing_instance)
    mocker.patch("advanced_alchemy.repository._async.inspect", return_value=mock_mapper)
    mock_repo.session.merge.return_value = existing_instance  # pyright: ignore[reportFunctionMemberAccess]

    # This should not raise an error even though items is a write_only relationship
    instance = await maybe_async(mock_repo.update(mock_instance))

    # Verify the relationship was not processed (no merge attempted for relationships)
    mock_repo.session.merge.assert_called_once_with(existing_instance, load=True)  # pyright: ignore[reportFunctionMemberAccess]
    assert instance is existing_instance


async def test_update_skips_dynamic_relationships(
    mock_repo: SQLAlchemyAsyncRepository[Any],
    mocker: MockerFixture,
) -> None:
    """Test that update method skips dynamic relationships without error."""
    id_ = 3
    mock_instance = MagicMock()
    existing_instance = MagicMock()

    # Mock the mapper and relationship
    mock_mapper = MagicMock()
    mock_relationship = MagicMock()
    mock_relationship.key = "items"
    mock_relationship.lazy = "dynamic"
    mock_relationship.viewonly = False
    mock_mapper.mapper.columns = []
    mock_mapper.mapper.relationships = [mock_relationship]

    # Mock the data object to have the dynamic relationship attribute
    mock_instance.items = MagicMock()  # This would be an AppenderQuery in reality

    mocker.patch.object(mock_repo, "get_id_attribute_value", return_value=id_)
    mocker.patch.object(mock_repo, "get", return_value=existing_instance)
    mocker.patch("advanced_alchemy.repository._async.inspect", return_value=mock_mapper)
    mock_repo.session.merge.return_value = existing_instance  # pyright: ignore[reportFunctionMemberAccess]

    # This should not raise an error even though items is a dynamic relationship
    instance = await maybe_async(mock_repo.update(mock_instance))

    # Verify the relationship was not processed (no merge attempted for relationships)
    mock_repo.session.merge.assert_called_once_with(existing_instance, load=True)  # pyright: ignore[reportFunctionMemberAccess]
    assert instance is existing_instance


async def test_update_skips_viewonly_relationships(
    mock_repo: SQLAlchemyAsyncRepository[Any],
    mocker: MockerFixture,
) -> None:
    """Test that update method skips viewonly relationships without error."""
    id_ = 3
    mock_instance = MagicMock()
    existing_instance = MagicMock()

    # Mock the mapper and relationship
    mock_mapper = MagicMock()
    mock_relationship = MagicMock()
    mock_relationship.key = "readonly_items"
    mock_relationship.lazy = "select"  # Normal lazy loading
    mock_relationship.viewonly = True  # But marked as view-only
    mock_mapper.mapper.columns = []
    mock_mapper.mapper.relationships = [mock_relationship]

    # Mock the data object to have the viewonly relationship attribute
    mock_instance.readonly_items = [MagicMock()]

    mocker.patch.object(mock_repo, "get_id_attribute_value", return_value=id_)
    mocker.patch.object(mock_repo, "get", return_value=existing_instance)
    mocker.patch("advanced_alchemy.repository._async.inspect", return_value=mock_mapper)
    mock_repo.session.merge.return_value = existing_instance  # pyright: ignore[reportFunctionMemberAccess]

    # This should not raise an error even though readonly_items is viewonly
    instance = await maybe_async(mock_repo.update(mock_instance))

    # Verify the relationship was not processed (no merge attempted for relationships)
    mock_repo.session.merge.assert_called_once_with(existing_instance, load=True)  # pyright: ignore[reportFunctionMemberAccess]
    assert instance is existing_instance


async def test_update_skips_raise_lazy_relationships(
    mock_repo: SQLAlchemyAsyncRepository[Any],
    mocker: MockerFixture,
) -> None:
    """Test that update method skips raise lazy strategy relationships without error."""
    id_ = 3
    mock_instance = MagicMock()
    existing_instance = MagicMock()

    # Mock the mapper and relationship
    mock_mapper = MagicMock()
    mock_relationship = MagicMock()
    mock_relationship.key = "items"
    mock_relationship.lazy = "raise"
    mock_relationship.viewonly = False
    mock_mapper.mapper.columns = []
    mock_mapper.mapper.relationships = [mock_relationship]

    # Mock the data object to raise an error when accessing the relationship
    type(mock_instance).items = PropertyMock(side_effect=InvalidRequestError)

    mocker.patch.object(mock_repo, "get_id_attribute_value", return_value=id_)
    mocker.patch.object(mock_repo, "get", return_value=existing_instance)
    mocker.patch("advanced_alchemy.repository._sync.inspect", return_value=mock_mapper)
    mocker.patch("advanced_alchemy.repository._async.inspect", return_value=mock_mapper)
    mock_repo.session.merge.return_value = existing_instance  # pyright: ignore[reportFunctionMemberAccess]

    # This should not raise an error even though items has lazy="raise"
    instance = await maybe_async(mock_repo.update(mock_instance))

    # Verify the relationship was not processed (no merge attempted for relationships)
    mock_repo.session.merge.assert_called_once_with(existing_instance, load=True)  # pyright: ignore[reportFunctionMemberAccess]
    assert instance is existing_instance


async def test_update_processes_normal_relationships(
    mock_repo: SQLAlchemyAsyncRepository[Any],
    mocker: MockerFixture,
) -> None:
    """Test that update method still processes normal relationships correctly."""
    id_ = 3
    mock_instance = MagicMock()
    existing_instance = MagicMock()
    related_item = MagicMock()
    merged_related_item = MagicMock()

    # Mock the mapper and relationship
    mock_mapper = MagicMock()
    mock_relationship = MagicMock()
    mock_relationship.key = "items"
    mock_relationship.lazy = "select"  # Normal lazy loading
    mock_relationship.viewonly = False
    mock_mapper.mapper.columns = []
    mock_mapper.mapper.relationships = [mock_relationship]

    # Mock the data object to have a normal relationship with items
    mock_instance.items = [related_item]

    mocker.patch.object(mock_repo, "get_id_attribute_value", return_value=id_)
    mocker.patch.object(mock_repo, "get", return_value=existing_instance)
    mocker.patch("advanced_alchemy.repository._async.inspect", return_value=mock_mapper)

    # Mock session.merge to return different objects for main instance vs related items
    async def mock_merge(obj: Any, load: bool = True) -> Any:
        if obj is existing_instance:
            return existing_instance
        if obj is related_item:
            return merged_related_item
        return obj

    mock_repo.session.merge.side_effect = mock_merge

    # This should process the normal relationship correctly
    instance = await maybe_async(mock_repo.update(mock_instance))

    # Verify the relationship was processed - at minimum the main instance should be merged
    assert mock_repo.session.merge.call_count >= 1  # At least the main instance
    # The main point is that normal relationships don't cause errors
    assert instance is existing_instance


def test_model_from_dict_backward_compatibility() -> None:
    """Test that model_from_dict maintains backward compatibility with column-only data."""
    from tests.fixtures.uuid.models import UUIDAuthor

    author_data = {"name": "Compatible Author", "string_field": "compatibility test"}

    author = model_from_dict(UUIDAuthor, **author_data)

    assert author.name == "Compatible Author"
    assert author.string_field == "compatibility test"


def test_model_from_dict_ignores_unknown_attributes() -> None:
    """Test that model_from_dict still ignores unknown attributes."""
    from tests.fixtures.uuid.models import UUIDAuthor

    author_data = {"name": "Test Author", "unknown_attribute": "should be ignored", "another_unknown": 12345}

    author = model_from_dict(UUIDAuthor, **author_data)

    assert author.name == "Test Author"
    assert not hasattr(author, "unknown_attribute")
    assert not hasattr(author, "another_unknown")


def test_model_from_dict_empty_relationship() -> None:
    """Test that model_from_dict handles empty relationship lists."""
    from tests.fixtures.uuid.models import UUIDAuthor

    author_data = {
        "name": "Author Without Books",
        "books": [],  # Empty relationship
    }

    author = model_from_dict(UUIDAuthor, **author_data)

    assert author.name == "Author Without Books"
    assert hasattr(author, "books")
    assert author.books == []


def test_update_many_data_conversion_handles_mixed_types() -> None:
    """Test that update_many properly handles mixed input types (regression test).

    This verifies the fix for the type handling bug in update_many where
    the old logic would fail with AttributeError when mixing model instances
    and dictionaries.
    """
    from tests.fixtures.uuid.models import UUIDAuthor

    # Simulate the data conversion logic from the fixed code
    model_type = UUIDAuthor

    # Create a mock model instance
    mock_author = UUIDAuthor(name="Test Author")

    # Mix of model instances and dictionaries (the problematic case)
    mixed_data = [
        mock_author,  # Model instance with to_dict() method
        {"id": "dict-id", "name": "Dict Author"},  # Plain dictionary
    ]

    # This is the fixed logic from repository/_async.py and _sync.py
    data_to_update = []
    for v in mixed_data:
        if isinstance(v, model_type):
            data_to_update.append(v.to_dict())
        else:
            data_to_update.append(v)  # type: ignore[arg-type]

    # Verify no AttributeError was raised and data is properly converted
    assert len(data_to_update) == 2
    assert isinstance(data_to_update[0], dict)  # Model converted to dict
    assert isinstance(data_to_update[1], dict)  # Dict passed through
    assert data_to_update[0]["name"] == "Test Author"
    assert data_to_update[1]["name"] == "Dict Author"


def test_compare_values_handles_numpy_arrays() -> None:
    """Test that compare_values properly handles numpy arrays.

    This is a regression test for the issue where comparing numpy arrays
    (like pgvector's Vector type) would raise:
    ValueError: The truth value of an array with more than one element is ambiguous
    """
    from advanced_alchemy.repository._util import compare_values

    # Test with regular values (should work as before)
    assert compare_values("same", "same") is True
    assert compare_values("different", "other") is False
    assert compare_values(None, None) is True
    assert compare_values(None, "value") is False
    assert compare_values("value", None) is False

    # Test with mock numpy arrays (when numpy is not installed)
    class MockNumpyArray:
        """Mock numpy array for testing when numpy is not available."""

        def __init__(self, data: list[float]) -> None:
            self.data = data
            self.dtype = "float64"  # Required for is_numpy_array detection

        def __array__(self) -> list[float]:
            """Required for is_numpy_array detection."""
            return self.data

        def __eq__(self, other: object) -> list[bool]:  # type: ignore[override]
            """Simulate numpy's element-wise comparison that causes the issue."""
            if isinstance(other, MockNumpyArray):
                return [a == b for a, b in zip(self.data, other.data)]
            return [False] * len(self.data)

    # Create mock arrays
    array1 = MockNumpyArray([1.0, 2.0, 3.0])
    array2 = MockNumpyArray([1.0, 2.0, 3.0])  # Same data
    array3 = MockNumpyArray([4.0, 5.0, 6.0])  # Different data

    # Test array comparisons (these would previously raise ValueError)
    result_same = compare_values(array1, array2)
    result_different = compare_values(array1, array3)
    result_mixed = compare_values(array1, "not_an_array")

    # The important thing is that no ValueError is raised
    assert isinstance(result_same, bool)  # Should not raise ValueError
    assert isinstance(result_different, bool)  # Should not raise ValueError
    assert isinstance(result_mixed, bool)  # Should not raise ValueError

    # The specific results depend on whether numpy is installed:
    # - With numpy: MockNumpyArray is not detected as numpy array, falls back to __eq__
    # - Without numpy: stub functions are used which return False for safety
    # Either way, no ValueError should be raised

    # Test with values that would cause comparison errors
    class ProblematicValue:
        def __eq__(self, other: object) -> None:  # type: ignore[override]
            raise TypeError("Cannot compare")

    problematic = ProblematicValue()
    # Should handle comparison errors gracefully
    assert compare_values(problematic, "other") is False
    assert compare_values("other", problematic) is False


def test_compare_values_with_real_numpy_arrays() -> None:
    """Test compare_values with actual numpy arrays when numpy is installed.

    This test covers the real numpy code paths that were missing from coverage.
    """
    # This test will only run if numpy is actually installed
    try:
        import numpy as np
    except ImportError:
        pytest.skip("numpy not available")

    from advanced_alchemy.repository._util import compare_values

    # Test equal arrays
    arr1 = np.array([1.0, 2.0, 3.0])
    arr2 = np.array([1.0, 2.0, 3.0])
    assert compare_values(arr1, arr2) is True

    # Test different arrays
    arr3 = np.array([4.0, 5.0, 6.0])
    assert compare_values(arr1, arr3) is False

    # Test different shapes
    arr4 = np.array([[1.0, 2.0], [3.0, 4.0]])
    assert compare_values(arr1, arr4) is False

    # Test array vs non-array
    assert compare_values(arr1, [1.0, 2.0, 3.0]) is False
    assert compare_values(arr1, "not an array") is False

    # Test empty arrays
    empty1 = np.array([])
    empty2 = np.array([])
    assert compare_values(empty1, empty2) is True

    # Test different dtypes but same values
    int_arr = np.array([1, 2, 3])
    float_arr = np.array([1.0, 2.0, 3.0])
    assert compare_values(int_arr, float_arr) is True  # numpy considers these equal

    # Test NaN handling
    nan_arr1 = np.array([1.0, np.nan, 3.0])
    nan_arr2 = np.array([1.0, np.nan, 3.0])
    # numpy considers NaN != NaN, so arrays with NaN won't be equal
    assert compare_values(nan_arr1, nan_arr2) is False


def test_compare_values_covers_all_branches() -> None:
    """Test compare_values to ensure all code branches are covered."""
    from advanced_alchemy.repository._util import compare_values

    # Test standard equality that returns non-bool (should not happen with normal types)
    class WeirdComparison:
        def __eq__(self, other: object) -> str:  # type: ignore[override]
            return "weird"

    weird = WeirdComparison()
    # This tests the bool() conversion in the standard comparison path
    result = compare_values(weird, weird)
    assert isinstance(result, bool)  # Should convert "weird" to True
    assert result is True


def test_repository_update_methods_with_numpy_arrays() -> None:
    """Test that repository update methods work correctly with numpy array fields.

    This integration test covers the actual repository comparison paths
    that were missing from coverage.
    """
    # This test will only run if numpy is actually installed
    try:
        import numpy as np
    except ImportError:
        pytest.skip("numpy not available")

    from advanced_alchemy.repository._util import compare_values

    # Test data with numpy arrays
    arr1 = np.array([1.0, 2.0, 3.0])
    arr2 = np.array([1.0, 2.0, 3.0])  # Same as arr1
    arr3 = np.array([4.0, 5.0, 6.0])  # Different from arr1

    # These operations would previously fail with ValueError
    # Now they should work correctly by using our safe comparison

    # Test 1: Arrays with same data should be considered equal
    assert arr1 is not arr2  # Different objects
    # But compare_values should see them as equal
    assert compare_values(arr1, arr2) is True

    # Test 2: Arrays with different data should be considered different
    assert compare_values(arr1, arr3) is False

    # Test 3: Test with None values (common edge case)
    assert compare_values(None, None) is True
    assert compare_values(arr1, None) is False
    assert compare_values(None, arr1) is False

    # Test 4: Array vs non-array should be False
    assert compare_values(arr1, [1.0, 2.0, 3.0]) is False
    assert compare_values(arr1, "not an array") is False

    # Test 5: Test different shapes
    arr_2d = np.array([[1.0, 2.0], [3.0, 4.0]])
    assert compare_values(arr1, arr_2d) is False

    # Test 6: Test empty arrays
    empty1 = np.array([])
    empty2 = np.array([])
    assert compare_values(empty1, empty2) is True

    # Test 7: Test with complex numbers (edge case)
    complex1 = np.array([1 + 2j, 3 + 4j])
    complex2 = np.array([1 + 2j, 3 + 4j])
    complex3 = np.array([1 + 2j, 5 + 6j])
    assert compare_values(complex1, complex2) is True
    assert compare_values(complex1, complex3) is False


def test_was_attribute_set_with_explicitly_set_attributes() -> None:
    """Test was_attribute_set correctly identifies explicitly set attributes."""
    from sqlalchemy import inspect

    from advanced_alchemy.repository._util import was_attribute_set

    # Create an instance with explicitly set attributes
    instance = UUIDModel()
    instance.id = uuid4()  # Explicitly set id

    # Get the mapper/inspector
    mapper = inspect(instance)

    # Explicitly set attributes should return True
    assert was_attribute_set(instance, mapper, "id") is True


def test_was_attribute_set_with_uninitialized_attributes() -> None:
    """Test was_attribute_set correctly identifies uninitialized attributes."""
    from sqlalchemy import inspect

    from advanced_alchemy.repository._util import was_attribute_set

    # Use the existing UUIDModel which has created_at and updated_at audit fields
    # Create an instance - created_at and updated_at won't be in instance dict yet
    instance = UUIDModel()

    # Get the mapper/inspector
    mapper = inspect(instance)

    # Uninitialized audit attributes should return False
    # They exist on the model but haven't been explicitly set
    assert was_attribute_set(instance, mapper, "created_at") is False
    assert was_attribute_set(instance, mapper, "updated_at") is False


def test_was_attribute_set_with_modified_attributes() -> None:
    """Test was_attribute_set detects attributes with modification history."""
    from sqlalchemy import inspect

    from advanced_alchemy.repository._util import was_attribute_set

    # Create an instance and explicitly set attributes
    instance = UUIDModel()
    instance.id = uuid4()  # Explicitly set id

    # Also test setting a datetime attribute
    now = datetime.datetime.now(datetime.timezone.utc)
    instance.created_at = now  # Explicitly modify created_at

    # Get the mapper/inspector
    mapper = inspect(instance)

    # Modified attributes should return True
    assert was_attribute_set(instance, mapper, "id") is True
    assert was_attribute_set(instance, mapper, "created_at") is True


def test_was_attribute_set_with_nonexistent_attribute() -> None:
    """Test was_attribute_set handles nonexistent attributes gracefully."""
    from sqlalchemy import inspect

    from advanced_alchemy.repository._util import was_attribute_set

    # Create an instance
    instance = UUIDModel()

    # Get the mapper/inspector
    mapper = inspect(instance)

    # Nonexistent attribute should return False (attr_state is None)
    assert was_attribute_set(instance, mapper, "nonexistent_field") is False


# Tests for nested dict handling in model_from_dict (Issue #556)


def test_model_from_dict_nested_single_dict() -> None:
    """Test single nested dict is converted to model instance."""
    from tests.fixtures.uuid.models import UUIDAuthor, UUIDBook

    data = {
        "title": "Test Book",
        "author": {"name": "Test Author"},
    }
    book = model_from_dict(UUIDBook, **data)

    assert book.title == "Test Book"
    assert isinstance(book.author, UUIDAuthor)
    assert book.author.name == "Test Author"


def test_model_from_dict_nested_list_of_dicts() -> None:
    """Test list of nested dicts are converted to model instances."""
    from tests.fixtures.uuid.models import UUIDAuthor, UUIDBook

    data = {
        "name": "Test Author",
        "books": [
            {"title": "Book 1"},
            {"title": "Book 2"},
        ],
    }
    author = model_from_dict(UUIDAuthor, **data)

    assert author.name == "Test Author"
    assert len(author.books) == 2
    assert all(isinstance(b, UUIDBook) for b in author.books)
    assert author.books[0].title == "Book 1"
    assert author.books[1].title == "Book 2"


def test_model_from_dict_deeply_nested() -> None:
    """Test deeply nested structures (2+ levels) - author with books, each book with author."""
    from tests.fixtures.uuid.models import UUIDAuthor, UUIDBook

    # Create a book with nested author that has nested books
    # Note: SQLAlchemy's back_populates will automatically add the outer book
    # to author.books, so we get 3 books total (the outer book + 2 nested)
    data = {
        "title": "Test Book",
        "author": {
            "name": "Test Author",
            "books": [
                {"title": "Another Book 1"},
                {"title": "Another Book 2"},
            ],
        },
    }
    book = model_from_dict(UUIDBook, **data)

    assert book.title == "Test Book"
    assert isinstance(book.author, UUIDAuthor)
    assert book.author.name == "Test Author"
    # Due to back_populates, author.books contains the outer book + 2 nested books = 3 total
    assert len(book.author.books) == 3
    assert all(isinstance(b, UUIDBook) for b in book.author.books)
    # The first two are from the nested data
    titles = {b.title for b in book.author.books}
    assert "Another Book 1" in titles
    assert "Another Book 2" in titles
    assert "Test Book" in titles


def test_model_from_dict_none_relationship() -> None:
    """Test None value for relationship is preserved."""
    from tests.fixtures.uuid.models import UUIDBook

    data = {"title": "Orphan Book", "author": None}
    book = model_from_dict(UUIDBook, **data)

    assert book.title == "Orphan Book"
    assert book.author is None


def test_model_from_dict_empty_list_relationship() -> None:
    """Test empty list for relationship is preserved."""
    from tests.fixtures.uuid.models import UUIDAuthor

    data = {"name": "Author Without Books", "books": []}
    author = model_from_dict(UUIDAuthor, **data)

    assert author.name == "Author Without Books"
    assert author.books == []


def test_model_from_dict_mixed_list() -> None:
    """Test list with both dicts and model instances."""
    from tests.fixtures.uuid.models import UUIDAuthor, UUIDBook

    existing_book = UUIDBook(title="Existing Book")
    data = {
        "name": "Test Author",
        "books": [
            existing_book,
            {"title": "New Book"},
        ],
    }
    author = model_from_dict(UUIDAuthor, **data)

    assert len(author.books) == 2
    assert author.books[0] is existing_book
    assert isinstance(author.books[1], UUIDBook)
    assert author.books[1].title == "New Book"


def test_model_from_dict_preserves_existing_instance() -> None:
    """Test that existing model instances are passed through unchanged."""
    from tests.fixtures.uuid.models import UUIDAuthor, UUIDBook

    existing_author = UUIDAuthor(name="Existing")
    data = {
        "title": "Test Book",
        "author": existing_author,
    }
    book = model_from_dict(UUIDBook, **data)

    assert book.author is existing_author


def test_model_from_dict_single_item_for_collection() -> None:
    """Test single dict provided for collection relationship is wrapped in list."""
    from tests.fixtures.uuid.models import UUIDAuthor, UUIDBook

    data = {
        "name": "Test Author",
        "books": {"title": "Single Book"},  # Single dict instead of list
    }
    author = model_from_dict(UUIDAuthor, **data)

    assert len(author.books) == 1
    assert isinstance(author.books[0], UUIDBook)
    assert author.books[0].title == "Single Book"


def test_model_from_dict_performance_baseline() -> None:
    """Ensure minimal overhead for non-nested dicts."""
    import time

    from tests.fixtures.uuid.models import UUIDAuthor

    data = {"name": "Test Author", "string_field": "test"}

    # Warm up
    for _ in range(100):
        model_from_dict(UUIDAuthor, **data)

    # Benchmark
    start = time.perf_counter()
    for _ in range(10000):
        model_from_dict(UUIDAuthor, **data)
    elapsed = time.perf_counter() - start

    # Should complete quickly (< 1 second for 10k iterations)
    assert elapsed < 1.0


def test_model_from_dict_performance_nested() -> None:
    """Benchmark nested dict conversion."""
    import time

    from tests.fixtures.uuid.models import UUIDAuthor

    data = {
        "name": "Test Author",
        "books": [{"title": f"Book {i}"} for i in range(10)],
    }

    # Warm up
    for _ in range(100):
        model_from_dict(UUIDAuthor, **data)

    start = time.perf_counter()
    for _ in range(1000):
        model_from_dict(UUIDAuthor, **data)
    elapsed = time.perf_counter() - start

    # Should complete reasonably (< 2 seconds for 1k iterations with 10 children)
    assert elapsed < 2.0


def test_model_from_dict_many_to_many_relationship() -> None:
    """Test nested dict handling for many-to-many relationships."""
    from tests.fixtures.uuid.models import UUIDItem, UUIDTag

    data = {
        "name": "Test Item",
        "tags": [
            {"name": "Tag 1"},
            {"name": "Tag 2"},
        ],
    }
    item = model_from_dict(UUIDItem, **data)

    assert item.name == "Test Item"
    assert len(item.tags) == 2
    assert all(isinstance(t, UUIDTag) for t in item.tags)
    assert item.tags[0].name == "Tag 1"
    assert item.tags[1].name == "Tag 2"


def test_model_from_dict_tuple_for_collection() -> None:
    """Test tuple provided for collection relationship is handled correctly."""
    from tests.fixtures.uuid.models import UUIDAuthor, UUIDBook

    data = {
        "name": "Test Author",
        "books": ({"title": "Book 1"}, {"title": "Book 2"}),  # Tuple instead of list
    }
    author = model_from_dict(UUIDAuthor, **data)

    assert len(author.books) == 2
    assert all(isinstance(b, UUIDBook) for b in author.books)


def test_model_from_dict_with_model_key() -> None:
    """Regression test for https://github.com/litestar-org/advanced-alchemy/issues/668."""
    from tests.fixtures.uuid.models import UUIDAuthor

    data = {"name": "Test Author", "model": "some-model-value"}
    author = model_from_dict(UUIDAuthor, **data)
    assert author.name == "Test Author"


def test_model_from_dict_with_mapped_model_field() -> None:
    """Regression test for https://github.com/litestar-org/advanced-alchemy/issues/668."""

    class UUIDCar(base.UUIDAuditBase):
        make: Mapped[str] = mapped_column(String(length=50))  # pyright: ignore
        model: Mapped[str] = mapped_column(String(length=50))  # pyright: ignore

    data = {"make": "Advanced", "model": "Alchemy"}
    car = model_from_dict(UUIDCar, **data)

    assert car.make == "Advanced"
    assert car.model == "Alchemy"


def test_convert_relationship_value_helper() -> None:
    """Test the _convert_relationship_value helper function directly."""
    from advanced_alchemy.repository._util import _convert_relationship_value
    from tests.fixtures.uuid.models import UUIDBook

    # Test None
    assert _convert_relationship_value(None, UUIDBook, is_collection=False) is None
    assert _convert_relationship_value(None, UUIDBook, is_collection=True) is None

    # Test single dict for non-collection
    result = _convert_relationship_value({"title": "Test"}, UUIDBook, is_collection=False)
    assert isinstance(result, UUIDBook)
    assert result.title == "Test"

    # Test single dict for collection (should wrap in list)
    result = _convert_relationship_value({"title": "Test"}, UUIDBook, is_collection=True)
    assert isinstance(result, list)
    assert len(result) == 1
    assert isinstance(result[0], UUIDBook)

    # Test list of dicts for collection
    result = _convert_relationship_value(
        [{"title": "Book 1"}, {"title": "Book 2"}],
        UUIDBook,
        is_collection=True,
    )
    assert isinstance(result, list)
    assert len(result) == 2

    # Test existing instance pass-through
    existing = UUIDBook(title="Existing")
    result = _convert_relationship_value(existing, UUIDBook, is_collection=False)
    assert result is existing

    # Test existing instance in collection
    result = _convert_relationship_value([existing], UUIDBook, is_collection=True)
    assert result[0] is existing


def test_repository_and_service_annotations_are_accessible() -> None:
    """Regression test for Python 3.14 lazy annotation evaluation shadowing.

    On Python 3.14, bare ``list[...]`` in a class that defines a ``list()`` method
    resolves to the method instead of the builtin type. Using ``typing.List`` avoids this.
    """
    import typing

    from advanced_alchemy.repository._async import SQLAlchemyAsyncRepository
    from advanced_alchemy.repository._sync import SQLAlchemySyncRepository
    from advanced_alchemy.repository.memory._async import SQLAlchemyAsyncMockRepository
    from advanced_alchemy.repository.memory._sync import SQLAlchemySyncMockRepository
    from advanced_alchemy.repository.memory.base import InMemoryStore

    targets = [
        SQLAlchemyAsyncRepository,
        SQLAlchemySyncRepository,
        SQLAlchemyAsyncMockRepository,
        SQLAlchemySyncMockRepository,
        InMemoryStore,
    ]
    for cls in targets:
        annotations = typing.get_type_hints(cls.list)
        assert isinstance(annotations, dict), f"{cls.__name__}.list annotations should be a dict"
python-advanced-alchemy-1.9.3/tests/unit/test_repository_delete_expunge.py000066400000000000000000000246501516556515500273030ustar00rootroot00000000000000"""Tests for delete operations with auto_expunge.

This test module validates the fix for issue #514 where delete operations
with auto_expunge=True and auto_commit=True would fail with InvalidRequestError.
"""

from __future__ import annotations

from typing import TYPE_CHECKING, Any
from unittest.mock import AsyncMock, MagicMock

import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session

from advanced_alchemy import base
from advanced_alchemy.repository import SQLAlchemyAsyncRepository, SQLAlchemySyncRepository

if TYPE_CHECKING:
    from pytest_mock import MockerFixture


class ExpungeTestModel(base.UUIDAuditBase):
    """Test model for delete expunge tests."""


class ExpungeTestModelRepository(SQLAlchemyAsyncRepository[ExpungeTestModel]):
    """Async repository for ExpungeTestModel."""

    model_type = ExpungeTestModel


class ExpungeTestModelRepositorySync(SQLAlchemySyncRepository[ExpungeTestModel]):
    """Sync repository for ExpungeTestModel."""

    model_type = ExpungeTestModel


class TestExpungeDeletedObjects:
    """Test that _expunge() correctly handles deleted objects."""

    @pytest.fixture
    def mock_instance(self) -> MagicMock:
        """Create a mock instance for testing."""
        return MagicMock()

    def test_expunge_skips_deleted_objects_async(
        self,
        mock_instance: MagicMock,
        mocker: MockerFixture,
    ) -> None:
        """Test that _expunge() skips deleted objects to avoid InvalidRequestError.

        This is the core fix for issue #514.
        """
        # Setup: Mock session and repository
        session = AsyncMock(spec=AsyncSession, bind=MagicMock())
        repo = ExpungeTestModelRepository(session=session, auto_expunge=True)

        # Mock inspect to return deleted state
        mock_state = MagicMock()
        mock_state.deleted = True
        mocker.patch("advanced_alchemy.repository._async.inspect", return_value=mock_state)

        # Call _expunge - should not raise, should not call session.expunge
        repo._expunge(mock_instance, auto_expunge=True)

        # Verify: expunge was NOT called (because object is deleted)
        session.expunge.assert_not_called()

    def test_expunge_calls_session_for_non_deleted_async(
        self,
        mock_instance: MagicMock,
        mocker: MockerFixture,
    ) -> None:
        """Test that _expunge() still calls session.expunge for non-deleted objects."""
        # Setup
        session = AsyncMock(spec=AsyncSession, bind=MagicMock())
        repo = ExpungeTestModelRepository(session=session, auto_expunge=True)

        # Mock inspect to return non-deleted, non-detached state
        mock_state = MagicMock()
        mock_state.deleted = False
        mock_state.detached = False
        mocker.patch("advanced_alchemy.repository._async.inspect", return_value=mock_state)

        # Call _expunge - should call session.expunge
        repo._expunge(mock_instance, auto_expunge=True)

        # Verify: expunge WAS called (object not deleted and not detached)
        session.expunge.assert_called_once_with(mock_instance)

    def test_expunge_respects_auto_expunge_false_async(
        self,
        mock_instance: MagicMock,
        mocker: MockerFixture,
    ) -> None:
        """Test that _expunge() respects auto_expunge=False."""
        # Setup
        session = AsyncMock(spec=AsyncSession, bind=MagicMock())
        repo = ExpungeTestModelRepository(session=session, auto_expunge=False)

        # Mock inspect (shouldn't be called)
        mock_inspect = mocker.patch("advanced_alchemy.repository._async.inspect")

        # Call _expunge with auto_expunge=False
        repo._expunge(mock_instance, auto_expunge=False)

        # Verify: Neither inspect nor expunge were called
        mock_inspect.assert_not_called()
        session.expunge.assert_not_called()

    def test_expunge_handles_none_state_async(
        self,
        mock_instance: MagicMock,
        mocker: MockerFixture,
    ) -> None:
        """Test that _expunge() handles inspect returning None gracefully."""
        # Setup
        session = AsyncMock(spec=AsyncSession, bind=MagicMock())
        repo = ExpungeTestModelRepository(session=session, auto_expunge=True)

        # Mock inspect to return None (edge case)
        mocker.patch("advanced_alchemy.repository._async.inspect", return_value=None)

        # Call _expunge - should call session.expunge (no state check passes)
        result = repo._expunge(mock_instance, auto_expunge=True)  # type: ignore[func-returns-value]

        # Verify: expunge WAS called (None state doesn't have .deleted)
        session.expunge.assert_called_once_with(mock_instance)
        assert result is None

    def test_expunge_skips_detached_objects_async(
        self,
        mock_instance: MagicMock,
        mocker: MockerFixture,
    ) -> None:
        """Test that _expunge() skips detached objects.

        This handles the case where objects from DELETE...RETURNING have
        already been detached after commit.
        """
        # Setup
        session = AsyncMock(spec=AsyncSession, bind=MagicMock())
        repo = ExpungeTestModelRepository(session=session, auto_expunge=True)

        # Mock inspect to return detached state
        mock_state = MagicMock()
        mock_state.deleted = False
        mock_state.detached = True
        mocker.patch("advanced_alchemy.repository._async.inspect", return_value=mock_state)

        # Call _expunge - should not call session.expunge
        result = repo._expunge(mock_instance, auto_expunge=True)  # type: ignore[func-returns-value]

        # Verify: expunge was NOT called (object is detached)
        session.expunge.assert_not_called()
        assert result is None

    def test_expunge_skips_deleted_objects_sync(
        self,
        mock_instance: MagicMock,
        mocker: MockerFixture,
    ) -> None:
        """Test that _expunge() skips deleted objects in sync repository."""
        # Setup
        session = MagicMock(spec=Session, bind=MagicMock())
        repo = ExpungeTestModelRepositorySync(session=session, auto_expunge=True)

        # Mock inspect to return deleted state
        mock_state = MagicMock()
        mock_state.deleted = True
        mocker.patch("advanced_alchemy.repository._sync.inspect", return_value=mock_state)

        # Call _expunge - should not call session.expunge
        repo._expunge(mock_instance, auto_expunge=True)

        # Verify: expunge was NOT called
        session.expunge.assert_not_called()

    def test_expunge_calls_session_for_non_deleted_sync(
        self,
        mock_instance: MagicMock,
        mocker: MockerFixture,
    ) -> None:
        """Test that _expunge() calls session.expunge for non-deleted objects in sync repo."""
        # Setup
        session = MagicMock(spec=Session, bind=MagicMock())
        repo = ExpungeTestModelRepositorySync(session=session, auto_expunge=True)

        # Mock inspect to return non-deleted, non-detached state
        mock_state = MagicMock()
        mock_state.deleted = False
        mock_state.detached = False
        mocker.patch("advanced_alchemy.repository._sync.inspect", return_value=mock_state)

        # Call _expunge
        repo._expunge(mock_instance, auto_expunge=True)

        # Verify: expunge WAS called
        session.expunge.assert_called_once_with(mock_instance)


class TestDeleteMethodsStateChecking:
    """Test that delete methods interact correctly with the updated _expunge()."""

    @pytest.fixture
    def mock_instance(self) -> MagicMock:
        """Create a mock instance."""
        instance = MagicMock()
        instance.id = "test-id"
        return instance

    async def test_delete_with_auto_expunge_does_not_raise(
        self,
        mock_instance: MagicMock,
        mocker: MockerFixture,
    ) -> None:
        """Integration test: delete() with auto_expunge=True should not raise InvalidRequestError.

        This is the high-level regression test for issue #514.
        """
        # Setup
        session = AsyncMock(spec=AsyncSession, bind=MagicMock())
        repo = ExpungeTestModelRepository(session=session, auto_expunge=True, auto_commit=True)

        # Mock get to return our instance (delete() calls get, not get_one)
        mocker.patch.object(repo, "get", new=AsyncMock(return_value=mock_instance))

        # Mock inspect to simulate deleted state after commit
        mock_state = MagicMock()
        mock_state.deleted = True
        mocker.patch("advanced_alchemy.repository._async.inspect", return_value=mock_state)

        # Mock session.delete and commit
        session.delete = AsyncMock()
        session.commit = AsyncMock()

        # This should NOT raise InvalidRequestError
        result = await repo.delete("test-id")

        # Verify the instance was returned
        assert result == mock_instance

        # Verify session.expunge was NOT called (because object is deleted)
        session.expunge.assert_not_called()

    async def test_delete_many_with_auto_expunge_does_not_raise(
        self,
        mocker: MockerFixture,
    ) -> None:
        """Integration test: delete_many() with auto_expunge should not raise."""
        # Setup
        session = AsyncMock(spec=AsyncSession, bind=MagicMock())
        repo = ExpungeTestModelRepository(session=session, auto_expunge=True, auto_commit=True)

        instances = [MagicMock(id=f"id-{i}") for i in range(3)]

        # Mock _get_delete_many_statement to return a statement
        mocker.patch.object(repo, "_get_delete_many_statement", return_value=MagicMock())

        # Mock scalars to return instances - needs to be an async iterable
        async def mock_scalars(*args: Any, **kwargs: Any) -> Any:
            """Mock that returns an async iterable of instances."""

            class AsyncIterableResult:
                def __iter__(self) -> Any:
                    return iter(instances)

            return AsyncIterableResult()

        session.scalars = mock_scalars

        # Mock dialect to support returning
        repo._dialect.delete_executemany_returning = True

        # Mock inspect to simulate deleted state
        mock_state = MagicMock()
        mock_state.deleted = True
        mocker.patch("advanced_alchemy.repository._async.inspect", return_value=mock_state)

        # This should NOT raise InvalidRequestError
        result = await repo.delete_many(["id-1", "id-2", "id-3"])

        # Verify instances were returned
        assert len(result) == 3

        # Verify session.expunge was NOT called for any instance
        session.expunge.assert_not_called()
python-advanced-alchemy-1.9.3/tests/unit/test_repository_memory_error_messages.py000066400000000000000000000073531516556515500307170ustar00rootroot00000000000000from __future__ import annotations

from typing import Any, cast
from unittest.mock import create_autospec

from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
from sqlalchemy.orm import Session

from advanced_alchemy.repository._util import DEFAULT_ERROR_MESSAGE_TEMPLATES
from advanced_alchemy.repository.memory import (
    SQLAlchemyAsyncMockRepository,
    SQLAlchemySyncMockRepository,
)


def _make_async_session() -> AsyncSession:
    session = cast(AsyncSession, create_autospec(AsyncSession, instance=True))
    engine = cast(AsyncEngine, create_autospec(AsyncEngine, instance=True))
    engine.dialect.name = "mock"
    session.bind = engine
    session.get_bind.return_value = engine
    return session


def _make_sync_session() -> Session:
    session = cast(Session, create_autospec(Session, instance=True))
    session.bind = cast(Any, create_autospec(object, instance=True))
    return session


def test_async_mock_repository_error_messages_isolated() -> None:
    class BaseRepo(SQLAlchemyAsyncMockRepository[Any]):
        model_type = object

    class RepoA(BaseRepo):
        error_messages = {"not_found": "Async Repo A"}

    class RepoB(BaseRepo):
        error_messages = {"not_found": "Async Repo B"}

    repo_a_first = RepoA(session=_make_async_session())
    repo_b = RepoB(session=_make_async_session())
    repo_a_second = RepoA(session=_make_async_session())

    assert repo_a_first.error_messages is not DEFAULT_ERROR_MESSAGE_TEMPLATES
    assert repo_a_first.error_messages is not repo_b.error_messages
    assert repo_a_first.error_messages["not_found"] == "Async Repo A"
    assert repo_b.error_messages["not_found"] == "Async Repo B"
    assert repo_a_second.error_messages["not_found"] == "Async Repo A"
    assert DEFAULT_ERROR_MESSAGE_TEMPLATES["not_found"] == "The requested resource was not found"


def test_async_mock_repository_instance_override_does_not_mutate_class() -> None:
    class Repo(SQLAlchemyAsyncMockRepository[Any]):
        model_type = object
        error_messages = {"other": "default other"}

    repo_custom = Repo(session=_make_async_session(), error_messages={"other": "custom other"})
    repo_plain = Repo(session=_make_async_session())

    assert repo_custom.error_messages["other"] == "custom other"
    assert repo_plain.error_messages["other"] == "default other"
    assert Repo.error_messages["other"] == "default other"


def test_sync_mock_repository_error_messages_isolated() -> None:
    class BaseRepo(SQLAlchemySyncMockRepository[Any]):
        model_type = object

    class RepoA(BaseRepo):
        error_messages = {"not_found": "Sync Repo A"}

    class RepoB(BaseRepo):
        error_messages = {"not_found": "Sync Repo B"}

    repo_a_first = RepoA(session=_make_sync_session())
    repo_b = RepoB(session=_make_sync_session())
    repo_a_second = RepoA(session=_make_sync_session())

    assert repo_a_first.error_messages is not DEFAULT_ERROR_MESSAGE_TEMPLATES
    assert repo_a_first.error_messages is not repo_b.error_messages
    assert repo_a_first.error_messages["not_found"] == "Sync Repo A"
    assert repo_b.error_messages["not_found"] == "Sync Repo B"
    assert repo_a_second.error_messages["not_found"] == "Sync Repo A"


def test_sync_mock_repository_instance_override_does_not_mutate_class() -> None:
    class Repo(SQLAlchemySyncMockRepository[Any]):
        model_type = object
        error_messages = {"duplicate_key": "sync default"}

    repo_custom = Repo(session=_make_sync_session(), error_messages={"duplicate_key": "custom sync"})
    repo_plain = Repo(session=_make_sync_session())

    assert repo_custom.error_messages["duplicate_key"] == "custom sync"
    assert repo_plain.error_messages["duplicate_key"] == "sync default"
    assert Repo.error_messages["duplicate_key"] == "sync default"
python-advanced-alchemy-1.9.3/tests/unit/test_routing/000077500000000000000000000000001516556515500231155ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/tests/unit/test_routing/__init__.py000066400000000000000000000000511516556515500252220ustar00rootroot00000000000000"""Unit tests for read/write routing."""
python-advanced-alchemy-1.9.3/tests/unit/test_routing/conftest.py000066400000000000000000000012231516556515500253120ustar00rootroot00000000000000"""Pytest configuration for routing unit tests.

The routing subsystem uses ``ContextVar`` state to implement session-level read/write routing.
That state is process-local, so it can leak between tests when running without xdist or when
xdist schedules related tests onto the same worker.
"""

from collections.abc import Iterator

import pytest

from advanced_alchemy.routing.context import reset_routing_context


@pytest.fixture(autouse=True)
def _reset_routing_context() -> Iterator[None]:
    """Ensure routing ContextVar state never leaks between tests."""
    reset_routing_context()
    try:
        yield
    finally:
        reset_routing_context()
python-advanced-alchemy-1.9.3/tests/unit/test_routing/test_routing_async_session.py000066400000000000000000000064111516556515500311570ustar00rootroot00000000000000"""Unit tests for RoutingAsyncSession.

Tests the async session wrapper for routing.
Note: Some tests are limited because RoutingAsyncSession initialization
requires special handling that doesn't work well with simple mocks.
Full integration tests are in test_routing_maker.py.
"""

from unittest.mock import MagicMock

from sqlalchemy.ext.asyncio import AsyncEngine

from advanced_alchemy.routing.selectors import RoundRobinSelector
from advanced_alchemy.routing.session import RoutingAsyncSession, RoutingSyncSession


def test_routing_async_session_class_attribute_sync_session_class() -> None:
    """Test that sync_session_class is set to RoutingSyncSession."""
    assert RoutingAsyncSession.sync_session_class == RoutingSyncSession


def test_sync_replica_selector_wrapper_has_replicas() -> None:
    """Test _SyncReplicaSelectorWrapper.has_replicas()."""
    from advanced_alchemy.routing.session import _SyncEngineSelectorWrapper

    async_engines: list[AsyncEngine] = []
    for i in range(2):
        async_engine: AsyncEngine = MagicMock()
        setattr(async_engine, "sync_engine", MagicMock(name=f"sync_engine_{i}"))
        async_engines.append(async_engine)

    async_selector: RoundRobinSelector[AsyncEngine] = RoundRobinSelector(async_engines)
    wrapper = _SyncEngineSelectorWrapper(async_selector)

    assert wrapper.has_replicas() is True


def test_sync_replica_selector_wrapper_has_no_replicas() -> None:
    """Test _SyncReplicaSelectorWrapper.has_replicas() with empty selector."""
    from advanced_alchemy.routing.session import _SyncEngineSelectorWrapper

    async_selector: RoundRobinSelector[AsyncEngine] = RoundRobinSelector([])
    wrapper = _SyncEngineSelectorWrapper(async_selector)

    assert wrapper.has_replicas() is False


def test_sync_replica_selector_wrapper_next() -> None:
    """Test _SyncReplicaSelectorWrapper.next() returns sync engine."""
    from advanced_alchemy.routing.session import _SyncEngineSelectorWrapper

    async_engines: list[AsyncEngine] = []
    for i in range(2):
        async_engine: AsyncEngine = MagicMock()
        setattr(async_engine, "sync_engine", MagicMock(name=f"sync_engine_{i}"))
        async_engines.append(async_engine)

    async_selector: RoundRobinSelector[AsyncEngine] = RoundRobinSelector(async_engines)
    wrapper = _SyncEngineSelectorWrapper(async_selector)

    sync_engine = wrapper.next()

    assert sync_engine is async_engines[0].sync_engine


def test_sync_replica_selector_wrapper_cycles_through_sync_engines() -> None:
    """Test _SyncReplicaSelectorWrapper cycles through sync engines."""
    from advanced_alchemy.routing.session import _SyncEngineSelectorWrapper

    async_engines: list[AsyncEngine] = []
    for i in range(2):
        async_engine: AsyncEngine = MagicMock()
        setattr(async_engine, "sync_engine", MagicMock(name=f"sync_engine_{i}"))
        async_engines.append(async_engine)

    async_selector: RoundRobinSelector[AsyncEngine] = RoundRobinSelector(async_engines)
    wrapper = _SyncEngineSelectorWrapper(async_selector)

    sync_engine_0 = wrapper.next()
    assert sync_engine_0 is async_engines[0].sync_engine

    sync_engine_1 = wrapper.next()
    assert sync_engine_1 is async_engines[1].sync_engine

    sync_engine_2 = wrapper.next()
    assert sync_engine_2 is async_engines[0].sync_engine
python-advanced-alchemy-1.9.3/tests/unit/test_routing/test_routing_config.py000066400000000000000000000235501516556515500275470ustar00rootroot00000000000000"""Unit tests for routing configuration classes.

Tests the configuration classes used for read/write routing setup.
"""

from advanced_alchemy.config.routing import ReplicaConfig, RoutingConfig, RoutingStrategy


def test_routing_strategy_enum() -> None:
    """Test that RoutingStrategy enum has expected values."""
    assert RoutingStrategy.ROUND_ROBIN is not None
    assert RoutingStrategy.RANDOM is not None
    assert len(RoutingStrategy) == 2


def test_replica_config_defaults() -> None:
    """Test ReplicaConfig with default values."""
    replica = ReplicaConfig(connection_string="postgresql://replica:5432/db")

    assert replica.connection_string == "postgresql://replica:5432/db"
    assert replica.weight == 1
    assert replica.name == ""


def test_replica_config_custom_values() -> None:
    """Test ReplicaConfig with custom values."""
    replica = ReplicaConfig(
        connection_string="postgresql://replica:5432/db",
        weight=5,
        name="replica-1",
    )

    assert replica.connection_string == "postgresql://replica:5432/db"
    assert replica.weight == 5
    assert replica.name == "replica-1"


def test_routing_config_defaults() -> None:
    """Test RoutingConfig with default values."""
    config = RoutingConfig(primary_connection_string="postgresql://primary:5432/db")

    assert config.primary_connection_string == "postgresql://primary:5432/db"
    assert config.read_replicas == []
    assert config.routing_strategy == RoutingStrategy.ROUND_ROBIN
    assert config.enabled is True
    assert config.sticky_after_write is True
    assert config.reset_stickiness_on_commit is True


def test_routing_config_with_string_replicas() -> None:
    """Test RoutingConfig with replica connection strings."""
    config = RoutingConfig(
        primary_connection_string="postgresql://primary:5432/db",
        read_replicas=[
            "postgresql://replica1:5432/db",
            "postgresql://replica2:5432/db",
        ],
    )

    assert len(config.read_replicas) == 2
    assert config.read_replicas[0] == "postgresql://replica1:5432/db"
    assert config.read_replicas[1] == "postgresql://replica2:5432/db"


def test_routing_config_with_replica_configs() -> None:
    """Test RoutingConfig with ReplicaConfig objects."""
    config = RoutingConfig(
        primary_connection_string="postgresql://primary:5432/db",
        read_replicas=[
            ReplicaConfig(
                connection_string="postgresql://replica1:5432/db",
                weight=2,
                name="replica-1",
            ),
            ReplicaConfig(
                connection_string="postgresql://replica2:5432/db",
                weight=1,
                name="replica-2",
            ),
        ],
    )

    assert len(config.read_replicas) == 2
    assert isinstance(config.read_replicas[0], ReplicaConfig)
    assert isinstance(config.read_replicas[1], ReplicaConfig)


def test_routing_config_mixed_replicas() -> None:
    """Test RoutingConfig with mixed string and ReplicaConfig replicas."""
    config = RoutingConfig(
        primary_connection_string="postgresql://primary:5432/db",
        read_replicas=[
            "postgresql://replica1:5432/db",
            ReplicaConfig(
                connection_string="postgresql://replica2:5432/db",
                weight=2,
                name="replica-2",
            ),
        ],
    )

    assert len(config.read_replicas) == 2
    assert isinstance(config.read_replicas[0], str)
    assert isinstance(config.read_replicas[1], ReplicaConfig)


def test_routing_config_custom_strategy() -> None:
    """Test RoutingConfig with custom routing strategy."""
    config = RoutingConfig(
        primary_connection_string="postgresql://primary:5432/db",
        read_replicas=["postgresql://replica1:5432/db"],
        routing_strategy=RoutingStrategy.RANDOM,
    )

    assert config.routing_strategy == RoutingStrategy.RANDOM


def test_routing_config_disabled() -> None:
    """Test RoutingConfig with routing disabled."""
    config = RoutingConfig(
        primary_connection_string="postgresql://primary:5432/db",
        read_replicas=["postgresql://replica1:5432/db"],
        enabled=False,
    )

    assert config.enabled is False


def test_routing_config_no_sticky_after_write() -> None:
    """Test RoutingConfig with sticky_after_write disabled."""
    config = RoutingConfig(
        primary_connection_string="postgresql://primary:5432/db",
        read_replicas=["postgresql://replica1:5432/db"],
        sticky_after_write=False,
    )

    assert config.sticky_after_write is False


def test_routing_config_no_reset_on_commit() -> None:
    """Test RoutingConfig with reset_stickiness_on_commit disabled."""
    config = RoutingConfig(
        primary_connection_string="postgresql://primary:5432/db",
        read_replicas=["postgresql://replica1:5432/db"],
        reset_stickiness_on_commit=False,
    )

    assert config.reset_stickiness_on_commit is False


def test_get_replica_connection_strings_empty() -> None:
    """Test get_engine_configs with no replicas."""
    config = RoutingConfig(primary_connection_string="postgresql://primary:5432/db")

    connection_strings = [c.connection_string for c in config.get_engine_configs(config.read_group)]

    assert connection_strings == []


def test_get_replica_connection_strings_from_strings() -> None:
    """Test get_engine_configs with string replicas."""
    config = RoutingConfig(
        primary_connection_string="postgresql://primary:5432/db",
        read_replicas=[
            "postgresql://replica1:5432/db",
            "postgresql://replica2:5432/db",
        ],
    )

    connection_strings = [c.connection_string for c in config.get_engine_configs(config.read_group)]

    assert connection_strings == [
        "postgresql://replica1:5432/db",
        "postgresql://replica2:5432/db",
    ]


def test_get_replica_connection_strings_from_configs() -> None:
    """Test get_engine_configs with ReplicaConfig objects."""
    config = RoutingConfig(
        primary_connection_string="postgresql://primary:5432/db",
        read_replicas=[
            ReplicaConfig(connection_string="postgresql://replica1:5432/db"),
            ReplicaConfig(connection_string="postgresql://replica2:5432/db"),
        ],
    )

    connection_strings = [c.connection_string for c in config.get_engine_configs(config.read_group)]

    assert connection_strings == [
        "postgresql://replica1:5432/db",
        "postgresql://replica2:5432/db",
    ]


def test_get_replica_connection_strings_mixed() -> None:
    """Test get_engine_configs with mixed replicas."""
    config = RoutingConfig(
        primary_connection_string="postgresql://primary:5432/db",
        read_replicas=[
            "postgresql://replica1:5432/db",
            ReplicaConfig(connection_string="postgresql://replica2:5432/db"),
        ],
    )

    connection_strings = [c.connection_string for c in config.get_engine_configs(config.read_group)]

    assert connection_strings == [
        "postgresql://replica1:5432/db",
        "postgresql://replica2:5432/db",
    ]


def test_get_replica_configs_empty() -> None:
    """Test get_engine_configs with no replicas."""
    config = RoutingConfig(primary_connection_string="postgresql://primary:5432/db")

    replica_configs = config.get_engine_configs(config.read_group)

    assert replica_configs == []


def test_get_replica_configs_from_strings() -> None:
    """Test get_engine_configs converts strings to ReplicaConfig objects."""
    config = RoutingConfig(
        primary_connection_string="postgresql://primary:5432/db",
        read_replicas=[
            "postgresql://replica1:5432/db",
            "postgresql://replica2:5432/db",
        ],
    )

    replica_configs = config.get_engine_configs(config.read_group)

    assert len(replica_configs) == 2
    assert all(isinstance(r, ReplicaConfig) for r in replica_configs)
    assert replica_configs[0].connection_string == "postgresql://replica1:5432/db"
    assert replica_configs[1].connection_string == "postgresql://replica2:5432/db"
    assert replica_configs[0].weight == 1
    assert replica_configs[0].name == ""


def test_get_replica_configs_from_configs() -> None:
    """Test get_engine_configs returns ReplicaConfig objects as-is."""
    config = RoutingConfig(
        primary_connection_string="postgresql://primary:5432/db",
        read_replicas=[
            ReplicaConfig(
                connection_string="postgresql://replica1:5432/db",
                weight=2,
                name="replica-1",
            ),
            ReplicaConfig(
                connection_string="postgresql://replica2:5432/db",
                weight=1,
                name="replica-2",
            ),
        ],
    )

    replica_configs = config.get_engine_configs(config.read_group)

    assert len(replica_configs) == 2
    assert replica_configs[0].weight == 2
    assert replica_configs[0].name == "replica-1"
    assert replica_configs[1].weight == 1
    assert replica_configs[1].name == "replica-2"


def test_get_replica_configs_mixed() -> None:
    """Test get_engine_configs with mixed string and ReplicaConfig replicas."""
    config = RoutingConfig(
        primary_connection_string="postgresql://primary:5432/db",
        read_replicas=[
            "postgresql://replica1:5432/db",
            ReplicaConfig(
                connection_string="postgresql://replica2:5432/db",
                weight=3,
                name="replica-2",
            ),
        ],
    )

    replica_configs = config.get_engine_configs(config.read_group)

    assert len(replica_configs) == 2
    assert all(isinstance(r, ReplicaConfig) for r in replica_configs)
    assert replica_configs[0].connection_string == "postgresql://replica1:5432/db"
    assert replica_configs[0].weight == 1
    assert replica_configs[0].name == ""
    assert replica_configs[1].connection_string == "postgresql://replica2:5432/db"
    assert replica_configs[1].weight == 3
    assert replica_configs[1].name == "replica-2"
python-advanced-alchemy-1.9.3/tests/unit/test_routing/test_routing_context.py000066400000000000000000000150771516556515500277730ustar00rootroot00000000000000"""Unit tests for routing context variables and context managers.

Tests the context-based state management for routing decisions.
"""

import pytest

from advanced_alchemy.routing.context import (
    force_primary_var,
    primary_context,
    replica_context,
    reset_routing_context,
    set_sticky_primary,
    should_use_primary,
    stick_to_primary_var,
)


def test_context_vars_default_values() -> None:
    """Test that context variables have correct default values."""
    reset_routing_context()

    assert stick_to_primary_var.get() is False
    assert force_primary_var.get() is False


def test_set_sticky_primary() -> None:
    """Test that set_sticky_primary sets the sticky flag."""
    reset_routing_context()

    set_sticky_primary()

    assert stick_to_primary_var.get() is True


def test_reset_routing_context() -> None:
    """Test that reset_routing_context resets all flags."""
    stick_to_primary_var.set(True)
    force_primary_var.set(True)

    assert stick_to_primary_var.get() is True
    assert force_primary_var.get() is True

    reset_routing_context()

    assert stick_to_primary_var.get() is False
    assert force_primary_var.get() is False


def test_should_use_primary_when_not_forced() -> None:
    """Test should_use_primary returns False when no flags are set."""
    reset_routing_context()

    assert should_use_primary() is False


def test_should_use_primary_when_sticky() -> None:
    """Test should_use_primary returns True when sticky flag is set."""
    reset_routing_context()
    stick_to_primary_var.set(True)

    assert should_use_primary() is True


def test_should_use_primary_when_forced() -> None:
    """Test should_use_primary returns True when force flag is set."""
    reset_routing_context()
    force_primary_var.set(True)

    assert should_use_primary() is True


def test_should_use_primary_when_both_set() -> None:
    """Test should_use_primary returns True when both flags are set."""
    reset_routing_context()
    stick_to_primary_var.set(True)
    force_primary_var.set(True)

    assert should_use_primary() is True


def test_primary_context_forces_primary() -> None:
    """Test that primary_context sets force_primary flag."""
    reset_routing_context()

    assert force_primary_var.get() is False

    with primary_context():
        assert force_primary_var.get() is True

    assert force_primary_var.get() is False


def test_primary_context_resets_on_exit() -> None:
    """Test that primary_context properly resets the flag on exit."""
    reset_routing_context()

    with primary_context():
        assert force_primary_var.get() is True

    assert force_primary_var.get() is False


def test_primary_context_resets_on_exception() -> None:
    """Test that primary_context resets the flag even on exception."""
    reset_routing_context()

    def _raise_in_primary_context() -> None:
        with primary_context():
            assert force_primary_var.get() is True
            raise ValueError("test exception")

    with pytest.raises(ValueError):
        _raise_in_primary_context()

    assert force_primary_var.get() is False


def test_primary_context_nested() -> None:
    """Test that primary_context works correctly when nested."""
    reset_routing_context()

    with primary_context():
        assert force_primary_var.get() is True

        with primary_context():
            assert force_primary_var.get() is True

        assert force_primary_var.get() is True

    assert force_primary_var.get() is False


def test_replica_context_clears_sticky_flag() -> None:
    """Test that replica_context clears the sticky flag."""
    reset_routing_context()
    stick_to_primary_var.set(True)

    assert stick_to_primary_var.get() is True

    with replica_context():
        assert stick_to_primary_var.get() is False

    assert stick_to_primary_var.get() is True


def test_replica_context_clears_force_flag() -> None:
    """Test that replica_context clears the force flag."""
    reset_routing_context()
    force_primary_var.set(True)

    assert force_primary_var.get() is True

    with replica_context():
        assert force_primary_var.get() is False

    assert force_primary_var.get() is True


def test_replica_context_clears_both_flags() -> None:
    """Test that replica_context clears both flags."""
    reset_routing_context()
    stick_to_primary_var.set(True)
    force_primary_var.set(True)

    with replica_context():
        assert stick_to_primary_var.get() is False
        assert force_primary_var.get() is False

    assert stick_to_primary_var.get() is True
    assert force_primary_var.get() is True


def test_replica_context_resets_on_exception() -> None:
    """Test that replica_context properly resets flags on exception."""
    reset_routing_context()
    stick_to_primary_var.set(True)
    force_primary_var.set(True)

    def _raise_in_replica_context() -> None:
        with replica_context():
            assert stick_to_primary_var.get() is False
            assert force_primary_var.get() is False
            raise ValueError("test exception")

    with pytest.raises(ValueError):
        _raise_in_replica_context()

    assert stick_to_primary_var.get() is True
    assert force_primary_var.get() is True


def test_context_vars_are_isolated_per_context() -> None:
    """Test that context variables are isolated per execution context."""
    from contextvars import copy_context

    reset_routing_context()

    def set_force() -> None:
        force_primary_var.set(True)

    ctx = copy_context()
    ctx.run(set_force)

    assert force_primary_var.get() is False

    assert ctx.run(lambda: force_primary_var.get()) is True


def test_primary_and_replica_contexts_can_be_nested() -> None:
    """Test that primary_context and replica_context can be nested."""
    reset_routing_context()

    stick_to_primary_var.set(True)

    with replica_context():
        assert stick_to_primary_var.get() is False
        assert force_primary_var.get() is False

        with primary_context():
            assert force_primary_var.get() is True
            assert stick_to_primary_var.get() is False

        assert force_primary_var.get() is False

    assert stick_to_primary_var.get() is True
    assert force_primary_var.get() is False


def test_context_manager_as_decorator_not_supported() -> None:
    """Test that context managers are not meant to be used as decorators.

    This is a documentation test - the context managers should be used
    with 'with' statements, not as function decorators.
    """
    reset_routing_context()

    with primary_context():
        assert force_primary_var.get() is True

    assert force_primary_var.get() is False
python-advanced-alchemy-1.9.3/tests/unit/test_routing/test_routing_maker.py000066400000000000000000000346751516556515500274130ustar00rootroot00000000000000"""Unit tests for routing session makers.

Tests the factory classes that create routing sessions.
"""

from typing import Any
from unittest.mock import MagicMock

import pytest
from sqlalchemy import Engine
from sqlalchemy.ext.asyncio import AsyncEngine

from advanced_alchemy.config.routing import RoutingConfig, RoutingStrategy
from advanced_alchemy.routing.maker import RoutingAsyncSessionMaker, RoutingSyncSessionMaker
from advanced_alchemy.routing.selectors import RandomSelector, RoundRobinSelector
from advanced_alchemy.routing.session import RoutingSyncSession


@pytest.fixture
def routing_config() -> RoutingConfig:
    """Create a routing config with two replicas."""
    return RoutingConfig(
        primary_connection_string="postgresql://primary:5432/db",
        read_replicas=[
            "postgresql://replica1:5432/db",
            "postgresql://replica2:5432/db",
        ],
    )


def test_sync_session_maker_initialization(routing_config: RoutingConfig) -> None:
    """Test RoutingSyncSessionMaker initialization."""

    def create_mock_engine(url: str, **kwargs: Any) -> Engine:
        engine = MagicMock(spec=["dispose"])
        engine.url = url
        return engine

    maker = RoutingSyncSessionMaker(
        routing_config=routing_config,
        create_engine_callable=create_mock_engine,
    )

    assert maker.primary_engine is not None
    assert len(maker.replica_engines) == 2
    assert maker.primary_engine.url == "postgresql://primary:5432/db"
    assert maker.replica_engines[0].url == "postgresql://replica1:5432/db"
    assert maker.replica_engines[1].url == "postgresql://replica2:5432/db"


def test_sync_session_maker_creates_round_robin_selector_by_default(
    routing_config: RoutingConfig,
) -> None:
    """Test that RoutingSyncSessionMaker creates RoundRobinSelector by default."""

    def create_mock_engine(url: str, **kwargs: Any) -> Engine:
        engine = MagicMock(spec=["dispose"])
        engine.url = url
        return engine

    maker = RoutingSyncSessionMaker(
        routing_config=routing_config,
        create_engine_callable=create_mock_engine,
    )

    session = maker()
    assert isinstance(session._selectors[routing_config.read_group], RoundRobinSelector)


def test_sync_session_maker_creates_random_selector(routing_config: RoutingConfig) -> None:
    """Test that RoutingSyncSessionMaker creates RandomSelector when configured."""
    config = RoutingConfig(
        primary_connection_string="postgresql://primary:5432/db",
        read_replicas=["postgresql://replica1:5432/db"],
        routing_strategy=RoutingStrategy.RANDOM,
    )

    def create_mock_engine(url: str, **kwargs: Any) -> Engine:
        engine = MagicMock(spec=["dispose"])
        engine.url = url
        return engine

    maker = RoutingSyncSessionMaker(
        routing_config=config,
        create_engine_callable=create_mock_engine,
    )

    session = maker()
    assert isinstance(session._selectors[config.read_group], RandomSelector)


def test_sync_session_maker_call_creates_session(routing_config: RoutingConfig) -> None:
    """Test that calling the maker creates a RoutingSyncSession."""

    def create_mock_engine(url: str, **kwargs: Any) -> Engine:
        return MagicMock(spec=["dispose"])

    maker = RoutingSyncSessionMaker(
        routing_config=routing_config,
        create_engine_callable=create_mock_engine,
    )

    session = maker()

    assert isinstance(session, RoutingSyncSession)
    assert session._routing_config is routing_config


def test_sync_session_maker_passes_engine_config() -> None:
    """Test that RoutingSyncSessionMaker passes engine config to create_engine."""
    config = RoutingConfig(
        primary_connection_string="postgresql://primary:5432/db",
        read_replicas=["postgresql://replica1:5432/db"],
    )

    engine_config = {"pool_size": 10, "max_overflow": 20}

    mock_create_engine = MagicMock(return_value=MagicMock(spec=["dispose"]))

    RoutingSyncSessionMaker(
        routing_config=config,
        engine_config=engine_config,
        create_engine_callable=mock_create_engine,
    )

    assert mock_create_engine.call_count == 2
    for call in mock_create_engine.call_args_list:
        _, kwargs = call
        assert kwargs.get("pool_size") == 10
        assert kwargs.get("max_overflow") == 20


def test_sync_session_maker_passes_session_config(routing_config: RoutingConfig) -> None:
    """Test that RoutingSyncSessionMaker passes session config to session."""

    def create_mock_engine(url: str, **kwargs: Any) -> Engine:
        return MagicMock(spec=["dispose"])

    session_config = {"expire_on_commit": False, "autoflush": False}

    maker = RoutingSyncSessionMaker(
        routing_config=routing_config,
        engine_config={},
        session_config=session_config,
        create_engine_callable=create_mock_engine,
    )

    session = maker()

    assert session.expire_on_commit is False
    assert session.autoflush is False


def test_sync_session_maker_close_all_disposes_engines(routing_config: RoutingConfig) -> None:
    """Test that close_all disposes all engines."""
    mock_engines = []

    def create_mock_engine(url: str, **kwargs: Any) -> Engine:
        engine = MagicMock(spec=["dispose"])
        mock_engines.append(engine)
        return engine

    maker = RoutingSyncSessionMaker(
        routing_config=routing_config,
        create_engine_callable=create_mock_engine,
    )

    maker.close_all()

    assert len(mock_engines) == 3
    for engine in mock_engines:
        engine.dispose.assert_called_once()


def test_sync_session_maker_handles_engine_creation_errors() -> None:
    """Test that RoutingSyncSessionMaker handles TypeError from unsupported engine options."""
    config = RoutingConfig(
        primary_connection_string="postgresql://primary:5432/db",
        read_replicas=["postgresql://replica1:5432/db"],
    )

    call_count = 0

    def create_mock_engine(url: str, **kwargs: Any) -> Engine:
        nonlocal call_count
        call_count += 1

        if "json_serializer" in kwargs:
            raise TypeError("json_serializer not supported")

        engine = MagicMock(spec=["dispose"])
        engine.url = url
        return engine

    engine_config = {"pool_size": 10, "json_serializer": lambda x: x}

    maker = RoutingSyncSessionMaker(
        routing_config=config,
        engine_config=engine_config,
        create_engine_callable=create_mock_engine,
    )

    assert maker.primary_engine is not None
    assert len(maker.replica_engines) == 1


def test_async_session_maker_initialization(routing_config: RoutingConfig) -> None:
    """Test RoutingAsyncSessionMaker initialization."""

    def create_mock_async_engine(url: str, **kwargs: Any) -> AsyncEngine:
        engine = MagicMock(spec=["dispose", "sync_engine"])
        engine.url = url
        engine.sync_engine = MagicMock()
        return engine

    maker = RoutingAsyncSessionMaker(
        routing_config=routing_config,
        create_engine_callable=create_mock_async_engine,
    )

    assert maker.primary_engine is not None
    assert len(maker.replica_engines) == 2
    assert maker.primary_engine.url == "postgresql://primary:5432/db"


def test_async_session_maker_creates_round_robin_selector_by_default(
    routing_config: RoutingConfig,
) -> None:
    """Test that RoutingAsyncSessionMaker creates RoundRobinSelector by default."""

    def create_mock_async_engine(url: str, **kwargs: Any) -> AsyncEngine:
        engine = MagicMock(spec=["dispose", "sync_engine"])
        engine.sync_engine = MagicMock()
        return engine

    maker = RoutingAsyncSessionMaker(
        routing_config=routing_config,
        create_engine_callable=create_mock_async_engine,
    )

    assert isinstance(maker._selectors[routing_config.read_group], RoundRobinSelector)


def test_async_session_maker_creates_random_selector(routing_config: RoutingConfig) -> None:
    """Test that RoutingAsyncSessionMaker creates RandomSelector when configured."""
    config = RoutingConfig(
        primary_connection_string="postgresql://primary:5432/db",
        read_replicas=["postgresql://replica1:5432/db"],
        routing_strategy=RoutingStrategy.RANDOM,
    )

    def create_mock_async_engine(url: str, **kwargs: Any) -> AsyncEngine:
        engine = MagicMock(spec=["dispose", "sync_engine"])
        engine.sync_engine = MagicMock()
        return engine

    maker = RoutingAsyncSessionMaker(
        routing_config=config,
        create_engine_callable=create_mock_async_engine,
    )

    assert isinstance(maker._selectors[config.read_group], RandomSelector)


def test_async_session_maker_call_creates_session(routing_config: RoutingConfig) -> None:
    """Test that calling the async maker would create a RoutingAsyncSession.

    Note: We can't fully test this with mocks because RoutingAsyncSession
    initialization requires special engine handling. This test is covered
    in integration tests instead.
    """

    def create_mock_async_engine(url: str, **kwargs: Any) -> AsyncEngine:
        engine = MagicMock(spec=["dispose", "sync_engine"])
        engine.sync_engine = MagicMock()
        return engine

    maker = RoutingAsyncSessionMaker(
        routing_config=routing_config,
        create_engine_callable=create_mock_async_engine,
    )

    assert maker.primary_engine is not None
    assert len(maker.replica_engines) == 2


def test_async_session_maker_passes_engine_config() -> None:
    """Test that RoutingAsyncSessionMaker passes engine config to create_async_engine."""
    config = RoutingConfig(
        primary_connection_string="postgresql://primary:5432/db",
        read_replicas=["postgresql://replica1:5432/db"],
    )

    engine_config = {"pool_size": 10, "max_overflow": 20}

    mock_create_engine = MagicMock()
    mock_create_engine.return_value = MagicMock(spec=["dispose", "sync_engine"])
    mock_create_engine.return_value.sync_engine = MagicMock()

    RoutingAsyncSessionMaker(
        routing_config=config,
        engine_config=engine_config,
        create_engine_callable=mock_create_engine,
    )

    assert mock_create_engine.call_count == 2
    for call in mock_create_engine.call_args_list:
        _, kwargs = call
        assert kwargs.get("pool_size") == 10
        assert kwargs.get("max_overflow") == 20


@pytest.mark.asyncio
async def test_async_session_maker_close_all_disposes_engines(
    routing_config: RoutingConfig,
) -> None:
    """Test that close_all disposes all async engines."""
    import asyncio

    mock_engines = []

    def create_mock_async_engine(url: str, **kwargs: Any) -> AsyncEngine:
        engine = MagicMock(spec=["dispose", "sync_engine"])
        engine.sync_engine = MagicMock()

        async def mock_dispose() -> None:
            pass

        engine.dispose = MagicMock(side_effect=lambda: asyncio.create_task(mock_dispose()))
        mock_engines.append(engine)
        return engine

    maker = RoutingAsyncSessionMaker(
        routing_config=routing_config,
        create_engine_callable=create_mock_async_engine,
    )

    await maker.close_all()

    assert len(mock_engines) == 3
    for engine in mock_engines:
        engine.dispose.assert_called_once()


def test_async_session_maker_handles_engine_creation_errors() -> None:
    """Test that RoutingAsyncSessionMaker handles TypeError from unsupported options."""
    config = RoutingConfig(
        primary_connection_string="postgresql://primary:5432/db",
        read_replicas=["postgresql://replica1:5432/db"],
    )

    def create_mock_async_engine(url: str, **kwargs: Any) -> AsyncEngine:
        if "json_serializer" in kwargs:
            raise TypeError("json_serializer not supported")

        engine = MagicMock(spec=["dispose", "sync_engine"])
        engine.sync_engine = MagicMock()
        engine.url = url
        return engine

    engine_config = {"pool_size": 10, "json_serializer": lambda x: x}

    maker = RoutingAsyncSessionMaker(
        routing_config=config,
        engine_config=engine_config,
        create_engine_callable=create_mock_async_engine,
    )

    assert maker.primary_engine is not None
    assert len(maker.replica_engines) == 1


def test_sync_session_maker_no_replicas(routing_config: RoutingConfig) -> None:
    """Test RoutingSyncSessionMaker with no replicas."""
    config = RoutingConfig(
        primary_connection_string="postgresql://primary:5432/db",
        read_replicas=[],
    )

    def create_mock_engine(url: str, **kwargs: Any) -> Engine:
        return MagicMock(spec=["dispose"])

    maker = RoutingSyncSessionMaker(
        routing_config=config,
        create_engine_callable=create_mock_engine,
    )

    assert maker.primary_engine is not None
    assert len(maker.replica_engines) == 0


def test_async_session_maker_no_replicas(routing_config: RoutingConfig) -> None:
    """Test RoutingAsyncSessionMaker with no replicas."""
    config = RoutingConfig(
        primary_connection_string="postgresql://primary:5432/db",
        read_replicas=[],
    )

    def create_mock_async_engine(url: str, **kwargs: Any) -> AsyncEngine:
        engine = MagicMock(spec=["dispose", "sync_engine"])
        engine.sync_engine = MagicMock()
        return engine

    maker = RoutingAsyncSessionMaker(
        routing_config=config,
        create_engine_callable=create_mock_async_engine,
    )

    assert maker.primary_engine is not None
    assert len(maker.replica_engines) == 0


def test_sync_session_maker_removes_bind_from_session_config(
    routing_config: RoutingConfig,
) -> None:
    """Test that session maker removes 'bind' from session config."""

    def create_mock_engine(url: str, **kwargs: Any) -> Engine:
        return MagicMock(spec=["dispose"])

    session_config = {"bind": MagicMock(), "expire_on_commit": False}

    maker = RoutingSyncSessionMaker(
        routing_config=routing_config,
        session_config=session_config,
        create_engine_callable=create_mock_engine,
    )

    session = maker()
    assert isinstance(session, RoutingSyncSession)


def test_async_session_maker_stores_session_config(
    routing_config: RoutingConfig,
) -> None:
    """Test that async session maker stores session config correctly."""

    def create_mock_async_engine(url: str, **kwargs: Any) -> AsyncEngine:
        engine = MagicMock(spec=["dispose", "sync_engine"])
        engine.sync_engine = MagicMock()
        return engine

    session_config = {"expire_on_commit": False}

    maker = RoutingAsyncSessionMaker(
        routing_config=routing_config,
        session_config=session_config,
        create_engine_callable=create_mock_async_engine,
    )

    assert maker._session_config == session_config
python-advanced-alchemy-1.9.3/tests/unit/test_routing/test_routing_selectors.py000066400000000000000000000124171516556515500303050ustar00rootroot00000000000000"""Unit tests for replica selectors.

Tests different strategies for selecting read replicas.
"""

from unittest.mock import MagicMock

import pytest
from sqlalchemy import Engine

from advanced_alchemy.routing.selectors import RandomSelector, RoundRobinSelector


@pytest.fixture
def mock_engines() -> list[Engine]:
    """Create mock engines for testing."""
    return [MagicMock(name=f"engine_{i}") for i in range(3)]


def test_round_robin_selector_initialization(mock_engines: list[Engine]) -> None:
    """Test RoundRobinSelector initialization."""
    selector = RoundRobinSelector(mock_engines)

    assert selector.has_replicas() is True
    assert len(selector.replicas) == 3


def test_round_robin_selector_empty_initialization() -> None:
    """Test RoundRobinSelector initialization with no replicas."""
    selector: RoundRobinSelector[Engine] = RoundRobinSelector([])

    assert selector.has_replicas() is False
    assert len(selector.replicas) == 0


def test_round_robin_selector_cycles_through_replicas(mock_engines: list[Engine]) -> None:
    """Test that RoundRobinSelector cycles through replicas in order."""
    selector = RoundRobinSelector(mock_engines)

    assert selector.next() is mock_engines[0]
    assert selector.next() is mock_engines[1]
    assert selector.next() is mock_engines[2]

    assert selector.next() is mock_engines[0]
    assert selector.next() is mock_engines[1]
    assert selector.next() is mock_engines[2]


def test_round_robin_selector_single_replica() -> None:
    """Test RoundRobinSelector with a single replica."""
    engine = MagicMock(name="single_engine")
    selector = RoundRobinSelector([engine])

    assert selector.next() is engine
    assert selector.next() is engine
    assert selector.next() is engine


def test_round_robin_selector_no_replicas_raises() -> None:
    """Test that RoundRobinSelector raises RuntimeError when no replicas configured."""
    selector: RoundRobinSelector[Engine] = RoundRobinSelector([])

    with pytest.raises(RuntimeError, match="No engines configured for round-robin selection"):
        selector.next()


def test_round_robin_selector_thread_safe(mock_engines: list[Engine]) -> None:
    """Test that RoundRobinSelector is thread-safe (uses lock)."""
    import threading

    selector = RoundRobinSelector(mock_engines)
    results: list[Engine] = []

    def worker() -> None:
        results.extend([selector.next() for _ in range(10)])

    threads = [threading.Thread(target=worker) for _ in range(5)]
    for thread in threads:
        thread.start()
    for thread in threads:
        thread.join()

    assert len(results) == 50
    assert all(engine in mock_engines for engine in results)


def test_random_selector_initialization(mock_engines: list[Engine]) -> None:
    """Test RandomSelector initialization."""
    selector = RandomSelector(mock_engines)

    assert selector.has_replicas() is True
    assert len(selector.replicas) == 3


def test_random_selector_empty_initialization() -> None:
    """Test RandomSelector initialization with no replicas."""
    selector: RandomSelector[Engine] = RandomSelector([])

    assert selector.has_replicas() is False
    assert len(selector.replicas) == 0


def test_random_selector_returns_valid_replica(mock_engines: list[Engine]) -> None:
    """Test that RandomSelector returns valid replicas."""
    selector = RandomSelector(mock_engines)

    selections = [selector.next() for _ in range(100)]

    assert all(engine in mock_engines for engine in selections)


def test_random_selector_distributes_randomly(mock_engines: list[Engine]) -> None:
    """Test that RandomSelector distributes selections across replicas."""
    selector = RandomSelector(mock_engines)

    selections = [selector.next() for _ in range(1000)]

    counts = {engine: selections.count(engine) for engine in mock_engines}

    assert all(count > 0 for count in counts.values())

    for count in counts.values():
        assert 200 < count < 450, f"Distribution not random enough: {counts}"


def test_random_selector_single_replica() -> None:
    """Test RandomSelector with a single replica."""
    engine = MagicMock(name="single_engine")
    selector = RandomSelector([engine])

    assert selector.next() is engine
    assert selector.next() is engine
    assert selector.next() is engine


def test_random_selector_no_replicas_raises() -> None:
    """Test that RandomSelector raises RuntimeError when no replicas configured."""
    selector: RandomSelector[Engine] = RandomSelector([])

    with pytest.raises(RuntimeError, match="No engines configured for random selection"):
        selector.next()


def test_replica_selector_replicas_property(mock_engines: list[Engine]) -> None:
    """Test that replica selector exposes replicas property."""
    selector = RoundRobinSelector(mock_engines)

    assert selector.replicas == mock_engines


def test_has_replicas_returns_false_for_empty_list() -> None:
    """Test has_replicas returns False for empty replica list."""
    selector: RoundRobinSelector[Engine] = RoundRobinSelector([])

    assert selector.has_replicas() is False


def test_has_replicas_returns_true_for_non_empty_list(mock_engines: list[Engine]) -> None:
    """Test has_replicas returns True for non-empty replica list."""
    selector = RoundRobinSelector(mock_engines)

    assert selector.has_replicas() is True
python-advanced-alchemy-1.9.3/tests/unit/test_routing/test_routing_session.py000066400000000000000000000317271516556515500277720ustar00rootroot00000000000000"""Unit tests for routing sessions.

Tests the session classes that implement read/write routing via get_bind().
"""

from unittest.mock import MagicMock

import pytest
from sqlalchemy import Delete, Engine, Insert, Update, select

from advanced_alchemy.config.routing import RoutingConfig
from advanced_alchemy.routing.context import (
    force_primary_var,
    reset_routing_context,
    stick_to_primary_var,
)
from advanced_alchemy.routing.selectors import EngineSelector, RoundRobinSelector
from advanced_alchemy.routing.session import RoutingSyncSession


@pytest.fixture
def mock_primary_engine() -> Engine:
    """Create a mock primary engine."""
    engine = MagicMock(name="primary_engine")
    engine.url = "postgresql://primary:5432/db"
    return engine


@pytest.fixture
def mock_replica_engines() -> list[Engine]:
    """Create mock replica engines."""
    engines: list[Engine] = []
    for i in range(2):
        engine: Engine = MagicMock(name=f"replica_engine_{i}")
        setattr(engine, "url", f"postgresql://replica{i}:5432/db")
        engines.append(engine)
    return engines


@pytest.fixture
def mock_replica_selector(mock_replica_engines: list[Engine]) -> RoundRobinSelector[Engine]:
    """Create a mock replica selector."""
    return RoundRobinSelector(mock_replica_engines)


@pytest.fixture
def routing_config() -> RoutingConfig:
    """Create a default routing config."""
    return RoutingConfig(
        primary_connection_string="postgresql://primary:5432/db",
        read_replicas=["postgresql://replica:5432/db"],
    )


@pytest.fixture
def routing_session(
    mock_primary_engine: Engine,
    mock_replica_selector: RoundRobinSelector[Engine],
    routing_config: RoutingConfig,
) -> RoutingSyncSession:
    """Create a routing session for testing."""
    reset_routing_context()

    selectors: dict[str, EngineSelector[Engine]] = {routing_config.read_group: mock_replica_selector}
    return RoutingSyncSession(
        default_engine=mock_primary_engine,
        selectors=selectors,
        routing_config=routing_config,
    )


def test_routing_session_initialization(
    mock_primary_engine: Engine,
    mock_replica_selector: RoundRobinSelector[Engine],
    routing_config: RoutingConfig,
) -> None:
    """Test RoutingSyncSession initialization."""
    selectors: dict[str, EngineSelector[Engine]] = {routing_config.read_group: mock_replica_selector}
    session = RoutingSyncSession(
        default_engine=mock_primary_engine,
        selectors=selectors,
        routing_config=routing_config,
    )

    assert session._default_engine is mock_primary_engine
    assert session._selectors[routing_config.read_group] is mock_replica_selector
    assert session._routing_config is routing_config


def test_get_bind_routes_select_to_replica(
    routing_session: RoutingSyncSession,
    mock_replica_engines: list[Engine],
) -> None:
    """Test that SELECT queries route to replica."""
    stmt = select(1)

    engine = routing_session.get_bind(clause=stmt)

    assert engine in mock_replica_engines


def test_get_bind_routes_insert_to_primary(
    routing_session: RoutingSyncSession,
    mock_primary_engine: Engine,
) -> None:
    """Test that INSERT queries route to primary."""
    stmt = MagicMock(spec=Insert)
    stmt._execution_options = {}

    engine = routing_session.get_bind(clause=stmt)

    assert engine is mock_primary_engine


def test_get_bind_routes_update_to_primary(
    routing_session: RoutingSyncSession,
    mock_primary_engine: Engine,
) -> None:
    """Test that UPDATE queries route to primary."""
    stmt = MagicMock(spec=Update)
    stmt._execution_options = {}

    engine = routing_session.get_bind(clause=stmt)

    assert engine is mock_primary_engine


def test_get_bind_routes_delete_to_primary(
    routing_session: RoutingSyncSession,
    mock_primary_engine: Engine,
) -> None:
    """Test that DELETE queries route to primary."""
    stmt = MagicMock(spec=Delete)
    stmt._execution_options = {}

    engine = routing_session.get_bind(clause=stmt)

    assert engine is mock_primary_engine


def test_get_bind_sticky_after_write(
    routing_session: RoutingSyncSession,
    mock_primary_engine: Engine,
) -> None:
    """Test that reads stick to primary after a write."""
    insert_stmt = MagicMock(spec=Insert)
    insert_stmt._execution_options = {}
    routing_session.get_bind(clause=insert_stmt)

    select_stmt = select(1)
    engine = routing_session.get_bind(clause=select_stmt)

    assert engine is mock_primary_engine


def test_get_bind_respects_force_primary_context(
    routing_session: RoutingSyncSession,
    mock_primary_engine: Engine,
) -> None:
    """Test that force_primary context variable forces primary for reads."""
    force_primary_var.set(True)

    select_stmt = select(1)
    engine = routing_session.get_bind(clause=select_stmt)

    assert engine is mock_primary_engine


def test_get_bind_with_routing_disabled(
    mock_primary_engine: Engine,
    mock_replica_selector: RoundRobinSelector[Engine],
) -> None:
    """Test that all queries use primary when routing is disabled."""
    config = RoutingConfig(
        primary_connection_string="postgresql://primary:5432/db",
        read_replicas=["postgresql://replica:5432/db"],
        enabled=False,
    )

    selectors: dict[str, EngineSelector[Engine]] = {config.read_group: mock_replica_selector}
    session = RoutingSyncSession(
        default_engine=mock_primary_engine,
        selectors=selectors,
        routing_config=config,
    )

    select_stmt = select(1)
    engine = session.get_bind(clause=select_stmt)

    assert engine is mock_primary_engine


def test_get_bind_when_flushing_uses_primary(
    routing_session: RoutingSyncSession,
    mock_primary_engine: Engine,
) -> None:
    """Test that queries during flush use primary."""
    routing_session._flushing = True

    select_stmt = select(1)
    engine = routing_session.get_bind(clause=select_stmt)

    assert engine is mock_primary_engine


def test_get_bind_for_update_uses_primary(
    routing_session: RoutingSyncSession,
    mock_primary_engine: Engine,
) -> None:
    """Test that SELECT FOR UPDATE uses primary."""
    select_stmt = select(1).with_for_update()

    engine = routing_session.get_bind(clause=select_stmt)

    assert engine is mock_primary_engine


def test_get_bind_for_update_nowait_uses_primary(
    routing_session: RoutingSyncSession,
    mock_primary_engine: Engine,
) -> None:
    """Test that SELECT FOR UPDATE NOWAIT uses primary."""
    select_stmt = select(1).with_for_update(nowait=True)

    engine = routing_session.get_bind(clause=select_stmt)

    assert engine is mock_primary_engine


def test_get_bind_for_update_skip_locked_uses_primary(
    routing_session: RoutingSyncSession,
    mock_primary_engine: Engine,
) -> None:
    """Test that SELECT FOR UPDATE SKIP LOCKED uses primary."""
    select_stmt = select(1).with_for_update(skip_locked=True)

    engine = routing_session.get_bind(clause=select_stmt)

    assert engine is mock_primary_engine


def test_get_bind_no_replicas_falls_back_to_primary(
    mock_primary_engine: Engine,
) -> None:
    """Test that reads fall back to primary when no replicas are configured."""
    config = RoutingConfig(
        primary_connection_string="postgresql://primary:5432/db",
        read_replicas=[],
    )

    selector: RoundRobinSelector[Engine] = RoundRobinSelector([])

    selectors: dict[str, EngineSelector[Engine]] = {config.read_group: selector}

    session = RoutingSyncSession(
        default_engine=mock_primary_engine,
        selectors=selectors,
        routing_config=config,
    )

    select_stmt = select(1)
    engine = session.get_bind(clause=select_stmt)

    assert engine is mock_primary_engine


def test_get_bind_round_robin_through_replicas(
    routing_session: RoutingSyncSession,
    mock_replica_engines: list[Engine],
) -> None:
    """Test that reads cycle through replicas in round-robin fashion."""
    select_stmt = select(1)

    engine1 = routing_session.get_bind(clause=select_stmt)
    assert engine1 is mock_replica_engines[0]

    engine2 = routing_session.get_bind(clause=select_stmt)
    assert engine2 is mock_replica_engines[1]

    engine3 = routing_session.get_bind(clause=select_stmt)
    assert engine3 is mock_replica_engines[0]


def test_commit_resets_stickiness(
    routing_session: RoutingSyncSession,
    mock_replica_engines: list[Engine],
) -> None:
    """Test that commit resets sticky-to-primary state."""
    insert_stmt = MagicMock(spec=Insert)
    insert_stmt._execution_options = {}
    routing_session.get_bind(clause=insert_stmt)

    assert stick_to_primary_var.get() is True

    routing_session.commit()

    assert stick_to_primary_var.get() is False

    select_stmt = select(1)
    engine = routing_session.get_bind(clause=select_stmt)
    assert engine in mock_replica_engines


def test_commit_no_reset_when_disabled(
    mock_primary_engine: Engine,
    mock_replica_selector: RoundRobinSelector[Engine],
) -> None:
    """Test that commit doesn't reset stickiness when reset_stickiness_on_commit is False."""
    config = RoutingConfig(
        primary_connection_string="postgresql://primary:5432/db",
        read_replicas=["postgresql://replica:5432/db"],
        reset_stickiness_on_commit=False,
    )

    selectors: dict[str, EngineSelector[Engine]] = {config.read_group: mock_replica_selector}
    session = RoutingSyncSession(
        default_engine=mock_primary_engine,
        selectors=selectors,
        routing_config=config,
    )

    insert_stmt = MagicMock(spec=Insert)
    insert_stmt._execution_options = {}
    session.get_bind(clause=insert_stmt)

    session.commit()

    assert stick_to_primary_var.get() is True

    select_stmt = select(1)
    engine = session.get_bind(clause=select_stmt)
    assert engine is mock_primary_engine


def test_rollback_resets_stickiness(
    routing_session: RoutingSyncSession,
    mock_replica_engines: list[Engine],
) -> None:
    """Test that rollback resets sticky-to-primary state."""
    insert_stmt = MagicMock(spec=Insert)
    insert_stmt._execution_options = {}
    routing_session.get_bind(clause=insert_stmt)

    assert stick_to_primary_var.get() is True

    routing_session.rollback()

    assert stick_to_primary_var.get() is False

    select_stmt = select(1)
    engine = routing_session.get_bind(clause=select_stmt)
    assert engine in mock_replica_engines


def test_sticky_disabled_writes_dont_set_flag(
    mock_primary_engine: Engine,
    mock_replica_selector: RoundRobinSelector[Engine],
    mock_replica_engines: list[Engine],
) -> None:
    """Test that writes don't set stickiness when sticky_after_write is False."""
    config = RoutingConfig(
        primary_connection_string="postgresql://primary:5432/db",
        read_replicas=["postgresql://replica:5432/db"],
        sticky_after_write=False,
    )

    selectors: dict[str, EngineSelector[Engine]] = {config.read_group: mock_replica_selector}
    session = RoutingSyncSession(
        default_engine=mock_primary_engine,
        selectors=selectors,
        routing_config=config,
    )

    insert_stmt = MagicMock(spec=Insert)
    insert_stmt._execution_options = {}
    session.get_bind(clause=insert_stmt)

    assert stick_to_primary_var.get() is False

    select_stmt = select(1)
    engine = session.get_bind(clause=select_stmt)
    assert engine in mock_replica_engines


def test_get_bind_with_none_clause_uses_replica(
    routing_session: RoutingSyncSession,
    mock_replica_engines: list[Engine],
) -> None:
    """Test that get_bind with None clause uses replica."""
    engine = routing_session.get_bind(clause=None)

    assert engine in mock_replica_engines


def test_get_bind_with_none_clause_and_no_replicas(
    mock_primary_engine: Engine,
) -> None:
    """Test that get_bind with None clause falls back to primary when no replicas."""
    config = RoutingConfig(
        primary_connection_string="postgresql://primary:5432/db",
        read_replicas=[],
    )

    selector: RoundRobinSelector[Engine] = RoundRobinSelector([])

    selectors: dict[str, EngineSelector[Engine]] = {config.read_group: selector}

    session = RoutingSyncSession(
        default_engine=mock_primary_engine,
        selectors=selectors,
        routing_config=config,
    )

    engine = session.get_bind(clause=None)

    assert engine is mock_primary_engine


def test_has_for_update_detects_for_update_clause() -> None:
    """Test that _has_for_update correctly detects FOR UPDATE."""
    config = RoutingConfig(primary_connection_string="postgresql://primary:5432/db")
    primary_engine: Engine = MagicMock()
    selector: RoundRobinSelector[Engine] = RoundRobinSelector([])

    selectors: dict[str, EngineSelector[Engine]] = {config.read_group: selector}
    session = RoutingSyncSession(
        default_engine=primary_engine,
        selectors=selectors,
        routing_config=config,
    )

    select_with_for_update = select(1).with_for_update()

    assert session._has_for_update(select_with_for_update) is True

    regular_select = select(1)
    assert session._has_for_update(regular_select) is False

    assert session._has_for_update(None) is False
python-advanced-alchemy-1.9.3/tests/unit/test_service_to_model_flow.py000066400000000000000000000340351516556515500263560ustar00rootroot00000000000000"""Unit tests verifying to_model() operation flow for service.update()

This test suite validates GitHub issue #555 fix - ensuring that service.update()
calls to_model(data, "update") for ALL data types (dict, Pydantic, msgspec, attrs, model).

Before the fix, dict/Pydantic/msgspec/attrs data bypassed to_model() entirely.
"""

from __future__ import annotations

from typing import Any, Optional
from unittest.mock import AsyncMock, MagicMock

import pytest

from advanced_alchemy.repository import SQLAlchemyAsyncRepository, SQLAlchemySyncRepository
from advanced_alchemy.repository._util import get_primary_key_info
from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService, SQLAlchemySyncRepositoryService
from advanced_alchemy.service.typing import ATTRS_INSTALLED, MSGSPEC_INSTALLED, PYDANTIC_INSTALLED, ModelDictT

# Use real SQLAlchemy models from fixtures instead of mock
# Import from test fixtures which have proper SQLAlchemy declarative models
from tests.fixtures.uuid.models import UUIDAuthor as MockModel

pytestmark = [pytest.mark.unit]


class MockRepository(SQLAlchemyAsyncRepository[MockModel]):
    """Mock repository for testing."""

    model_type = MockModel

    def __init__(self) -> None:
        # Don't call super().__init__ to avoid needing session
        self.model_type = MockModel
        self.id_attribute = "id"
        # Initialize PK info for composite PK support
        self._pk_columns, self._pk_attr_names = get_primary_key_info(MockModel)


class MockSyncRepository(SQLAlchemySyncRepository[MockModel]):
    """Mock sync repository for testing."""

    model_type = MockModel

    def __init__(self) -> None:
        # Don't call super().__init__ to avoid needing session
        self.model_type = MockModel
        self.id_attribute = "id"
        # Initialize PK info for composite PK support
        self._pk_columns, self._pk_attr_names = get_primary_key_info(MockModel)


class TrackingService(SQLAlchemyAsyncRepositoryService[MockModel, MockRepository]):
    """Service that tracks to_model() calls for testing."""

    repository_type = MockRepository

    def __init__(self) -> None:
        # Create mock repository
        self._repository = MockRepository()
        # Mock model with proper SQLAlchemy attributes
        mock_model = MockModel()
        mock_model.id = "existing-id"  # type: ignore[assignment]
        mock_model.name = "existing"
        mock_model.dob = None  # type: ignore[assignment]
        self._repository.get = AsyncMock(return_value=mock_model)  # type: ignore[method-assign]
        self._repository.update = AsyncMock(side_effect=lambda data, **kwargs: data)  # type: ignore[method-assign]

        # Track method calls
        self.to_model_calls: list[tuple[Any, Optional[str]]] = []
        self.to_model_on_update_calls: list[Any] = []

    @property
    def repository(self) -> MockRepository:
        """Return mock repository."""
        return self._repository

    async def to_model(
        self,
        data: ModelDictT[MockModel],
        operation: Optional[str] = None,
    ) -> MockModel:
        """Track to_model calls."""
        self.to_model_calls.append((data, operation))
        return await super().to_model(data, operation)

    async def to_model_on_update(self, data: ModelDictT[MockModel]) -> ModelDictT[MockModel]:
        """Track to_model_on_update calls."""
        self.to_model_on_update_calls.append(data)
        return await super().to_model_on_update(data)


class TrackingSyncService(SQLAlchemySyncRepositoryService[MockModel, MockSyncRepository]):
    """Sync service that tracks to_model() calls for testing."""

    repository_type = MockSyncRepository

    def __init__(self) -> None:
        # Create mock repository
        self._repository = MockSyncRepository()
        # Mock model with proper SQLAlchemy attributes
        mock_model = MockModel()
        mock_model.id = "existing-id"  # type: ignore[assignment]
        mock_model.name = "existing"
        mock_model.dob = None  # type: ignore[assignment]
        self._repository.get = MagicMock(return_value=mock_model)  # type: ignore[method-assign]
        self._repository.update = MagicMock(side_effect=lambda data, **kwargs: data)  # type: ignore[method-assign]

        # Track method calls
        self.to_model_calls: list[tuple[Any, Optional[str]]] = []
        self.to_model_on_update_calls: list[Any] = []

    @property
    def repository(self) -> MockSyncRepository:
        """Return mock repository."""
        return self._repository

    def to_model(
        self,
        data: ModelDictT[MockModel],
        operation: Optional[str] = None,
    ) -> MockModel:
        """Track to_model calls."""
        self.to_model_calls.append((data, operation))
        return super().to_model(data, operation)

    def to_model_on_update(self, data: ModelDictT[MockModel]) -> ModelDictT[MockModel]:
        """Track to_model_on_update calls."""
        self.to_model_on_update_calls.append(data)
        return super().to_model_on_update(data)


# Tests for async service


@pytest.mark.asyncio
async def test_update_dict_calls_to_model_with_operation() -> None:
    """Test that update() with dict data calls to_model(data, 'update')."""
    service = TrackingService()

    # Update with dict data and item_id
    await service.update({"name": "Updated Name"}, item_id="test-id")

    # Verify to_model was called with operation="update"
    assert len(service.to_model_calls) == 1
    data, operation = service.to_model_calls[0]
    assert operation == "update"
    assert data == {"name": "Updated Name"}

    # Verify to_model_on_update was also called (via operation_map)
    assert len(service.to_model_on_update_calls) == 1


@pytest.mark.asyncio
async def test_update_model_instance_calls_to_model_with_operation() -> None:
    """Test that update() with model instance calls to_model(data, 'update')."""
    service = TrackingService()

    # Update with model instance
    model = MockModel()
    model.id = "test-id"  # type: ignore[assignment]
    model.name = "Updated Name"
    await service.update(model)

    # Verify to_model was called with operation="update"
    assert len(service.to_model_calls) == 1
    data, operation = service.to_model_calls[0]
    assert operation == "update"
    assert data is model


@pytest.mark.skipif(not PYDANTIC_INSTALLED, reason="Pydantic not installed")
@pytest.mark.asyncio
async def test_update_pydantic_calls_to_model_with_operation() -> None:
    """Test that update() with Pydantic data calls to_model(data, 'update')."""
    from pydantic import BaseModel

    class AuthorSchema(BaseModel):
        name: str

    service = TrackingService()

    # Update with Pydantic model
    schema = AuthorSchema(name="Updated Name")
    await service.update(schema, item_id="test-id")

    # Verify to_model was called with operation="update"
    assert len(service.to_model_calls) == 1
    data, operation = service.to_model_calls[0]
    assert operation == "update"
    assert isinstance(data, AuthorSchema)


@pytest.mark.skipif(not MSGSPEC_INSTALLED, reason="msgspec not installed")
@pytest.mark.asyncio
async def test_update_msgspec_calls_to_model_with_operation() -> None:
    """Test that update() with msgspec data calls to_model(data, 'update')."""
    import msgspec

    class AuthorStruct(msgspec.Struct):
        name: str

    service = TrackingService()

    # Update with msgspec struct
    struct = AuthorStruct(name="Updated Name")
    await service.update(struct, item_id="test-id")

    # Verify to_model was called with operation="update"
    assert len(service.to_model_calls) == 1
    data, operation = service.to_model_calls[0]
    assert operation == "update"
    assert isinstance(data, AuthorStruct)


@pytest.mark.skipif(not ATTRS_INSTALLED, reason="attrs not installed")
@pytest.mark.asyncio
async def test_update_attrs_calls_to_model_with_operation() -> None:
    """Test that update() with attrs data calls to_model(data, 'update')."""
    from attrs import define

    @define
    class AuthorAttrs:
        name: str

    service = TrackingService()

    # Update with attrs instance
    attrs_obj = AuthorAttrs(name="Updated Name")
    await service.update(attrs_obj, item_id="test-id")

    # Verify to_model was called with operation="update"
    assert len(service.to_model_calls) == 1
    data, operation = service.to_model_calls[0]
    assert operation == "update"
    assert isinstance(data, AuthorAttrs)


@pytest.mark.asyncio
async def test_update_operation_map_routes_to_to_model_on_update() -> None:
    """Test that to_model() with operation='update' routes to to_model_on_update()."""
    service = TrackingService()

    # Update with dict data
    await service.update({"name": "updated"}, item_id="test-id")

    # Verify both methods were called
    assert len(service.to_model_calls) == 1
    assert len(service.to_model_on_update_calls) == 1

    # Verify operation_map routing worked
    _, operation = service.to_model_calls[0]
    assert operation == "update"


@pytest.mark.asyncio
async def test_update_propagates_with_for_update_flag() -> None:
    """Ensure the async service forwards locking hints to the repository."""

    service = TrackingService()
    await service.update({"name": "updated"}, item_id="test-id", with_for_update=True)

    service.repository.get.assert_awaited_once()
    assert service.repository.get.call_args.kwargs["with_for_update"] is True


# Tests for sync service


def test_sync_update_dict_calls_to_model_with_operation() -> None:
    """Test that sync update() with dict data calls to_model(data, 'update')."""
    service = TrackingSyncService()

    # Update with dict data and item_id
    service.update({"name": "Updated Name"}, item_id="test-id")

    # Verify to_model was called with operation="update"
    assert len(service.to_model_calls) == 1
    data, operation = service.to_model_calls[0]
    assert operation == "update"
    assert data == {"name": "Updated Name"}

    # Verify to_model_on_update was also called (via operation_map)
    assert len(service.to_model_on_update_calls) == 1


def test_sync_update_model_instance_calls_to_model_with_operation() -> None:
    """Test that sync update() with model instance calls to_model(data, 'update')."""
    service = TrackingSyncService()

    # Update with model instance
    model = MockModel()
    model.id = "test-id"  # type: ignore[assignment]
    model.name = "Updated Name"
    service.update(model)

    # Verify to_model was called with operation="update"
    assert len(service.to_model_calls) == 1
    data, operation = service.to_model_calls[0]
    assert operation == "update"
    assert data is model


def test_sync_update_propagates_with_for_update_flag() -> None:
    """Ensure the sync service forwards the locking flag to its repository."""

    service = TrackingSyncService()
    service.update({"name": "updated"}, item_id="test-id", with_for_update=True)

    service.repository.get.assert_called_once()
    assert service.repository.get.call_args.kwargs["with_for_update"] is True


# Tests for backward compatibility


@pytest.mark.asyncio
async def test_backward_compat_to_model_on_update_only() -> None:
    """Test backward compatibility - service with ONLY to_model_on_update() override."""

    class LegacyService(SQLAlchemyAsyncRepositoryService[MockModel, MockRepository]):
        repository_type = MockRepository

        def __init__(self) -> None:
            self._repository = MockRepository()
            mock_model = MockModel()
            mock_model.id = "test-id"  # type: ignore[assignment]
            mock_model.name = "Old Name"
            self._repository.get = AsyncMock(return_value=mock_model)  # type: ignore[method-assign]
            self._repository.update = AsyncMock(side_effect=lambda data, **kwargs: data)  # type: ignore[method-assign]
            self.update_hook_called = False

        @property
        def repository(self) -> MockRepository:
            return self._repository

        async def to_model_on_update(self, data: ModelDictT[MockModel]) -> ModelDictT[MockModel]:
            """Legacy pattern - only override to_model_on_update."""
            self.update_hook_called = True
            return await super().to_model_on_update(data)

    service = LegacyService()
    await service.update({"name": "Updated Name"}, item_id="test-id")

    # Verify to_model_on_update was called (backward compatible)
    assert service.update_hook_called


# Real-world pattern tests using SlugBook fixtures are in integration tests
# The SlugBookAsyncService and SlugBookSyncService in tests/fixtures/uuid/services.py
# demonstrate the exact pattern this fix enables:
# - Custom to_model() that checks operation == "update"
# - Regenerates slug when title changes during update
# - This pattern would have been broken before the fix for dict/Pydantic/msgspec/attrs data


# Edge case tests


@pytest.mark.asyncio
async def test_update_without_item_id_uses_model_id() -> None:
    """Test update without item_id uses ID from model instance."""
    service = TrackingService()

    # Update with model that has ID
    model = MockModel()
    model.id = "model-id"  # type: ignore[assignment]
    model.name = "Updated Name"
    await service.update(model)

    # Should work - uses model's ID
    assert len(service.to_model_calls) == 1


@pytest.mark.asyncio
async def test_update_preserves_existing_instance_attributes() -> None:
    """Test that update with item_id preserves existing instance attributes."""
    service = TrackingService()

    # Update only name field
    result = await service.update({"name": "New Name"}, item_id="test-id")

    # Existing ID should be preserved from existing instance
    assert result.name == "New Name"
    assert result.id == "existing-id"  # From mock repository's get()


def test_sync_update_preserves_existing_instance_attributes() -> None:
    """Test that sync update with item_id preserves existing instance attributes."""
    service = TrackingSyncService()

    # Update only name field
    result = service.update({"name": "New Name"}, item_id="test-id")

    # Existing ID should be preserved from existing instance
    assert result.name == "New Name"
    assert result.id == "existing-id"  # From mock repository's get()
python-advanced-alchemy-1.9.3/tests/unit/test_sqlmodel_compat.py000066400000000000000000000344211516556515500251670ustar00rootroot00000000000000"""Tests for SQLModel compatibility with Advanced Alchemy.

Validates that SQLModel table=True models can be used with AA repositories and services.
"""

from collections.abc import Generator
from typing import Optional, cast
from unittest.mock import MagicMock

import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker

sqlmodel = pytest.importorskip("sqlmodel")

from sqlmodel import Field as SQLModelField  # noqa: E402
from sqlmodel import SQLModel  # noqa: E402

from advanced_alchemy.base import ModelProtocol, model_to_dict  # noqa: E402
from advanced_alchemy.repository import SQLAlchemySyncRepository  # noqa: E402
from advanced_alchemy.repository._util import get_instrumented_attr, get_primary_key_info, model_from_dict  # noqa: E402
from advanced_alchemy.service.typing import (  # noqa: E402
    is_pydantic_model,
    is_schema,
    is_schema_or_dict,
    is_schema_or_dict_with_field,
    is_schema_or_dict_without_field,
    is_schema_with_field,
    is_schema_without_field,
    is_sqlmodel_table_model,
    schema_dump,
)

# ---------------------------------------------------------------------------
# Test fixtures: SQLModel table models
# ---------------------------------------------------------------------------


class HeroModel(SQLModel, table=True):
    """A simple SQLModel table model for testing."""

    __tablename__ = "test_hero"  # type: ignore[assignment]

    id: Optional[int] = SQLModelField(default=None, primary_key=True)
    name: str
    secret_name: str
    age: Optional[int] = None


class PlainSchema(SQLModel):
    """A plain SQLModel schema (no table) โ€” should NOT satisfy ModelProtocol."""

    name: str
    age: int


# ---------------------------------------------------------------------------
# Task 1.1: ModelProtocol no longer requires to_dict()
# ---------------------------------------------------------------------------


def test_sqlmodel_table_satisfies_model_protocol() -> None:
    """SQLModel table=True models should satisfy ModelProtocol (no to_dict required)."""
    hero = HeroModel(id=1, name="Spider-Boy", secret_name="Pedro", age=10)
    assert isinstance(hero, ModelProtocol)


def test_sqlmodel_table_class_has_required_attrs() -> None:
    """SQLModel table=True classes have __table__ and __mapper__."""
    assert hasattr(HeroModel, "__table__")
    assert hasattr(HeroModel, "__mapper__")


def test_plain_schema_does_not_satisfy_protocol() -> None:
    """Plain SQLModel schemas (no table) should NOT satisfy ModelProtocol."""
    schema = PlainSchema(name="test", age=5)
    # Plain schemas don't have __table__ or __mapper__
    assert not hasattr(schema, "__table__")
    assert not hasattr(schema, "__mapper__")


# ---------------------------------------------------------------------------
# Task 1.2: model_to_dict utility
# ---------------------------------------------------------------------------


def test_model_to_dict_with_sqlmodel() -> None:
    """model_to_dict should work with SQLModel table instances (no to_dict method)."""
    hero = HeroModel(id=1, name="Spider-Boy", secret_name="Pedro", age=10)
    result = model_to_dict(cast(ModelProtocol, hero))
    assert result["id"] == 1
    assert result["name"] == "Spider-Boy"
    assert result["secret_name"] == "Pedro"
    assert result["age"] == 10


def test_model_to_dict_with_exclude() -> None:
    """model_to_dict should respect the exclude parameter."""
    hero = HeroModel(id=1, name="Spider-Boy", secret_name="Pedro", age=10)
    result = model_to_dict(cast(ModelProtocol, hero), exclude={"secret_name"})
    assert "secret_name" not in result
    assert result["name"] == "Spider-Boy"


def test_model_to_dict_with_aa_model_delegates_to_to_dict() -> None:
    """model_to_dict should call to_dict() when available (AA models)."""
    mock_model = MagicMock()
    mock_model.to_dict.return_value = {"id": 1, "name": "test"}
    result = model_to_dict(mock_model, exclude={"secret"})
    mock_model.to_dict.assert_called_once_with(exclude={"secret"})
    assert result == {"id": 1, "name": "test"}


def test_model_to_dict_excludes_sentinel_fields() -> None:
    """model_to_dict should exclude sa_orm_sentinel and _sentinel by default."""
    hero = HeroModel(id=1, name="Spider-Boy", secret_name="Pedro", age=10)
    result = model_to_dict(cast(ModelProtocol, hero))
    assert "sa_orm_sentinel" not in result
    assert "_sentinel" not in result


# ---------------------------------------------------------------------------
# Task 1.3: is_sqlmodel_table_model detection
# ---------------------------------------------------------------------------


def test_is_sqlmodel_table_model_with_table_instance() -> None:
    """SQLModel table=True instances should be detected."""
    hero = HeroModel(id=1, name="Spider-Boy", secret_name="Pedro", age=10)
    assert is_sqlmodel_table_model(hero) is True


def test_is_sqlmodel_table_model_with_table_class() -> None:
    """SQLModel table=True classes should be detected."""
    assert is_sqlmodel_table_model(HeroModel) is True


def test_is_sqlmodel_table_model_with_plain_schema() -> None:
    """Plain SQLModel schemas (no table) should NOT be detected."""
    schema = PlainSchema(name="test", age=5)
    assert is_sqlmodel_table_model(schema) is False


def test_is_sqlmodel_table_model_with_dict() -> None:
    """Dicts should not be detected as SQLModel table models."""
    assert is_sqlmodel_table_model({"name": "test"}) is False


def test_is_sqlmodel_table_model_with_none() -> None:
    """None should not be detected."""
    assert is_sqlmodel_table_model(None) is False


# ---------------------------------------------------------------------------
# Task 3.1: schema_dump should NOT decompose SQLModel table instances
# ---------------------------------------------------------------------------


def test_schema_dump_preserves_sqlmodel_table_instance() -> None:
    """schema_dump should return SQLModel table instances as-is (not call model_dump)."""
    hero = HeroModel(id=1, name="Spider-Boy", secret_name="Pedro", age=10)
    result = schema_dump(hero)
    # mypy can't see HeroModel as ModelProtocol (sqlmodel stubs lack __mapper__/__table__)
    # so it resolves to the Any catch-all overload returning dict[str, Any]
    assert result is hero  # type: ignore[comparison-overlap]


def test_schema_dump_converts_plain_schema_to_dict() -> None:
    """schema_dump should still convert plain Pydantic/SQLModel schemas to dicts."""
    schema = PlainSchema(name="test", age=5)
    result = schema_dump(schema)
    assert isinstance(result, dict)
    assert result["name"] == "test"
    assert result["age"] == 5


# ---------------------------------------------------------------------------
# Chapter 2: is_schema() family excludes SQLModel table models
# ---------------------------------------------------------------------------


def test_is_pydantic_model_still_matches_sqlmodel_table() -> None:
    """is_pydantic_model() is a structural check โ€” SQLModel table models ARE BaseModel subclasses."""
    hero = HeroModel(id=1, name="Spider-Boy", secret_name="Pedro", age=10)
    assert is_pydantic_model(hero) is True


def test_is_pydantic_model_matches_plain_schema() -> None:
    """Plain SQLModel schemas are also BaseModel subclasses."""
    schema = PlainSchema(name="test", age=5)
    assert is_pydantic_model(schema) is True


def test_is_schema_excludes_sqlmodel_table_instance() -> None:
    """is_schema() is a semantic check โ€” SQLModel table models are NOT schemas."""
    hero = HeroModel(id=1, name="Spider-Boy", secret_name="Pedro", age=10)
    assert is_schema(hero) is False


def test_is_schema_includes_plain_sqlmodel_schema() -> None:
    """Plain SQLModel schemas (no table) should still be recognized as schemas."""
    schema = PlainSchema(name="test", age=5)
    assert is_schema(schema) is True


def test_is_schema_with_field_excludes_sqlmodel_table() -> None:
    """is_schema_with_field() should return False for SQLModel table models."""
    hero = HeroModel(id=1, name="Spider-Boy", secret_name="Pedro", age=10)
    assert is_schema_with_field(hero, "name") is False


def test_is_schema_with_field_includes_plain_schema() -> None:
    """is_schema_with_field() should work for plain SQLModel schemas."""
    schema = PlainSchema(name="test", age=5)
    assert is_schema_with_field(schema, "name") is True


def test_is_schema_without_field_excludes_sqlmodel_table() -> None:
    """is_schema_without_field() should return False for SQLModel table models."""
    hero = HeroModel(id=1, name="Spider-Boy", secret_name="Pedro", age=10)
    assert is_schema_without_field(hero, "nonexistent") is False


def test_is_schema_or_dict_excludes_sqlmodel_table() -> None:
    """is_schema_or_dict() should return False for SQLModel table models."""
    hero = HeroModel(id=1, name="Spider-Boy", secret_name="Pedro", age=10)
    assert is_schema_or_dict(hero) is False


def test_is_schema_or_dict_with_field_excludes_sqlmodel_table() -> None:
    """is_schema_or_dict_with_field() should return False for SQLModel table models."""
    hero = HeroModel(id=1, name="Spider-Boy", secret_name="Pedro", age=10)
    assert is_schema_or_dict_with_field(hero, "name") is False


def test_is_schema_or_dict_without_field_excludes_sqlmodel_table() -> None:
    """is_schema_or_dict_without_field() should return False for SQLModel table models."""
    hero = HeroModel(id=1, name="Spider-Boy", secret_name="Pedro", age=10)
    assert is_schema_or_dict_without_field(hero, "nonexistent") is False


# ---------------------------------------------------------------------------
# Chapter 3: Repository utilities with SQLModel
# ---------------------------------------------------------------------------


def test_model_from_dict_creates_sqlmodel_instance() -> None:
    """model_from_dict should create a SQLModel table instance from kwargs."""
    hero = model_from_dict(HeroModel, id=1, name="Spider-Boy", secret_name="Pedro", age=10)
    assert isinstance(hero, HeroModel)
    assert hero.name == "Spider-Boy"
    assert hero.secret_name == "Pedro"
    assert hero.age == 10


def test_model_from_dict_with_partial_kwargs() -> None:
    """model_from_dict should handle partial kwargs (optional fields omitted)."""
    hero = model_from_dict(HeroModel, name="Spider-Boy", secret_name="Pedro")
    assert isinstance(hero, HeroModel)
    assert hero.name == "Spider-Boy"
    assert hero.age is None


def test_get_primary_key_info_with_sqlmodel() -> None:
    """get_primary_key_info should extract PK info from SQLModel table models."""
    pk_columns, pk_attr_names = get_primary_key_info(cast("type[ModelProtocol]", HeroModel))
    assert len(pk_columns) == 1
    assert pk_attr_names == ("id",)


def test_get_instrumented_attr_with_sqlmodel() -> None:
    """get_instrumented_attr should retrieve attributes from SQLModel table models."""
    attr = get_instrumented_attr(cast("type[ModelProtocol]", HeroModel), "name")
    assert attr.key == "name"


def test_get_instrumented_attr_passthrough() -> None:
    """get_instrumented_attr should pass through InstrumentedAttribute objects."""
    attr = get_instrumented_attr(cast("type[ModelProtocol]", HeroModel), HeroModel.name)  # type: ignore[arg-type]
    assert attr.key == "name"


def test_model_to_dict_roundtrip_via_model_from_dict() -> None:
    """model_to_dict -> model_from_dict should produce an equivalent instance."""
    hero = HeroModel(id=1, name="Spider-Boy", secret_name="Pedro", age=10)
    as_dict = model_to_dict(cast(ModelProtocol, hero))
    rebuilt = model_from_dict(HeroModel, **as_dict)
    assert rebuilt.id == hero.id
    assert rebuilt.name == hero.name
    assert rebuilt.secret_name == hero.secret_name
    assert rebuilt.age == hero.age


# ---------------------------------------------------------------------------
# Chapter 4: Repository integration with SQLModel (in-memory SQLite)
# ---------------------------------------------------------------------------


class HeroRepository(SQLAlchemySyncRepository[HeroModel]):
    """Repository for HeroModel."""

    model_type = HeroModel


@pytest.fixture()
def hero_session() -> "Generator[Session, None, None]":
    """Create an in-memory SQLite session with the HeroModel table."""
    engine = create_engine("sqlite:///:memory:")
    SQLModel.metadata.create_all(engine)
    session_factory = sessionmaker(engine, expire_on_commit=False)
    with session_factory() as session:
        yield session  # type: ignore[misc]


def test_repo_update_many_with_sqlmodel(hero_session: "Session") -> None:
    """Repository.update_many should handle SQLModel model instances via model_to_dict."""
    repo = HeroRepository(session=hero_session)
    hero = repo.add(HeroModel(name="Spider-Boy", secret_name="Pedro", age=10))
    hero_session.commit()

    hero.age = 20
    updated = repo.update_many([hero])
    hero_session.commit()
    assert updated[0].age == 20


def test_repo_upsert_creates_with_sqlmodel(hero_session: "Session") -> None:
    """Repository.upsert should create a new SQLModel instance when not found."""
    repo = HeroRepository(session=hero_session)
    hero = HeroModel(name="Spider-Boy", secret_name="Pedro", age=10)
    result = repo.upsert(hero)
    hero_session.commit()
    assert result.name == "Spider-Boy"
    assert result.id is not None


def test_repo_upsert_updates_with_sqlmodel(hero_session: "Session") -> None:
    """Repository.upsert should update existing SQLModel instance when found by match_fields."""
    repo = HeroRepository(session=hero_session)
    existing = repo.add(HeroModel(name="Spider-Boy", secret_name="Pedro", age=10))
    hero_session.commit()

    updated_hero = HeroModel(name="Spider-Boy", secret_name="Pedro P", age=15)
    result = repo.upsert(updated_hero, match_fields=["name"])
    hero_session.commit()
    assert result.id == existing.id
    assert result.secret_name == "Pedro P"


def test_repo_upsert_fallback_match_by_all_fields(hero_session: "Session") -> None:
    """Repository.upsert should match by all non-PK fields when no id and no match_fields."""
    repo = HeroRepository(session=hero_session)
    repo.add(HeroModel(name="Spider-Boy", secret_name="Pedro", age=10))
    hero_session.commit()

    # No id set, no match_fields โ€” triggers model_to_dict(data, exclude=exclude_cols) fallback
    lookup = HeroModel(name="Spider-Boy", secret_name="Pedro", age=10)
    result = repo.upsert(lookup)
    hero_session.commit()
    assert result.name == "Spider-Boy"
    assert result.id is not None
python-advanced-alchemy-1.9.3/tests/unit/test_utils/000077500000000000000000000000001516556515500225665ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/tests/unit/test_utils/__init__.py000066400000000000000000000000001516556515500246650ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/tests/unit/test_utils/test_click.py000066400000000000000000000244441516556515500252740ustar00rootroot00000000000000import builtins
import importlib
import sys
import types

import click as base_click
import pytest


def _reload_utils_click(monkeypatch: "pytest.MonkeyPatch", variant: str) -> types.ModuleType:
    """Reload the compatibility module with a given rich-click variant.

    variant:
        - "none": simulate no rich-click installed
        - "old": rich-click without aliases support
        - "new": rich-click with aliases support
    """

    monkeypatch.syspath_prepend("")  # ensure reload works with patched imports

    for mod in ("advanced_alchemy.utils.cli_tools", "rich_click"):
        sys.modules.pop(mod, None)

    if variant == "none":
        real_import = builtins.__import__

        def blocking_import(name: str, *args: object, **kwargs: object) -> object:
            if name == "rich_click":
                raise ImportError("rich_click not available")
            return real_import(name, *args, **kwargs)  # type: ignore[arg-type]

        monkeypatch.setattr(builtins, "__import__", blocking_import)

    else:
        # Build a lightweight fake rich_click module that exposes all base click attributes
        class _RichGroupBase(base_click.Group):
            pass

        if variant == "new":

            def _rich_group_init(self, *args, aliases=None, **kwargs) -> None:  # type: ignore[no-untyped-def]
                self.aliases = tuple(aliases or ())
                _RichGroupBase.__init__(self, *args, **kwargs)

        else:

            def _rich_group_init(self, *args, **kwargs) -> None:  # type: ignore[no-untyped-def]
                _RichGroupBase.__init__(self, *args, **kwargs)

        FakeRichGroup = type("FakeRichGroup", (_RichGroupBase,), {"__init__": _rich_group_init})

        # Create a module-like object that also inherits base_click's attributes
        class FakeRichClickModule(types.ModuleType):
            """Fake rich_click module for testing."""

            RichGroup = FakeRichGroup

            def __getattr__(self, name: str) -> object:
                # Delegate to base click for any attribute not defined here
                return getattr(base_click, name)

        fake_rich_click = FakeRichClickModule("rich_click")

        monkeypatch.setitem(sys.modules, "rich_click", fake_rich_click)

    return importlib.import_module("advanced_alchemy.utils.cli_tools")


def _build_alias_group(mod: types.ModuleType, aliases: "tuple[str, ...]" = ("db",)) -> base_click.Group:
    @mod.group(name="database", aliases=aliases)
    def database_group() -> None: ...

    @mod.command(name="status", aliases=("st",))
    def status_cmd() -> None: ...

    database_group.add_command(status_cmd)
    return database_group  # type: ignore[return-value,no-any-return]


def test_plain_click_alias_resolution(monkeypatch: pytest.MonkeyPatch) -> None:
    mod = _reload_utils_click(monkeypatch, "none")

    group = _build_alias_group(mod)

    ctx = base_click.Context(group)
    cmd = group.get_command(ctx, "st")

    assert isinstance(group, mod.AliasedGroup)
    assert cmd is group.get_command(ctx, "status")


def test_plain_click_resolve_command_returns_canonical(monkeypatch: pytest.MonkeyPatch) -> None:
    mod = _reload_utils_click(monkeypatch, "none")
    group = _build_alias_group(mod)
    ctx = base_click.Context(group)

    cmd_name, cmd, remaining = group.resolve_command(ctx, ["st"])  # type: ignore[arg-type]
    assert cmd_name == "status"
    assert cmd is not None
    assert remaining == []


def test_old_rich_click_uses_aliased_group(monkeypatch: pytest.MonkeyPatch) -> None:
    mod = _reload_utils_click(monkeypatch, "old")
    group = _build_alias_group(mod)
    assert not mod._RICH_CLICK_ALIASES_SUPPORTED
    assert isinstance(group, mod.AliasedGroup)


def test_new_rich_click_prefers_rich_group(monkeypatch: pytest.MonkeyPatch) -> None:
    mod = _reload_utils_click(monkeypatch, "new")

    @mod.group(name="database", aliases=("db",))
    def grp() -> None: ...

    assert mod._RICH_CLICK_AVAILABLE
    assert mod._RICH_CLICK_ALIASES_SUPPORTED
    assert grp.__class__.__name__.startswith("FakeRichGroup")


def test_command_wrapper_stores_aliases_on_plain_click(monkeypatch: pytest.MonkeyPatch) -> None:
    mod = _reload_utils_click(monkeypatch, "none")

    @mod.command(name="ping", aliases=("p",))
    def ping() -> None: ...

    assert getattr(ping, "aliases", ()) == ("p",)


def test_parent_group_resolves_child_aliases_plain_click(monkeypatch: pytest.MonkeyPatch) -> None:
    """Parent group (like Litestar CLI) should resolve aliases of child groups.

    This tests the scenario where a child group with aliases is added to a parent
    group that was created with plain click (not our wrapper). The global patch
    should make the parent group alias-aware.
    """
    mod = _reload_utils_click(monkeypatch, "none")

    # Create a parent group using base click (simulating Litestar's main CLI)
    @base_click.group(name="main")
    def parent_group() -> None:
        pass

    # Create child group with aliases (simulating database_group)
    @mod.group(name="database", aliases=("db",))
    def child_group() -> None:
        pass

    # Add child to parent (this is what Litestar does)
    parent_group.add_command(child_group)

    # Parent should resolve "db" to "database"
    ctx = base_click.Context(parent_group)
    resolved = parent_group.get_command(ctx, "db")

    assert resolved is not None
    assert resolved.name == "database"


def test_parent_group_resolves_child_aliases_old_rich_click(monkeypatch: pytest.MonkeyPatch) -> None:
    """Parent group resolves aliases with old rich-click (< 1.9.0)."""
    mod = _reload_utils_click(monkeypatch, "old")

    # Create a parent group using base click
    @base_click.group(name="main")
    def parent_group() -> None:
        pass

    # Create child group with aliases
    @mod.group(name="database", aliases=("db",))
    def child_group() -> None:
        pass

    parent_group.add_command(child_group)

    ctx = base_click.Context(parent_group)
    resolved = parent_group.get_command(ctx, "db")

    assert resolved is not None
    assert resolved.name == "database"


def test_alias_collision_last_wins(monkeypatch: pytest.MonkeyPatch) -> None:
    """When two commands register the same alias, last registration wins."""
    mod = _reload_utils_click(monkeypatch, "none")

    @mod.group(name="main")
    def main_group() -> None:
        pass

    @mod.command(name="command-one", aliases=("c",))
    def cmd_one() -> None:
        pass

    @mod.command(name="command-two", aliases=("c",))
    def cmd_two() -> None:
        pass

    main_group.add_command(cmd_one)
    main_group.add_command(cmd_two)

    ctx = base_click.Context(main_group)
    resolved = main_group.get_command(ctx, "c")

    # Last registered command with alias "c" wins
    assert resolved is not None
    assert resolved.name == "command-two"


def test_non_aliased_command_lookup_still_works(monkeypatch: pytest.MonkeyPatch) -> None:
    """Ensure patching doesn't break normal command lookup without aliases."""
    mod = _reload_utils_click(monkeypatch, "none")

    @mod.group(name="main")
    def main_group() -> None:
        pass

    @mod.command(name="simple")
    def simple_cmd() -> None:
        pass

    @mod.command(name="another", aliases=("a",))
    def another_cmd() -> None:
        pass

    main_group.add_command(simple_cmd)
    main_group.add_command(another_cmd)

    ctx = base_click.Context(main_group)

    # Non-aliased command should resolve by name
    simple_resolved = main_group.get_command(ctx, "simple")
    assert simple_resolved is not None
    assert simple_resolved.name == "simple"

    # Aliased command should resolve by both name and alias
    another_by_name = main_group.get_command(ctx, "another")
    another_by_alias = main_group.get_command(ctx, "a")
    assert another_by_name is not None
    assert another_by_alias is not None
    assert another_by_name.name == "another"
    assert another_by_alias.name == "another"


def test_new_rich_click_child_with_plain_click_parent(monkeypatch: pytest.MonkeyPatch) -> None:
    """New rich-click child group added to plain click parent group.

    This validates the integration scenario where a child group using
    new rich-click (with native alias support) is added to a parent group
    that was created with plain click.
    """
    mod = _reload_utils_click(monkeypatch, "new")

    # Create parent with plain click (simulating framework CLI)
    @base_click.group(name="main")
    def parent_group() -> None:
        pass

    # Create child with new rich-click (native aliases)
    @mod.group(name="database", aliases=("db",))
    def child_group() -> None:
        pass

    parent_group.add_command(child_group)

    ctx = base_click.Context(parent_group)

    # Should resolve both by name and alias
    by_name = parent_group.get_command(ctx, "database")
    by_alias = parent_group.get_command(ctx, "db")

    assert by_name is not None
    assert by_name.name == "database"
    assert by_alias is not None
    assert by_alias.name == "database"


def test_command_name_not_added_to_own_alias_mapping(monkeypatch: pytest.MonkeyPatch) -> None:
    """A command's own name is not added to the alias mapping.

    This ensures that looking up a command by its canonical name works
    through normal click resolution, not through alias mapping.
    """
    mod = _reload_utils_click(monkeypatch, "none")

    @mod.group(name="main")
    def main_group() -> None:
        pass

    @mod.command(name="status", aliases=("st", "stat"))
    def status_cmd() -> None:
        pass

    main_group.add_command(status_cmd)

    ctx = base_click.Context(main_group)

    # Command resolves by canonical name (through normal click)
    by_name = main_group.get_command(ctx, "status")
    # And by aliases (through alias mapping)
    by_alias_st = main_group.get_command(ctx, "st")
    by_alias_stat = main_group.get_command(ctx, "stat")

    assert by_name is not None
    assert by_alias_st is not None
    assert by_alias_stat is not None
    assert by_name.name == "status"
    assert by_alias_st.name == "status"
    assert by_alias_stat.name == "status"

    # The alias mapping should NOT contain the command's own name
    # This is why we do `self._alias_mapping.pop(command_name, None)`
    alias_mapping = getattr(main_group, "_alias_mapping", {})
    assert "status" not in alias_mapping
    assert "st" in alias_mapping
    assert "stat" in alias_mapping
python-advanced-alchemy-1.9.3/tests/unit/test_utils/test_fixtures.py000066400000000000000000001144761516556515500260650ustar00rootroot00000000000000"""Tests for advanced_alchemy.utils.fixtures module."""

import csv
import gzip
import io
import json
import tempfile
import zipfile
from collections.abc import Generator
from pathlib import Path
from typing import Any

import pytest

from advanced_alchemy.utils.fixtures import open_fixture, open_fixture_async


@pytest.fixture
def sample_data() -> "list[dict[str, Any]]":
    """Sample JSON data for testing."""
    return [
        {"id": 1, "name": "Alice", "email": "alice@example.com"},
        {"id": 2, "name": "Bob", "email": "bob@example.com"},
        {"id": 3, "name": "Charlie", "email": "charlie@example.com"},
    ]


@pytest.fixture
def sample_csv_data() -> "list[dict[str, str]]":
    """Sample CSV data for testing (note: CSV values are strings)."""
    return [
        {"id": "1", "name": "Alice", "email": "alice@example.com"},
        {"id": "2", "name": "Bob", "email": "bob@example.com"},
        {"id": "3", "name": "Charlie", "email": "charlie@example.com"},
    ]


@pytest.fixture
def temp_fixtures_dir(
    sample_data: "list[dict[str, Any]]", sample_csv_data: "list[dict[str, str]]"
) -> "Generator[Path, None, None]":
    """Create temporary directory with test fixtures in various formats."""
    with tempfile.TemporaryDirectory() as temp_dir:
        fixtures_path = Path(temp_dir)

        # Create plain JSON fixture
        json_file = fixtures_path / "users.json"
        with open(json_file, "w", encoding="utf-8") as f:
            json.dump(sample_data, f, indent=2)

        # Create gzipped JSON fixture
        gz_file = fixtures_path / "users_gz.json.gz"
        with gzip.open(gz_file, "wt", encoding="utf-8") as f:
            json.dump(sample_data, f, indent=2)

        # Create zipped JSON fixture (single file)
        zip_file = fixtures_path / "users_zip.json.zip"
        with zipfile.ZipFile(zip_file, "w", zipfile.ZIP_DEFLATED) as zf:
            zf.writestr("users_zip.json", json.dumps(sample_data, indent=2))

        # Create zipped JSON fixture with multiple files (should pick the first)
        multi_zip_file = fixtures_path / "users_multi.json.zip"
        with zipfile.ZipFile(multi_zip_file, "w", zipfile.ZIP_DEFLATED) as zf:
            zf.writestr("other.json", json.dumps([{"other": "data"}]))
            zf.writestr("users_multi.json", json.dumps(sample_data, indent=2))

        # Create zipped JSON fixture with preferred name match
        preferred_zip_file = fixtures_path / "users_preferred.json.zip"
        with zipfile.ZipFile(preferred_zip_file, "w", zipfile.ZIP_DEFLATED) as zf:
            zf.writestr("other.json", json.dumps([{"other": "data"}]))
            zf.writestr("users_preferred.json", json.dumps(sample_data, indent=2))

        # Create empty zip file (should raise error)
        empty_zip_file = fixtures_path / "empty.json.zip"
        with zipfile.ZipFile(empty_zip_file, "w", zipfile.ZIP_DEFLATED):
            pass  # Empty zip

        # Create zip with no JSON files
        no_json_zip_file = fixtures_path / "no_json.json.zip"
        with zipfile.ZipFile(no_json_zip_file, "w", zipfile.ZIP_DEFLATED) as zf:
            zf.writestr("readme.txt", "No JSON files here")

        # Create plain CSV fixture
        csv_file = fixtures_path / "users_csv.csv"
        with open(csv_file, "w", encoding="utf-8", newline="") as f:
            if sample_csv_data:
                writer = csv.DictWriter(f, fieldnames=sample_csv_data[0].keys())
                writer.writeheader()
                writer.writerows(sample_csv_data)

        # Create gzipped CSV fixture
        gz_csv_file = fixtures_path / "users_csv_gz.csv.gz"
        with gzip.open(gz_csv_file, "wt", encoding="utf-8", newline="") as f:
            writer = csv.DictWriter(f, fieldnames=sample_csv_data[0].keys())
            writer.writeheader()
            writer.writerows(sample_csv_data)

        # Create zipped CSV fixture (single file)
        zip_csv_file = fixtures_path / "users_csv_zip.csv.zip"
        with zipfile.ZipFile(zip_csv_file, "w", zipfile.ZIP_DEFLATED) as zf:
            csv_content = io.StringIO()
            writer = csv.DictWriter(csv_content, fieldnames=sample_csv_data[0].keys())
            writer.writeheader()
            writer.writerows(sample_csv_data)
            zf.writestr("users_csv_zip.csv", csv_content.getvalue())

        # Create zipped CSV fixture with multiple files (should pick matching name)
        multi_zip_csv_file = fixtures_path / "users_csv_multi.csv.zip"
        with zipfile.ZipFile(multi_zip_csv_file, "w", zipfile.ZIP_DEFLATED) as zf:
            # Add a different CSV first
            other_content = io.StringIO()
            other_writer = csv.DictWriter(other_content, fieldnames=["other"])
            other_writer.writeheader()
            other_writer.writerow({"other": "data"})
            zf.writestr("other.csv", other_content.getvalue())

            # Add the actual fixture
            csv_content = io.StringIO()
            writer = csv.DictWriter(csv_content, fieldnames=sample_csv_data[0].keys())
            writer.writeheader()
            writer.writerows(sample_csv_data)
            zf.writestr("users_csv_multi.csv", csv_content.getvalue())

        # Create empty CSV zip file (should raise error)
        empty_csv_zip = fixtures_path / "empty_csv.csv.zip"
        with zipfile.ZipFile(empty_csv_zip, "w", zipfile.ZIP_DEFLATED):
            pass

        # Create zip with no CSV files
        no_csv_zip = fixtures_path / "no_csv.csv.zip"
        with zipfile.ZipFile(no_csv_zip, "w", zipfile.ZIP_DEFLATED) as zf:
            zf.writestr("readme.txt", "No CSV files here")

        yield fixtures_path


class TestOpenFixture:
    """Test cases for synchronous open_fixture function."""

    def test_open_plain_json_fixture(self, temp_fixtures_dir: Path, sample_data: "list[dict[str, Any]]") -> None:
        """Test loading plain JSON fixture."""
        result = open_fixture(temp_fixtures_dir, "users")
        assert result == sample_data

    def test_open_gzipped_fixture(self, temp_fixtures_dir: Path, sample_data: "list[dict[str, Any]]") -> None:
        """Test loading gzipped JSON fixture."""
        result = open_fixture(temp_fixtures_dir, "users_gz")
        assert result == sample_data

    def test_open_zipped_fixture(self, temp_fixtures_dir: Path, sample_data: "list[dict[str, Any]]") -> None:
        """Test loading zipped JSON fixture."""
        result = open_fixture(temp_fixtures_dir, "users_zip")
        assert result == sample_data

    def test_open_zipped_fixture_multiple_files(
        self, temp_fixtures_dir: Path, sample_data: "list[dict[str, Any]]"
    ) -> None:
        """Test loading zipped JSON fixture with multiple files, should prefer matching name."""
        result = open_fixture(temp_fixtures_dir, "users_multi")
        assert result == sample_data

    def test_open_zipped_fixture_preferred_name(
        self, temp_fixtures_dir: Path, sample_data: "list[dict[str, Any]]"
    ) -> None:
        """Test loading zipped JSON fixture prefers file with matching name."""
        result = open_fixture(temp_fixtures_dir, "users_preferred")
        assert result == sample_data

    def test_case_insensitive_support(self, temp_fixtures_dir: Path, sample_data: "list[dict[str, Any]]") -> None:
        """Test case-insensitive fixture loading (uppercase takes priority for compressed files)."""
        # Create uppercase gzipped file
        uppercase_gz_file = temp_fixtures_dir / "TESTCASE.json.gz"
        with gzip.open(uppercase_gz_file, "wt", encoding="utf-8") as f:
            json.dump(sample_data, f)

        # Test that uppercase is found
        result = open_fixture(temp_fixtures_dir, "testcase")
        assert result == sample_data

        # Create lowercase version and test priority (uppercase should still win)
        lowercase_gz_file = temp_fixtures_dir / "testcase.json.gz"
        lowercase_data = [{"different": "data"}]
        with gzip.open(lowercase_gz_file, "wt", encoding="utf-8") as f:
            json.dump(lowercase_data, f)

        # Should still load uppercase version first
        result = open_fixture(temp_fixtures_dir, "testcase")
        assert result == sample_data  # Original data, not lowercase_data

        # Remove uppercase, should fallback to lowercase
        uppercase_gz_file.unlink()
        result = open_fixture(temp_fixtures_dir, "testcase")
        assert result == lowercase_data

    def test_file_format_priority(self, temp_fixtures_dir: Path, sample_data: "list[dict[str, Any]]") -> None:
        """Test that plain JSON is preferred over compressed formats."""
        # Create all three formats for the same fixture name
        json_file = temp_fixtures_dir / "priority.json"
        gz_file = temp_fixtures_dir / "priority.json.gz"
        zip_file = temp_fixtures_dir / "priority.json.zip"

        # Different data for each format to test which one is loaded
        plain_data = [{"format": "plain"}]
        gz_data = [{"format": "gzip"}]
        zip_data = [{"format": "zip"}]

        with open(json_file, "w", encoding="utf-8") as f:
            json.dump(plain_data, f)

        with gzip.open(gz_file, "wt", encoding="utf-8") as f:
            json.dump(gz_data, f)

        with zipfile.ZipFile(zip_file, "w", zipfile.ZIP_DEFLATED) as zf:
            zf.writestr("priority.json", json.dumps(zip_data))

        # Should load plain JSON first
        result = open_fixture(temp_fixtures_dir, "priority")
        assert result == plain_data

        # Remove plain JSON, should load gzip
        json_file.unlink()
        result = open_fixture(temp_fixtures_dir, "priority")
        assert result == gz_data

        # Remove gzip, should load zip
        gz_file.unlink()
        result = open_fixture(temp_fixtures_dir, "priority")
        assert result == zip_data

    def test_fixture_not_found(self, temp_fixtures_dir: Path) -> None:
        """Test FileNotFoundError when fixture doesn't exist."""
        with pytest.raises(FileNotFoundError) as exc_info:
            open_fixture(temp_fixtures_dir, "nonexistent")

        assert "Could not find the nonexistent fixture" in str(exc_info.value)
        assert "(tried .json, .json.gz, .json.zip, .csv, .csv.gz, .csv.zip with case variations)" in str(exc_info.value)

    def test_empty_zip_file(self, temp_fixtures_dir: Path) -> None:
        """Test error handling for empty zip file."""
        with pytest.raises(ValueError) as exc_info:
            open_fixture(temp_fixtures_dir, "empty")

        assert "No JSON files found in zip archive" in str(exc_info.value)

    def test_zip_with_no_json_files(self, temp_fixtures_dir: Path) -> None:
        """Test error handling for zip file with no JSON files."""
        with pytest.raises(ValueError) as exc_info:
            open_fixture(temp_fixtures_dir, "no_json")

        assert "No JSON files found in zip archive" in str(exc_info.value)

    def test_corrupted_gzip_file(self, temp_fixtures_dir: Path) -> None:
        """Test error handling for corrupted gzip file."""
        # Create corrupted gzip file
        corrupted_file = temp_fixtures_dir / "corrupted.json.gz"
        with open(corrupted_file, "wb") as f:
            f.write(b"not a gzip file")

        with pytest.raises(OSError) as exc_info:
            open_fixture(temp_fixtures_dir, "corrupted")

        assert "Error reading fixture file" in str(exc_info.value)

    def test_corrupted_zip_file(self, temp_fixtures_dir: Path) -> None:
        """Test error handling for corrupted zip file."""
        # Create corrupted zip file
        corrupted_file = temp_fixtures_dir / "corrupted_zip.json.zip"
        with open(corrupted_file, "wb") as f:
            f.write(b"not a zip file")

        with pytest.raises(OSError) as exc_info:
            open_fixture(temp_fixtures_dir, "corrupted_zip")

        assert "Error reading fixture file" in str(exc_info.value)

    def test_invalid_json_content(self, temp_fixtures_dir: Path) -> None:
        """Test error handling for invalid JSON content."""
        invalid_file = temp_fixtures_dir / "invalid.json"
        with open(invalid_file, "w", encoding="utf-8") as f:
            f.write("{ invalid json content")

        with pytest.raises(Exception):  # decode_json will raise an appropriate exception
            open_fixture(temp_fixtures_dir, "invalid")


class TestOpenFixtureCSV:
    """Test cases for CSV fixture loading."""

    def test_open_plain_csv_fixture(self, temp_fixtures_dir: Path, sample_csv_data: "list[dict[str, str]]") -> None:
        """Test loading plain CSV fixture."""
        result = open_fixture(temp_fixtures_dir, "users_csv")
        assert result == sample_csv_data
        # Verify it's a list of dicts
        assert isinstance(result, list)
        assert all(isinstance(row, dict) for row in result)

    def test_open_gzipped_csv_fixture(self, temp_fixtures_dir: Path, sample_csv_data: "list[dict[str, str]]") -> None:
        """Test loading gzipped CSV fixture."""
        result = open_fixture(temp_fixtures_dir, "users_csv_gz")
        assert result == sample_csv_data

    def test_open_zipped_csv_fixture(self, temp_fixtures_dir: Path, sample_csv_data: "list[dict[str, str]]") -> None:
        """Test loading zipped CSV fixture."""
        result = open_fixture(temp_fixtures_dir, "users_csv_zip")
        assert result == sample_csv_data

    def test_open_zipped_csv_fixture_multiple_files(
        self, temp_fixtures_dir: Path, sample_csv_data: "list[dict[str, str]]"
    ) -> None:
        """Test loading zipped CSV fixture with multiple files, should prefer matching name."""
        result = open_fixture(temp_fixtures_dir, "users_csv_multi")
        assert result == sample_csv_data

    def test_csv_values_are_strings(self, temp_fixtures_dir: Path) -> None:
        """Test that CSV values are strings (important difference from JSON)."""
        result = open_fixture(temp_fixtures_dir, "users_csv")
        # CSV returns strings, not integers
        assert result[0]["id"] == "1"
        assert isinstance(result[0]["id"], str)

    def test_json_priority_over_csv(
        self, temp_fixtures_dir: Path, sample_data: "list[dict[str, Any]]", sample_csv_data: "list[dict[str, str]]"
    ) -> None:
        """Test that JSON fixtures are loaded before CSV when both exist."""
        # Create both JSON and CSV fixtures with same name
        json_file = temp_fixtures_dir / "priority_format.json"
        csv_file = temp_fixtures_dir / "priority_format.csv"

        with open(json_file, "w", encoding="utf-8") as f:
            json.dump(sample_data, f)

        with open(csv_file, "w", encoding="utf-8", newline="") as f:
            writer = csv.DictWriter(f, fieldnames=sample_csv_data[0].keys())
            writer.writeheader()
            writer.writerows(sample_csv_data)

        # Should load JSON first
        result = open_fixture(temp_fixtures_dir, "priority_format")
        assert result == sample_data  # JSON data, not CSV data

        # Remove JSON, should load CSV
        json_file.unlink()
        result = open_fixture(temp_fixtures_dir, "priority_format")
        assert result == sample_csv_data

    def test_empty_csv_zip_file(self, temp_fixtures_dir: Path) -> None:
        """Test error handling for empty CSV zip file."""
        with pytest.raises(ValueError) as exc_info:
            open_fixture(temp_fixtures_dir, "empty_csv")
        assert "No CSV files found in zip archive" in str(exc_info.value)

    def test_csv_zip_with_no_csv_files(self, temp_fixtures_dir: Path) -> None:
        """Test error handling for zip file with no CSV files."""
        with pytest.raises(ValueError) as exc_info:
            open_fixture(temp_fixtures_dir, "no_csv")
        assert "No CSV files found in zip archive" in str(exc_info.value)

    def test_csv_with_special_characters(self, temp_fixtures_dir: Path) -> None:
        """Test CSV with special characters, quotes, and commas."""
        special_data = [
            {"name": "O'Brien", "description": "Has apostrophe"},
            {"name": "Smith, Jr.", "description": "Has comma"},
            {"name": 'Quote"Test', "description": "Has quote"},
        ]

        csv_file = temp_fixtures_dir / "special.csv"
        with open(csv_file, "w", encoding="utf-8", newline="") as f:
            writer = csv.DictWriter(f, fieldnames=special_data[0].keys())
            writer.writeheader()
            writer.writerows(special_data)

        result = open_fixture(temp_fixtures_dir, "special")
        assert result == special_data

    def test_csv_with_unicode(self, temp_fixtures_dir: Path) -> None:
        """Test CSV with Unicode characters."""
        unicode_data = [
            {"name": "Franรงois", "city": "Zรผrich"},
            {"name": "ๆ—ฅๆœฌ", "city": "ๆฑไบฌ"},
            {"name": "ะœะพัะบะฒะฐ", "city": "ะ ะพััะธั"},
        ]

        csv_file = temp_fixtures_dir / "unicode.csv"
        with open(csv_file, "w", encoding="utf-8", newline="") as f:
            writer = csv.DictWriter(f, fieldnames=unicode_data[0].keys())
            writer.writeheader()
            writer.writerows(unicode_data)

        result = open_fixture(temp_fixtures_dir, "unicode")
        assert result == unicode_data

    def test_csv_with_embedded_newlines(self, temp_fixtures_dir: Path) -> None:
        """Test CSV with embedded newlines in quoted fields (RFC 4180 compliance)."""
        # This is a critical test - embedded newlines must be preserved
        csv_content = """name,description
Alice,"Line 1
Line 2"
Bob,"Simple description"
Charlie,"Multiple
embedded
newlines"
"""
        csv_file = temp_fixtures_dir / "embedded_newlines.csv"
        with open(csv_file, "w", encoding="utf-8", newline="") as f:
            f.write(csv_content)

        result = open_fixture(temp_fixtures_dir, "embedded_newlines")
        assert len(result) == 3
        assert result[0]["name"] == "Alice"
        assert result[0]["description"] == "Line 1\nLine 2"  # Newline must be preserved
        assert result[1]["description"] == "Simple description"
        assert result[2]["description"] == "Multiple\nembedded\nnewlines"

    def test_csv_with_embedded_newlines_gzip(self, temp_fixtures_dir: Path) -> None:
        """Test gzipped CSV with embedded newlines in quoted fields."""
        csv_content = """name,description
Alice,"Line 1
Line 2"
"""
        gz_file = temp_fixtures_dir / "embedded_newlines_gz.csv.gz"
        with gzip.open(gz_file, "wt", encoding="utf-8", newline="") as f:
            f.write(csv_content)

        result = open_fixture(temp_fixtures_dir, "embedded_newlines_gz")
        assert len(result) == 1
        assert result[0]["description"] == "Line 1\nLine 2"  # Newline must be preserved

    def test_csv_with_embedded_newlines_zip(self, temp_fixtures_dir: Path) -> None:
        """Test zipped CSV with embedded newlines in quoted fields."""
        csv_content = """name,description
Alice,"Line 1
Line 2"
"""
        zip_file = temp_fixtures_dir / "embedded_newlines_zip.csv.zip"
        with zipfile.ZipFile(zip_file, "w", zipfile.ZIP_DEFLATED) as zf:
            zf.writestr("embedded_newlines_zip.csv", csv_content)

        result = open_fixture(temp_fixtures_dir, "embedded_newlines_zip")
        assert len(result) == 1
        assert result[0]["description"] == "Line 1\nLine 2"  # Newline must be preserved

    def test_csv_with_utf8_bom(self, temp_fixtures_dir: Path) -> None:
        """Test CSV with UTF-8 BOM (common in Excel exports)."""
        # UTF-8 BOM is \ufeff at the start of the file
        csv_content = "\ufeffname,value\nAlice,1\nBob,2\n"
        csv_file = temp_fixtures_dir / "bom.csv"
        with open(csv_file, "w", encoding="utf-8", newline="") as f:
            f.write(csv_content)

        result = open_fixture(temp_fixtures_dir, "bom")
        assert len(result) == 2
        # BOM should be stripped, so first key should be "name" not "\ufeffname"
        assert "name" in result[0]
        assert "\ufeffname" not in result[0]
        assert result[0]["name"] == "Alice"

    def test_csv_with_utf8_bom_gzip(self, temp_fixtures_dir: Path) -> None:
        """Test gzipped CSV with UTF-8 BOM."""
        csv_content = "\ufeffname,value\nAlice,1\n"
        # Write as bytes to preserve BOM
        gz_file = temp_fixtures_dir / "bom_gz.csv.gz"
        with gzip.open(gz_file, "wb") as f:
            f.write(csv_content.encode("utf-8"))

        result = open_fixture(temp_fixtures_dir, "bom_gz")
        assert len(result) == 1
        assert "name" in result[0]
        assert "\ufeffname" not in result[0]

    def test_csv_with_utf8_bom_zip(self, temp_fixtures_dir: Path) -> None:
        """Test zipped CSV with UTF-8 BOM."""
        csv_content = "\ufeffname,value\nAlice,1\n"
        zip_file = temp_fixtures_dir / "bom_zip.csv.zip"
        with zipfile.ZipFile(zip_file, "w", zipfile.ZIP_DEFLATED) as zf:
            # Write as bytes to preserve BOM
            zf.writestr("bom_zip.csv", csv_content.encode("utf-8"))

        result = open_fixture(temp_fixtures_dir, "bom_zip")
        assert len(result) == 1
        assert "name" in result[0]
        assert "\ufeffname" not in result[0]

    def test_csv_empty_data_rows(self, temp_fixtures_dir: Path) -> None:
        """Test CSV with headers only (no data rows)."""
        csv_content = "name,email,age\n"
        csv_file = temp_fixtures_dir / "headers_only.csv"
        with open(csv_file, "w", encoding="utf-8", newline="") as f:
            f.write(csv_content)

        result = open_fixture(temp_fixtures_dir, "headers_only")
        assert result == []  # Empty list, no rows

    def test_uppercase_plain_csv(self, temp_fixtures_dir: Path) -> None:
        """Test loading uppercase plain CSV file."""
        csv_content = "name,value\nUPPER,1\n"
        csv_file = temp_fixtures_dir / "UPPERTEST.csv"
        with open(csv_file, "w", encoding="utf-8", newline="") as f:
            f.write(csv_content)

        # Should find UPPERTEST.csv when searching for "uppertest"
        result = open_fixture(temp_fixtures_dir, "uppertest")
        assert len(result) == 1
        assert result[0]["name"] == "UPPER"


class TestOpenFixtureAsync:
    """Test cases for asynchronous open_fixture_async function."""

    @pytest.mark.asyncio
    async def test_open_plain_json_fixture_async(
        self, temp_fixtures_dir: Path, sample_data: "list[dict[str, Any]]"
    ) -> None:
        """Test loading plain JSON fixture asynchronously."""
        result = await open_fixture_async(temp_fixtures_dir, "users")
        assert result == sample_data

    @pytest.mark.asyncio
    async def test_open_gzipped_fixture_async(
        self, temp_fixtures_dir: Path, sample_data: "list[dict[str, Any]]"
    ) -> None:
        """Test loading gzipped JSON fixture asynchronously."""
        result = await open_fixture_async(temp_fixtures_dir, "users_gz")
        assert result == sample_data

    @pytest.mark.asyncio
    async def test_open_zipped_fixture_async(
        self, temp_fixtures_dir: Path, sample_data: "list[dict[str, Any]]"
    ) -> None:
        """Test loading zipped JSON fixture asynchronously."""
        result = await open_fixture_async(temp_fixtures_dir, "users_zip")
        assert result == sample_data

    @pytest.mark.asyncio
    async def test_open_zipped_fixture_multiple_files_async(
        self, temp_fixtures_dir: Path, sample_data: "list[dict[str, Any]]"
    ) -> None:
        """Test loading zipped JSON fixture with multiple files asynchronously."""
        result = await open_fixture_async(temp_fixtures_dir, "users_multi")
        assert result == sample_data

    @pytest.mark.asyncio
    async def test_case_insensitive_support_async(
        self, temp_fixtures_dir: Path, sample_data: "list[dict[str, Any]]"
    ) -> None:
        """Test case-insensitive fixture loading asynchronously."""
        # Create uppercase gzipped file
        uppercase_gz_file = temp_fixtures_dir / "ASYNCCASE.json.gz"
        with gzip.open(uppercase_gz_file, "wt", encoding="utf-8") as f:
            json.dump(sample_data, f)

        # Test that uppercase is found
        result = await open_fixture_async(temp_fixtures_dir, "asynccase")
        assert result == sample_data

    @pytest.mark.asyncio
    async def test_file_format_priority_async(self, temp_fixtures_dir: Path) -> None:
        """Test that plain JSON is preferred over compressed formats in async version."""
        # Create all three formats for the same fixture name
        json_file = temp_fixtures_dir / "priority_async.json"
        gz_file = temp_fixtures_dir / "priority_async.json.gz"
        zip_file = temp_fixtures_dir / "priority_async.json.zip"

        # Different data for each format to test which one is loaded
        plain_data = [{"format": "plain"}]
        gz_data = [{"format": "gzip"}]
        zip_data = [{"format": "zip"}]

        with open(json_file, "w", encoding="utf-8") as f:
            json.dump(plain_data, f)

        with gzip.open(gz_file, "wt", encoding="utf-8") as f:
            json.dump(gz_data, f)

        with zipfile.ZipFile(zip_file, "w", zipfile.ZIP_DEFLATED) as zf:
            zf.writestr("priority_async.json", json.dumps(zip_data))

        # Should load plain JSON first
        result = await open_fixture_async(temp_fixtures_dir, "priority_async")
        assert result == plain_data

    @pytest.mark.asyncio
    async def test_fixture_not_found_async(self, temp_fixtures_dir: Path) -> None:
        """Test FileNotFoundError when fixture doesn't exist in async version."""
        with pytest.raises(FileNotFoundError) as exc_info:
            await open_fixture_async(temp_fixtures_dir, "nonexistent")

        assert "Could not find the nonexistent fixture" in str(exc_info.value)
        assert "(tried .json, .json.gz, .json.zip, .csv, .csv.gz, .csv.zip with case variations)" in str(exc_info.value)

    @pytest.mark.asyncio
    async def test_empty_zip_file_async(self, temp_fixtures_dir: Path) -> None:
        """Test error handling for empty zip file in async version."""
        with pytest.raises(ValueError) as exc_info:
            await open_fixture_async(temp_fixtures_dir, "empty")

        assert "No JSON files found in zip archive" in str(exc_info.value)

    @pytest.mark.asyncio
    async def test_corrupted_gzip_file_async(self, temp_fixtures_dir: Path) -> None:
        """Test error handling for corrupted gzip file in async version."""
        # Create corrupted gzip file
        corrupted_file = temp_fixtures_dir / "corrupted_async.json.gz"
        with open(corrupted_file, "wb") as f:
            f.write(b"not a gzip file")

        with pytest.raises(OSError) as exc_info:
            await open_fixture_async(temp_fixtures_dir, "corrupted_async")

        assert "Error reading fixture file" in str(exc_info.value)

    @pytest.mark.skip(reason="Import mocking is complex and anyio is required by the project")
    @pytest.mark.asyncio
    async def test_missing_anyio_dependency(self, temp_fixtures_dir: Path) -> None:
        """Test MissingDependencyError when anyio is not available."""
        # Note: This test documents the expected behavior when anyio is not available.
        # In practice, anyio is a required dependency for this project.
        pass

    # Async CSV Tests
    @pytest.mark.asyncio
    async def test_open_plain_csv_fixture_async(
        self, temp_fixtures_dir: Path, sample_csv_data: "list[dict[str, str]]"
    ) -> None:
        """Test loading plain CSV fixture asynchronously."""
        result = await open_fixture_async(temp_fixtures_dir, "users_csv")
        assert result == sample_csv_data

    @pytest.mark.asyncio
    async def test_open_gzipped_csv_fixture_async(
        self, temp_fixtures_dir: Path, sample_csv_data: "list[dict[str, str]]"
    ) -> None:
        """Test loading gzipped CSV fixture asynchronously."""
        result = await open_fixture_async(temp_fixtures_dir, "users_csv_gz")
        assert result == sample_csv_data

    @pytest.mark.asyncio
    async def test_open_zipped_csv_fixture_async(
        self, temp_fixtures_dir: Path, sample_csv_data: "list[dict[str, str]]"
    ) -> None:
        """Test loading zipped CSV fixture asynchronously."""
        result = await open_fixture_async(temp_fixtures_dir, "users_csv_zip")
        assert result == sample_csv_data

    @pytest.mark.asyncio
    async def test_open_zipped_csv_fixture_multiple_files_async(
        self, temp_fixtures_dir: Path, sample_csv_data: "list[dict[str, str]]"
    ) -> None:
        """Test loading zipped CSV fixture with multiple files asynchronously."""
        result = await open_fixture_async(temp_fixtures_dir, "users_csv_multi")
        assert result == sample_csv_data

    @pytest.mark.asyncio
    async def test_json_priority_over_csv_async(
        self, temp_fixtures_dir: Path, sample_data: "list[dict[str, Any]]", sample_csv_data: "list[dict[str, str]]"
    ) -> None:
        """Test that JSON fixtures are loaded before CSV when both exist in async version."""
        json_file = temp_fixtures_dir / "priority_async.json"
        csv_file = temp_fixtures_dir / "priority_async.csv"

        with open(json_file, "w", encoding="utf-8") as f:
            json.dump(sample_data, f)

        with open(csv_file, "w", encoding="utf-8", newline="") as f:
            writer = csv.DictWriter(f, fieldnames=sample_csv_data[0].keys())
            writer.writeheader()
            writer.writerows(sample_csv_data)

        result = await open_fixture_async(temp_fixtures_dir, "priority_async")
        assert result == sample_data

    @pytest.mark.asyncio
    async def test_empty_csv_zip_file_async(self, temp_fixtures_dir: Path) -> None:
        """Test error handling for empty CSV zip file in async version."""
        with pytest.raises(ValueError) as exc_info:
            await open_fixture_async(temp_fixtures_dir, "empty_csv")
        assert "No CSV files found in zip archive" in str(exc_info.value)

    @pytest.mark.asyncio
    async def test_csv_with_embedded_newlines_async(self, temp_fixtures_dir: Path) -> None:
        """Test async CSV loading with embedded newlines in quoted fields."""
        csv_content = """name,description
Alice,"Line 1
Line 2"
"""
        csv_file = temp_fixtures_dir / "embedded_async.csv"
        with open(csv_file, "w", encoding="utf-8", newline="") as f:
            f.write(csv_content)

        result = await open_fixture_async(temp_fixtures_dir, "embedded_async")
        assert len(result) == 1
        assert result[0]["description"] == "Line 1\nLine 2"

    @pytest.mark.asyncio
    async def test_csv_with_embedded_newlines_gzip_async(self, temp_fixtures_dir: Path) -> None:
        """Test async gzipped CSV loading with embedded newlines."""
        csv_content = """name,description
Alice,"Line 1
Line 2"
"""
        gz_file = temp_fixtures_dir / "embedded_gz_async.csv.gz"
        with gzip.open(gz_file, "wt", encoding="utf-8", newline="") as f:
            f.write(csv_content)

        result = await open_fixture_async(temp_fixtures_dir, "embedded_gz_async")
        assert len(result) == 1
        assert result[0]["description"] == "Line 1\nLine 2"

    @pytest.mark.asyncio
    async def test_csv_with_embedded_newlines_zip_async(self, temp_fixtures_dir: Path) -> None:
        """Test async zipped CSV loading with embedded newlines."""
        csv_content = """name,description
Alice,"Line 1
Line 2"
"""
        zip_file = temp_fixtures_dir / "embedded_zip_async.csv.zip"
        with zipfile.ZipFile(zip_file, "w", zipfile.ZIP_DEFLATED) as zf:
            zf.writestr("embedded_zip_async.csv", csv_content)

        result = await open_fixture_async(temp_fixtures_dir, "embedded_zip_async")
        assert len(result) == 1
        assert result[0]["description"] == "Line 1\nLine 2"

    @pytest.mark.asyncio
    async def test_csv_with_utf8_bom_async(self, temp_fixtures_dir: Path) -> None:
        """Test async CSV loading with UTF-8 BOM."""
        csv_content = "\ufeffname,value\nAlice,1\n"
        csv_file = temp_fixtures_dir / "bom_async.csv"
        with open(csv_file, "w", encoding="utf-8", newline="") as f:
            f.write(csv_content)

        result = await open_fixture_async(temp_fixtures_dir, "bom_async")
        assert len(result) == 1
        assert "name" in result[0]
        assert "\ufeffname" not in result[0]

    @pytest.mark.asyncio
    async def test_concurrent_csv_reads(self, temp_fixtures_dir: Path) -> None:
        """Test concurrent async reads of the same CSV fixture."""
        import asyncio

        csv_content = "name,value\nAlice,1\nBob,2\n"
        csv_file = temp_fixtures_dir / "concurrent.csv"
        with open(csv_file, "w", encoding="utf-8", newline="") as f:
            f.write(csv_content)

        # Perform 5 concurrent reads
        results = await asyncio.gather(*(open_fixture_async(temp_fixtures_dir, "concurrent") for _ in range(5)))

        # All results should be identical
        assert len(results) == 5
        for result in results:
            assert len(result) == 2
            assert result[0]["name"] == "Alice"
            assert result[1]["name"] == "Bob"

    @pytest.mark.asyncio
    async def test_uppercase_plain_csv_async(self, temp_fixtures_dir: Path) -> None:
        """Test async loading of uppercase plain CSV file."""
        csv_content = "name,value\nUPPER,1\n"
        csv_file = temp_fixtures_dir / "UPPERASYNC.csv"
        with open(csv_file, "w", encoding="utf-8", newline="") as f:
            f.write(csv_content)

        result = await open_fixture_async(temp_fixtures_dir, "upperasync")
        assert len(result) == 1
        assert result[0]["name"] == "UPPER"


class TestIntegration:
    """Integration tests to ensure compatibility with existing usage patterns."""

    def test_backward_compatibility_sync(self, temp_fixtures_dir: Path, sample_data: "list[dict[str, Any]]") -> None:
        """Test that existing sync code still works as expected."""
        # This mirrors the usage pattern in test_sqlquery_service.py
        fixture_data = open_fixture(temp_fixtures_dir, "users")
        assert fixture_data == sample_data
        assert len(fixture_data) == 3
        assert fixture_data[0]["name"] == "Alice"

    @pytest.mark.asyncio
    async def test_backward_compatibility_async(
        self, temp_fixtures_dir: Path, sample_data: "list[dict[str, Any]]"
    ) -> None:
        """Test that existing async code still works as expected."""
        # This mirrors the usage pattern in test_sqlquery_service.py
        fixture_data = await open_fixture_async(temp_fixtures_dir, "users")
        assert fixture_data == sample_data
        assert len(fixture_data) == 3
        assert fixture_data[0]["name"] == "Alice"

    def test_compression_efficiency(self, temp_fixtures_dir: Path, sample_data: "list[dict[str, Any]]") -> None:
        """Test that compressed fixtures are actually smaller than plain JSON."""
        # Create a larger dataset for meaningful compression
        large_data = sample_data * 100  # Repeat the data 100 times

        # Create plain JSON
        json_file = temp_fixtures_dir / "large.json"
        with open(json_file, "w", encoding="utf-8") as f:
            json.dump(large_data, f, indent=2)

        # Create gzipped version
        gz_file = temp_fixtures_dir / "large.json.gz"
        with gzip.open(gz_file, "wt", encoding="utf-8") as f:
            json.dump(large_data, f, indent=2)

        # Create zipped version
        zip_file = temp_fixtures_dir / "large.json.zip"
        with zipfile.ZipFile(zip_file, "w", zipfile.ZIP_DEFLATED) as zf:
            zf.writestr("large.json", json.dumps(large_data, indent=2))

        # Check file sizes
        json_size = json_file.stat().st_size
        gz_size = gz_file.stat().st_size
        zip_size = zip_file.stat().st_size

        # Compressed versions should be smaller
        assert gz_size < json_size
        assert zip_size < json_size

        # Verify all formats load the same data
        json_data = open_fixture(temp_fixtures_dir, "large")

        # Remove plain JSON to force loading compressed versions
        json_file.unlink()
        gz_data = open_fixture(temp_fixtures_dir, "large")

        gz_file.unlink()
        zip_data = open_fixture(temp_fixtures_dir, "large")

        assert json_data == gz_data == zip_data == large_data

    def test_csv_fixture_integration_sync(self, temp_fixtures_dir: Path) -> None:
        """Test CSV fixture loading in a realistic scenario."""
        # Create a CSV fixture similar to real-world usage
        states_data = [
            {"abbreviation": "AL", "name": "Alabama"},
            {"abbreviation": "AK", "name": "Alaska"},
            {"abbreviation": "AZ", "name": "Arizona"},
        ]

        csv_file = temp_fixtures_dir / "us_state_lookup.csv"
        with open(csv_file, "w", encoding="utf-8", newline="") as f:
            writer = csv.DictWriter(f, fieldnames=["abbreviation", "name"])
            writer.writeheader()
            writer.writerows(states_data)

        # Load fixture
        fixture_data = open_fixture(temp_fixtures_dir, "us_state_lookup")
        assert fixture_data == states_data
        assert len(fixture_data) == 3
        assert fixture_data[0]["name"] == "Alabama"
        assert fixture_data[0]["abbreviation"] == "AL"

    @pytest.mark.asyncio
    async def test_csv_fixture_integration_async(self, temp_fixtures_dir: Path) -> None:
        """Test CSV fixture loading in a realistic async scenario."""
        states_data = [
            {"abbreviation": "CA", "name": "California"},
            {"abbreviation": "TX", "name": "Texas"},
        ]

        csv_file = temp_fixtures_dir / "states_async.csv"
        with open(csv_file, "w", encoding="utf-8", newline="") as f:
            writer = csv.DictWriter(f, fieldnames=["abbreviation", "name"])
            writer.writeheader()
            writer.writerows(states_data)

        fixture_data = await open_fixture_async(temp_fixtures_dir, "states_async")
        assert fixture_data == states_data
        assert len(fixture_data) == 2

    def test_mixed_format_directory(
        self, temp_fixtures_dir: Path, sample_data: "list[dict[str, Any]]", sample_csv_data: "list[dict[str, str]]"
    ) -> None:
        """Test loading from directory with mixed JSON and CSV fixtures."""
        # Create JSON fixture
        json_file = temp_fixtures_dir / "data_json.json"
        with open(json_file, "w", encoding="utf-8") as f:
            json.dump(sample_data, f)

        # Create CSV fixture
        csv_file = temp_fixtures_dir / "data_csv.csv"
        with open(csv_file, "w", encoding="utf-8", newline="") as f:
            writer = csv.DictWriter(f, fieldnames=sample_csv_data[0].keys())
            writer.writeheader()
            writer.writerows(sample_csv_data)

        # Both should load correctly
        json_result = open_fixture(temp_fixtures_dir, "data_json")
        csv_result = open_fixture(temp_fixtures_dir, "data_csv")

        assert json_result == sample_data
        assert csv_result == sample_csv_data
python-advanced-alchemy-1.9.3/tests/unit/test_utils/test_module_loader.py000066400000000000000000000034171516556515500270170ustar00rootroot00000000000000from pathlib import Path

import pytest
from _pytest.monkeypatch import MonkeyPatch

from advanced_alchemy.config import GenericAlembicConfig
from advanced_alchemy.utils.module_loader import import_string, module_to_os_path


def test_import_string() -> None:
    cls = import_string("advanced_alchemy.config.GenericAlembicConfig")
    assert type(cls) is type(GenericAlembicConfig)

    with pytest.raises(ImportError):
        _ = import_string("GenericAlembicConfigNew")
        _ = import_string("advanced_alchemy.config.GenericAlembicConfigNew")
        _ = import_string("imaginary_module_that_doesnt_exist.Config")  # a random nonexistent class


def test_module_path(tmp_path: Path, monkeypatch: MonkeyPatch) -> None:
    the_path = module_to_os_path("advanced_alchemy.config")
    assert the_path.exists()

    tmp_path.joinpath("simple_module.py").write_text("x = 'foo'")
    monkeypatch.syspath_prepend(tmp_path)  # pyright: ignore[reportUnknownMemberType]
    os_path = module_to_os_path("simple_module")
    assert os_path == Path(tmp_path)
    with pytest.raises(
        (
            ImportError,
            TypeError,
        )
    ):
        _ = module_to_os_path("advanced_alchemy.config.GenericAlembicConfig")
        _ = module_to_os_path("advanced_alchemy.config.GenericAlembicConfig.extra.module")


def test_import_non_existing_attribute_raises() -> None:
    with pytest.raises(ImportError):
        import_string("advanced_alchemy.config.SuperGenericAlembicConfig")


def test_import_string_cached(tmp_path: Path, monkeypatch: MonkeyPatch) -> None:
    tmp_path.joinpath("testmodule.py").write_text("x = 'foo'")
    monkeypatch.chdir(tmp_path)
    monkeypatch.syspath_prepend(tmp_path)  # pyright: ignore[reportUnknownMemberType]
    assert import_string("testmodule.x") == "foo"
python-advanced-alchemy-1.9.3/tests/unit/test_utils/test_portals.py000066400000000000000000000035071516556515500256700ustar00rootroot00000000000000import asyncio
from collections.abc import Coroutine
from typing import Any, Callable

import pytest

from advanced_alchemy.utils.portals import Portal, PortalProvider


@pytest.fixture
async def async_function() -> Callable[[int], Coroutine[Any, Any, int]]:
    async def sample_async_function(x: int) -> int:
        await asyncio.sleep(0.1)
        return x * 2

    return sample_async_function


def test_portal_provider_singleton() -> None:
    provider1 = PortalProvider()
    provider2 = PortalProvider()
    assert provider1 is provider2, "PortalProvider is not a singleton"


def test_portal_provider_start_stop() -> None:
    provider = PortalProvider()
    provider.start()
    assert provider.is_running, "Provider should be running after start()"
    assert provider.is_ready, "Provider should be ready after start()"
    provider.stop()
    assert not provider.is_running, "Provider should not be running after stop()"


def test_portal_provider_call(async_function: Callable[[int], Coroutine[Any, Any, int]]) -> None:
    provider = PortalProvider()
    provider.start()
    result = provider.call(async_function, 5)
    assert result == 10, "The result of the async function should be 10"
    provider.stop()


def test_portal_provider_call_exception() -> None:
    async def faulty_async_function() -> None:
        raise ValueError("Intentional error")

    provider = PortalProvider()
    provider.start()
    with pytest.raises(ValueError, match="Intentional error"):
        provider.call(faulty_async_function)
    provider.stop()


def test_portal_call(async_function: Callable[[int], Coroutine[Any, Any, int]]) -> None:
    provider = PortalProvider()
    portal = Portal(provider)
    provider.start()
    result = portal.call(async_function, 3)
    assert result == 6, "The result of the async function should be 6"
    provider.stop()
python-advanced-alchemy-1.9.3/tests/unit/test_utils/test_sync_tools.py000066400000000000000000000171551516556515500264040ustar00rootroot00000000000000import asyncio
from collections.abc import AsyncIterator, Awaitable, Iterator
from contextlib import asynccontextmanager, contextmanager
from typing import Callable, Optional, TypeVar

import pytest

from advanced_alchemy.utils.sync_tools import (
    CapacityLimiter,
    async_,
    await_,
    ensure_async_,
    run_,
    with_ensure_async_,
)

T = TypeVar("T")


async def test_ensure_async_() -> None:
    @ensure_async_
    def sync_func(x: int) -> int:
        return x * 2

    @ensure_async_  # type: ignore[arg-type]
    async def async_func(x: int) -> int:
        return x * 2

    assert await sync_func(21) == 42
    assert await async_func(21) == 42


@pytest.mark.asyncio
async def test_with_ensure_async_() -> None:
    @contextmanager
    def sync_cm() -> Iterator[int]:
        yield 42

    @asynccontextmanager
    async def async_cm() -> AsyncIterator[int]:
        yield 42

    async with with_ensure_async_(sync_cm()) as value:
        assert value == 42

    async with with_ensure_async_(async_cm()) as value:
        assert value == 42


@pytest.mark.asyncio
async def test_capacity_limiter() -> None:
    limiter = CapacityLimiter(1)

    async with limiter:
        assert limiter.total_tokens == 0

    assert limiter.total_tokens == 1


def test_run_() -> None:
    async def async_func(x: int) -> int:
        return x * 2

    sync_func = run_(async_func)
    assert sync_func(21) == 42


def test_await_() -> None:
    async def async_func(x: int) -> int:
        return x * 2

    sync_func = await_(async_func, raise_sync_error=False)
    assert sync_func(21) == 42


async def test_async_() -> None:
    def sync_func(x: int) -> int:
        return x * 2

    async_func = async_(sync_func)
    assert await async_func(21) == 42


async def test_capacity_limiter_setter() -> None:
    limiter = CapacityLimiter(2)
    assert limiter.total_tokens == 2
    limiter.total_tokens = 5
    assert limiter.total_tokens == 5


async def test_capacity_limiter_release_without_acquire() -> None:
    limiter = CapacityLimiter(1)
    # Release without acquire should not raise, but will increase tokens beyond initial
    limiter.release()
    assert limiter.total_tokens == 2


async def test_run_with_running_loop(monkeypatch: pytest.MonkeyPatch) -> None:
    async def async_func(x: int) -> int:
        return x * 2

    sync_func = run_(async_func)

    # Simulate running loop
    class DummyLoop:
        def is_running(self) -> bool:
            return True

    monkeypatch.setattr("asyncio.get_running_loop", lambda: DummyLoop())

    # The new implementation should handle running loops correctly using ThreadPoolExecutor
    result = sync_func(1)
    assert result == 2


def test_run_with_uvloop(monkeypatch: pytest.MonkeyPatch) -> None:
    async def async_func(x: int) -> int:
        return x * 2

    sync_func = run_(async_func)
    monkeypatch.setattr(
        "advanced_alchemy.utils.sync_tools.uvloop", type("UVLoop", (), {"install": staticmethod(lambda: None)})()
    )
    monkeypatch.setattr("sys.platform", "linux")
    # Should not raise
    assert sync_func(2) == 4


def test_await_no_loop_raises() -> None:
    async def async_func(x: int) -> int:
        return x * 2

    sync_func = await_(async_func, raise_sync_error=True)
    # Remove running loop
    orig = asyncio.get_running_loop
    asyncio.get_running_loop = lambda: (_ for _ in ()).throw(RuntimeError())
    try:
        with pytest.raises(RuntimeError, match="await_ called without a running event loop and raise_sync_error=True"):
            sync_func(1)
    finally:
        asyncio.get_running_loop = orig


def test_await_in_async_task(monkeypatch: pytest.MonkeyPatch) -> None:
    from typing import Optional

    async def async_func(x: int) -> int:
        return x * 2

    sync_func = await_(async_func, raise_sync_error=True)

    class DummyLoop:
        def __init__(self) -> None:
            self.running = True

        def is_running(self) -> bool:
            return self.running

        def _run_once(self) -> None:
            # Simulate loop iteration
            self.running = False

    class DummyTask:
        pass

    class DummyFuture:
        def __init__(self) -> None:
            self._done = False
            self._result = 4

        def done(self) -> bool:
            return self._done

        def result(self) -> int:
            self._done = True
            return self._result

    loop = DummyLoop()
    monkeypatch.setattr("asyncio.get_running_loop", lambda: loop)

    def dummy_current_task(loop: Optional[object] = None) -> DummyTask:
        return DummyTask()

    def dummy_ensure_future(coro: object, loop: object = None) -> DummyFuture:
        return DummyFuture()

    monkeypatch.setattr("asyncio.current_task", dummy_current_task)
    monkeypatch.setattr("asyncio.ensure_future", dummy_ensure_future)

    # The new implementation uses _run_once() workaround and should succeed
    result = sync_func(1)
    assert result == 4


def test_await_non_running_loop(monkeypatch: pytest.MonkeyPatch) -> None:
    async def async_func(x: int) -> int:
        return x * 2

    sync_func = await_(async_func, raise_sync_error=True)

    class DummyLoop:
        def is_running(self) -> bool:
            return False

    monkeypatch.setattr("asyncio.get_running_loop", lambda: DummyLoop())
    with pytest.raises(RuntimeError, match="await_ found a non-running loop via get_running_loop"):
        sync_func(1)


def test_ensure_async_identity() -> None:
    async def afunc(x: int) -> int:
        return x

    wrapped: Callable[[int], Awaitable[int]] = ensure_async_(afunc)
    assert wrapped is afunc


def test_ensure_async_awaitable() -> None:
    def sync_func(x: int) -> Awaitable[int]:
        async def coro() -> int:
            return x * 2

        return coro()

    wrapped: Callable[[int], Awaitable[int]] = ensure_async_(sync_func)

    async def runner() -> int:
        return await wrapped(21)

    assert asyncio.run(runner()) == 42


def test_ensure_async_non_awaitable() -> None:
    def sync_func(x: int) -> int:
        return x * 2

    wrapped = ensure_async_(sync_func)

    async def runner() -> int:
        return await wrapped(21)

    assert asyncio.run(runner()) == 42


def test_context_manager_wrapper_exceptions() -> None:
    from types import TracebackType

    class DummyCM:
        def __enter__(self) -> int:
            raise ValueError("enter error")

        def __exit__(
            self,
            exc_type: Optional[type[BaseException]],
            exc_val: Optional[BaseException],
            exc_tb: Optional[TracebackType],
        ) -> None:
            raise ValueError("exit error")

    wrapper = with_ensure_async_(DummyCM())
    with pytest.raises(ValueError, match="enter error"):
        asyncio.run(wrapper.__aenter__())
    # __aexit__ should propagate exception
    with pytest.raises(ValueError, match="exit error"):
        asyncio.run(wrapper.__aexit__(None, None, None))


def test_with_ensure_async_identity() -> None:
    from types import TracebackType

    class DummyAsyncCM:
        async def __aenter__(self) -> int:
            return 42

        async def __aexit__(
            self,
            exc_type: Optional[type[BaseException]],
            exc_val: Optional[BaseException],
            exc_tb: Optional[TracebackType],
        ) -> None:
            return None

    acm = DummyAsyncCM()
    assert with_ensure_async_(acm) is acm


async def test_async_with_custom_limiter() -> None:
    def sync_func(x: int) -> int:
        return x * 2

    limiter = CapacityLimiter(1)
    async_func = async_(sync_func, limiter=limiter)

    async def runner() -> int:
        return await async_func(21)

    assert await runner() == 42
python-advanced-alchemy-1.9.3/tests/unit/test_utils/test_text.py000066400000000000000000000007421516556515500251660ustar00rootroot00000000000000from advanced_alchemy.utils.text import check_email, slugify


def test_check_email() -> None:
    valid_email = "test@test.com"
    valid_email_upper = "TEST@TEST.COM"

    assert check_email(valid_email) == valid_email
    assert check_email(valid_email_upper) == valid_email


def test_slugify() -> None:
    string = "This is a Test!"
    expected_slug = "this-is-a-test"
    assert slugify(string) == expected_slug
    assert slugify(string, separator="_") == "this_is_a_test"
python-advanced-alchemy-1.9.3/tools/000077500000000000000000000000001516556515500174065ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/tools/__init__.py000066400000000000000000000000001516556515500215050ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/tools/build_docs.py000066400000000000000000000056531516556515500221000ustar00rootroot00000000000000from __future__ import annotations

import argparse
import importlib.metadata
import json
import os
import shutil
import subprocess
from collections.abc import Generator
from contextlib import contextmanager
from pathlib import Path
from typing import TYPE_CHECKING, TypedDict, cast

if TYPE_CHECKING:
    from collections.abc import Generator

REDIRECT_TEMPLATE = """


    
        Page Redirection
        
        
        
    
    
        You are being redirected. If this does not work, click this link
    

"""

parser = argparse.ArgumentParser()
parser.add_argument("--version", required=False)
parser.add_argument("output")


class VersionSpec(TypedDict):
    versions: list[str]
    latest: str


@contextmanager
def checkout(branch: str, skip: bool = False) -> Generator[None]:
    if not skip:
        subprocess.run(["git", "checkout", branch], check=True)  # noqa: S603, S607
    yield
    if not skip:
        subprocess.run(["git", "checkout", "-"], check=True)  # noqa: S607


def load_version_spec() -> VersionSpec:
    versions_file = Path("docs/_static/versions.json")
    if versions_file.exists():
        return cast("VersionSpec", json.loads(versions_file.read_text()))
    return {"versions": [], "latest": ""}


def build(output_dir: str, version: str | None) -> None:
    if version is None:
        version = importlib.metadata.version("advanced_alchemy").rsplit(".")[0]
    else:
        os.environ["_ADVANCED_ALCHEMY_DOCS_BUILD_VERSION"] = version

    subprocess.run(["make", "docs"], check=True)  # noqa: S607

    Path(output_dir).mkdir(exist_ok=True, parents=True)
    Path(output_dir).joinpath(".nojekyll").touch(exist_ok=True)

    version_spec = load_version_spec()
    is_latest = version == version_spec["latest"]

    docs_src_path = Path("docs/_build/html")

    Path(output_dir).joinpath("index.html").write_text(REDIRECT_TEMPLATE.format(target="latest"))

    if is_latest:
        shutil.copytree(docs_src_path, Path(output_dir) / "latest", dirs_exist_ok=True)
    shutil.copytree(docs_src_path, Path(output_dir) / version, dirs_exist_ok=True)

    # copy existing versions into our output dir to preserve them when cleaning the branch
    with checkout("gh-pages", skip=True):
        for other_version in [*version_spec["versions"], "latest"]:
            other_version_path = Path(other_version)
            other_version_target_path = Path(output_dir) / other_version
            if other_version_path.exists() and not other_version_target_path.exists():
                shutil.copytree(other_version_path, other_version_target_path)


def main() -> None:
    args = parser.parse_args()
    build(output_dir=args.output, version=args.version)


if __name__ == "__main__":
    main()
python-advanced-alchemy-1.9.3/tools/convert_docs.sh000077500000000000000000000002721516556515500224360ustar00rootroot00000000000000#!/bin/bash

CHANGELOG=docs/changelog.rst

filename="${CHANGELOG%.*}"
echo "Converting $CHANGELOG to $filename.md"
pandoc --wrap=preserve $CHANGELOG -f rst -t markdown -o "$filename".md
python-advanced-alchemy-1.9.3/tools/prepare_release.py000066400000000000000000000431511516556515500231220ustar00rootroot00000000000000import asyncio
import contextlib
import datetime
import os
import pathlib
import re
import shutil
import subprocess
import sys
from collections import defaultdict
from collections.abc import Generator
from dataclasses import dataclass
from typing import Optional

import click
import httpx
import msgspec

_polar = "[Polar.sh](https://polar.sh/litestar-org)"
_open_collective = "[OpenCollective](https://opencollective.com/litestar)"
_github_sponsors = "[GitHub Sponsors](https://github.com/sponsors/litestar-org/)"
_RETRYABLE_STATUS_CODES = {429, 502, 503, 504}
_DEFAULT_HTTP_TIMEOUT = httpx.Timeout(timeout=10.0, connect=10.0, read=30.0)
_MAX_HTTP_RETRIES = 3


class PullRequest(msgspec.Struct, kw_only=True):
    title: str
    number: int
    body: str
    created_at: str
    user: "RepoUser"
    merge_commit_sha: Optional[str] = None


class Comp(msgspec.Struct):
    sha: str

    class _Commit(msgspec.Struct):
        message: str
        url: str

    commit: _Commit


class RepoUser(msgspec.Struct):
    login: str
    id: int
    type: str


@dataclass
class PRInfo:
    url: str
    title: str
    clean_title: str
    cc_type: str
    number: int
    closes: list[int]
    created_at: datetime.datetime
    description: str
    user: RepoUser


@dataclass
class ReleaseInfo:
    base: str
    release_tag: str
    version: str
    pull_requests: dict[str, list[PRInfo]]
    first_time_prs: list[PRInfo]

    @property
    def compare_url(self) -> str:
        return f"https://github.com/litestar-org/advanced-alchemy/compare/{self.base}...{self.release_tag}"


def _pr_number_from_commit_message(message: str) -> Optional[int]:
    # this is an ugly hack, but it appears to actually be the most reliably way to
    # extract the most "reliable" way to extract the info we want from GH ยฏ\_(ใƒ„)_/ยฏ
    message_head = message.split("\n\n", maxsplit=1)[0]
    match = re.search(r"\(#(\d+)\)$", message_head) or re.search(r"Merge pull request #(\d+)", message_head)
    if not match:
        print(f"Could not find PR number in {message_head}")  # noqa: T201
    return int(match[1]) if match else None


class _Thing:
    def __init__(self, *, gh_token: str, base: str, release_branch: str, tag: str, version: str) -> None:
        self._gh_token = gh_token
        self._base = base
        self._new_release_tag = tag
        self._release_branch = release_branch
        self._new_release_version = version
        self._base_client = httpx.AsyncClient(
            headers={
                "Authorization": f"Bearer {gh_token}",
            },
            timeout=_DEFAULT_HTTP_TIMEOUT,
        )
        self._api_client = httpx.AsyncClient(
            headers={
                **self._base_client.headers,
                "X-GitHub-Api-Version": "2022-11-28",
                "Accept": "application/vnd.github+json",
            },
            base_url="https://api.github.com/repos/litestar-org/advanced-alchemy/",
            timeout=_DEFAULT_HTTP_TIMEOUT,
        )

    async def _request_with_retries(
        self,
        client: httpx.AsyncClient,
        method: str,
        url: str,
        **kwargs: object,
    ) -> httpx.Response:
        last_error: Optional[BaseException] = None
        for attempt in range(_MAX_HTTP_RETRIES):
            try:
                response = await client.request(method, url, **kwargs)
                if response.status_code not in _RETRYABLE_STATUS_CODES or attempt == _MAX_HTTP_RETRIES - 1:
                    return response
                last_error = httpx.HTTPStatusError(
                    f"Retryable response status {response.status_code}",
                    request=response.request,
                    response=response,
                )
            except (httpx.NetworkError, httpx.TimeoutException) as exc:
                last_error = exc
            delay = 0.5 * (2**attempt)
            click.secho(
                f"GitHub API {method} {url} failed ({type(last_error).__name__}), retrying in {delay:.1f}s",
                fg="yellow",
            )
            await asyncio.sleep(delay)
        if last_error:
            raise last_error
        msg = f"Request {method} {url} failed without returning a response"
        raise RuntimeError(msg)

    @staticmethod
    def _get_pr_numbers_from_git_range(base: str, release_branch: str) -> list[int]:
        git_executable = shutil.which("git")
        if not git_executable:
            msg = "git executable not found"
            raise FileNotFoundError(msg)
        proc = subprocess.run(  # noqa: S603
            [git_executable, "log", "--format=%s%x00", f"{base}..{release_branch}"],
            check=True,
            capture_output=True,
            text=True,
        )
        messages = [message for message in proc.stdout.split("\x00") if message.strip()]
        pr_numbers = [number for number in (_pr_number_from_commit_message(message) for message in messages) if number]
        return list(dict.fromkeys(pr_numbers))

    async def get_closing_issues_references(self, pr_number: int) -> list[int]:
        graphql_query = """{
        repository(owner: "litestar-org", name: "advanced-alchemy") {
            pullRequest(number: %d) {
                id
                closingIssuesReferences (first: 10) {
                    edges {
                        node {
                            number
                        }
                    }
                }
            }
        }
    }"""
        query = graphql_query % (pr_number,)
        res = await self._request_with_retries(
            self._base_client,
            "POST",
            "https://api.github.com/graphql",
            json={"query": query},
        )
        if res.is_client_error:
            return []
        data = res.json()
        return [
            edge["node"]["number"]
            for edge in data["data"]["repository"]["pullRequest"]["closingIssuesReferences"]["edges"]
        ]

    async def _get_pr_info_for_pr(self, number: int) -> Optional[PRInfo]:
        res = await self._request_with_retries(self._api_client, "GET", f"/pulls/{number}")
        if res.is_client_error:
            click.secho(
                f"Could not get PR info for {number}.  Fetch request returned a status of {res.status_code}",
                fg="yellow",
            )
            return None
        res.raise_for_status()
        data = res.json()
        if not data["body"]:
            data["body"] = ""
        if not data:
            return None
        pr = msgspec.convert(data, type=PullRequest)

        if ":" in pr.title:
            cc_prefix, clean_title = pr.title.split(":", maxsplit=1)
            cc_type = cc_prefix.split("(", maxsplit=1)[0].lower()
            clean_title = clean_title.strip()
        else:
            cc_type = "misc"
            clean_title = pr.title.strip()
        closes_issues = await self.get_closing_issues_references(pr_number=pr.number)

        return PRInfo(
            number=pr.number,
            cc_type=cc_type,
            clean_title=clean_title,
            url=f"https://github.com/litestar-org/advanced-alchemy/pull/{pr.number}",
            closes=closes_issues,
            title=pr.title,
            created_at=datetime.datetime.strptime(pr.created_at, "%Y-%m-%dT%H:%M:%S%z"),
            description=pr.body,
            user=pr.user,
        )

    async def get_prs(self) -> dict[str, list[PRInfo]]:
        pr_numbers = self._get_pr_numbers_from_git_range(self._base, self._release_branch)
        pulls = await asyncio.gather(*map(self._get_pr_info_for_pr, pr_numbers))

        prs: dict[str, list[PRInfo]] = defaultdict(list)
        for pr in pulls:
            if not pr:
                continue
            if pr.user.type != "Bot":
                prs[pr.cc_type].append(pr)
        return prs

    async def _get_first_time_contributions(self, prs: dict[str, list[PRInfo]]) -> list[PRInfo]:
        # there's probably a way to peel this information out of the GraphQL API but
        # this was easier to implement, and it works well enough ยฏ\_(ใƒ„)_/ยฏ
        # the logic is: if we don't find a commit to the main branch, dated before the
        # first commit within this release, it's the user's first contribution
        prs_by_user_login: dict[str, list[PRInfo]] = defaultdict(list)
        for pr in [p for type_prs in prs.values() for p in type_prs]:
            prs_by_user_login[pr.user.login].append(pr)

        first_prs: list[PRInfo] = []

        async def is_user_first_commit(user_login: str) -> None:
            first_pr = sorted(prs_by_user_login[user_login], key=lambda p: p.created_at)[0]
            res = await self._request_with_retries(
                self._api_client,
                "GET",
                "/commits",
                params={
                    "author": user_login,
                    "sha": "main",
                    "until": first_pr.created_at.isoformat(),
                    "per_page": 1,
                },
            )
            res.raise_for_status()

            if len(res.json()) == 0:
                first_prs.append(first_pr)

        await asyncio.gather(*map(is_user_first_commit, prs_by_user_login.keys()))

        return first_prs

    async def get_release_info(self) -> ReleaseInfo:
        prs = await self.get_prs()
        first_time_contributors = await self._get_first_time_contributions(prs)
        return ReleaseInfo(
            pull_requests=prs,
            first_time_prs=first_time_contributors,
            base=self._base,
            release_tag=self._new_release_tag,
            version=self._new_release_version,
        )

    async def create_draft_release(self, body: str, release_branch: str) -> str:
        is_prerelease = bool(re.search(r"(a|b|rc)\d+$", self._new_release_version))
        res = await self._api_client.post(
            "/releases",
            json={
                "tag_name": self._new_release_tag,
                "target_commitish": release_branch,
                "name": self._new_release_tag,
                "draft": True,
                "prerelease": is_prerelease,
                "body": body,
            },
        )
        res.raise_for_status()
        return res.json()["html_url"]  # type: ignore[no-any-return]


class GHReleaseWriter:
    def __init__(self) -> None:
        self.text = ""

    def add_line(self, line: str) -> None:
        self.text += line + "\n"

    def add_pr_descriptions(self, infos: list[PRInfo]) -> None:
        for info in infos:
            self.add_line(f"* {info.title} by @{info.user.login} in {info.url}")


class ChangelogEntryWriter:
    def __init__(self) -> None:
        self.text = ""
        self._level = 0
        self._indent = "    "
        self._cc_type_map = {"fix": "bugfix", "feat": "feature"}

    def add_line(self, line: str) -> None:
        self.text += (self._indent * self._level) + line + "\n"

    def add_change(self, pr: PRInfo) -> None:
        with self.directive(
            "change",
            arg=pr.clean_title,
            type=self._cc_type_map.get(pr.cc_type, "misc"),
            pr=str(pr.number),
            issue=", ".join(map(str, pr.closes)),
        ):
            self.add_line("")
            for line in pr.description.splitlines():
                self.add_line(line)

    @contextlib.contextmanager
    def directive(self, name: str, arg: Optional[str] = None, **options: str) -> Generator[None, None, None]:
        self.add_line(f".. {name}:: {arg or ''}")
        self._level += 1
        for key, value in options.items():
            if value:
                self.add_line(f":{key}: {value}")
        yield
        self._level -= 1
        self.add_line("")


def build_gh_release_notes(release_info: ReleaseInfo) -> str:
    # this is for the most part just recreating GitHub's autogenerated release notes
    # but with three important differences:
    # 1. PRs are sorted into categories
    # 2. The conventional commit type is stripped from the title
    # 3. It works with our release branch process. GitHub doesn't pick up (all) commits
    #    made there depending on how things were merged
    doc = GHReleaseWriter()

    # doc.add_line("## Sponsors ๐ŸŒŸ")  # noqa: ERA001
    # doc.add_line(f"- A huge 'Thank you!' to all sponsors across {_polar}, {_open_collective} and {_github_sponsors}!")  # noqa: ERA001

    doc.add_line("## What's changed")
    if features := release_info.pull_requests.get("feat"):
        doc.add_line("\n### New features ๐Ÿš€")
        doc.add_pr_descriptions(features)
    if fixes := release_info.pull_requests.get("fix"):
        doc.add_line("\n### Bugfixes ๐Ÿ›")
        doc.add_pr_descriptions(fixes)
    if release_info.first_time_prs:
        doc.add_line("\n## New contributors ๐ŸŽ‰")
        for pr in release_info.first_time_prs:
            doc.add_line(f"* @{pr.user.login} made their first contribution in {pr.url}")

    ignore_sections = {"fix", "feat", "ci", "chore"}

    if other := [pr for k, prs in release_info.pull_requests.items() if k not in ignore_sections for pr in prs]:
        doc.add_line("\n")
        doc.add_line("### Other changes")
        doc.add_pr_descriptions(other)

    doc.add_line("\n**Full Changelog**")
    doc.add_line(release_info.compare_url)

    return doc.text


def build_changelog_entry(release_info: ReleaseInfo, interactive: bool = False) -> str:
    doc = ChangelogEntryWriter()
    with doc.directive("changelog", release_info.version):
        doc.add_line(f":date: {datetime.datetime.now(tz=datetime.timezone.utc).date().isoformat()}")
        doc.add_line("")
        change_types = {"fix", "feat"}
        for prs in release_info.pull_requests.values():
            for pr in prs:
                cc_type = pr.cc_type
                if cc_type in change_types or (interactive and click.confirm(f"Include PR #{pr.number} {pr.title!r}?")):
                    doc.add_change(pr)
                else:
                    click.secho(f"Ignoring change with type {cc_type}", fg="yellow")

    return doc.text


def _get_gh_token() -> str:
    if gh_token := os.getenv("GH_TOKEN"):
        click.secho("Using GitHub token from env", fg="blue")
        return gh_token

    gh_executable = shutil.which("gh")
    if not gh_executable:
        click.secho("GitHub CLI not installed", fg="yellow")
    else:
        click.secho("Using GitHub CLI to obtain GitHub token", fg="blue")
        proc = subprocess.run([gh_executable, "auth", "token"], check=True, capture_output=True, text=True)  # noqa: S603
        if out := (proc.stdout or "").strip():
            return out

    click.secho("Could not find any GitHub token", fg="red")
    sys.exit(1)


def _get_latest_tag() -> str:
    click.secho("Using latest tag", fg="blue")
    return subprocess.run(  # noqa: S602
        "git tag --sort=taggerdate | tail -1",  # noqa: S607
        check=True,
        capture_output=True,
        text=True,
        shell=True,
    ).stdout.strip()


def _write_changelog_entry(changelog_entry: str) -> None:
    changelog_path = pathlib.Path("docs/changelog.rst")
    changelog_lines = changelog_path.read_text().splitlines()
    line_no = next(
        (i for i, line in enumerate(changelog_lines) if line.startswith(".. changelog::")),
        None,
    )
    if not line_no:
        msg = "Changelog start not found"
        raise ValueError(msg)

    changelog_lines[line_no:line_no] = changelog_entry.splitlines()
    changelog_path.write_text("\n".join(changelog_lines))


def update_pyproject_version(new_version: str) -> None:
    # can't use tomli-w / tomllib for this as is messes up the formatting
    pyproject = pathlib.Path("pyproject.toml")
    content = pyproject.read_text()
    content = re.sub(r'(\nversion ?= ?")\d+\.\d+\.\d+(?:(?:a|b|rc)\d+)?("\s*\n)', rf"\g<1>{new_version}\g<2>", content)
    pyproject.write_text(content)


@click.command()
@click.argument("version")
@click.option("--base", help="Previous release tag. Defaults to the latest tag")
@click.option("--branch", help="Release branch", default="main")
@click.option(
    "--gh-token",
    help="GitHub token. If not provided, read from the GH_TOKEN env variable. "
    "Alternatively, if the GitHub CLI is installed, it will be used to fetch a token",
)
@click.option(
    "-i",
    "--interactive",
    is_flag=True,
    help="Interactively decide which commits should be included in the release notes",
    default=False,
)
@click.option("-c", "--create-draft-release", is_flag=True, help="Create draft release on GitHub")
def cli(
    base: Optional[str],
    branch: str,
    version: str,
    gh_token: Optional[str],
    interactive: bool,
    create_draft_release: bool,
) -> None:
    if gh_token is None:
        gh_token = _get_gh_token()
    if base is None:
        base = _get_latest_tag()

    if not re.match(r"\d+\.\d+\.\d+((a|b|rc)\d+)?$", version):
        click.secho(f"Invalid version: {version!r}")
        sys.exit(1)

    new_tag = f"v{version}"

    click.secho(f"Creating release notes for tag {new_tag}, using {base} as a base", fg="cyan")

    thing = _Thing(gh_token=gh_token, base=base, release_branch=branch, tag=new_tag, version=version)
    loop = asyncio.new_event_loop()

    release_info = loop.run_until_complete(thing.get_release_info())
    gh_release_notes = build_gh_release_notes(release_info)
    changelog_entry = build_changelog_entry(release_info, interactive=interactive)

    click.secho("Writing changelog entry", fg="green")
    _write_changelog_entry(changelog_entry)

    if create_draft_release:
        click.secho("Creating draft release", fg="blue")
        release_url = loop.run_until_complete(thing.create_draft_release(body=gh_release_notes, release_branch=branch))
        click.echo(f"Draft release available at: {release_url}")
    else:
        click.echo(gh_release_notes)

    loop.close()


if __name__ == "__main__":
    cli()
python-advanced-alchemy-1.9.3/tools/pypi_readme.py000066400000000000000000000020171516556515500222560ustar00rootroot00000000000000import re
from pathlib import Path

PYPI_BANNER = 'Litestar Logo - Light'


def generate_pypi_readme() -> None:
    source = Path("README.md").read_text(encoding="utf-8")
    output = re.sub(r"[\w\W]*?", PYPI_BANNER, source, count=1)
    output = re.sub(r"[\w\W]*?", "", output)
    output = re.sub(r"[\w\W]*?", "", output)
    output = re.sub(r"", "", output)

    # ensure a newline here so the other pre-commit hooks don't complain
    output = output.strip() + "\n"
    Path("docs/PYPI_README.md").write_text(output, encoding="utf-8")


if __name__ == "__main__":
    generate_pypi_readme()
python-advanced-alchemy-1.9.3/tools/sphinx_ext/000077500000000000000000000000001516556515500215775ustar00rootroot00000000000000python-advanced-alchemy-1.9.3/tools/sphinx_ext/__init__.py000066400000000000000000000006441516556515500237140ustar00rootroot00000000000000from __future__ import annotations

from typing import TYPE_CHECKING

from tools.sphinx_ext import changelog, missing_references

if TYPE_CHECKING:
    from sphinx.application import Sphinx


def setup(app: Sphinx) -> dict[str, bool]:
    ext_config = {}
    ext_config.update(missing_references.setup(app))
    ext_config.update(changelog.setup(app))  # type: ignore[arg-type]

    return ext_config  # pyright: ignore
python-advanced-alchemy-1.9.3/tools/sphinx_ext/changelog.py000066400000000000000000000140431516556515500241020ustar00rootroot00000000000000from collections.abc import Callable
from functools import partial
from typing import TYPE_CHECKING, Any, ClassVar, Literal, Optional, Union, cast

from docutils import nodes
from docutils.parsers.rst import directives
from sphinx.application import Sphinx
from sphinx.util.docutils import SphinxDirective
from sphinx.util.nodes import clean_astext

if TYPE_CHECKING:
    from sphinx.domains.std import StandardDomain

_GH_BASE_URL = "https://github.com/litestar-org/advanced-alchemy"


def _parse_gh_reference(raw: str, type_: Literal["issues", "pull"]) -> list[str]:
    return [f"{_GH_BASE_URL}/{type_}/{r.strip()}" for r in raw.split(" ") if r]


class Change(nodes.General, nodes.Element):
    """A change node for the changelog."""


class ChangeDirective(SphinxDirective):
    """A directive for the changelog."""

    required_arguments = 1
    has_content = True
    final_argument_whitespace = True
    option_spec: ClassVar[Optional[dict[str, Callable[[str], Any]]]] = {
        "type": partial(directives.choice, values=("feature", "bugfix", "misc")),
        "breaking": directives.flag,
        "issue": directives.unchanged,
        "pr": directives.unchanged,
    }

    def run(self) -> list[nodes.Node]:
        """Run the directive.

        Returns:
            A list of nodes.
        """
        self.assert_has_content()

        change_type = self.options.get("type", "misc").lower()
        title = self.arguments[0]

        change_node = nodes.container("\n".join(self.content))
        change_node.attributes["classes"].append("changelog-change")

        self.state.nested_parse(self.content, self.content_offset, change_node)  # pyright: ignore[reportUnknownMemberType]

        reference_links = [
            *_parse_gh_reference(self.options.get("issue", ""), "issues"),
            *_parse_gh_reference(self.options.get("pr", ""), "pull"),
        ]

        references_paragraph = nodes.paragraph()
        references_paragraph.append(nodes.Text("References: "))
        for i, link in enumerate(reference_links, 1):
            link_node = nodes.inline()
            link_node += nodes.reference("", link, refuri=link, external=True)
            references_paragraph.append(link_node)
            if i != len(reference_links):
                references_paragraph.append(nodes.Text(", "))

        change_node.append(references_paragraph)

        return [
            Change(
                "",
                change_node,
                title=self.state.inliner.parse(title, 0, self.state.memo, change_node)[0],
                change_type=change_type,
                breaking="breaking" in self.options,
            ),
        ]


class ChangelogDirective(SphinxDirective):
    required_arguments = 1
    has_content = True
    option_spec = {"date": directives.unchanged}

    def run(self) -> list[nodes.Node]:
        self.assert_has_content()

        version = self.arguments[0]
        release_date = self.options.get("date")

        changelog_node = nodes.section()
        changelog_node += nodes.title(version, version)
        section_target = nodes.target("", "", ids=[version])

        if release_date:
            changelog_node += nodes.strong("", "Released: ")
            changelog_node += nodes.Text(release_date)

        self.state.nested_parse(self.content, self.content_offset, changelog_node)  # pyright: ignore[reportUnknownMemberType]

        domain = cast("StandardDomain", self.env.get_domain("std"))

        change_group_lists = {
            "feature": nodes.definition_list(),
            "bugfix": nodes.definition_list(),
            "misc": nodes.definition_list(),
        }

        change_group_titles = {"bugfix": "Bugfixes", "feature": "Features", "misc": "Other changes"}

        nodes_to_remove = []

        for _i, change_node in enumerate(changelog_node.findall(Change)):
            change_type = change_node.attributes["change_type"]
            title = change_node.attributes["title"]

            list_item = nodes.definition_list_item("")

            term = nodes.term()
            term += title
            target_id = f"{version}-{nodes.fully_normalize_name(title[0].astext())}"
            term += nodes.reference(
                "#",
                "#",
                refuri=f"#{target_id}",
                internal=True,
                classes=["headerlink"],
                ids=[target_id],
            )

            reference_id = f"change:{target_id}"
            domain.anonlabels[reference_id] = self.env.docname, target_id
            domain.labels[reference_id] = (
                self.env.docname,
                target_id,
                f"Change: {clean_astext(title[0])}",
            )

            if change_node.attributes["breaking"]:
                breaking_notice = nodes.inline("breaking", "breaking")
                breaking_notice.attributes["classes"].append("breaking-change")
                term += breaking_notice

            list_item += [term]

            list_item += nodes.definition("", change_node.children[0])

            nodes_to_remove.append(change_node)  # pyright: ignore[reportUnknownMemberType]

            change_group_lists[change_type] += list_item

        for node in nodes_to_remove:  # pyright: ignore[reportUnknownVariableType]
            changelog_node.remove(node)  # pyright: ignore[reportUnknownArgumentType]

        for change_group_type, change_group_list in change_group_lists.items():
            if not change_group_list.children:
                continue

            section = nodes.section()

            target_id = f"{version}-{change_group_type}"
            target_node = nodes.target("", "", ids=[target_id])
            title = change_group_titles[change_group_type]

            section += nodes.title(title, title)
            section += change_group_list

            changelog_node += [target_node, section]

        return [section_target, changelog_node]


def setup(app: Sphinx) -> dict[str, Union[str, bool]]:
    app.add_directive("changelog", ChangelogDirective)
    app.add_directive("change", ChangeDirective)

    return {"parallel_read_safe": True, "parallel_write_safe": True}
python-advanced-alchemy-1.9.3/tools/sphinx_ext/missing_references.py000066400000000000000000000275701516556515500260360ustar00rootroot00000000000000"""Sphinx extension for changelog and change directives."""

# ruff: noqa: PLR0911, ARG001
from __future__ import annotations

import ast
import importlib
import inspect
from pathlib import Path
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from collections.abc import Generator

    from docutils.nodes import Element, Node
    from sphinx.addnodes import pending_xref
    from sphinx.application import Sphinx
    from sphinx.environment import BuildEnvironment


def _get_module_ast(source_file: str) -> ast.AST | ast.Module:
    return ast.parse(Path(source_file).read_text(encoding="utf-8"))


def _get_import_nodes(nodes: list[ast.stmt]) -> Generator[ast.Import | ast.ImportFrom, None, None]:
    for node in nodes:
        if isinstance(node, (ast.Import, ast.ImportFrom)):
            yield node
        elif isinstance(node, ast.If) and getattr(node.test, "id", None) == "TYPE_CHECKING":
            yield from _get_import_nodes(node.body)


def get_module_global_imports(module_import_path: str, reference_target_source_obj: str) -> set[str]:
    """Return a set of names that are imported globally within the containing module of ``reference_target_source_obj``,
    including imports in ``if TYPE_CHECKING`` blocks.
    """
    module = importlib.import_module(module_import_path)
    obj = getattr(module, reference_target_source_obj)
    tree = _get_module_ast(inspect.getsourcefile(obj))  # type: ignore[arg-type]  # pyright: ignore[reportArgumentType]

    import_nodes = _get_import_nodes(tree.body)  # type: ignore[attr-defined]
    return {path.asname or path.name for import_node in import_nodes for path in import_node.names}


def _resolve_local_reference(module_path: str, target: str) -> bool:
    """Attempt to resolve a reference within the local codebase.

    Args:
        module_path: The module path (e.g., 'advanced_alchemy.base')
        target: The target class/attribute name

    Returns:
        bool: True if reference exists, False otherwise
    """
    try:
        module = importlib.import_module(module_path)
        if "." in target:
            # Handle fully qualified names (e.g., advanced_alchemy.base.BasicAttributes)
            parts = target.split(".")
            current = module
            for part in parts:
                current = getattr(current, part)
            return True
        return hasattr(module, target)
    except (ImportError, AttributeError):
        return False


def _resolve_sqlalchemy_reference(target: str) -> bool:
    """Attempt to resolve SQLAlchemy references.

    Args:
        target: The target class/attribute name

    Returns:
        bool: True if reference exists, False otherwise
    """
    try:
        import sqlalchemy

        if "." in target:
            # Handle nested attributes (e.g., Connection.in_transaction)
            obj_name, attr_name = target.rsplit(".", 1)
            obj = getattr(sqlalchemy, obj_name)
            return hasattr(obj, attr_name)
        return hasattr(sqlalchemy, target)
    except (ImportError, AttributeError):
        return False


def _resolve_litestar_reference(target: str) -> bool:
    """Attempt to resolve Litestar references.

    Args:
        target: The target class/attribute name

    Returns:
        bool: True if reference exists, False otherwise
    """
    try:
        import litestar
        from litestar import datastructures

        # Handle common Litestar classes
        if target in {"Litestar", "State", "Scope", "Message", "AppConfig", "BeforeMessageSendHookHandler"}:
            return True
        if target.startswith("datastructures."):
            _, attr = target.split(".")
            return hasattr(datastructures, attr)
        if target.startswith("config.app."):
            return True  # These are valid Litestar config references
        return hasattr(litestar, target)
    except ImportError:
        return False


def _resolve_sqlalchemy_type_reference(target: str) -> bool:
    """Attempt to resolve SQLAlchemy type references.

    Args:
        target: The target class/attribute name

    Returns:
        bool: True if reference exists, False otherwise
    """
    try:
        from sqlalchemy import types as sa_types

        type_classes = {
            "TypeEngine",
            "TypeDecorator",
            "UserDefinedType",
            "ExternalType",
            "Dialect",
            "_types.TypeDecorator",
        }

        if target in type_classes:
            return True
        if target.startswith("_types."):
            _, attr = target.split(".")
            return hasattr(sa_types, attr)
        return hasattr(sa_types, target)
    except ImportError:
        return False


def _resolve_advanced_alchemy_reference(target: str, module: str) -> bool:
    """Attempt to resolve Advanced Alchemy references.

    Args:
        target: The target class/attribute name
        module: The current module context

    Returns:
        bool: True if reference exists, False otherwise
    """
    # Handle base module references
    base_classes = {
        "BasicAttributes",
        "CommonTableAttributes",
        "AuditColumns",
        "BigIntPrimaryKey",
        "UUIDPrimaryKey",
        "UUIDv6PrimaryKey",
        "UUIDv7PrimaryKey",
        "NanoIDPrimaryKey",
        "Empty",
        "TableArgsType",
        "DeclarativeBase",
    }

    # Handle config module references
    config_classes = {
        "EngineT",
        "SessionT",
        "SessionMakerT",
        "ConnectionT",
        "GenericSessionConfig",
        "GenericAlembicConfig",
    }

    func_references = {"repository.SQLAlchemyAsyncRepositoryProtocol.add_many"}

    # Handle type module references
    type_classes = {"DateTimeUTC", "ORA_JSONB", "GUID", "EncryptedString", "EncryptedText"}

    if target in base_classes or target in config_classes or target in type_classes:
        return True

    # Handle fully qualified references
    if target.startswith("advanced_alchemy."):
        parts = target.split(".")
        if parts[-1] in base_classes | config_classes | type_classes | func_references:
            return True

    # Handle module-relative references
    return bool(module.startswith("advanced_alchemy."))


def _resolve_serialization_reference(target: str) -> bool:
    """Attempt to resolve serialization-related references.

    Args:
        target: The target class/attribute name

    Returns:
        bool: True if reference exists, False otherwise
    """
    serialization_attrs = {"decode_json", "encode_json", "serialization.decode_json", "serialization.encode_json"}
    return target in serialization_attrs


def _resolve_click_reference(target: str) -> bool:
    """Attempt to resolve Click references.

    Args:
        target: The target class/attribute name

    Returns:
        bool: True if reference exists, False otherwise
    """
    try:
        import click

        if target == "Group":
            return True
        return hasattr(click, target)
    except ImportError:
        return False


def on_warn_missing_reference(app: Sphinx, domain: str, node: Node) -> bool | None:
    if node.tagname != "pending_xref":  # type: ignore[attr-defined]
        return None

    if not hasattr(node, "attributes"):
        return None

    # Wrap the main logic in a try-except to catch potential AttributeErrors (e.g., startswith on None)
    try:
        attributes = node.attributes  # type: ignore[attr-defined,unused-ignore]
        target = attributes["reftarget"]  # pyright: ignore
        ref_type = attributes.get("reftype")  # pyright: ignore
        module = attributes.get("py:module", "")  # pyright: ignore

        # Handle TypeVar references
        if hasattr(target, "__class__") and target.__class__.__name__ == "TypeVar":  # pyright: ignore
            return True

        # Handle Advanced Alchemy references
        if _resolve_advanced_alchemy_reference(target, module):  # pyright: ignore
            return True

        # Handle SQLAlchemy type system references
        if ref_type in {"class", "meth", "attr"} and any(
            x in target for x in ["TypeDecorator", "TypeEngine", "Dialect", "ExternalType", "UserDefinedType"]
        ):
            return _resolve_sqlalchemy_type_reference(target)  # pyright: ignore

        # Handle SQLAlchemy core references
        if (isinstance(target, str) and target.startswith("sqlalchemy.")) or (
            ref_type in {"class", "attr", "obj", "meth"}
            and target
            in {
                "Engine",
                "Session",
                "Connection",
                "MetaData",
                "AsyncSession",
                "AsyncEngine",
                "AsyncConnection",
                "sessionmaker",
                "async_sessionmaker",
            }
        ):
            # Ensure target is string before replace
            clean_target = target.replace("sqlalchemy.", "") if isinstance(target, str) else ""
            if clean_target and _resolve_sqlalchemy_reference(clean_target):
                return True

        # Handle Litestar references
        if ref_type in {"class", "obj"} and (
            (isinstance(target, str) and target.startswith(("datastructures.", "config.app.")))
            or target
            in {
                "Litestar",
                "State",
                "Scope",
                "Message",
                "AppConfig",
                "BeforeMessageSendHookHandler",
                "FieldDefinition",
                "ImproperConfigurationError",
            }
        ):
            return _resolve_litestar_reference(target)  # pyright: ignore

        # Handle serialization references
        if ref_type in {"attr", "meth"} and _resolve_serialization_reference(target):  # pyright: ignore
            return True

        # Handle Click references
        if ref_type == "class" and _resolve_click_reference(target):  # pyright: ignore
            return True

    except AttributeError:
        # Catch the specific error (likely startswith on None) and allow Sphinx to handle the warning normally
        return None

    return None


def on_missing_reference(app: Sphinx, env: BuildEnvironment, node: pending_xref, contnode: Element) -> Element | None:
    """Handle missing references by attempting to resolve them through different methods.

    Args:
        app: The Sphinx application instance
        env: The Sphinx build environment
        node: The pending cross-reference node
        contnode: The content node

    Returns:
        Element | None: The resolved reference node if found, None otherwise
    """
    if not hasattr(node, "attributes"):
        return None

    attributes = node.attributes  # type: ignore[attr-defined,unused-ignore]
    target = attributes["reftarget"]

    # Remove this check since it's causing issues
    if not isinstance(target, str):
        return None

    py_domain = env.domains["py"]

    # autodoc sometimes incorrectly resolves these types, so we try to resolve them as py:data first and fall back to any
    new_node = py_domain.resolve_xref(env, node["refdoc"], app.builder, "data", target, node, contnode)
    if new_node is None:
        resolved_xrefs = py_domain.resolve_any_xref(env, node["refdoc"], app.builder, target, node, contnode)
        for ref in resolved_xrefs:
            if ref:
                return ref[1]
    return new_node


def on_env_before_read_docs(app: Sphinx, env: BuildEnvironment, docnames: set[str]) -> None:
    tmp_examples_path = Path.cwd() / "docs/_build/_tmp_examples"
    tmp_examples_path.mkdir(exist_ok=True, parents=True)
    env.tmp_examples_path = tmp_examples_path  # type: ignore[attr-defined] # pyright: ignore[reportAttributeAccessIssue]


def setup(app: Sphinx) -> dict[str, bool]:
    app.connect("env-before-read-docs", on_env_before_read_docs)
    app.connect("missing-reference", on_missing_reference)
    app.connect("warn-missing-reference", on_warn_missing_reference)
    app.add_config_value("ignore_missing_refs", default={}, rebuild="")
    return {"parallel_read_safe": True, "parallel_write_safe": True}
python-advanced-alchemy-1.9.3/uv.lock000066400000000000000000066222011516556515500175630ustar00rootroot00000000000000version = 1
revision = 3
requires-python = ">=3.9"
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
    "python_full_version < '3.10'",
]

[options]

[options.exclude-newer-package]
mysql-connector-python = "2026-01-21T05:00:00Z"

[[package]]
name = "accessible-pygments"
version = "0.0.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bc/c1/bbac6a50d02774f91572938964c582fff4270eee73ab822a4aeea4d8b11b/accessible_pygments-0.0.5.tar.gz", hash = "sha256:40918d3e6a2b619ad424cb91e556bd3bd8865443d9f22f1dcdf79e33c8046872", size = 1377899, upload-time = "2024-05-10T11:23:10.216Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/8d/3f/95338030883d8c8b91223b4e21744b04d11b161a3ef117295d8241f50ab4/accessible_pygments-0.0.5-py3-none-any.whl", hash = "sha256:88ae3211e68a1d0b011504b2ffc1691feafce124b845bd072ab6f9f66f34d4b7", size = 1395903, upload-time = "2024-05-10T11:23:08.421Z" },
]

[[package]]
name = "advanced-alchemy"
version = "1.9.3"
source = { editable = "." }
dependencies = [
    { name = "alembic", version = "1.16.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "alembic", version = "1.18.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "eval-type-backport", marker = "python_full_version < '3.10'" },
    { name = "exceptiongroup", marker = "python_full_version < '3.11'" },
    { name = "greenlet", version = "3.2.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "greenlet", version = "3.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "sqlalchemy" },
    { name = "typing-extensions" },
]

[package.optional-dependencies]
argon2 = [
    { name = "argon2-cffi", version = "23.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "argon2-cffi", version = "25.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]
cli = [
    { name = "rich-click" },
]
dogpile = [
    { name = "dogpile-cache", version = "1.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "dogpile-cache", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]
fsspec = [
    { name = "fsspec", version = "2025.10.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "fsspec", version = "2026.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]
nanoid = [
    { name = "fastnanoid" },
]
obstore = [
    { name = "obstore", version = "0.8.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "obstore", version = "0.9.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]
passlib = [
    { name = "passlib", extra = ["argon2"] },
]
pwdlib = [
    { name = "pwdlib", version = "0.2.1", source = { registry = "https://pypi.org/simple" }, extra = ["argon2"], marker = "python_full_version < '3.10'" },
    { name = "pwdlib", version = "0.3.0", source = { registry = "https://pypi.org/simple" }, extra = ["argon2"], marker = "python_full_version >= '3.10'" },
]
uuid = [
    { name = "uuid-utils" },
]

[package.dev-dependencies]
build = [
    { name = "bump-my-version" },
]
cockroachdb = [
    { name = "asyncpg" },
    { name = "psycopg", version = "3.2.13", source = { registry = "https://pypi.org/simple" }, extra = ["binary", "pool"], marker = "python_full_version < '3.10'" },
    { name = "psycopg", version = "3.3.3", source = { registry = "https://pypi.org/simple" }, extra = ["binary", "pool"], marker = "python_full_version >= '3.10'" },
    { name = "psycopg2-binary" },
    { name = "sqlalchemy-cockroachdb" },
]
dev = [
    { name = "accessible-pygments" },
    { name = "aioodbc" },
    { name = "aiosqlite" },
    { name = "asgi-lifespan" },
    { name = "asyncmy" },
    { name = "asyncpg" },
    { name = "asyncpg-stubs" },
    { name = "attrs" },
    { name = "auto-pytabs", extra = ["sphinx"] },
    { name = "bump-my-version" },
    { name = "cattrs", version = "25.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "cattrs", version = "26.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "click", version = "8.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "coverage", version = "7.10.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "coverage", version = "7.13.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "dishka", marker = "python_full_version >= '3.10'" },
    { name = "dogpile-cache", version = "1.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "dogpile-cache", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "duckdb", version = "1.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "duckdb", version = "1.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "duckdb-engine" },
    { name = "fastapi", version = "0.128.8", source = { registry = "https://pypi.org/simple" }, extra = ["all"], marker = "python_full_version < '3.10'" },
    { name = "fastapi", version = "0.135.3", source = { registry = "https://pypi.org/simple" }, extra = ["all"], marker = "python_full_version >= '3.10'" },
    { name = "flask", extra = ["async"] },
    { name = "flask-sqlalchemy" },
    { name = "fsspec", version = "2025.10.0", source = { registry = "https://pypi.org/simple" }, extra = ["s3"], marker = "python_full_version < '3.10'" },
    { name = "fsspec", version = "2026.3.0", source = { registry = "https://pypi.org/simple" }, extra = ["s3"], marker = "python_full_version >= '3.10'" },
    { name = "litestar", extra = ["cli"] },
    { name = "mypy", version = "1.19.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "mypy", version = "1.20.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "myst-parser", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "myst-parser", version = "4.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" },
    { name = "myst-parser", version = "5.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
    { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" },
    { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
    { name = "oracledb" },
    { name = "pgvector" },
    { name = "pre-commit", version = "4.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "pre-commit", version = "4.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "psycopg", version = "3.2.13", source = { registry = "https://pypi.org/simple" }, extra = ["binary", "pool"], marker = "python_full_version < '3.10'" },
    { name = "psycopg", version = "3.3.3", source = { registry = "https://pypi.org/simple" }, extra = ["binary", "pool"], marker = "python_full_version >= '3.10'" },
    { name = "psycopg2-binary" },
    { name = "pydantic-extra-types" },
    { name = "pyodbc" },
    { name = "pyright" },
    { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "pytest-asyncio", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "pytest-asyncio", version = "1.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "pytest-click" },
    { name = "pytest-cov" },
    { name = "pytest-databases", extra = ["bigquery", "cockroachdb", "minio", "mssql", "mysql", "oracle", "postgres", "spanner"] },
    { name = "pytest-lazy-fixtures" },
    { name = "pytest-mock" },
    { name = "pytest-rerunfailures", version = "16.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "pytest-rerunfailures", version = "16.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "pytest-sugar" },
    { name = "pytest-xdist" },
    { name = "pytz" },
    { name = "rich-click" },
    { name = "ruff" },
    { name = "sanic", version = "25.3.0", source = { registry = "https://pypi.org/simple" }, extra = ["ext"], marker = "python_full_version < '3.10'" },
    { name = "sanic", version = "25.12.0", source = { registry = "https://pypi.org/simple" }, extra = ["ext"], marker = "python_full_version >= '3.10'" },
    { name = "sanic-testing" },
    { name = "shibuya", version = "2025.10.21", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "shibuya", version = "2026.1.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "slotscheck" },
    { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" },
    { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
    { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
    { name = "sphinx-autobuild", version = "2024.10.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
    { name = "sphinx-autobuild", version = "2025.8.25", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
    { name = "sphinx-autodoc-typehints", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "sphinx-autodoc-typehints", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" },
    { name = "sphinx-autodoc-typehints", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
    { name = "sphinx-autodoc-typehints", version = "3.9.11", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
    { name = "sphinx-click", version = "6.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "sphinx-click", version = "6.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "sphinx-copybutton" },
    { name = "sphinx-design", version = "0.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
    { name = "sphinx-design", version = "0.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
    { name = "sphinx-paramlinks" },
    { name = "sphinx-togglebutton" },
    { name = "sphinxcontrib-mermaid", version = "1.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "sphinxcontrib-mermaid", version = "2.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "sqlalchemy-cockroachdb" },
    { name = "sqlalchemy-spanner" },
    { name = "sqlmodel", version = "0.0.34", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "sqlmodel", version = "0.0.38", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "starlette", version = "0.49.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "starlette", version = "1.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "sybil", version = "9.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
    { name = "sybil", version = "10.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
    { name = "time-machine", version = "2.19.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "time-machine", version = "3.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "types-aiofiles" },
    { name = "types-colorama" },
    { name = "types-cryptography" },
    { name = "types-docutils", version = "0.22.3.20251115", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "types-docutils", version = "0.22.3.20260322", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "types-passlib", version = "1.7.7.20250602", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "types-passlib", version = "1.7.7.20260211", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "types-pillow" },
    { name = "types-psycopg2", version = "2.9.21.20251012", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "types-psycopg2", version = "2.9.21.20260223", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "types-pygments", version = "2.19.0.20251121", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "types-pygments", version = "2.20.0.20260406", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "types-pymysql" },
    { name = "types-python-dateutil", version = "2.9.0.20260124", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "types-python-dateutil", version = "2.9.0.20260402", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "types-pytz", version = "2025.2.0.20251108", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "types-pytz", version = "2026.1.1.20260402", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "types-pyyaml" },
    { name = "types-ujson" },
]
doc = [
    { name = "accessible-pygments" },
    { name = "auto-pytabs", extra = ["sphinx"] },
    { name = "myst-parser", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "myst-parser", version = "4.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" },
    { name = "myst-parser", version = "5.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
    { name = "shibuya", version = "2025.10.21", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "shibuya", version = "2026.1.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" },
    { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
    { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
    { name = "sphinx-autobuild", version = "2024.10.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
    { name = "sphinx-autobuild", version = "2025.8.25", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
    { name = "sphinx-autodoc-typehints", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "sphinx-autodoc-typehints", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" },
    { name = "sphinx-autodoc-typehints", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
    { name = "sphinx-autodoc-typehints", version = "3.9.11", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
    { name = "sphinx-click", version = "6.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "sphinx-click", version = "6.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "sphinx-copybutton" },
    { name = "sphinx-design", version = "0.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
    { name = "sphinx-design", version = "0.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
    { name = "sphinx-paramlinks" },
    { name = "sphinx-togglebutton" },
    { name = "sphinxcontrib-mermaid", version = "1.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "sphinxcontrib-mermaid", version = "2.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "sybil", version = "9.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
    { name = "sybil", version = "10.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
]
duckdb = [
    { name = "duckdb", version = "1.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "duckdb", version = "1.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "duckdb-engine" },
    { name = "pytz" },
]
fastapi = [
    { name = "fastapi", version = "0.128.8", source = { registry = "https://pypi.org/simple" }, extra = ["all"], marker = "python_full_version < '3.10'" },
    { name = "fastapi", version = "0.135.3", source = { registry = "https://pypi.org/simple" }, extra = ["all"], marker = "python_full_version >= '3.10'" },
    { name = "starlette", version = "0.49.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "starlette", version = "1.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]
flask = [
    { name = "flask", extra = ["async"] },
    { name = "flask-sqlalchemy" },
]
fsspec = [
    { name = "fsspec", version = "2025.10.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "fsspec", version = "2026.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]
lint = [
    { name = "asyncpg-stubs" },
    { name = "mypy", version = "1.19.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "mypy", version = "1.20.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "pre-commit", version = "4.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "pre-commit", version = "4.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "pyright" },
    { name = "ruff" },
    { name = "slotscheck" },
    { name = "types-aiofiles" },
    { name = "types-colorama" },
    { name = "types-cryptography" },
    { name = "types-docutils", version = "0.22.3.20251115", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "types-docutils", version = "0.22.3.20260322", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "types-passlib", version = "1.7.7.20250602", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "types-passlib", version = "1.7.7.20260211", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "types-pillow" },
    { name = "types-psycopg2", version = "2.9.21.20251012", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "types-psycopg2", version = "2.9.21.20260223", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "types-pygments", version = "2.19.0.20251121", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "types-pygments", version = "2.20.0.20260406", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "types-pymysql" },
    { name = "types-python-dateutil", version = "2.9.0.20260124", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "types-python-dateutil", version = "2.9.0.20260402", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "types-pytz", version = "2025.2.0.20251108", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "types-pytz", version = "2026.1.1.20260402", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "types-pyyaml" },
    { name = "types-ujson" },
]
litestar = [
    { name = "litestar", extra = ["cli"] },
]
mssql = [
    { name = "aioodbc" },
    { name = "pyodbc" },
]
mysql = [
    { name = "asyncmy" },
]
oracle = [
    { name = "oracledb" },
]
postgres = [
    { name = "asyncpg" },
    { name = "psycopg", version = "3.2.13", source = { registry = "https://pypi.org/simple" }, extra = ["binary", "pool"], marker = "python_full_version < '3.10'" },
    { name = "psycopg", version = "3.3.3", source = { registry = "https://pypi.org/simple" }, extra = ["binary", "pool"], marker = "python_full_version >= '3.10'" },
    { name = "psycopg2-binary" },
]
sanic = [
    { name = "sanic", version = "25.3.0", source = { registry = "https://pypi.org/simple" }, extra = ["ext"], marker = "python_full_version < '3.10'" },
    { name = "sanic", version = "25.12.0", source = { registry = "https://pypi.org/simple" }, extra = ["ext"], marker = "python_full_version >= '3.10'" },
    { name = "sanic-testing" },
]
spanner = [
    { name = "sqlalchemy-spanner" },
]
sqlite = [
    { name = "aiosqlite" },
]
test = [
    { name = "asgi-lifespan" },
    { name = "attrs" },
    { name = "cattrs", version = "25.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "cattrs", version = "26.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "click", version = "8.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "coverage", version = "7.10.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "coverage", version = "7.13.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "dishka", marker = "python_full_version >= '3.10'" },
    { name = "dogpile-cache", version = "1.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "dogpile-cache", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "fsspec", version = "2025.10.0", source = { registry = "https://pypi.org/simple" }, extra = ["s3"], marker = "python_full_version < '3.10'" },
    { name = "fsspec", version = "2026.3.0", source = { registry = "https://pypi.org/simple" }, extra = ["s3"], marker = "python_full_version >= '3.10'" },
    { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" },
    { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
    { name = "pgvector" },
    { name = "pydantic-extra-types" },
    { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "pytest-asyncio", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "pytest-asyncio", version = "1.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "pytest-click" },
    { name = "pytest-cov" },
    { name = "pytest-databases", extra = ["bigquery", "cockroachdb", "minio", "mssql", "mysql", "oracle", "postgres", "spanner"] },
    { name = "pytest-lazy-fixtures" },
    { name = "pytest-mock" },
    { name = "pytest-rerunfailures", version = "16.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "pytest-rerunfailures", version = "16.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "pytest-sugar" },
    { name = "pytest-xdist" },
    { name = "rich-click" },
    { name = "sqlmodel", version = "0.0.34", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "sqlmodel", version = "0.0.38", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "time-machine", version = "2.19.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "time-machine", version = "3.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]

[package.metadata]
requires-dist = [
    { name = "alembic", specifier = ">=1.12.0" },
    { name = "argon2-cffi", marker = "extra == 'argon2'" },
    { name = "dogpile-cache", marker = "extra == 'dogpile'" },
    { name = "eval-type-backport", marker = "python_full_version < '3.10'" },
    { name = "exceptiongroup", marker = "python_full_version < '3.11'" },
    { name = "fastnanoid", marker = "extra == 'nanoid'", specifier = ">=0.4.1" },
    { name = "fsspec", marker = "extra == 'fsspec'" },
    { name = "greenlet" },
    { name = "obstore", marker = "extra == 'obstore'" },
    { name = "passlib", extras = ["argon2"], marker = "extra == 'passlib'" },
    { name = "pwdlib", extras = ["argon2"], marker = "extra == 'pwdlib'" },
    { name = "rich-click", marker = "extra == 'cli'" },
    { name = "sqlalchemy", specifier = ">=2.0.20" },
    { name = "typing-extensions", specifier = ">=4.0.0" },
    { name = "uuid-utils", marker = "extra == 'uuid'", specifier = ">=0.6.1" },
]
provides-extras = ["argon2", "cli", "dogpile", "fsspec", "nanoid", "obstore", "passlib", "pwdlib", "uuid"]

[package.metadata.requires-dev]
build = [{ name = "bump-my-version" }]
cockroachdb = [
    { name = "asyncpg", specifier = ">=0.29.0" },
    { name = "psycopg", extras = ["binary", "pool"], specifier = ">=3.2.3" },
    { name = "psycopg2-binary", specifier = ">=2.9.10" },
    { name = "sqlalchemy-cockroachdb", specifier = ">=2.0.2" },
]
dev = [
    { name = "accessible-pygments", specifier = ">=0.0.5" },
    { name = "aioodbc", specifier = ">=0.5.0" },
    { name = "aiosqlite", specifier = ">=0.20.0" },
    { name = "asgi-lifespan" },
    { name = "asyncmy", specifier = ">=0.2.9" },
    { name = "asyncpg", specifier = ">=0.29.0" },
    { name = "asyncpg-stubs" },
    { name = "attrs" },
    { name = "auto-pytabs", extras = ["sphinx"], specifier = ">=0.5.0" },
    { name = "bump-my-version" },
    { name = "cattrs" },
    { name = "click" },
    { name = "coverage", specifier = ">=7.6.1" },
    { name = "dishka", marker = "python_full_version >= '3.10'" },
    { name = "dogpile-cache" },
    { name = "duckdb", specifier = ">=1.1.2" },
    { name = "duckdb-engine", specifier = ">=0.13.4" },
    { name = "fastapi", extras = ["all"], specifier = ">=0.115.3" },
    { name = "flask", extras = ["async"] },
    { name = "flask-sqlalchemy", specifier = ">=3.1.1" },
    { name = "fsspec", extras = ["s3"] },
    { name = "litestar", extras = ["cli"], specifier = ">=2.15.0" },
    { name = "mypy", specifier = ">=1.13.0" },
    { name = "myst-parser" },
    { name = "numpy" },
    { name = "oracledb", specifier = ">=2.4.1" },
    { name = "pgvector" },
    { name = "pre-commit", specifier = ">=3.5.0" },
    { name = "psycopg", extras = ["binary", "pool"], specifier = ">=3.2.3" },
    { name = "psycopg2-binary", specifier = ">=2.9.10" },
    { name = "pydantic-extra-types" },
    { name = "pyodbc", specifier = ">=5.2.0" },
    { name = "pyright", specifier = ">=1.1.386" },
    { name = "pytest", specifier = ">=7.4.4" },
    { name = "pytest-asyncio", specifier = ">=0.23.8" },
    { name = "pytest-click" },
    { name = "pytest-cov", specifier = ">=5.0.0" },
    { name = "pytest-databases", extras = ["postgres", "oracle", "cockroachdb", "mssql", "bigquery", "spanner", "mysql", "minio"] },
    { name = "pytest-lazy-fixtures", specifier = ">=1.1.1" },
    { name = "pytest-mock", specifier = ">=3.14.0" },
    { name = "pytest-rerunfailures" },
    { name = "pytest-sugar", specifier = ">=1.0.0" },
    { name = "pytest-xdist", specifier = ">=3.6.1" },
    { name = "pytz", specifier = ">=2024.2" },
    { name = "rich-click" },
    { name = "ruff", specifier = ">=0.7.1" },
    { name = "sanic" },
    { name = "sanic", extras = ["ext"], specifier = ">=24.6.0" },
    { name = "sanic-testing", specifier = ">=24.6.0" },
    { name = "shibuya" },
    { name = "slotscheck", specifier = ">=0.16.5" },
    { name = "sphinx", marker = "python_full_version < '3.10'", specifier = ">=7.0.0" },
    { name = "sphinx", marker = "python_full_version >= '3.10'", specifier = ">=8.0.0" },
    { name = "sphinx-autobuild", specifier = ">=2021.3.14" },
    { name = "sphinx-autodoc-typehints" },
    { name = "sphinx-click", specifier = ">=6.0.0" },
    { name = "sphinx-copybutton", specifier = ">=0.5.2" },
    { name = "sphinx-design", specifier = ">=0.5.0" },
    { name = "sphinx-paramlinks", specifier = ">=0.6.0" },
    { name = "sphinx-togglebutton", specifier = ">=0.3.2" },
    { name = "sphinxcontrib-mermaid", specifier = ">=0.9.2" },
    { name = "sqlalchemy-cockroachdb", specifier = ">=2.0.2" },
    { name = "sqlalchemy-spanner", specifier = ">=1.7.0" },
    { name = "sqlmodel" },
    { name = "starlette" },
    { name = "sybil" },
    { name = "time-machine", specifier = ">=2.15.0" },
    { name = "types-aiofiles" },
    { name = "types-colorama" },
    { name = "types-cryptography" },
    { name = "types-docutils" },
    { name = "types-passlib" },
    { name = "types-pillow" },
    { name = "types-psycopg2" },
    { name = "types-pygments" },
    { name = "types-pymysql" },
    { name = "types-python-dateutil" },
    { name = "types-pytz" },
    { name = "types-pyyaml" },
    { name = "types-ujson" },
]
doc = [
    { name = "accessible-pygments", specifier = ">=0.0.5" },
    { name = "auto-pytabs", extras = ["sphinx"], specifier = ">=0.5.0" },
    { name = "myst-parser" },
    { name = "shibuya" },
    { name = "sphinx", marker = "python_full_version < '3.10'", specifier = ">=7.0.0" },
    { name = "sphinx", marker = "python_full_version >= '3.10'", specifier = ">=8.0.0" },
    { name = "sphinx-autobuild", specifier = ">=2021.3.14" },
    { name = "sphinx-autodoc-typehints" },
    { name = "sphinx-click", specifier = ">=6.0.0" },
    { name = "sphinx-copybutton", specifier = ">=0.5.2" },
    { name = "sphinx-design", specifier = ">=0.5.0" },
    { name = "sphinx-paramlinks", specifier = ">=0.6.0" },
    { name = "sphinx-togglebutton", specifier = ">=0.3.2" },
    { name = "sphinxcontrib-mermaid", specifier = ">=0.9.2" },
    { name = "sybil" },
]
duckdb = [
    { name = "duckdb", specifier = ">=1.1.2" },
    { name = "duckdb-engine", specifier = ">=0.13.4" },
    { name = "pytz", specifier = ">=2024.2" },
]
fastapi = [
    { name = "fastapi", extras = ["all"], specifier = ">=0.115.3" },
    { name = "starlette" },
]
flask = [
    { name = "flask", extras = ["async"] },
    { name = "flask-sqlalchemy", specifier = ">=3.1.1" },
]
fsspec = [{ name = "fsspec", specifier = ">=2024.10.0" }]
lint = [
    { name = "asyncpg-stubs" },
    { name = "mypy", specifier = ">=1.13.0" },
    { name = "pre-commit", specifier = ">=3.5.0" },
    { name = "pyright", specifier = ">=1.1.386" },
    { name = "ruff", specifier = ">=0.7.1" },
    { name = "slotscheck", specifier = ">=0.16.5" },
    { name = "types-aiofiles" },
    { name = "types-colorama" },
    { name = "types-cryptography" },
    { name = "types-docutils" },
    { name = "types-passlib" },
    { name = "types-pillow" },
    { name = "types-psycopg2" },
    { name = "types-pygments" },
    { name = "types-pymysql" },
    { name = "types-python-dateutil" },
    { name = "types-pytz" },
    { name = "types-pyyaml" },
    { name = "types-ujson" },
]
litestar = [{ name = "litestar", extras = ["cli"], specifier = ">=2.15.0" }]
mssql = [
    { name = "aioodbc", specifier = ">=0.5.0" },
    { name = "pyodbc", specifier = ">=5.2.0" },
]
mysql = [{ name = "asyncmy", specifier = ">=0.2.9" }]
oracle = [{ name = "oracledb", specifier = ">=2.4.1" }]
postgres = [
    { name = "asyncpg", specifier = ">=0.29.0" },
    { name = "psycopg", extras = ["binary", "pool"], specifier = ">=3.2.3" },
    { name = "psycopg2-binary", specifier = ">=2.9.10" },
]
sanic = [
    { name = "sanic" },
    { name = "sanic", extras = ["ext"], specifier = ">=24.6.0" },
    { name = "sanic-testing", specifier = ">=24.6.0" },
]
spanner = [{ name = "sqlalchemy-spanner", specifier = ">=1.7.0" }]
sqlite = [{ name = "aiosqlite", specifier = ">=0.20.0" }]
test = [
    { name = "asgi-lifespan" },
    { name = "attrs" },
    { name = "cattrs" },
    { name = "click" },
    { name = "coverage", specifier = ">=7.6.1" },
    { name = "dishka", marker = "python_full_version >= '3.10'" },
    { name = "dogpile-cache" },
    { name = "fsspec", extras = ["s3"] },
    { name = "numpy" },
    { name = "pgvector" },
    { name = "pydantic-extra-types" },
    { name = "pytest", specifier = ">=7.4.4" },
    { name = "pytest-asyncio", specifier = ">=0.23.8" },
    { name = "pytest-click" },
    { name = "pytest-cov", specifier = ">=5.0.0" },
    { name = "pytest-databases", extras = ["postgres", "oracle", "cockroachdb", "mssql", "bigquery", "spanner", "mysql", "minio"] },
    { name = "pytest-lazy-fixtures", specifier = ">=1.1.1" },
    { name = "pytest-mock", specifier = ">=3.14.0" },
    { name = "pytest-rerunfailures" },
    { name = "pytest-sugar", specifier = ">=1.0.0" },
    { name = "pytest-xdist", specifier = ">=3.6.1" },
    { name = "rich-click" },
    { name = "sqlmodel" },
    { name = "time-machine", specifier = ">=2.15.0" },
]

[[package]]
name = "aiobotocore"
version = "2.26.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
dependencies = [
    { name = "aiohttp", marker = "python_full_version < '3.10'" },
    { name = "aioitertools", marker = "python_full_version < '3.10'" },
    { name = "botocore", version = "1.41.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "jmespath", marker = "python_full_version < '3.10'" },
    { name = "multidict", marker = "python_full_version < '3.10'" },
    { name = "python-dateutil", marker = "python_full_version < '3.10'" },
    { name = "wrapt", version = "1.17.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4d/f8/99fa90d9c25b78292899fd4946fce97b6353838b5ecc139ad8ba1436e70c/aiobotocore-2.26.0.tar.gz", hash = "sha256:50567feaf8dfe2b653570b4491f5bc8c6e7fb9622479d66442462c021db4fadc", size = 122026, upload-time = "2025-11-28T07:54:59.956Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/b7/58/3bf0b7d474607dc7fd67dd1365c4e0f392c8177eaf4054e5ddee3ebd53b5/aiobotocore-2.26.0-py3-none-any.whl", hash = "sha256:a793db51c07930513b74ea7a95bd79aaa42f545bdb0f011779646eafa216abec", size = 87333, upload-time = "2025-11-28T07:54:58.457Z" },
]

[[package]]
name = "aiobotocore"
version = "3.3.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
dependencies = [
    { name = "aiohttp", marker = "python_full_version >= '3.10'" },
    { name = "aioitertools", marker = "python_full_version >= '3.10'" },
    { name = "botocore", version = "1.42.70", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "jmespath", marker = "python_full_version >= '3.10'" },
    { name = "multidict", marker = "python_full_version >= '3.10'" },
    { name = "python-dateutil", marker = "python_full_version >= '3.10'" },
    { name = "typing-extensions", marker = "python_full_version == '3.10.*'" },
    { name = "wrapt", version = "2.1.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/9f/a0568deaf008f4a7e3d57a7f80f1537df894df0e49bd4a790bb22f9a2d8e/aiobotocore-3.3.0.tar.gz", hash = "sha256:9abc21d91edd6c9c2e4a07e11bdfcbb159f0b9116ab2a0a5a349113533a18fb2", size = 122940, upload-time = "2026-03-18T09:58:49.077Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/16/54/a295bd8d7ac900c339b2c7024ed0ff9538afb60e92eb0979b8bb49deb20e/aiobotocore-3.3.0-py3-none-any.whl", hash = "sha256:9125ab2b63740dfe3b66b8d5a90d13aed9587b850aa53225ef214a04a1aa7fdc", size = 87817, upload-time = "2026-03-18T09:58:47.466Z" },
]

[[package]]
name = "aiofiles"
version = "25.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" },
]

[[package]]
name = "aiohappyeyeballs"
version = "2.6.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" },
]

[[package]]
name = "aiohttp"
version = "3.13.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "aiohappyeyeballs" },
    { name = "aiosignal" },
    { name = "async-timeout", marker = "python_full_version < '3.11'" },
    { name = "attrs" },
    { name = "frozenlist" },
    { name = "multidict" },
    { name = "propcache" },
    { name = "yarl", version = "1.22.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "yarl", version = "1.23.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/bd/85/cebc47ee74d8b408749073a1a46c6fcba13d170dc8af7e61996c6c9394ac/aiohttp-3.13.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:02222e7e233295f40e011c1b00e3b0bd451f22cf853a0304c3595633ee47da4b", size = 750547, upload-time = "2026-03-31T21:56:30.024Z" },
    { url = "https://files.pythonhosted.org/packages/05/98/afd308e35b9d3d8c9ec54c0918f1d722c86dc17ddfec272fcdbcce5a3124/aiohttp-3.13.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bace460460ed20614fa6bc8cb09966c0b8517b8c58ad8046828c6078d25333b5", size = 503535, upload-time = "2026-03-31T21:56:31.935Z" },
    { url = "https://files.pythonhosted.org/packages/6f/4d/926c183e06b09d5270a309eb50fbde7b09782bfd305dec1e800f329834fb/aiohttp-3.13.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f546a4dc1e6a5edbb9fd1fd6ad18134550e096a5a43f4ad74acfbd834fc6670", size = 497830, upload-time = "2026-03-31T21:56:33.654Z" },
    { url = "https://files.pythonhosted.org/packages/e4/d6/f47d1c690f115a5c2a5e8938cce4a232a5be9aac5c5fb2647efcbbbda333/aiohttp-3.13.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c86969d012e51b8e415a8c6ce96f7857d6a87d6207303ab02d5d11ef0cad2274", size = 1682474, upload-time = "2026-03-31T21:56:35.513Z" },
    { url = "https://files.pythonhosted.org/packages/01/44/056fd37b1bb52eac760303e5196acc74d9d546631b035704ae5927f7b4ac/aiohttp-3.13.5-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b6f6cd1560c5fa427e3b6074bb24d2c64e225afbb7165008903bd42e4e33e28a", size = 1655259, upload-time = "2026-03-31T21:56:37.843Z" },
    { url = "https://files.pythonhosted.org/packages/91/9f/78eb1a20c1c28ae02f6a3c0f4d7b0dcc66abce5290cadd53d78ce3084175/aiohttp-3.13.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:636bc362f0c5bbc7372bc3ae49737f9e3030dbce469f0f422c8f38079780363d", size = 1736204, upload-time = "2026-03-31T21:56:39.822Z" },
    { url = "https://files.pythonhosted.org/packages/de/6c/d20d7de23f0b52b8c1d9e2033b2db1ac4dacbb470bb74c56de0f5f86bb4f/aiohttp-3.13.5-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6a7cbeb06d1070f1d14895eeeed4dac5913b22d7b456f2eb969f11f4b3993796", size = 1826198, upload-time = "2026-03-31T21:56:41.378Z" },
    { url = "https://files.pythonhosted.org/packages/2f/86/a6f3ff1fd795f49545a7c74b2c92f62729135d73e7e4055bf74da5a26c82/aiohttp-3.13.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca9ef7517fd7874a1a08970ae88f497bf5c984610caa0bf40bd7e8450852b95", size = 1681329, upload-time = "2026-03-31T21:56:43.374Z" },
    { url = "https://files.pythonhosted.org/packages/fb/68/84cd3dab6b7b4f3e6fe9459a961acb142aaab846417f6e8905110d7027e5/aiohttp-3.13.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:019a67772e034a0e6b9b17c13d0a8fe56ad9fb150fc724b7f3ffd3724288d9e5", size = 1560023, upload-time = "2026-03-31T21:56:45.031Z" },
    { url = "https://files.pythonhosted.org/packages/41/2c/db61b64b0249e30f954a65ab4cb4970ced57544b1de2e3c98ee5dc24165f/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f34ecee82858e41dd217734f0c41a532bd066bcaab636ad830f03a30b2a96f2a", size = 1652372, upload-time = "2026-03-31T21:56:47.075Z" },
    { url = "https://files.pythonhosted.org/packages/25/6f/e96988a6c982d047810c772e28c43c64c300c943b0ed5c1c0c4ce1e1027c/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:4eac02d9af4813ee289cd63a361576da36dba57f5a1ab36377bc2600db0cbb73", size = 1662031, upload-time = "2026-03-31T21:56:48.835Z" },
    { url = "https://files.pythonhosted.org/packages/b7/26/a56feace81f3d347b4052403a9d03754a0ab23f7940780dada0849a38c92/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4beac52e9fe46d6abf98b0176a88154b742e878fdf209d2248e99fcdf73cd297", size = 1708118, upload-time = "2026-03-31T21:56:50.833Z" },
    { url = "https://files.pythonhosted.org/packages/78/6e/b6173a8ff03d01d5e1a694bc06764b5dad1df2d4ed8f0ceec12bb3277936/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c180f480207a9b2475f2b8d8bd7204e47aec952d084b2a2be58a782ffcf96074", size = 1548667, upload-time = "2026-03-31T21:56:52.81Z" },
    { url = "https://files.pythonhosted.org/packages/16/13/13296ffe2c132d888b3fe2c195c8b9c0c24c89c3fa5cc2c44464dc23b22e/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2837fb92951564d6339cedae4a7231692aa9f73cbc4fb2e04263b96844e03b4e", size = 1724490, upload-time = "2026-03-31T21:56:54.541Z" },
    { url = "https://files.pythonhosted.org/packages/7a/b4/1f1c287f4a79782ef36e5a6e62954c85343bc30470d862d30bd5f26c9fa2/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d9010032a0b9710f58012a1e9c222528763d860ba2ee1422c03473eab47703e7", size = 1667109, upload-time = "2026-03-31T21:56:56.21Z" },
    { url = "https://files.pythonhosted.org/packages/ef/42/8461a2aaf60a8f4ea4549a4056be36b904b0eb03d97ca9a8a2604681a500/aiohttp-3.13.5-cp310-cp310-win32.whl", hash = "sha256:7c4b6668b2b2b9027f209ddf647f2a4407784b5d88b8be4efcc72036f365baf9", size = 439478, upload-time = "2026-03-31T21:56:58.292Z" },
    { url = "https://files.pythonhosted.org/packages/e5/71/06956304cb5ee439dfe8d86e1b2e70088bd88ed1ced1f42fb29e5d855f0e/aiohttp-3.13.5-cp310-cp310-win_amd64.whl", hash = "sha256:cd3db5927bf9167d5a6157ddb2f036f6b6b0ad001ac82355d43e97a4bde76d76", size = 462047, upload-time = "2026-03-31T21:57:00.257Z" },
    { url = "https://files.pythonhosted.org/packages/d6/f5/a20c4ac64aeaef1679e25c9983573618ff765d7aa829fa2b84ae7573169e/aiohttp-3.13.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ab7229b6f9b5c1ba4910d6c41a9eb11f543eadb3f384df1b4c293f4e73d44d6", size = 757513, upload-time = "2026-03-31T21:57:02.146Z" },
    { url = "https://files.pythonhosted.org/packages/75/0a/39fa6c6b179b53fcb3e4b3d2b6d6cad0180854eda17060c7218540102bef/aiohttp-3.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8f14c50708bb156b3a3ca7230b3d820199d56a48e3af76fa21c2d6087190fe3d", size = 506748, upload-time = "2026-03-31T21:57:04.275Z" },
    { url = "https://files.pythonhosted.org/packages/87/ec/e38ce072e724fd7add6243613f8d1810da084f54175353d25ccf9f9c7e5a/aiohttp-3.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e7d2f8616f0ff60bd332022279011776c3ac0faa0f1b463f7bb12326fbc97a1c", size = 501673, upload-time = "2026-03-31T21:57:06.208Z" },
    { url = "https://files.pythonhosted.org/packages/ba/ba/3bc7525d7e2beaa11b309a70d48b0d3cfc3c2089ec6a7d0820d59c657053/aiohttp-3.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2567b72e1ffc3ab25510db43f355b29eeada56c0a622e58dcdb19530eb0a3cb", size = 1763757, upload-time = "2026-03-31T21:57:07.882Z" },
    { url = "https://files.pythonhosted.org/packages/5e/ab/e87744cf18f1bd78263aba24924d4953b41086bd3a31d22452378e9028a0/aiohttp-3.13.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fb0540c854ac9c0c5ad495908fdfd3e332d553ec731698c0e29b1877ba0d2ec6", size = 1720152, upload-time = "2026-03-31T21:57:09.946Z" },
    { url = "https://files.pythonhosted.org/packages/6b/f3/ed17a6f2d742af17b50bae2d152315ed1b164b07a5fd5cc1754d99e4dfa5/aiohttp-3.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9883051c6972f58bfc4ebb2116345ee2aa151178e99c3f2b2bbe2af712abd13", size = 1818010, upload-time = "2026-03-31T21:57:12.157Z" },
    { url = "https://files.pythonhosted.org/packages/53/06/ecbc63dc937192e2a5cb46df4d3edb21deb8225535818802f210a6ea5816/aiohttp-3.13.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2294172ce08a82fb7c7273485895de1fa1186cc8294cfeb6aef4af42ad261174", size = 1907251, upload-time = "2026-03-31T21:57:14.023Z" },
    { url = "https://files.pythonhosted.org/packages/7e/a5/0521aa32c1ddf3aa1e71dcc466be0b7db2771907a13f18cddaa45967d97b/aiohttp-3.13.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a807cabd5115fb55af198b98178997a5e0e57dead43eb74a93d9c07d6d4a7dc", size = 1759969, upload-time = "2026-03-31T21:57:16.146Z" },
    { url = "https://files.pythonhosted.org/packages/f6/78/a38f8c9105199dd3b9706745865a8a59d0041b6be0ca0cc4b2ccf1bab374/aiohttp-3.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6d0d932e0f39c02b80744273cd5c388a2d9bc07760a03164f229c8e02662f6", size = 1616871, upload-time = "2026-03-31T21:57:17.856Z" },
    { url = "https://files.pythonhosted.org/packages/6f/41/27392a61ead8ab38072105c71aa44ff891e71653fe53d576a7067da2b4e8/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:60869c7ac4aaabe7110f26499f3e6e5696eae98144735b12a9c3d9eae2b51a49", size = 1739844, upload-time = "2026-03-31T21:57:19.679Z" },
    { url = "https://files.pythonhosted.org/packages/6e/55/5564e7ae26d94f3214250009a0b1c65a0c6af4bf88924ccb6fdab901de28/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:26d2f8546f1dfa75efa50c3488215a903c0168d253b75fba4210f57ab77a0fb8", size = 1731969, upload-time = "2026-03-31T21:57:22.006Z" },
    { url = "https://files.pythonhosted.org/packages/6d/c5/705a3929149865fc941bcbdd1047b238e4a72bcb215a9b16b9d7a2e8d992/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1162a1492032c82f14271e831c8f4b49f2b6078f4f5fc74de2c912fa225d51d", size = 1795193, upload-time = "2026-03-31T21:57:24.256Z" },
    { url = "https://files.pythonhosted.org/packages/a6/19/edabed62f718d02cff7231ca0db4ef1c72504235bc467f7b67adb1679f48/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8b14eb3262fad0dc2f89c1a43b13727e709504972186ff6a99a3ecaa77102b6c", size = 1606477, upload-time = "2026-03-31T21:57:26.364Z" },
    { url = "https://files.pythonhosted.org/packages/de/fc/76f80ef008675637d88d0b21584596dc27410a990b0918cb1e5776545b5b/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ca9ac61ac6db4eb6c2a0cd1d0f7e1357647b638ccc92f7e9d8d133e71ed3c6ac", size = 1813198, upload-time = "2026-03-31T21:57:28.316Z" },
    { url = "https://files.pythonhosted.org/packages/e5/67/5b3ac26b80adb20ea541c487f73730dc8fa107d632c998f25bbbab98fcda/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7996023b2ed59489ae4762256c8516df9820f751cf2c5da8ed2fb20ee50abab3", size = 1752321, upload-time = "2026-03-31T21:57:30.549Z" },
    { url = "https://files.pythonhosted.org/packages/88/06/e4a2e49255ea23fa4feeb5ab092d90240d927c15e47b5b5c48dff5a9ce29/aiohttp-3.13.5-cp311-cp311-win32.whl", hash = "sha256:77dfa48c9f8013271011e51c00f8ada19851f013cde2c48fca1ba5e0caf5bb06", size = 439069, upload-time = "2026-03-31T21:57:32.388Z" },
    { url = "https://files.pythonhosted.org/packages/c0/43/8c7163a596dab4f8be12c190cf467a1e07e4734cf90eebb39f7f5d53fc6a/aiohttp-3.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:d3a4834f221061624b8887090637db9ad4f61752001eae37d56c52fddade2dc8", size = 462859, upload-time = "2026-03-31T21:57:34.455Z" },
    { url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" },
    { url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" },
    { url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" },
    { url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" },
    { url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" },
    { url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" },
    { url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" },
    { url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" },
    { url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" },
    { url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" },
    { url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" },
    { url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" },
    { url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" },
    { url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" },
    { url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" },
    { url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" },
    { url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" },
    { url = "https://files.pythonhosted.org/packages/78/e9/d76bf503005709e390122d34e15256b88f7008e246c4bdbe915cd4f1adce/aiohttp-3.13.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5029cc80718bbd545123cd8fe5d15025eccaaaace5d0eeec6bd556ad6163d61", size = 742930, upload-time = "2026-03-31T21:58:13.155Z" },
    { url = "https://files.pythonhosted.org/packages/57/00/4b7b70223deaebd9bb85984d01a764b0d7bd6526fcdc73cca83bcbe7243e/aiohttp-3.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4bb6bf5811620003614076bdc807ef3b5e38244f9d25ca5fe888eaccea2a9832", size = 496927, upload-time = "2026-03-31T21:58:15.073Z" },
    { url = "https://files.pythonhosted.org/packages/9c/f5/0fb20fb49f8efdcdce6cd8127604ad2c503e754a8f139f5e02b01626523f/aiohttp-3.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a84792f8631bf5a94e52d9cc881c0b824ab42717165a5579c760b830d9392ac9", size = 497141, upload-time = "2026-03-31T21:58:17.009Z" },
    { url = "https://files.pythonhosted.org/packages/3b/86/b7c870053e36a94e8951b803cb5b909bfbc9b90ca941527f5fcafbf6b0fa/aiohttp-3.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57653eac22c6a4c13eb22ecf4d673d64a12f266e72785ab1c8b8e5940d0e8090", size = 1732476, upload-time = "2026-03-31T21:58:18.925Z" },
    { url = "https://files.pythonhosted.org/packages/b5/e5/4e161f84f98d80c03a238671b4136e6530453d65262867d989bbe78244d0/aiohttp-3.13.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5e5f7debc7a57af53fdf5c5009f9391d9f4c12867049d509bf7bb164a6e295b", size = 1706507, upload-time = "2026-03-31T21:58:21.094Z" },
    { url = "https://files.pythonhosted.org/packages/d4/56/ea11a9f01518bd5a2a2fcee869d248c4b8a0cfa0bb13401574fa31adf4d4/aiohttp-3.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c719f65bebcdf6716f10e9eff80d27567f7892d8988c06de12bbbd39307c6e3a", size = 1773465, upload-time = "2026-03-31T21:58:23.159Z" },
    { url = "https://files.pythonhosted.org/packages/eb/40/333ca27fb74b0383f17c90570c748f7582501507307350a79d9f9f3c6eb1/aiohttp-3.13.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d97f93fdae594d886c5a866636397e2bcab146fd7a132fd6bb9ce182224452f8", size = 1873523, upload-time = "2026-03-31T21:58:25.59Z" },
    { url = "https://files.pythonhosted.org/packages/f0/d2/e2f77eef1acb7111405433c707dc735e63f67a56e176e72e9e7a2cd3f493/aiohttp-3.13.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df334e39d4c2f899a914f1dba283c1aadc311790733f705182998c6f7cae665", size = 1754113, upload-time = "2026-03-31T21:58:27.624Z" },
    { url = "https://files.pythonhosted.org/packages/fb/56/3f653d7f53c89669301ec9e42c95233e2a0c0a6dd051269e6e678db4fdb0/aiohttp-3.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe6970addfea9e5e081401bcbadf865d2b6da045472f58af08427e108d618540", size = 1562351, upload-time = "2026-03-31T21:58:29.918Z" },
    { url = "https://files.pythonhosted.org/packages/ec/a6/9b3e91eb8ae791cce4ee736da02211c85c6f835f1bdfac0594a8a3b7018c/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7becdf835feff2f4f335d7477f121af787e3504b48b449ff737afb35869ba7bb", size = 1693205, upload-time = "2026-03-31T21:58:32.214Z" },
    { url = "https://files.pythonhosted.org/packages/98/fc/bfb437a99a2fcebd6b6eaec609571954de2ed424f01c352f4b5504371dd3/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:676e5651705ad5d8a70aeb8eb6936c436d8ebbd56e63436cb7dd9bb36d2a9a46", size = 1730618, upload-time = "2026-03-31T21:58:34.728Z" },
    { url = "https://files.pythonhosted.org/packages/e4/b6/c8534862126191a034f68153194c389addc285a0f1347d85096d349bbc15/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9b16c653d38eb1a611cc898c41e76859ca27f119d25b53c12875fd0474ae31a8", size = 1745185, upload-time = "2026-03-31T21:58:36.909Z" },
    { url = "https://files.pythonhosted.org/packages/0b/93/4ca8ee2ef5236e2707e0fd5fecb10ce214aee1ff4ab307af9c558bda3b37/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:999802d5fa0389f58decd24b537c54aa63c01c3219ce17d1214cbda3c2b22d2d", size = 1557311, upload-time = "2026-03-31T21:58:39.38Z" },
    { url = "https://files.pythonhosted.org/packages/57/ae/76177b15f18c5f5d094f19901d284025db28eccc5ae374d1d254181d33f4/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ec707059ee75732b1ba130ed5f9580fe10ff75180c812bc267ded039db5128c6", size = 1773147, upload-time = "2026-03-31T21:58:41.476Z" },
    { url = "https://files.pythonhosted.org/packages/01/a4/62f05a0a98d88af59d93b7fcac564e5f18f513cb7471696ac286db970d6a/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d6d44a5b48132053c2f6cd5c8cb14bc67e99a63594e336b0f2af81e94d5530c", size = 1730356, upload-time = "2026-03-31T21:58:44.049Z" },
    { url = "https://files.pythonhosted.org/packages/e4/85/fc8601f59dfa8c9523808281f2da571f8b4699685f9809a228adcc90838d/aiohttp-3.13.5-cp313-cp313-win32.whl", hash = "sha256:329f292ed14d38a6c4c435e465f48bebb47479fd676a0411936cc371643225cc", size = 432637, upload-time = "2026-03-31T21:58:46.167Z" },
    { url = "https://files.pythonhosted.org/packages/c0/1b/ac685a8882896acf0f6b31d689e3792199cfe7aba37969fa91da63a7fa27/aiohttp-3.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:69f571de7500e0557801c0b51f4780482c0ec5fe2ac851af5a92cfce1af1cb83", size = 458896, upload-time = "2026-03-31T21:58:48.119Z" },
    { url = "https://files.pythonhosted.org/packages/5d/ce/46572759afc859e867a5bc8ec3487315869013f59281ce61764f76d879de/aiohttp-3.13.5-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:eb4639f32fd4a9904ab8fb45bf3383ba71137f3d9d4ba25b3b3f3109977c5b8c", size = 745721, upload-time = "2026-03-31T21:58:50.229Z" },
    { url = "https://files.pythonhosted.org/packages/13/fe/8a2efd7626dbe6049b2ef8ace18ffda8a4dfcbe1bcff3ac30c0c7575c20b/aiohttp-3.13.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:7e5dc4311bd5ac493886c63cbf76ab579dbe4641268e7c74e48e774c74b6f2be", size = 497663, upload-time = "2026-03-31T21:58:52.232Z" },
    { url = "https://files.pythonhosted.org/packages/9b/91/cc8cc78a111826c54743d88651e1687008133c37e5ee615fee9b57990fac/aiohttp-3.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:756c3c304d394977519824449600adaf2be0ccee76d206ee339c5e76b70ded25", size = 499094, upload-time = "2026-03-31T21:58:54.566Z" },
    { url = "https://files.pythonhosted.org/packages/0a/33/a8362cb15cf16a3af7e86ed11962d5cd7d59b449202dc576cdc731310bde/aiohttp-3.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecc26751323224cf8186efcf7fbcbc30f4e1d8c7970659daf25ad995e4032a56", size = 1726701, upload-time = "2026-03-31T21:58:56.864Z" },
    { url = "https://files.pythonhosted.org/packages/45/0c/c091ac5c3a17114bd76cbf85d674650969ddf93387876cf67f754204bd77/aiohttp-3.13.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10a75acfcf794edf9d8db50e5a7ec5fc818b2a8d3f591ce93bc7b1210df016d2", size = 1683360, upload-time = "2026-03-31T21:58:59.072Z" },
    { url = "https://files.pythonhosted.org/packages/23/73/bcee1c2b79bc275e964d1446c55c54441a461938e70267c86afaae6fba27/aiohttp-3.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f7a18f258d124cd678c5fe072fe4432a4d5232b0657fca7c1847f599233c83a", size = 1773023, upload-time = "2026-03-31T21:59:01.776Z" },
    { url = "https://files.pythonhosted.org/packages/c7/ef/720e639df03004fee2d869f771799d8c23046dec47d5b81e396c7cda583a/aiohttp-3.13.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df6104c009713d3a89621096f3e3e88cc323fd269dbd7c20afe18535094320be", size = 1853795, upload-time = "2026-03-31T21:59:04.568Z" },
    { url = "https://files.pythonhosted.org/packages/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:241a94f7de7c0c3b616627aaad530fe2cb620084a8b144d3be7b6ecfe95bae3b", size = 1730405, upload-time = "2026-03-31T21:59:07.221Z" },
    { url = "https://files.pythonhosted.org/packages/ce/75/ee1fd286ca7dc599d824b5651dad7b3be7ff8d9a7e7b3fe9820d9180f7db/aiohttp-3.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c974fb66180e58709b6fc402846f13791240d180b74de81d23913abe48e96d94", size = 1558082, upload-time = "2026-03-31T21:59:09.484Z" },
    { url = "https://files.pythonhosted.org/packages/c3/20/1e9e6650dfc436340116b7aa89ff8cb2bbdf0abc11dfaceaad8f74273a10/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6e27ea05d184afac78aabbac667450c75e54e35f62238d44463131bd3f96753d", size = 1692346, upload-time = "2026-03-31T21:59:12.068Z" },
    { url = "https://files.pythonhosted.org/packages/d8/40/8ebc6658d48ea630ac7903912fe0dd4e262f0e16825aa4c833c56c9f1f56/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a79a6d399cef33a11b6f004c67bb07741d91f2be01b8d712d52c75711b1e07c7", size = 1698891, upload-time = "2026-03-31T21:59:14.552Z" },
    { url = "https://files.pythonhosted.org/packages/d8/78/ea0ae5ec8ba7a5c10bdd6e318f1ba5e76fcde17db8275188772afc7917a4/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c632ce9c0b534fbe25b52c974515ed674937c5b99f549a92127c85f771a78772", size = 1742113, upload-time = "2026-03-31T21:59:17.068Z" },
    { url = "https://files.pythonhosted.org/packages/8a/66/9d308ed71e3f2491be1acb8769d96c6f0c47d92099f3bc9119cada27b357/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fceedde51fbd67ee2bcc8c0b33d0126cc8b51ef3bbde2f86662bd6d5a6f10ec5", size = 1553088, upload-time = "2026-03-31T21:59:19.541Z" },
    { url = "https://files.pythonhosted.org/packages/da/a6/6cc25ed8dfc6e00c90f5c6d126a98e2cf28957ad06fa1036bd34b6f24a2c/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f92995dfec9420bb69ae629abf422e516923ba79ba4403bc750d94fb4a6c68c1", size = 1757976, upload-time = "2026-03-31T21:59:22.311Z" },
    { url = "https://files.pythonhosted.org/packages/c1/2b/cce5b0ffe0de99c83e5e36d8f828e4161e415660a9f3e58339d07cce3006/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20ae0ff08b1f2c8788d6fb85afcb798654ae6ba0b747575f8562de738078457b", size = 1712444, upload-time = "2026-03-31T21:59:24.635Z" },
    { url = "https://files.pythonhosted.org/packages/6c/cf/9e1795b4160c58d29421eafd1a69c6ce351e2f7c8d3c6b7e4ca44aea1a5b/aiohttp-3.13.5-cp314-cp314-win32.whl", hash = "sha256:b20df693de16f42b2472a9c485e1c948ee55524786a0a34345511afdd22246f3", size = 438128, upload-time = "2026-03-31T21:59:27.291Z" },
    { url = "https://files.pythonhosted.org/packages/22/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:f85c6f327bf0b8c29da7d93b1cabb6363fb5e4e160a32fa241ed2dce21b73162", size = 464029, upload-time = "2026-03-31T21:59:29.429Z" },
    { url = "https://files.pythonhosted.org/packages/79/11/c27d9332ee20d68dd164dc12a6ecdef2e2e35ecc97ed6cf0d2442844624b/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1efb06900858bb618ff5cee184ae2de5828896c448403d51fb633f09e109be0a", size = 778758, upload-time = "2026-03-31T21:59:31.547Z" },
    { url = "https://files.pythonhosted.org/packages/04/fb/377aead2e0a3ba5f09b7624f702a964bdf4f08b5b6728a9799830c80041e/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fee86b7c4bd29bdaf0d53d14739b08a106fdda809ca5fe032a15f52fae5fe254", size = 512883, upload-time = "2026-03-31T21:59:34.098Z" },
    { url = "https://files.pythonhosted.org/packages/bb/a6/aa109a33671f7a5d3bd78b46da9d852797c5e665bfda7d6b373f56bff2ec/aiohttp-3.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:20058e23909b9e65f9da62b396b77dfa95965cbe840f8def6e572538b1d32e36", size = 516668, upload-time = "2026-03-31T21:59:36.497Z" },
    { url = "https://files.pythonhosted.org/packages/79/b3/ca078f9f2fa9563c36fb8ef89053ea2bb146d6f792c5104574d49d8acb63/aiohttp-3.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cf20a8d6868cb15a73cab329ffc07291ba8c22b1b88176026106ae39aa6df0f", size = 1883461, upload-time = "2026-03-31T21:59:38.723Z" },
    { url = "https://files.pythonhosted.org/packages/b7/e3/a7ad633ca1ca497b852233a3cce6906a56c3225fb6d9217b5e5e60b7419d/aiohttp-3.13.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:330f5da04c987f1d5bdb8ae189137c77139f36bd1cb23779ca1a354a4b027800", size = 1747661, upload-time = "2026-03-31T21:59:41.187Z" },
    { url = "https://files.pythonhosted.org/packages/33/b9/cd6fe579bed34a906d3d783fe60f2fa297ef55b27bb4538438ee49d4dc41/aiohttp-3.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f1cbf0c7926d315c3c26c2da41fd2b5d2fe01ac0e157b78caefc51a782196cf", size = 1863800, upload-time = "2026-03-31T21:59:43.84Z" },
    { url = "https://files.pythonhosted.org/packages/c0/3f/2c1e2f5144cefa889c8afd5cf431994c32f3b29da9961698ff4e3811b79a/aiohttp-3.13.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53fc049ed6390d05423ba33103ded7281fe897cf97878f369a527070bd95795b", size = 1958382, upload-time = "2026-03-31T21:59:46.187Z" },
    { url = "https://files.pythonhosted.org/packages/66/1d/f31ec3f1013723b3babe3609e7f119c2c2fb6ef33da90061a705ef3e1bc8/aiohttp-3.13.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:898703aa2667e3c5ca4c54ca36cd73f58b7a38ef87a5606414799ebce4d3fd3a", size = 1803724, upload-time = "2026-03-31T21:59:48.656Z" },
    { url = "https://files.pythonhosted.org/packages/0e/b4/57712dfc6f1542f067daa81eb61da282fab3e6f1966fca25db06c4fc62d5/aiohttp-3.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0494a01ca9584eea1e5fbd6d748e61ecff218c51b576ee1999c23db7066417d8", size = 1640027, upload-time = "2026-03-31T21:59:51.284Z" },
    { url = "https://files.pythonhosted.org/packages/25/3c/734c878fb43ec083d8e31bf029daae1beafeae582d1b35da234739e82ee7/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6cf81fe010b8c17b09495cbd15c1d35afbc8fb405c0c9cf4738e5ae3af1d65be", size = 1806644, upload-time = "2026-03-31T21:59:53.753Z" },
    { url = "https://files.pythonhosted.org/packages/20/a5/f671e5cbec1c21d044ff3078223f949748f3a7f86b14e34a365d74a5d21f/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c564dd5f09ddc9d8f2c2d0a301cd30a79a2cc1b46dd1a73bef8f0038863d016b", size = 1791630, upload-time = "2026-03-31T21:59:56.239Z" },
    { url = "https://files.pythonhosted.org/packages/0b/63/fb8d0ad63a0b8a99be97deac8c04dacf0785721c158bdf23d679a87aa99e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2994be9f6e51046c4f864598fd9abeb4fba6e88f0b2152422c9666dcd4aea9c6", size = 1809403, upload-time = "2026-03-31T21:59:59.103Z" },
    { url = "https://files.pythonhosted.org/packages/59/0c/bfed7f30662fcf12206481c2aac57dedee43fe1c49275e85b3a1e1742294/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:157826e2fa245d2ef46c83ea8a5faf77ca19355d278d425c29fda0beb3318037", size = 1634924, upload-time = "2026-03-31T22:00:02.116Z" },
    { url = "https://files.pythonhosted.org/packages/17/d6/fd518d668a09fd5a3319ae5e984d4d80b9a4b3df4e21c52f02251ef5a32e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a8aca50daa9493e9e13c0f566201a9006f080e7c50e5e90d0b06f53146a54500", size = 1836119, upload-time = "2026-03-31T22:00:04.756Z" },
    { url = "https://files.pythonhosted.org/packages/78/b7/15fb7a9d52e112a25b621c67b69c167805cb1f2ab8f1708a5c490d1b52fe/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3b13560160d07e047a93f23aaa30718606493036253d5430887514715b67c9d9", size = 1772072, upload-time = "2026-03-31T22:00:07.494Z" },
    { url = "https://files.pythonhosted.org/packages/7e/df/57ba7f0c4a553fc2bd8b6321df236870ec6fd64a2a473a8a13d4f733214e/aiohttp-3.13.5-cp314-cp314t-win32.whl", hash = "sha256:9a0f4474b6ea6818b41f82172d799e4b3d29e22c2c520ce4357856fced9af2f8", size = 471819, upload-time = "2026-03-31T22:00:10.277Z" },
    { url = "https://files.pythonhosted.org/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441, upload-time = "2026-03-31T22:00:12.791Z" },
    { url = "https://files.pythonhosted.org/packages/e2/a5/630bc484695d4a1342bbae85fb8689bf979106525684fc88f05b397324ad/aiohttp-3.13.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:347542f0ea3f95b2a955ee6656461fa1c776e401ac50ebce055a6c38454a0adf", size = 752872, upload-time = "2026-03-31T22:00:15.553Z" },
    { url = "https://files.pythonhosted.org/packages/cd/b8/6a19dda37fda94a9ebefb3c1ae0ff419ac7fbf4fb40750e992829fc13614/aiohttp-3.13.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:178c7b5e62b454c2bc790786e6058c3cc968613b4419251b478c153a4aec32b1", size = 504582, upload-time = "2026-03-31T22:00:18.191Z" },
    { url = "https://files.pythonhosted.org/packages/d5/34/8413eafee3421ade2d6ce9e7c0da1213e1d7f0049be09dcdc342b03a39ba/aiohttp-3.13.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:af545c2cffdb0967a96b6249e6f5f7b0d92cdfd267f9d5238d5b9ca63e8edb10", size = 499094, upload-time = "2026-03-31T22:00:21.118Z" },
    { url = "https://files.pythonhosted.org/packages/da/cf/c6f97006093d1e8ca40fbab843ff49ec7725ab668f0714dd1cb702c62cbd/aiohttp-3.13.5-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:206b7b3ef96e4ce211754f0cd003feb28b7d81f0ad26b8d077a5d5161436067f", size = 1669505, upload-time = "2026-03-31T22:00:24.01Z" },
    { url = "https://files.pythonhosted.org/packages/c2/27/3b2288e66dcec8b04771b2bee3909f70e4072bea995cde5ab7e775e73ddc/aiohttp-3.13.5-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ee5e86776273de1795947d17bddd6bb19e0365fd2af4289c0d2c5454b6b1d36b", size = 1648928, upload-time = "2026-03-31T22:00:27.001Z" },
    { url = "https://files.pythonhosted.org/packages/3a/7f/605d766887594a88dcc27a19663499c7c5e13e7aa87f129b763765a2ee63/aiohttp-3.13.5-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:95d14ca7abefde230f7639ec136ade282655431fd5db03c343b19dda72dd1643", size = 1731800, upload-time = "2026-03-31T22:00:29.603Z" },
    { url = "https://files.pythonhosted.org/packages/71/94/5a878e728e30699d22b118f1a6ad576ab6fff9eb2c6fc8a7faa9376a1c3e/aiohttp-3.13.5-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:912d4b6af530ddb1338a66229dac3a25ff11d4448be3ec3d6340583995f56031", size = 1824247, upload-time = "2026-03-31T22:00:32.139Z" },
    { url = "https://files.pythonhosted.org/packages/37/99/84b448291e9996bb83bf4fad3a71a9786d542f19c50a3ff0531bfaba6fac/aiohttp-3.13.5-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e999f0c88a458c836d5fb521814e92ed2172c649200336a6df514987c1488258", size = 1670742, upload-time = "2026-03-31T22:00:34.788Z" },
    { url = "https://files.pythonhosted.org/packages/14/a8/d8d5d1ab6d29a4a3bdb9db31f161e338bfdf6638f6574ea8380f1d4a243c/aiohttp-3.13.5-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:39380e12bd1f2fdab4285b6e055ad48efbaed5c836433b142ed4f5b9be71036a", size = 1562474, upload-time = "2026-03-31T22:00:37.623Z" },
    { url = "https://files.pythonhosted.org/packages/92/e8/bd889697916f10b65524422c61b4eeaf919eb35a170290cccb680cbe4eb4/aiohttp-3.13.5-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9efcc0f11d850cefcafdd9275b9576ad3bfb539bed96807663b32ad99c4d4b88", size = 1642235, upload-time = "2026-03-31T22:00:40.541Z" },
    { url = "https://files.pythonhosted.org/packages/60/42/3f1928107131f1413a5972ace14ddcd5364968e9bd7b3ad71272defafc9c/aiohttp-3.13.5-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:147b4f501d0292077f29d5268c16bb7c864a1f054d7001c4c1812c0421ea1ed0", size = 1655397, upload-time = "2026-03-31T22:00:43.167Z" },
    { url = "https://files.pythonhosted.org/packages/b2/79/c4bbcf4cac3a4715a326e49720ccdc3a4b5e14a367c5029eae7727d06029/aiohttp-3.13.5-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:d147004fede1b12f6013a6dbb2a26a986a671a03c6ea740ddc76500e5f1c399f", size = 1703509, upload-time = "2026-03-31T22:00:45.908Z" },
    { url = "https://files.pythonhosted.org/packages/d1/e6/32d245876f211a7308a7d5437707f9296b1f9837a2888a407ed04e61321c/aiohttp-3.13.5-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:9277145d36a01653863899c665243871434694bcc3431922c3b35c978061bdb8", size = 1550098, upload-time = "2026-03-31T22:00:49.48Z" },
    { url = "https://files.pythonhosted.org/packages/db/62/ab0f1304def56ce2356e6fbb9f0b024d6544010351430070f48f53b89e0a/aiohttp-3.13.5-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4e704c52438f66fdd89588346183d898bb42167cf88f8b7ff1c0f9fc957c348f", size = 1724326, upload-time = "2026-03-31T22:00:52.165Z" },
    { url = "https://files.pythonhosted.org/packages/c4/9a/aab4469689024046220ea438aa020ea2ae04cd1dd71aea3057e094f8c357/aiohttp-3.13.5-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a8a4d3427e8de1312ddf309cc482186466c79895b3a139fed3259fc01dfa9a5b", size = 1658824, upload-time = "2026-03-31T22:00:55.122Z" },
    { url = "https://files.pythonhosted.org/packages/b0/98/bcc35d4db687acabf06d41f561a99fa88bca145292513388c858d99b72c5/aiohttp-3.13.5-cp39-cp39-win32.whl", hash = "sha256:6f497a6876aa4b1a102b04996ce4c1170c7040d83faa9387dd921c16e30d5c83", size = 440302, upload-time = "2026-03-31T22:00:57.673Z" },
    { url = "https://files.pythonhosted.org/packages/25/61/b0203c2ef6bd268fca0eda142f0efbba7cbebd7ad38f7bb01dd31c2ff68e/aiohttp-3.13.5-cp39-cp39-win_amd64.whl", hash = "sha256:cb979826071c0986a5f08333a36104153478ce6018c58cba7f9caddaf63d5d67", size = 463076, upload-time = "2026-03-31T22:01:00.264Z" },
]

[[package]]
name = "aioitertools"
version = "0.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "typing-extensions", marker = "python_full_version < '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fd/3c/53c4a17a05fb9ea2313ee1777ff53f5e001aefd5cc85aa2f4c2d982e1e38/aioitertools-0.13.0.tar.gz", hash = "sha256:620bd241acc0bbb9ec819f1ab215866871b4bbd1f73836a55f799200ee86950c", size = 19322, upload-time = "2025-11-06T22:17:07.609Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/10/a1/510b0a7fadc6f43a6ce50152e69dbd86415240835868bb0bd9b5b88b1e06/aioitertools-0.13.0-py3-none-any.whl", hash = "sha256:0be0292b856f08dfac90e31f4739432f4cb6d7520ab9eb73e143f4f2fa5259be", size = 24182, upload-time = "2025-11-06T22:17:06.502Z" },
]

[[package]]
name = "aioodbc"
version = "0.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "pyodbc" },
]
sdist = { url = "https://files.pythonhosted.org/packages/45/87/3a7580938f217212a574ba0d1af78203fc278fc439815f3fc515a7fdc12b/aioodbc-0.5.0.tar.gz", hash = "sha256:cbccd89ce595c033a49c9e6b4b55bbace7613a104b8a46e3d4c58c4bc4f25075", size = 41298, upload-time = "2023-10-28T21:37:29.966Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/b0/80/4d1565bc16b53cd603c73dc4bc770e2e6418d957417e05031314760dc28c/aioodbc-0.5.0-py3-none-any.whl", hash = "sha256:bcaf16f007855fa4bf0ce6754b1f72c6c5a3d544188849577ddd55c5dc42985e", size = 19449, upload-time = "2023-10-28T21:37:28.51Z" },
]

[[package]]
name = "aiosignal"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "frozenlist" },
    { name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
]

[[package]]
name = "aiosqlite"
version = "0.22.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" },
]

[[package]]
name = "alabaster"
version = "0.7.16"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/3e/13dd8e5ed9094e734ac430b5d0eb4f2bb001708a8b7856cbf8e084e001ba/alabaster-0.7.16.tar.gz", hash = "sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65", size = 23776, upload-time = "2024-01-10T00:56:10.189Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/32/34/d4e1c02d3bee589efb5dfa17f88ea08bdb3e3eac12bc475462aec52ed223/alabaster-0.7.16-py3-none-any.whl", hash = "sha256:b46733c07dce03ae4e150330b975c75737fa60f0a7c591b6c8bf4928a28e2c92", size = 13511, upload-time = "2024-01-10T00:56:08.388Z" },
]

[[package]]
name = "alabaster"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" },
]

[[package]]
name = "alembic"
version = "1.16.5"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
dependencies = [
    { name = "mako", marker = "python_full_version < '3.10'" },
    { name = "sqlalchemy", marker = "python_full_version < '3.10'" },
    { name = "tomli", marker = "python_full_version < '3.10'" },
    { name = "typing-extensions", marker = "python_full_version < '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9a/ca/4dc52902cf3491892d464f5265a81e9dff094692c8a049a3ed6a05fe7ee8/alembic-1.16.5.tar.gz", hash = "sha256:a88bb7f6e513bd4301ecf4c7f2206fe93f9913f9b48dac3b78babde2d6fe765e", size = 1969868, upload-time = "2025-08-27T18:02:05.668Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/39/4a/4c61d4c84cfd9befb6fa08a702535b27b21fff08c946bc2f6139decbf7f7/alembic-1.16.5-py3-none-any.whl", hash = "sha256:e845dfe090c5ffa7b92593ae6687c5cb1a101e91fa53868497dbd79847f9dbe3", size = 247355, upload-time = "2025-08-27T18:02:07.37Z" },
]

[[package]]
name = "alembic"
version = "1.18.4"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
dependencies = [
    { name = "mako", marker = "python_full_version >= '3.10'" },
    { name = "sqlalchemy", marker = "python_full_version >= '3.10'" },
    { name = "tomli", marker = "python_full_version == '3.10.*'" },
    { name = "typing-extensions", marker = "python_full_version >= '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" },
]

[[package]]
name = "annotated-doc"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
]

[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]

[[package]]
name = "anyio"
version = "4.12.1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
dependencies = [
    { name = "exceptiongroup", marker = "python_full_version < '3.10'" },
    { name = "idna", marker = "python_full_version < '3.10'" },
    { name = "typing-extensions", marker = "python_full_version < '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
]

[[package]]
name = "anyio"
version = "4.13.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
dependencies = [
    { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" },
    { name = "idna", marker = "python_full_version >= '3.10'" },
    { name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
]

[[package]]
name = "argon2-cffi"
version = "23.1.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
dependencies = [
    { name = "argon2-cffi-bindings", marker = "python_full_version < '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/31/fa/57ec2c6d16ecd2ba0cf15f3c7d1c3c2e7b5fcb83555ff56d7ab10888ec8f/argon2_cffi-23.1.0.tar.gz", hash = "sha256:879c3e79a2729ce768ebb7d36d4609e3a78a4ca2ec3a9f12286ca057e3d0db08", size = 42798, upload-time = "2023-08-15T14:13:12.711Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/a4/6a/e8a041599e78b6b3752da48000b14c8d1e8a04ded09c88c714ba047f34f5/argon2_cffi-23.1.0-py3-none-any.whl", hash = "sha256:c670642b78ba29641818ab2e68bd4e6a78ba53b7eff7b4c3815ae16abf91c7ea", size = 15124, upload-time = "2023-08-15T14:13:10.752Z" },
]

[[package]]
name = "argon2-cffi"
version = "25.1.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
dependencies = [
    { name = "argon2-cffi-bindings", marker = "python_full_version >= '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" },
]

[[package]]
name = "argon2-cffi-bindings"
version = "25.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "cffi" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/60/97/3c0a35f46e52108d4707c44b95cfe2afcafc50800b5450c197454569b776/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f", size = 54393, upload-time = "2025-07-30T10:01:40.97Z" },
    { url = "https://files.pythonhosted.org/packages/9d/f4/98bbd6ee89febd4f212696f13c03ca302b8552e7dbf9c8efa11ea4a388c3/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b", size = 29328, upload-time = "2025-07-30T10:01:41.916Z" },
    { url = "https://files.pythonhosted.org/packages/43/24/90a01c0ef12ac91a6be05969f29944643bc1e5e461155ae6559befa8f00b/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a", size = 31269, upload-time = "2025-07-30T10:01:42.716Z" },
    { url = "https://files.pythonhosted.org/packages/d4/d3/942aa10782b2697eee7af5e12eeff5ebb325ccfb86dd8abda54174e377e4/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44", size = 86558, upload-time = "2025-07-30T10:01:43.943Z" },
    { url = "https://files.pythonhosted.org/packages/0d/82/b484f702fec5536e71836fc2dbc8c5267b3f6e78d2d539b4eaa6f0db8bf8/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb", size = 92364, upload-time = "2025-07-30T10:01:44.887Z" },
    { url = "https://files.pythonhosted.org/packages/c9/c1/a606ff83b3f1735f3759ad0f2cd9e038a0ad11a3de3b6c673aa41c24bb7b/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92", size = 85637, upload-time = "2025-07-30T10:01:46.225Z" },
    { url = "https://files.pythonhosted.org/packages/44/b4/678503f12aceb0262f84fa201f6027ed77d71c5019ae03b399b97caa2f19/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85", size = 91934, upload-time = "2025-07-30T10:01:47.203Z" },
    { url = "https://files.pythonhosted.org/packages/f0/c7/f36bd08ef9bd9f0a9cff9428406651f5937ce27b6c5b07b92d41f91ae541/argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f", size = 28158, upload-time = "2025-07-30T10:01:48.341Z" },
    { url = "https://files.pythonhosted.org/packages/b3/80/0106a7448abb24a2c467bf7d527fe5413b7fdfa4ad6d6a96a43a62ef3988/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6", size = 32597, upload-time = "2025-07-30T10:01:49.112Z" },
    { url = "https://files.pythonhosted.org/packages/05/b8/d663c9caea07e9180b2cb662772865230715cbd573ba3b5e81793d580316/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623", size = 28231, upload-time = "2025-07-30T10:01:49.92Z" },
    { url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121, upload-time = "2025-07-30T10:01:50.815Z" },
    { url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177, upload-time = "2025-07-30T10:01:51.681Z" },
    { url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090, upload-time = "2025-07-30T10:01:53.184Z" },
    { url = "https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246, upload-time = "2025-07-30T10:01:54.145Z" },
    { url = "https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126, upload-time = "2025-07-30T10:01:55.074Z" },
    { url = "https://files.pythonhosted.org/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343, upload-time = "2025-07-30T10:01:56.007Z" },
    { url = "https://files.pythonhosted.org/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777, upload-time = "2025-07-30T10:01:56.943Z" },
    { url = "https://files.pythonhosted.org/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180, upload-time = "2025-07-30T10:01:57.759Z" },
    { url = "https://files.pythonhosted.org/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715, upload-time = "2025-07-30T10:01:58.56Z" },
    { url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" },
    { url = "https://files.pythonhosted.org/packages/11/2d/ba4e4ca8d149f8dcc0d952ac0967089e1d759c7e5fcf0865a317eb680fbb/argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6dca33a9859abf613e22733131fc9194091c1fa7cb3e131c143056b4856aa47e", size = 24549, upload-time = "2025-07-30T10:02:00.101Z" },
    { url = "https://files.pythonhosted.org/packages/5c/82/9b2386cc75ac0bd3210e12a44bfc7fd1632065ed8b80d573036eecb10442/argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:21378b40e1b8d1655dd5310c84a40fc19a9aa5e6366e835ceb8576bf0fea716d", size = 25539, upload-time = "2025-07-30T10:02:00.929Z" },
    { url = "https://files.pythonhosted.org/packages/31/db/740de99a37aa727623730c90d92c22c9e12585b3c98c54b7960f7810289f/argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d588dec224e2a83edbdc785a5e6f3c6cd736f46bfd4b441bbb5aa1f5085e584", size = 28467, upload-time = "2025-07-30T10:02:02.08Z" },
    { url = "https://files.pythonhosted.org/packages/71/7a/47c4509ea18d755f44e2b92b7178914f0c113946d11e16e626df8eaa2b0b/argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5acb4e41090d53f17ca1110c3427f0a130f944b896fc8c83973219c97f57b690", size = 27355, upload-time = "2025-07-30T10:02:02.867Z" },
    { url = "https://files.pythonhosted.org/packages/ee/82/82745642d3c46e7cea25e1885b014b033f4693346ce46b7f47483cf5d448/argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:da0c79c23a63723aa5d782250fbf51b768abca630285262fb5144ba5ae01e520", size = 29187, upload-time = "2025-07-30T10:02:03.674Z" },
]

[[package]]
name = "asgi-lifespan"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "sniffio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6a/da/e7908b54e0f8043725a990bf625f2041ecf6bfe8eb7b19407f1c00b630f7/asgi-lifespan-2.1.0.tar.gz", hash = "sha256:5e2effaf0bfe39829cf2d64e7ecc47c7d86d676a6599f7afba378c31f5e3a308", size = 15627, upload-time = "2023-03-28T17:35:49.126Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/2f/f5/c36551e93acba41a59939ae6a0fb77ddb3f2e8e8caa716410c65f7341f72/asgi_lifespan-2.1.0-py3-none-any.whl", hash = "sha256:ed840706680e28428c01e14afb3875d7d76d3206f3d5b2f2294e059b5c23804f", size = 10895, upload-time = "2023-03-28T17:35:47.772Z" },
]

[[package]]
name = "asgiref"
version = "3.11.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550, upload-time = "2026-02-03T13:30:14.33Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" },
]

[[package]]
name = "async-timeout"
version = "5.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" },
]

[[package]]
name = "asyncmy"
version = "0.2.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/78/3c/d8297584c40f3d1af55365026bcdca7844ecfea1d917ad19df48f8331a26/asyncmy-0.2.11.tar.gz", hash = "sha256:c3d65d959dde62c911e39ecd1ad0f1339a5e6929fc411d48cfc2f82846190bf4", size = 62865, upload-time = "2026-01-15T11:32:30.368Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/c3/35/9612472ac9722d9be9517ee02bcffa623a1888a5fdd7e69b8c007d98d7e7/asyncmy-0.2.11-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d1c3b957024d1eccb5053e78aa6e1e522943922a4853b03402acf13c93e9e54d", size = 1759953, upload-time = "2026-01-15T11:31:28.223Z" },
    { url = "https://files.pythonhosted.org/packages/d8/be/63fdc4594f48083fe9319e28533fa8a9374a15a65014d7c4a3abe11b5ca4/asyncmy-0.2.11-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d6fc770fd29784302b4961e0ce865712e88aabc8451d42381193fa8f2b2a92ad", size = 1726944, upload-time = "2026-01-15T11:31:30.124Z" },
    { url = "https://files.pythonhosted.org/packages/ba/13/608c1bfa94ae149ba32f68b478d6a8dcf7b3ee84dbb91f9c04129c43a6e6/asyncmy-0.2.11-cp310-cp310-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:dc88df5765058638a12f0bf6e4f5ef00d9f31f7297b60cd283313eadaa5a472b", size = 4866928, upload-time = "2026-01-15T11:31:32.81Z" },
    { url = "https://files.pythonhosted.org/packages/35/00/a4e1c5dc2fa1d6ee20a01c06480fcf20569ed2a5b73bd7a19606727a9f10/asyncmy-0.2.11-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efe78f5b498499e3b1730a0f1d3fdd98514bb4c5fd07eaf87e21f9078086ece2", size = 5053411, upload-time = "2026-01-15T11:31:34.672Z" },
    { url = "https://files.pythonhosted.org/packages/59/66/581e610303746bade5e766a5de2edc958e2a066dfc55a432db8d0bede7d3/asyncmy-0.2.11-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5ed33eb733f5f22cb5245a8186d45dc3ce5cf6f0d71b3c0d0d05944b0fe4d495", size = 4895574, upload-time = "2026-01-15T11:31:36.255Z" },
    { url = "https://files.pythonhosted.org/packages/89/e8/3af264bd42f73d8c8794bb1372fb985595edd05bdbd3271ea1a9f8e99f52/asyncmy-0.2.11-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:549477362902aa7698f03a3f18fe07f8dd5e04eb97ccddd26949ba67b4b7c4df", size = 5007339, upload-time = "2026-01-15T11:31:38.226Z" },
    { url = "https://files.pythonhosted.org/packages/62/c1/7f33704f680a4b3d1b630a4621d80a06d17bf46c0084fd169288844119fe/asyncmy-0.2.11-cp310-cp310-win32.whl", hash = "sha256:52e86c354d43da07dc924635874a1edd0824e97355d46d75532b29ee87559412", size = 1584808, upload-time = "2026-01-15T11:31:39.517Z" },
    { url = "https://files.pythonhosted.org/packages/88/ef/a78244b5e293ef2e4fca1b66dc3c3f65d73aa035531800bf6d1d37a87667/asyncmy-0.2.11-cp310-cp310-win_amd64.whl", hash = "sha256:76ff057608aa78bba5c8e6a5b0fd373cd4759fcbd31f1ea4d984c925ef84f575", size = 1653423, upload-time = "2026-01-15T11:31:40.889Z" },
    { url = "https://files.pythonhosted.org/packages/cb/58/ec29057913334cacaa2be98eacb21ad468bc58214ff25aab28b78487d697/asyncmy-0.2.11-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6b324b70438120cabdb1b0d8789082b8a2770257e7beaa7d036322ba5f603f25", size = 1757174, upload-time = "2026-01-15T11:31:43.006Z" },
    { url = "https://files.pythonhosted.org/packages/7e/46/7939167e05143c8feba1812dbdd478579d215d4b6d7f84856981ab71db7c/asyncmy-0.2.11-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:919811ec4157506632a354047b943b995c71f31182d430ab87c60ccee844c1d9", size = 1724357, upload-time = "2026-01-15T11:31:44.826Z" },
    { url = "https://files.pythonhosted.org/packages/1f/a0/5cfc1bdeb6166ea26eac97d3cefb574d490aabc052478a3f65730b7379d9/asyncmy-0.2.11-cp311-cp311-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:8f416fba944dad20d47eb9a4db1a1853a0f7856b12b0a3b52c1a2fba2f447851", size = 5100925, upload-time = "2026-01-15T11:31:46.842Z" },
    { url = "https://files.pythonhosted.org/packages/0e/37/d9bda68844e6741a7e7e1e449da6cc7926b7ccbf11af26a806b19fd7e7ae/asyncmy-0.2.11-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:905a6693e3dbaf4280dfced96131bada108554754df40d75a7ff91961ba7331a", size = 5303964, upload-time = "2026-01-15T11:31:48.719Z" },
    { url = "https://files.pythonhosted.org/packages/57/da/358b6e50148a808e05963acd2ae47c1fbef4cdead0157ce2fc90fd4ecf7d/asyncmy-0.2.11-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ff3ac9feb09e4e80c227280f6350c8015cfed018efe9090f41e2fb4ab8cbf163", size = 5116361, upload-time = "2026-01-15T11:31:50.26Z" },
    { url = "https://files.pythonhosted.org/packages/e9/e8/6b11d62b0e164adbff5ed882d650c8b60fa710001965c54c70c77cffc941/asyncmy-0.2.11-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:be18725eeccb616b5769ed8cbcabb2758a47e162c4232d7bef98b978950228ad", size = 5241238, upload-time = "2026-01-15T11:31:51.708Z" },
    { url = "https://files.pythonhosted.org/packages/34/6b/d28fe93160900aba9573cbbf9be5291ac2f1a37eab4fd85e366d5ecbfd84/asyncmy-0.2.11-cp311-cp311-win32.whl", hash = "sha256:d32e03278b72f7ef80c0692797e0ce1c03a63ac0192f858d804a6ecd471754e3", size = 1583714, upload-time = "2026-01-15T11:31:52.968Z" },
    { url = "https://files.pythonhosted.org/packages/09/29/39effc0c56fea76b695490b95df0da4dd0b6f040ed160ad40740e1358265/asyncmy-0.2.11-cp311-cp311-win_amd64.whl", hash = "sha256:adfe7d271f9ef52c5f96ffd716ed7726839ec7c39fccf98748803f871b399dbd", size = 1655038, upload-time = "2026-01-15T11:31:54.161Z" },
    { url = "https://files.pythonhosted.org/packages/ca/93/3b4c7f9b35a27e80bd2f305c4c8d7ae56b6dd40d616d34dd4dbb818c90c9/asyncmy-0.2.11-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:914dddd2ba884822a304297f6ca96026548a100339a4e0ca5c427f6cfa3e4b62", size = 1731817, upload-time = "2026-01-15T11:31:55.486Z" },
    { url = "https://files.pythonhosted.org/packages/7b/65/c70b2b8d014b21504de7e2027e2456f7774cec855766ec1808da47d70b24/asyncmy-0.2.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5ea7ca4bcf7747b1fd4e80f16aa461972e0931cecd3bc43a3ba8e14a5d368a98", size = 1712017, upload-time = "2026-01-15T11:31:56.795Z" },
    { url = "https://files.pythonhosted.org/packages/65/a9/f326999a1ffacc7738376fa68c7ede164db9e5520bb4dbd35f1fdd5704dd/asyncmy-0.2.11-cp312-cp312-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:b2fd6e04efca56d9176e7b620ee47dd1dd334e2eb03c1ae1580954c2625b99d1", size = 4986605, upload-time = "2026-01-15T11:31:58.462Z" },
    { url = "https://files.pythonhosted.org/packages/b5/f2/634326efc5fca15620eb106194d2287997a31625dc95f1940a3cef2f80a7/asyncmy-0.2.11-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4df583dd9d09e817c4cc68b706133d5da453faf56da431a24fcdd64b9552e26b", size = 5226256, upload-time = "2026-01-15T11:32:00.095Z" },
    { url = "https://files.pythonhosted.org/packages/35/7f/4ecd2dcee1d13d49a301ab8ee11a33c75ded4b3089bdb7bf5fb385ed162d/asyncmy-0.2.11-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b2d473b683db7fa1acb167a4bc25ea38e398c991427bd8aa708a9b75059a1d33", size = 5045263, upload-time = "2026-01-15T11:32:01.653Z" },
    { url = "https://files.pythonhosted.org/packages/fe/3e/d94fc4a0ca1e2e492982db607c26b48a95ba668e24755f3bf00da68ae9be/asyncmy-0.2.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d03803975e5dfe74ef4af18411da3716681fde78a65a8758237e7e3e4a342ba1", size = 5194397, upload-time = "2026-01-15T11:32:03.068Z" },
    { url = "https://files.pythonhosted.org/packages/1e/06/d1bb47ce9ed32ba02f2ff44118d5eb36702d38f97cd824bd6a51d3decfdf/asyncmy-0.2.11-cp312-cp312-win32.whl", hash = "sha256:564fc38b3a0665663b8b2ec35fc34fd2768688ba5c869a0d8bd1eb57b85351b2", size = 1560945, upload-time = "2026-01-15T11:32:04.543Z" },
    { url = "https://files.pythonhosted.org/packages/74/d8/973c576c84f4b706a45372c959778feca6842033ccbbd26b2bfe344ebc4b/asyncmy-0.2.11-cp312-cp312-win_amd64.whl", hash = "sha256:84e23466602407da7e126fb5f2da2948c69a6f0d40d4ea7771331771e05c1c2e", size = 1637442, upload-time = "2026-01-15T11:32:05.802Z" },
    { url = "https://files.pythonhosted.org/packages/83/9a/b5b77690f7287acb0a284319e85378c6f4063cd3617dd5311e00f332d628/asyncmy-0.2.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a48be02bdae5e5324ac2d142d7afc6dd9c6af546fd892804c9d8e58d8107980", size = 1727740, upload-time = "2026-01-15T11:32:07.443Z" },
    { url = "https://files.pythonhosted.org/packages/10/28/7b168dc84704edb0b60f7906bfba3a451fd90c0cb2443edbb377b1a11d20/asyncmy-0.2.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:babed99ea1cf7edb476dba23c27560b2a042de46e61678c0cfa3bc017e5f49e4", size = 1706138, upload-time = "2026-01-15T11:32:08.898Z" },
    { url = "https://files.pythonhosted.org/packages/ec/27/ac7363e8ab95f2048852851bbbef12d4eee62363d202d7e566291023ece4/asyncmy-0.2.11-cp313-cp313-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:709cc8147edad072b5176d87878a985323c87cc017c460073414f2b7d5ae9d01", size = 4942591, upload-time = "2026-01-15T11:32:10.127Z" },
    { url = "https://files.pythonhosted.org/packages/08/81/092314cc97e3732535804f2d3e1b966daeaa3a33a8e9a686328cf09498ad/asyncmy-0.2.11-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:373aecf8cd17662c13bab69dc36db7242be8e956242164469b8733886fb2ec0a", size = 5178039, upload-time = "2026-01-15T11:32:12.088Z" },
    { url = "https://files.pythonhosted.org/packages/2a/9b/b884404bac62d9b6efbc9006c4b80ad55e8b0bb6f585b44eee1eceb07b1c/asyncmy-0.2.11-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e23ea478e6638e479dfab2674d2c39a21160c7d750d5c8cf2a0e205d947a63b7", size = 4987628, upload-time = "2026-01-15T11:32:13.979Z" },
    { url = "https://files.pythonhosted.org/packages/00/65/68e576aecd2a43d383123e3a66339e6a3535495b0e81443e374a3d3c356d/asyncmy-0.2.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:577272e238aff9b985eb880b49b1ba009e1fd1133b754fc71c833ab5bd9561ee", size = 5143375, upload-time = "2026-01-15T11:32:15.38Z" },
    { url = "https://files.pythonhosted.org/packages/4e/e4/cd30ea75ab96e5c6fe0daf6bd1871753fe5a1677515530fa0bc1a807dd6c/asyncmy-0.2.11-cp313-cp313-win32.whl", hash = "sha256:29536a08bf8c96437188ae4080fdd09c5a82cbe93794d0996cd0dd238f632664", size = 1559106, upload-time = "2026-01-15T11:32:16.895Z" },
    { url = "https://files.pythonhosted.org/packages/0c/3e/497e3ac839d7d18e79770b977f90e6f17a87181f95b8aed59359ff4aba0c/asyncmy-0.2.11-cp313-cp313-win_amd64.whl", hash = "sha256:f095af7b980505158609ca0bcdd0d14d1e48893e43fc1856c7cecfd9439f498c", size = 1635619, upload-time = "2026-01-15T11:32:18.241Z" },
    { url = "https://files.pythonhosted.org/packages/40/a9/c6bfa912503ed676fd13415dd5156fefb3a57d345423553655f4612215f7/asyncmy-0.2.11-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d94af52f2cf39332997294bc5e157a49315b3a08db7bd40b6ced75f322f91a0e", size = 1764735, upload-time = "2026-01-15T11:32:19.84Z" },
    { url = "https://files.pythonhosted.org/packages/7c/05/6a1d0431e681d503463645168d3f51661105ee0bafd54600fd98e2cff36b/asyncmy-0.2.11-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:dbe4f5abac282c940ba6fa77f1d799855ce3ffe9cb9da200ee82da5451c9d26b", size = 1731628, upload-time = "2026-01-15T11:32:21.134Z" },
    { url = "https://files.pythonhosted.org/packages/d5/55/15b88f78584f1b95e73329984c478b5efb561bad56a6afc29666217222ce/asyncmy-0.2.11-cp39-cp39-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:d561e8cbaa8988e0d5b785fa45a90428b4ccac32c6d9575634b44a8e5acd5d22", size = 4869878, upload-time = "2026-01-15T11:32:22.504Z" },
    { url = "https://files.pythonhosted.org/packages/e6/5b/74c04293fd829cd346515b64bbf8d254db88a246e6f043da85f91973dcc6/asyncmy-0.2.11-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a623d14af563095de76fcb5aff60ad0dc2112f87eb5110117d4e5003055a331", size = 5055488, upload-time = "2026-01-15T11:32:23.917Z" },
    { url = "https://files.pythonhosted.org/packages/93/1a/2a156fa6437232e1c7c47dd695b6448d951e7331bcc754591d0f1465e3c9/asyncmy-0.2.11-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:19d0e6018c0ea5f1478bbf51b963c611b584391e29b8340b5cdb4e22a51d2849", size = 4912397, upload-time = "2026-01-15T11:32:25.207Z" },
    { url = "https://files.pythonhosted.org/packages/e5/94/bc3c238146a5891350b2e79a2b35e2f5b9931d35157d9067bbdd00e98e48/asyncmy-0.2.11-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a17f174776cdf1e16dc875a97da13175e572f37377d7204928fe0d973b296710", size = 5026167, upload-time = "2026-01-15T11:32:26.564Z" },
    { url = "https://files.pythonhosted.org/packages/2f/0c/ab6101c69f8a10179548ad8ff39747e3f5a0aa582fc7658ee43295a85a8e/asyncmy-0.2.11-cp39-cp39-win32.whl", hash = "sha256:23c9cff618ed53384639a4e9ad61b61f72b54ad6ad7c559bb5e1372a523deff6", size = 1587269, upload-time = "2026-01-15T11:32:28.034Z" },
    { url = "https://files.pythonhosted.org/packages/d7/d1/64c0068b695062278bc334f202f0f27848999bcb8ba5831c3bb391df3d1a/asyncmy-0.2.11-cp39-cp39-win_amd64.whl", hash = "sha256:0a289c3d86c71921822bc287af45c848979a11ab0b22ba41074d67fa8d97164a", size = 1656531, upload-time = "2026-01-15T11:32:29.191Z" },
]

[[package]]
name = "asyncpg"
version = "0.31.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "async-timeout", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/c3/d9/507c80bdac2e95e5a525644af94b03fa7f9a44596a84bd48a6e80f854f92/asyncpg-0.31.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:831712dd3cf117eec68575a9b50da711893fd63ebe277fc155ecae1c6c9f0f61", size = 644865, upload-time = "2025-11-24T23:25:23.527Z" },
    { url = "https://files.pythonhosted.org/packages/ea/03/f93b5e543f65c5f504e91405e8d21bb9e600548be95032951a754781a41d/asyncpg-0.31.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0b17c89312c2f4ccea222a3a6571f7df65d4ba2c0e803339bfc7bed46a96d3be", size = 639297, upload-time = "2025-11-24T23:25:25.192Z" },
    { url = "https://files.pythonhosted.org/packages/e5/1e/de2177e57e03a06e697f6c1ddf2a9a7fcfdc236ce69966f54ffc830fd481/asyncpg-0.31.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3faa62f997db0c9add34504a68ac2c342cfee4d57a0c3062fcf0d86c7f9cb1e8", size = 2816679, upload-time = "2025-11-24T23:25:26.718Z" },
    { url = "https://files.pythonhosted.org/packages/d0/98/1a853f6870ac7ad48383a948c8ff3c85dc278066a4d69fc9af7d3d4b1106/asyncpg-0.31.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ea599d45c361dfbf398cb67da7fd052affa556a401482d3ff1ee99bd68808a1", size = 2867087, upload-time = "2025-11-24T23:25:28.399Z" },
    { url = "https://files.pythonhosted.org/packages/11/29/7e76f2a51f2360a7c90d2cf6d0d9b210c8bb0ae342edebd16173611a55c2/asyncpg-0.31.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:795416369c3d284e1837461909f58418ad22b305f955e625a4b3a2521d80a5f3", size = 2747631, upload-time = "2025-11-24T23:25:30.154Z" },
    { url = "https://files.pythonhosted.org/packages/5d/3f/716e10cb57c4f388248db46555e9226901688fbfabd0afb85b5e1d65d5a7/asyncpg-0.31.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a8d758dac9d2e723e173d286ef5e574f0b350ec00e9186fce84d0fc5f6a8e6b8", size = 2855107, upload-time = "2025-11-24T23:25:31.888Z" },
    { url = "https://files.pythonhosted.org/packages/7e/ec/3ebae9dfb23a1bd3f68acfd4f795983b65b413291c0e2b0d982d6ae6c920/asyncpg-0.31.0-cp310-cp310-win32.whl", hash = "sha256:2d076d42eb583601179efa246c5d7ae44614b4144bc1c7a683ad1222814ed095", size = 521990, upload-time = "2025-11-24T23:25:33.402Z" },
    { url = "https://files.pythonhosted.org/packages/20/b4/9fbb4b0af4e36d96a61d026dd37acab3cf521a70290a09640b215da5ab7c/asyncpg-0.31.0-cp310-cp310-win_amd64.whl", hash = "sha256:9ea33213ac044171f4cac23740bed9a3805abae10e7025314cfbd725ec670540", size = 581629, upload-time = "2025-11-24T23:25:34.846Z" },
    { url = "https://files.pythonhosted.org/packages/08/17/cc02bc49bc350623d050fa139e34ea512cd6e020562f2a7312a7bcae4bc9/asyncpg-0.31.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eee690960e8ab85063ba93af2ce128c0f52fd655fdff9fdb1a28df01329f031d", size = 643159, upload-time = "2025-11-24T23:25:36.443Z" },
    { url = "https://files.pythonhosted.org/packages/a4/62/4ded7d400a7b651adf06f49ea8f73100cca07c6df012119594d1e3447aa6/asyncpg-0.31.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2657204552b75f8288de08ca60faf4a99a65deef3a71d1467454123205a88fab", size = 638157, upload-time = "2025-11-24T23:25:37.89Z" },
    { url = "https://files.pythonhosted.org/packages/d6/5b/4179538a9a72166a0bf60ad783b1ef16efb7960e4d7b9afe9f77a5551680/asyncpg-0.31.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a429e842a3a4b4ea240ea52d7fe3f82d5149853249306f7ff166cb9948faa46c", size = 2918051, upload-time = "2025-11-24T23:25:39.461Z" },
    { url = "https://files.pythonhosted.org/packages/e6/35/c27719ae0536c5b6e61e4701391ffe435ef59539e9360959240d6e47c8c8/asyncpg-0.31.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0807be46c32c963ae40d329b3a686356e417f674c976c07fa49f1b30303f109", size = 2972640, upload-time = "2025-11-24T23:25:41.512Z" },
    { url = "https://files.pythonhosted.org/packages/43/f4/01ebb9207f29e645a64699b9ce0eefeff8e7a33494e1d29bb53736f7766b/asyncpg-0.31.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e5d5098f63beeae93512ee513d4c0c53dc12e9aa2b7a1af5a81cddf93fe4e4da", size = 2851050, upload-time = "2025-11-24T23:25:43.153Z" },
    { url = "https://files.pythonhosted.org/packages/3e/f4/03ff1426acc87be0f4e8d40fa2bff5c3952bef0080062af9efc2212e3be8/asyncpg-0.31.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37fc6c00a814e18eef51833545d1891cac9aa69140598bb076b4cd29b3e010b9", size = 2962574, upload-time = "2025-11-24T23:25:44.942Z" },
    { url = "https://files.pythonhosted.org/packages/c7/39/cc788dfca3d4060f9d93e67be396ceec458dfc429e26139059e58c2c244d/asyncpg-0.31.0-cp311-cp311-win32.whl", hash = "sha256:5a4af56edf82a701aece93190cc4e094d2df7d33f6e915c222fb09efbb5afc24", size = 521076, upload-time = "2025-11-24T23:25:46.486Z" },
    { url = "https://files.pythonhosted.org/packages/28/fc/735af5384c029eb7f1ca60ccb8fa95521dbdaeef788edf4cecfc604c3cab/asyncpg-0.31.0-cp311-cp311-win_amd64.whl", hash = "sha256:480c4befbdf079c14c9ca43c8c5e1fe8b6296c96f1f927158d4f1e750aacc047", size = 584980, upload-time = "2025-11-24T23:25:47.938Z" },
    { url = "https://files.pythonhosted.org/packages/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042, upload-time = "2025-11-24T23:25:49.578Z" },
    { url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504, upload-time = "2025-11-24T23:25:51.501Z" },
    { url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241, upload-time = "2025-11-24T23:25:53.278Z" },
    { url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321, upload-time = "2025-11-24T23:25:54.982Z" },
    { url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685, upload-time = "2025-11-24T23:25:57.43Z" },
    { url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858, upload-time = "2025-11-24T23:25:59.636Z" },
    { url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852, upload-time = "2025-11-24T23:26:01.084Z" },
    { url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" },
    { url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" },
    { url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" },
    { url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" },
    { url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" },
    { url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" },
    { url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" },
    { url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" },
    { url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" },
    { url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" },
    { url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" },
    { url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" },
    { url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" },
    { url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" },
    { url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" },
    { url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" },
    { url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" },
    { url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" },
    { url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" },
    { url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" },
    { url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" },
    { url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" },
    { url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" },
    { url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" },
    { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" },
    { url = "https://files.pythonhosted.org/packages/3d/f9/104361bb10203039569eb56fdd4eddb185d7480cec71d5f93d4c5454142d/asyncpg-0.31.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ebb3cde58321a1f89ce41812be3f2a98dddedc1e76d0838aba1d724f1e4e1a95", size = 645552, upload-time = "2025-11-24T23:26:45.659Z" },
    { url = "https://files.pythonhosted.org/packages/13/38/bbb09ea041a935dc7720e283b6876c3487ace4b180b8d58c07db6cd8c941/asyncpg-0.31.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e6974f36eb9a224d8fb428bcf66bd411aa12cf57c2967463178149e73d4de366", size = 639850, upload-time = "2025-11-24T23:26:47.236Z" },
    { url = "https://files.pythonhosted.org/packages/60/9f/1f9491f6b73096e1d5eb0da2207ddbde3f9b1fc0ea926183f0f5eadfdd05/asyncpg-0.31.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc2b685f400ceae428f79f78b58110470d7b4466929a7f78d455964b17ad1008", size = 2805292, upload-time = "2025-11-24T23:26:48.821Z" },
    { url = "https://files.pythonhosted.org/packages/96/9c/7426e5f4483acc5b2622091b2ae6dd44e1a490847aff434c0bc7c99d1242/asyncpg-0.31.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb223567dea5f47c45d347f2bde5486be8d9f40339f27217adb3fb1c3be51298", size = 2859461, upload-time = "2025-11-24T23:26:50.596Z" },
    { url = "https://files.pythonhosted.org/packages/13/68/cb5d6a43d34e5735928fb745b4087cee2d7e6b8b0cc902ad520520cfd16f/asyncpg-0.31.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:22be6e02381bab3101cd502d9297ac71e2f966c86e20e78caead9934c98a8af6", size = 2734644, upload-time = "2025-11-24T23:26:52.795Z" },
    { url = "https://files.pythonhosted.org/packages/af/3b/e9a33ab89fe9bd4c6fb7a9e0707f0e7656e07dd2af5fff8a5375c13b03b5/asyncpg-0.31.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:37a58919cfef2448a920df00d1b2f821762d17194d0dbf355d6dde8d952c04f9", size = 2844195, upload-time = "2025-11-24T23:26:55.224Z" },
    { url = "https://files.pythonhosted.org/packages/88/7a/04dcdc53e4e255e4f60e68fc1934e5523fa5654ec1b51e2c75d0657004a6/asyncpg-0.31.0-cp39-cp39-win32.whl", hash = "sha256:c1a9c5b71d2371a2290bc93336cd05ba4ec781683cab292adbddc084f89443c6", size = 522403, upload-time = "2025-11-24T23:26:56.806Z" },
    { url = "https://files.pythonhosted.org/packages/c5/03/ea5fd3fa18a26ba1aa663fce57620238d7e413d262dda284b71631ca8d2a/asyncpg-0.31.0-cp39-cp39-win_amd64.whl", hash = "sha256:c1e1ab5bc65373d92dd749d7308c5b26fb2dc0fbe5d3bf68a32b676aa3bcd24a", size = 582103, upload-time = "2025-11-24T23:26:58.716Z" },
]

[[package]]
name = "asyncpg-stubs"
version = "0.31.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "asyncpg" },
    { name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2f/28/fa8e7e310df35c59f050b64533023c5532e234eecb32510f5f70b9e8a4da/asyncpg_stubs-0.31.2.tar.gz", hash = "sha256:0cb46152f70d1f42f1e1b1dd0d9d9fa573fa896abb1796feeea18b91e113438d", size = 20675, upload-time = "2026-02-19T16:21:18.445Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/17/07/bd4dc51369d05878e6344abeabd47d55411dff16dc356a1a50a771b6ab88/asyncpg_stubs-0.31.2-py3-none-any.whl", hash = "sha256:b808913997f279687c36c6cd056d9c47b4e1421611db1f8bcd42d54956fcfbea", size = 27624, upload-time = "2026-02-19T16:21:19.321Z" },
]

[[package]]
name = "attrs"
version = "26.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
]

[[package]]
name = "auto-pytabs"
version = "0.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "ruff" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f9/ff/f5752f43f659ee62dd563af5bb0fe0a63111c3ff4708e9596279385f52bb/auto_pytabs-0.5.0.tar.gz", hash = "sha256:30087831c8be5b2314e663efd06c96b84c096572a060a492540f586362cc4326", size = 15362, upload-time = "2024-08-18T13:02:28.437Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/6e/df/e76dc1261882283f7ae93ebbf75438e85d8bb713a51dbbd5d17fef29e607/auto_pytabs-0.5.0-py3-none-any.whl", hash = "sha256:e59fb6d2f8b41b05d0906a322dd4bb1a86749d429483ec10036587de3657dcc8", size = 13748, upload-time = "2024-08-18T13:02:26.907Z" },
]

[package.optional-dependencies]
sphinx = [
    { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" },
    { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
    { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
]

[[package]]
name = "babel"
version = "2.18.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" },
]

[[package]]
name = "backports-asyncio-runner"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" },
]

[[package]]
name = "blinker"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
]

[[package]]
name = "botocore"
version = "1.41.5"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
dependencies = [
    { name = "jmespath", marker = "python_full_version < '3.10'" },
    { name = "python-dateutil", marker = "python_full_version < '3.10'" },
    { name = "urllib3", version = "1.26.20", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/90/22/7fe08c726a2e3b11a0aef8bf177e83891c9cb2dc1809d35c9ed91a9e60e6/botocore-1.41.5.tar.gz", hash = "sha256:0367622b811597d183bfcaab4a350f0d3ede712031ce792ef183cabdee80d3bf", size = 14668152, upload-time = "2025-11-26T20:27:38.026Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/4e/4e/21cd0b8f365449f1576f93de1ec8718ed18a7a3bc086dfbdeb79437bba7a/botocore-1.41.5-py3-none-any.whl", hash = "sha256:3fef7fcda30c82c27202d232cfdbd6782cb27f20f8e7e21b20606483e66ee73a", size = 14337008, upload-time = "2025-11-26T20:27:35.208Z" },
]

[[package]]
name = "botocore"
version = "1.42.70"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
dependencies = [
    { name = "jmespath", marker = "python_full_version >= '3.10'" },
    { name = "python-dateutil", marker = "python_full_version >= '3.10'" },
    { name = "urllib3", version = "2.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/54/b80e1fcee4f732e0e9314bbb8679be9d5690caa1566c4a4cd14e9724d2dd/botocore-1.42.70.tar.gz", hash = "sha256:9ee17553b7febd1a0c1253b3b62ab5d79607eb6163c8fb943470a8893c31d4fa", size = 14997068, upload-time = "2026-03-17T19:43:10.678Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/fb/51/08f32aea872253173f513ba68122f4300966290677c8e59887b4ffd5d957/botocore-1.42.70-py3-none-any.whl", hash = "sha256:54ed9d25f05f810efd22b0dfda0bb9178df3ad8952b2e4359e05156c9321bd3c", size = 14671393, upload-time = "2026-03-17T19:43:06.777Z" },
]

[[package]]
name = "bracex"
version = "2.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/63/9a/fec38644694abfaaeca2798b58e276a8e61de49e2e37494ace423395febc/bracex-2.6.tar.gz", hash = "sha256:98f1347cd77e22ee8d967a30ad4e310b233f7754dbf31ff3fceb76145ba47dc7", size = 26642, upload-time = "2025-06-22T19:12:31.254Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/9d/2a/9186535ce58db529927f6cf5990a849aa9e052eea3e2cfefe20b9e1802da/bracex-2.6-py3-none-any.whl", hash = "sha256:0b0049264e7340b3ec782b5cb99beb325f36c3782a32e36e876452fd49a09952", size = 11508, upload-time = "2025-06-22T19:12:29.781Z" },
]

[[package]]
name = "bump-my-version"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "click", version = "8.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "httpx" },
    { name = "pydantic" },
    { name = "pydantic-settings", version = "2.11.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "pydantic-settings", version = "2.13.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "questionary" },
    { name = "rich" },
    { name = "rich-click" },
    { name = "tomlkit" },
    { name = "wcmatch" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/61/07b90027091a4192b4a0290dc3da1aeea6b9e7b6b4c0f7fd30dab36070c1/bump_my_version-1.3.0.tar.gz", hash = "sha256:5780137a8d93378af3839798fcba01c7e6cb28dcc5aa5a7ab4d8507787f1995c", size = 1142429, upload-time = "2026-03-22T13:27:34.923Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/36/01/b168791bfbfb0322ef6d38d236f6f17a02e41fb7753e23e4cdb0f19ac969/bump_my_version-1.3.0-py3-none-any.whl", hash = "sha256:3cdaa54588d2443a29303b77e7539417187952c3d22f87bfdd32c0fe6af2f570", size = 64878, upload-time = "2026-03-22T13:27:33.006Z" },
]

[[package]]
name = "cattrs"
version = "25.3.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
dependencies = [
    { name = "attrs", marker = "python_full_version < '3.10'" },
    { name = "exceptiongroup", marker = "python_full_version < '3.10'" },
    { name = "typing-extensions", marker = "python_full_version < '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6e/00/2432bb2d445b39b5407f0a90e01b9a271475eea7caf913d7a86bcb956385/cattrs-25.3.0.tar.gz", hash = "sha256:1ac88d9e5eda10436c4517e390a4142d88638fe682c436c93db7ce4a277b884a", size = 509321, upload-time = "2025-10-07T12:26:08.737Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/d8/2b/a40e1488fdfa02d3f9a653a61a5935ea08b3c2225ee818db6a76c7ba9695/cattrs-25.3.0-py3-none-any.whl", hash = "sha256:9896e84e0a5bf723bc7b4b68f4481785367ce07a8a02e7e9ee6eb2819bc306ff", size = 70738, upload-time = "2025-10-07T12:26:06.603Z" },
]

[[package]]
name = "cattrs"
version = "26.1.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
dependencies = [
    { name = "attrs", marker = "python_full_version >= '3.10'" },
    { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" },
    { name = "typing-extensions", marker = "python_full_version >= '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a0/ec/ba18945e7d6e55a58364d9fb2e46049c1c2998b3d805f19b703f14e81057/cattrs-26.1.0.tar.gz", hash = "sha256:fa239e0f0ec0715ba34852ce813986dfed1e12117e209b816ab87401271cdd40", size = 495672, upload-time = "2026-02-18T22:15:19.406Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/80/56/60547f7801b97c67e97491dc3d9ade9fbccbd0325058fd3dfcb2f5d98d90/cattrs-26.1.0-py3-none-any.whl", hash = "sha256:d1e0804c42639494d469d08d4f26d6b9de9b8ab26b446db7b5f8c2e97f7c3096", size = 73054, upload-time = "2026-02-18T22:15:17.958Z" },
]

[[package]]
name = "certifi"
version = "2026.2.25"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
]

[[package]]
name = "cffi"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "pycparser", version = "2.23", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' and implementation_name != 'PyPy'" },
    { name = "pycparser", version = "3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" },
    { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" },
    { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" },
    { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" },
    { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" },
    { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" },
    { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" },
    { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" },
    { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" },
    { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" },
    { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" },
    { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" },
    { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" },
    { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" },
    { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" },
    { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" },
    { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" },
    { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" },
    { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" },
    { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" },
    { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" },
    { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" },
    { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" },
    { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" },
    { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" },
    { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
    { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
    { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
    { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
    { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
    { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
    { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
    { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
    { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
    { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
    { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
    { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
    { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
    { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
    { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
    { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
    { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
    { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
    { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
    { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
    { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
    { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
    { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
    { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
    { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
    { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
    { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
    { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
    { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
    { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
    { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
    { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
    { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
    { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
    { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
    { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
    { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
    { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
    { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
    { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
    { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
    { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
    { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
    { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
    { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
    { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
    { url = "https://files.pythonhosted.org/packages/c0/cc/08ed5a43f2996a16b462f64a7055c6e962803534924b9b2f1371d8c00b7b/cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf", size = 184288, upload-time = "2025-09-08T23:23:48.404Z" },
    { url = "https://files.pythonhosted.org/packages/3d/de/38d9726324e127f727b4ecc376bc85e505bfe61ef130eaf3f290c6847dd4/cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7", size = 180509, upload-time = "2025-09-08T23:23:49.73Z" },
    { url = "https://files.pythonhosted.org/packages/9b/13/c92e36358fbcc39cf0962e83223c9522154ee8630e1df7c0b3a39a8124e2/cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c", size = 208813, upload-time = "2025-09-08T23:23:51.263Z" },
    { url = "https://files.pythonhosted.org/packages/15/12/a7a79bd0df4c3bff744b2d7e52cc1b68d5e7e427b384252c42366dc1ecbc/cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165", size = 216498, upload-time = "2025-09-08T23:23:52.494Z" },
    { url = "https://files.pythonhosted.org/packages/a3/ad/5c51c1c7600bdd7ed9a24a203ec255dccdd0ebf4527f7b922a0bde2fb6ed/cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534", size = 203243, upload-time = "2025-09-08T23:23:53.836Z" },
    { url = "https://files.pythonhosted.org/packages/32/f2/81b63e288295928739d715d00952c8c6034cb6c6a516b17d37e0c8be5600/cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f", size = 203158, upload-time = "2025-09-08T23:23:55.169Z" },
    { url = "https://files.pythonhosted.org/packages/1f/74/cc4096ce66f5939042ae094e2e96f53426a979864aa1f96a621ad128be27/cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63", size = 216548, upload-time = "2025-09-08T23:23:56.506Z" },
    { url = "https://files.pythonhosted.org/packages/e8/be/f6424d1dc46b1091ffcc8964fa7c0ab0cd36839dd2761b49c90481a6ba1b/cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2", size = 218897, upload-time = "2025-09-08T23:23:57.825Z" },
    { url = "https://files.pythonhosted.org/packages/f7/e0/dda537c2309817edf60109e39265f24f24aa7f050767e22c98c53fe7f48b/cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65", size = 211249, upload-time = "2025-09-08T23:23:59.139Z" },
    { url = "https://files.pythonhosted.org/packages/2b/e7/7c769804eb75e4c4b35e658dba01de1640a351a9653c3d49ca89d16ccc91/cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322", size = 218041, upload-time = "2025-09-08T23:24:00.496Z" },
    { url = "https://files.pythonhosted.org/packages/aa/d9/6218d78f920dcd7507fc16a766b5ef8f3b913cc7aa938e7fc80b9978d089/cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a", size = 172138, upload-time = "2025-09-08T23:24:01.7Z" },
    { url = "https://files.pythonhosted.org/packages/54/8f/a1e836f82d8e32a97e6b29cc8f641779181ac7363734f12df27db803ebda/cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9", size = 182794, upload-time = "2025-09-08T23:24:02.943Z" },
]

[[package]]
name = "cfgv"
version = "3.4.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" },
]

[[package]]
name = "cfgv"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" },
]

[[package]]
name = "charset-normalizer"
version = "3.4.7"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182, upload-time = "2026-04-02T09:25:40.673Z" },
    { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329, upload-time = "2026-04-02T09:25:42.354Z" },
    { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230, upload-time = "2026-04-02T09:25:44.281Z" },
    { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890, upload-time = "2026-04-02T09:25:45.475Z" },
    { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930, upload-time = "2026-04-02T09:25:46.58Z" },
    { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109, upload-time = "2026-04-02T09:25:48.031Z" },
    { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684, upload-time = "2026-04-02T09:25:49.245Z" },
    { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785, upload-time = "2026-04-02T09:25:50.671Z" },
    { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055, upload-time = "2026-04-02T09:25:51.802Z" },
    { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502, upload-time = "2026-04-02T09:25:53.388Z" },
    { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295, upload-time = "2026-04-02T09:25:54.765Z" },
    { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145, upload-time = "2026-04-02T09:25:55.904Z" },
    { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884, upload-time = "2026-04-02T09:25:57.074Z" },
    { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343, upload-time = "2026-04-02T09:25:58.199Z" },
    { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174, upload-time = "2026-04-02T09:25:59.322Z" },
    { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805, upload-time = "2026-04-02T09:26:00.756Z" },
    { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" },
    { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" },
    { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" },
    { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" },
    { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" },
    { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" },
    { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" },
    { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" },
    { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" },
    { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" },
    { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" },
    { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" },
    { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" },
    { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" },
    { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" },
    { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" },
    { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" },
    { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" },
    { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" },
    { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" },
    { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" },
    { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" },
    { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" },
    { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" },
    { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" },
    { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" },
    { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" },
    { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" },
    { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" },
    { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" },
    { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" },
    { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" },
    { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" },
    { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" },
    { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" },
    { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" },
    { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" },
    { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" },
    { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" },
    { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" },
    { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" },
    { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" },
    { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" },
    { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" },
    { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" },
    { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" },
    { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" },
    { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" },
    { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" },
    { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" },
    { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" },
    { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" },
    { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" },
    { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" },
    { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" },
    { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" },
    { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" },
    { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" },
    { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" },
    { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" },
    { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" },
    { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" },
    { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" },
    { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" },
    { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" },
    { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" },
    { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" },
    { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" },
    { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" },
    { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" },
    { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" },
    { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" },
    { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" },
    { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" },
    { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" },
    { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" },
    { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" },
    { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" },
    { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" },
    { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" },
    { url = "https://files.pythonhosted.org/packages/01/1b/ef725f8eb19b5a261b30f78efa9252ef9d017985cb499102f6f49834cd12/charset_normalizer-3.4.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217", size = 299121, upload-time = "2026-04-02T09:28:14.372Z" },
    { url = "https://files.pythonhosted.org/packages/a3/22/2f12878fbc680fbbb52386cd39a379801f62eaca74fc8b323381325f0f04/charset_normalizer-3.4.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5", size = 200612, upload-time = "2026-04-02T09:28:16.162Z" },
    { url = "https://files.pythonhosted.org/packages/bc/b6/10c84e789126ca97d4a7228863a30481e786980a8b8cfcbf4f30658ca63c/charset_normalizer-3.4.7-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9", size = 221041, upload-time = "2026-04-02T09:28:17.554Z" },
    { url = "https://files.pythonhosted.org/packages/21/7b/c414866a138400b2e81973d006da7f694cfeaf895ef07d2cba9a8743841a/charset_normalizer-3.4.7-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a", size = 216323, upload-time = "2026-04-02T09:28:18.863Z" },
    { url = "https://files.pythonhosted.org/packages/2e/92/bdcf94997e06b223d826df3abed45a5ad6e17f609b7df9d25cd23b5bde30/charset_normalizer-3.4.7-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc", size = 208419, upload-time = "2026-04-02T09:28:20.332Z" },
    { url = "https://files.pythonhosted.org/packages/1a/64/3f9142293c88b1b10e199649ed1330f070c2a68e305335a5819fa7f25fa7/charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00", size = 195016, upload-time = "2026-04-02T09:28:21.657Z" },
    { url = "https://files.pythonhosted.org/packages/c1/d1/d8a6b7dd5c5636b76ce0d080bc57d8e56c7bbd6bc2ac941529a35e41d84a/charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776", size = 206115, upload-time = "2026-04-02T09:28:23.259Z" },
    { url = "https://files.pythonhosted.org/packages/dd/8c/60ebe912379627d023eb96995b40bc50308729f210f43d66109ca0a7bbd2/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319", size = 204022, upload-time = "2026-04-02T09:28:24.779Z" },
    { url = "https://files.pythonhosted.org/packages/d5/2a/41816ceda78a551cbfdfbeab6f3891152b0e3f758ce6580c2c18c829f774/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24", size = 195914, upload-time = "2026-04-02T09:28:26.181Z" },
    { url = "https://files.pythonhosted.org/packages/8f/9b/7c7f4b7f11525fcbdfba752455314ac60646bae91cdd671d531c1f7a97c6/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42", size = 222159, upload-time = "2026-04-02T09:28:27.504Z" },
    { url = "https://files.pythonhosted.org/packages/9f/57/301682e7469bdbfa2ce219a804f0668b2266ab8520570d85d3b3ef483ea3/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4", size = 206154, upload-time = "2026-04-02T09:28:28.848Z" },
    { url = "https://files.pythonhosted.org/packages/20/ec/90339ff5cdc598b265748c1f231c7d7fbd9123a92cee10f757e0b1448de4/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67", size = 217423, upload-time = "2026-04-02T09:28:30.248Z" },
    { url = "https://files.pythonhosted.org/packages/2e/e7/a7a6147f8e3375676309cf584b25c72a3bab784ea4085b0011fa07b23aeb/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274", size = 210604, upload-time = "2026-04-02T09:28:31.736Z" },
    { url = "https://files.pythonhosted.org/packages/1a/62/d9340c7a79c393e57807d7fb6c57e82060687891f81b74d3201958b919c1/charset_normalizer-3.4.7-cp39-cp39-win32.whl", hash = "sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366", size = 144631, upload-time = "2026-04-02T09:28:33.158Z" },
    { url = "https://files.pythonhosted.org/packages/21/e7/92901117e2ddc8facfe8235a3ecd4eb482185b2ad5d5b6606b37c1afea06/charset_normalizer-3.4.7-cp39-cp39-win_amd64.whl", hash = "sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444", size = 154710, upload-time = "2026-04-02T09:28:34.557Z" },
    { url = "https://files.pythonhosted.org/packages/cc/4f/e1fb138201ad9a32499dd9a98aa4a5a5441fbf7f56b52b619a54b7ee8777/charset_normalizer-3.4.7-cp39-cp39-win_arm64.whl", hash = "sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c", size = 143716, upload-time = "2026-04-02T09:28:35.908Z" },
    { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
]

[[package]]
name = "click"
version = "8.1.8"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
dependencies = [
    { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" },
]

[[package]]
name = "click"
version = "8.3.2"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
dependencies = [
    { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" },
]

[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]

[[package]]
name = "coverage"
version = "7.10.7"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
sdist = { url = "https://files.pythonhosted.org/packages/51/26/d22c300112504f5f9a9fd2297ce33c35f3d353e4aeb987c8419453b2a7c2/coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239", size = 827704, upload-time = "2025-09-21T20:03:56.815Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/e5/6c/3a3f7a46888e69d18abe3ccc6fe4cb16cccb1e6a2f99698931dafca489e6/coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a", size = 217987, upload-time = "2025-09-21T20:00:57.218Z" },
    { url = "https://files.pythonhosted.org/packages/03/94/952d30f180b1a916c11a56f5c22d3535e943aa22430e9e3322447e520e1c/coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5", size = 218388, upload-time = "2025-09-21T20:01:00.081Z" },
    { url = "https://files.pythonhosted.org/packages/50/2b/9e0cf8ded1e114bcd8b2fd42792b57f1c4e9e4ea1824cde2af93a67305be/coverage-7.10.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:240af60539987ced2c399809bd34f7c78e8abe0736af91c3d7d0e795df633d17", size = 245148, upload-time = "2025-09-21T20:01:01.768Z" },
    { url = "https://files.pythonhosted.org/packages/19/20/d0384ac06a6f908783d9b6aa6135e41b093971499ec488e47279f5b846e6/coverage-7.10.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8421e088bc051361b01c4b3a50fd39a4b9133079a2229978d9d30511fd05231b", size = 246958, upload-time = "2025-09-21T20:01:03.355Z" },
    { url = "https://files.pythonhosted.org/packages/60/83/5c283cff3d41285f8eab897651585db908a909c572bdc014bcfaf8a8b6ae/coverage-7.10.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be8ed3039ae7f7ac5ce058c308484787c86e8437e72b30bf5e88b8ea10f3c87", size = 248819, upload-time = "2025-09-21T20:01:04.968Z" },
    { url = "https://files.pythonhosted.org/packages/60/22/02eb98fdc5ff79f423e990d877693e5310ae1eab6cb20ae0b0b9ac45b23b/coverage-7.10.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e28299d9f2e889e6d51b1f043f58d5f997c373cc12e6403b90df95b8b047c13e", size = 245754, upload-time = "2025-09-21T20:01:06.321Z" },
    { url = "https://files.pythonhosted.org/packages/b4/bc/25c83bcf3ad141b32cd7dc45485ef3c01a776ca3aa8ef0a93e77e8b5bc43/coverage-7.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4e16bd7761c5e454f4efd36f345286d6f7c5fa111623c355691e2755cae3b9e", size = 246860, upload-time = "2025-09-21T20:01:07.605Z" },
    { url = "https://files.pythonhosted.org/packages/3c/b7/95574702888b58c0928a6e982038c596f9c34d52c5e5107f1eef729399b5/coverage-7.10.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b1c81d0e5e160651879755c9c675b974276f135558cf4ba79fee7b8413a515df", size = 244877, upload-time = "2025-09-21T20:01:08.829Z" },
    { url = "https://files.pythonhosted.org/packages/47/b6/40095c185f235e085df0e0b158f6bd68cc6e1d80ba6c7721dc81d97ec318/coverage-7.10.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:606cc265adc9aaedcc84f1f064f0e8736bc45814f15a357e30fca7ecc01504e0", size = 245108, upload-time = "2025-09-21T20:01:10.527Z" },
    { url = "https://files.pythonhosted.org/packages/c8/50/4aea0556da7a4b93ec9168420d170b55e2eb50ae21b25062513d020c6861/coverage-7.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:10b24412692df990dbc34f8fb1b6b13d236ace9dfdd68df5b28c2e39cafbba13", size = 245752, upload-time = "2025-09-21T20:01:11.857Z" },
    { url = "https://files.pythonhosted.org/packages/6a/28/ea1a84a60828177ae3b100cb6723838523369a44ec5742313ed7db3da160/coverage-7.10.7-cp310-cp310-win32.whl", hash = "sha256:b51dcd060f18c19290d9b8a9dd1e0181538df2ce0717f562fff6cf74d9fc0b5b", size = 220497, upload-time = "2025-09-21T20:01:13.459Z" },
    { url = "https://files.pythonhosted.org/packages/fc/1a/a81d46bbeb3c3fd97b9602ebaa411e076219a150489bcc2c025f151bd52d/coverage-7.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:3a622ac801b17198020f09af3eaf45666b344a0d69fc2a6ffe2ea83aeef1d807", size = 221392, upload-time = "2025-09-21T20:01:14.722Z" },
    { url = "https://files.pythonhosted.org/packages/d2/5d/c1a17867b0456f2e9ce2d8d4708a4c3a089947d0bec9c66cdf60c9e7739f/coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59", size = 218102, upload-time = "2025-09-21T20:01:16.089Z" },
    { url = "https://files.pythonhosted.org/packages/54/f0/514dcf4b4e3698b9a9077f084429681bf3aad2b4a72578f89d7f643eb506/coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a", size = 218505, upload-time = "2025-09-21T20:01:17.788Z" },
    { url = "https://files.pythonhosted.org/packages/20/f6/9626b81d17e2a4b25c63ac1b425ff307ecdeef03d67c9a147673ae40dc36/coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699", size = 248898, upload-time = "2025-09-21T20:01:19.488Z" },
    { url = "https://files.pythonhosted.org/packages/b0/ef/bd8e719c2f7417ba03239052e099b76ea1130ac0cbb183ee1fcaa58aaff3/coverage-7.10.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f5e3f9e455bb17831876048355dca0f758b6df22f49258cb5a91da23ef437d", size = 250831, upload-time = "2025-09-21T20:01:20.817Z" },
    { url = "https://files.pythonhosted.org/packages/a5/b6/bf054de41ec948b151ae2b79a55c107f5760979538f5fb80c195f2517718/coverage-7.10.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e", size = 252937, upload-time = "2025-09-21T20:01:22.171Z" },
    { url = "https://files.pythonhosted.org/packages/0f/e5/3860756aa6f9318227443c6ce4ed7bf9e70bb7f1447a0353f45ac5c7974b/coverage-7.10.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23", size = 249021, upload-time = "2025-09-21T20:01:23.907Z" },
    { url = "https://files.pythonhosted.org/packages/26/0f/bd08bd042854f7fd07b45808927ebcce99a7ed0f2f412d11629883517ac2/coverage-7.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab", size = 250626, upload-time = "2025-09-21T20:01:25.721Z" },
    { url = "https://files.pythonhosted.org/packages/8e/a7/4777b14de4abcc2e80c6b1d430f5d51eb18ed1d75fca56cbce5f2db9b36e/coverage-7.10.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82", size = 248682, upload-time = "2025-09-21T20:01:27.105Z" },
    { url = "https://files.pythonhosted.org/packages/34/72/17d082b00b53cd45679bad682fac058b87f011fd8b9fe31d77f5f8d3a4e4/coverage-7.10.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2", size = 248402, upload-time = "2025-09-21T20:01:28.629Z" },
    { url = "https://files.pythonhosted.org/packages/81/7a/92367572eb5bdd6a84bfa278cc7e97db192f9f45b28c94a9ca1a921c3577/coverage-7.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61", size = 249320, upload-time = "2025-09-21T20:01:30.004Z" },
    { url = "https://files.pythonhosted.org/packages/2f/88/a23cc185f6a805dfc4fdf14a94016835eeb85e22ac3a0e66d5e89acd6462/coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14", size = 220536, upload-time = "2025-09-21T20:01:32.184Z" },
    { url = "https://files.pythonhosted.org/packages/fe/ef/0b510a399dfca17cec7bc2f05ad8bd78cf55f15c8bc9a73ab20c5c913c2e/coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2", size = 221425, upload-time = "2025-09-21T20:01:33.557Z" },
    { url = "https://files.pythonhosted.org/packages/51/7f/023657f301a276e4ba1850f82749bc136f5a7e8768060c2e5d9744a22951/coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a", size = 220103, upload-time = "2025-09-21T20:01:34.929Z" },
    { url = "https://files.pythonhosted.org/packages/13/e4/eb12450f71b542a53972d19117ea5a5cea1cab3ac9e31b0b5d498df1bd5a/coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417", size = 218290, upload-time = "2025-09-21T20:01:36.455Z" },
    { url = "https://files.pythonhosted.org/packages/37/66/593f9be12fc19fb36711f19a5371af79a718537204d16ea1d36f16bd78d2/coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973", size = 218515, upload-time = "2025-09-21T20:01:37.982Z" },
    { url = "https://files.pythonhosted.org/packages/66/80/4c49f7ae09cafdacc73fbc30949ffe77359635c168f4e9ff33c9ebb07838/coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c", size = 250020, upload-time = "2025-09-21T20:01:39.617Z" },
    { url = "https://files.pythonhosted.org/packages/a6/90/a64aaacab3b37a17aaedd83e8000142561a29eb262cede42d94a67f7556b/coverage-7.10.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7", size = 252769, upload-time = "2025-09-21T20:01:41.341Z" },
    { url = "https://files.pythonhosted.org/packages/98/2e/2dda59afd6103b342e096f246ebc5f87a3363b5412609946c120f4e7750d/coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6", size = 253901, upload-time = "2025-09-21T20:01:43.042Z" },
    { url = "https://files.pythonhosted.org/packages/53/dc/8d8119c9051d50f3119bb4a75f29f1e4a6ab9415cd1fa8bf22fcc3fb3b5f/coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59", size = 250413, upload-time = "2025-09-21T20:01:44.469Z" },
    { url = "https://files.pythonhosted.org/packages/98/b3/edaff9c5d79ee4d4b6d3fe046f2b1d799850425695b789d491a64225d493/coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b", size = 251820, upload-time = "2025-09-21T20:01:45.915Z" },
    { url = "https://files.pythonhosted.org/packages/11/25/9a0728564bb05863f7e513e5a594fe5ffef091b325437f5430e8cfb0d530/coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a", size = 249941, upload-time = "2025-09-21T20:01:47.296Z" },
    { url = "https://files.pythonhosted.org/packages/e0/fd/ca2650443bfbef5b0e74373aac4df67b08180d2f184b482c41499668e258/coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb", size = 249519, upload-time = "2025-09-21T20:01:48.73Z" },
    { url = "https://files.pythonhosted.org/packages/24/79/f692f125fb4299b6f963b0745124998ebb8e73ecdfce4ceceb06a8c6bec5/coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1", size = 251375, upload-time = "2025-09-21T20:01:50.529Z" },
    { url = "https://files.pythonhosted.org/packages/5e/75/61b9bbd6c7d24d896bfeec57acba78e0f8deac68e6baf2d4804f7aae1f88/coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256", size = 220699, upload-time = "2025-09-21T20:01:51.941Z" },
    { url = "https://files.pythonhosted.org/packages/ca/f3/3bf7905288b45b075918d372498f1cf845b5b579b723c8fd17168018d5f5/coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba", size = 221512, upload-time = "2025-09-21T20:01:53.481Z" },
    { url = "https://files.pythonhosted.org/packages/5c/44/3e32dbe933979d05cf2dac5e697c8599cfe038aaf51223ab901e208d5a62/coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf", size = 220147, upload-time = "2025-09-21T20:01:55.2Z" },
    { url = "https://files.pythonhosted.org/packages/9a/94/b765c1abcb613d103b64fcf10395f54d69b0ef8be6a0dd9c524384892cc7/coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d", size = 218320, upload-time = "2025-09-21T20:01:56.629Z" },
    { url = "https://files.pythonhosted.org/packages/72/4f/732fff31c119bb73b35236dd333030f32c4bfe909f445b423e6c7594f9a2/coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b", size = 218575, upload-time = "2025-09-21T20:01:58.203Z" },
    { url = "https://files.pythonhosted.org/packages/87/02/ae7e0af4b674be47566707777db1aa375474f02a1d64b9323e5813a6cdd5/coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e", size = 249568, upload-time = "2025-09-21T20:01:59.748Z" },
    { url = "https://files.pythonhosted.org/packages/a2/77/8c6d22bf61921a59bce5471c2f1f7ac30cd4ac50aadde72b8c48d5727902/coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b", size = 252174, upload-time = "2025-09-21T20:02:01.192Z" },
    { url = "https://files.pythonhosted.org/packages/b1/20/b6ea4f69bbb52dac0aebd62157ba6a9dddbfe664f5af8122dac296c3ee15/coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49", size = 253447, upload-time = "2025-09-21T20:02:02.701Z" },
    { url = "https://files.pythonhosted.org/packages/f9/28/4831523ba483a7f90f7b259d2018fef02cb4d5b90bc7c1505d6e5a84883c/coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911", size = 249779, upload-time = "2025-09-21T20:02:04.185Z" },
    { url = "https://files.pythonhosted.org/packages/a7/9f/4331142bc98c10ca6436d2d620c3e165f31e6c58d43479985afce6f3191c/coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0", size = 251604, upload-time = "2025-09-21T20:02:06.034Z" },
    { url = "https://files.pythonhosted.org/packages/ce/60/bda83b96602036b77ecf34e6393a3836365481b69f7ed7079ab85048202b/coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f", size = 249497, upload-time = "2025-09-21T20:02:07.619Z" },
    { url = "https://files.pythonhosted.org/packages/5f/af/152633ff35b2af63977edd835d8e6430f0caef27d171edf2fc76c270ef31/coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c", size = 249350, upload-time = "2025-09-21T20:02:10.34Z" },
    { url = "https://files.pythonhosted.org/packages/9d/71/d92105d122bd21cebba877228990e1646d862e34a98bb3374d3fece5a794/coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f", size = 251111, upload-time = "2025-09-21T20:02:12.122Z" },
    { url = "https://files.pythonhosted.org/packages/a2/9e/9fdb08f4bf476c912f0c3ca292e019aab6712c93c9344a1653986c3fd305/coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698", size = 220746, upload-time = "2025-09-21T20:02:13.919Z" },
    { url = "https://files.pythonhosted.org/packages/b1/b1/a75fd25df44eab52d1931e89980d1ada46824c7a3210be0d3c88a44aaa99/coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843", size = 221541, upload-time = "2025-09-21T20:02:15.57Z" },
    { url = "https://files.pythonhosted.org/packages/14/3a/d720d7c989562a6e9a14b2c9f5f2876bdb38e9367126d118495b89c99c37/coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546", size = 220170, upload-time = "2025-09-21T20:02:17.395Z" },
    { url = "https://files.pythonhosted.org/packages/bb/22/e04514bf2a735d8b0add31d2b4ab636fc02370730787c576bb995390d2d5/coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c", size = 219029, upload-time = "2025-09-21T20:02:18.936Z" },
    { url = "https://files.pythonhosted.org/packages/11/0b/91128e099035ece15da3445d9015e4b4153a6059403452d324cbb0a575fa/coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15", size = 219259, upload-time = "2025-09-21T20:02:20.44Z" },
    { url = "https://files.pythonhosted.org/packages/8b/51/66420081e72801536a091a0c8f8c1f88a5c4bf7b9b1bdc6222c7afe6dc9b/coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4", size = 260592, upload-time = "2025-09-21T20:02:22.313Z" },
    { url = "https://files.pythonhosted.org/packages/5d/22/9b8d458c2881b22df3db5bb3e7369e63d527d986decb6c11a591ba2364f7/coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0", size = 262768, upload-time = "2025-09-21T20:02:24.287Z" },
    { url = "https://files.pythonhosted.org/packages/f7/08/16bee2c433e60913c610ea200b276e8eeef084b0d200bdcff69920bd5828/coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0", size = 264995, upload-time = "2025-09-21T20:02:26.133Z" },
    { url = "https://files.pythonhosted.org/packages/20/9d/e53eb9771d154859b084b90201e5221bca7674ba449a17c101a5031d4054/coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65", size = 259546, upload-time = "2025-09-21T20:02:27.716Z" },
    { url = "https://files.pythonhosted.org/packages/ad/b0/69bc7050f8d4e56a89fb550a1577d5d0d1db2278106f6f626464067b3817/coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541", size = 262544, upload-time = "2025-09-21T20:02:29.216Z" },
    { url = "https://files.pythonhosted.org/packages/ef/4b/2514b060dbd1bc0aaf23b852c14bb5818f244c664cb16517feff6bb3a5ab/coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6", size = 260308, upload-time = "2025-09-21T20:02:31.226Z" },
    { url = "https://files.pythonhosted.org/packages/54/78/7ba2175007c246d75e496f64c06e94122bdb914790a1285d627a918bd271/coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999", size = 258920, upload-time = "2025-09-21T20:02:32.823Z" },
    { url = "https://files.pythonhosted.org/packages/c0/b3/fac9f7abbc841409b9a410309d73bfa6cfb2e51c3fada738cb607ce174f8/coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2", size = 261434, upload-time = "2025-09-21T20:02:34.86Z" },
    { url = "https://files.pythonhosted.org/packages/ee/51/a03bec00d37faaa891b3ff7387192cef20f01604e5283a5fabc95346befa/coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a", size = 221403, upload-time = "2025-09-21T20:02:37.034Z" },
    { url = "https://files.pythonhosted.org/packages/53/22/3cf25d614e64bf6d8e59c7c669b20d6d940bb337bdee5900b9ca41c820bb/coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb", size = 222469, upload-time = "2025-09-21T20:02:39.011Z" },
    { url = "https://files.pythonhosted.org/packages/49/a1/00164f6d30d8a01c3c9c48418a7a5be394de5349b421b9ee019f380df2a0/coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb", size = 220731, upload-time = "2025-09-21T20:02:40.939Z" },
    { url = "https://files.pythonhosted.org/packages/23/9c/5844ab4ca6a4dd97a1850e030a15ec7d292b5c5cb93082979225126e35dd/coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520", size = 218302, upload-time = "2025-09-21T20:02:42.527Z" },
    { url = "https://files.pythonhosted.org/packages/f0/89/673f6514b0961d1f0e20ddc242e9342f6da21eaba3489901b565c0689f34/coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32", size = 218578, upload-time = "2025-09-21T20:02:44.468Z" },
    { url = "https://files.pythonhosted.org/packages/05/e8/261cae479e85232828fb17ad536765c88dd818c8470aca690b0ac6feeaa3/coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f", size = 249629, upload-time = "2025-09-21T20:02:46.503Z" },
    { url = "https://files.pythonhosted.org/packages/82/62/14ed6546d0207e6eda876434e3e8475a3e9adbe32110ce896c9e0c06bb9a/coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a", size = 252162, upload-time = "2025-09-21T20:02:48.689Z" },
    { url = "https://files.pythonhosted.org/packages/ff/49/07f00db9ac6478e4358165a08fb41b469a1b053212e8a00cb02f0d27a05f/coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360", size = 253517, upload-time = "2025-09-21T20:02:50.31Z" },
    { url = "https://files.pythonhosted.org/packages/a2/59/c5201c62dbf165dfbc91460f6dbbaa85a8b82cfa6131ac45d6c1bfb52deb/coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69", size = 249632, upload-time = "2025-09-21T20:02:51.971Z" },
    { url = "https://files.pythonhosted.org/packages/07/ae/5920097195291a51fb00b3a70b9bbd2edbfe3c84876a1762bd1ef1565ebc/coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14", size = 251520, upload-time = "2025-09-21T20:02:53.858Z" },
    { url = "https://files.pythonhosted.org/packages/b9/3c/a815dde77a2981f5743a60b63df31cb322c944843e57dbd579326625a413/coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe", size = 249455, upload-time = "2025-09-21T20:02:55.807Z" },
    { url = "https://files.pythonhosted.org/packages/aa/99/f5cdd8421ea656abefb6c0ce92556709db2265c41e8f9fc6c8ae0f7824c9/coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e", size = 249287, upload-time = "2025-09-21T20:02:57.784Z" },
    { url = "https://files.pythonhosted.org/packages/c3/7a/e9a2da6a1fc5d007dd51fca083a663ab930a8c4d149c087732a5dbaa0029/coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd", size = 250946, upload-time = "2025-09-21T20:02:59.431Z" },
    { url = "https://files.pythonhosted.org/packages/ef/5b/0b5799aa30380a949005a353715095d6d1da81927d6dbed5def2200a4e25/coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2", size = 221009, upload-time = "2025-09-21T20:03:01.324Z" },
    { url = "https://files.pythonhosted.org/packages/da/b0/e802fbb6eb746de006490abc9bb554b708918b6774b722bb3a0e6aa1b7de/coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681", size = 221804, upload-time = "2025-09-21T20:03:03.4Z" },
    { url = "https://files.pythonhosted.org/packages/9e/e8/71d0c8e374e31f39e3389bb0bd19e527d46f00ea8571ec7ec8fd261d8b44/coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880", size = 220384, upload-time = "2025-09-21T20:03:05.111Z" },
    { url = "https://files.pythonhosted.org/packages/62/09/9a5608d319fa3eba7a2019addeacb8c746fb50872b57a724c9f79f146969/coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63", size = 219047, upload-time = "2025-09-21T20:03:06.795Z" },
    { url = "https://files.pythonhosted.org/packages/f5/6f/f58d46f33db9f2e3647b2d0764704548c184e6f5e014bef528b7f979ef84/coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2", size = 219266, upload-time = "2025-09-21T20:03:08.495Z" },
    { url = "https://files.pythonhosted.org/packages/74/5c/183ffc817ba68e0b443b8c934c8795553eb0c14573813415bd59941ee165/coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d", size = 260767, upload-time = "2025-09-21T20:03:10.172Z" },
    { url = "https://files.pythonhosted.org/packages/0f/48/71a8abe9c1ad7e97548835e3cc1adbf361e743e9d60310c5f75c9e7bf847/coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0", size = 262931, upload-time = "2025-09-21T20:03:11.861Z" },
    { url = "https://files.pythonhosted.org/packages/84/fd/193a8fb132acfc0a901f72020e54be5e48021e1575bb327d8ee1097a28fd/coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699", size = 265186, upload-time = "2025-09-21T20:03:13.539Z" },
    { url = "https://files.pythonhosted.org/packages/b1/8f/74ecc30607dd95ad50e3034221113ccb1c6d4e8085cc761134782995daae/coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9", size = 259470, upload-time = "2025-09-21T20:03:15.584Z" },
    { url = "https://files.pythonhosted.org/packages/0f/55/79ff53a769f20d71b07023ea115c9167c0bb56f281320520cf64c5298a96/coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f", size = 262626, upload-time = "2025-09-21T20:03:17.673Z" },
    { url = "https://files.pythonhosted.org/packages/88/e2/dac66c140009b61ac3fc13af673a574b00c16efdf04f9b5c740703e953c0/coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1", size = 260386, upload-time = "2025-09-21T20:03:19.36Z" },
    { url = "https://files.pythonhosted.org/packages/a2/f1/f48f645e3f33bb9ca8a496bc4a9671b52f2f353146233ebd7c1df6160440/coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0", size = 258852, upload-time = "2025-09-21T20:03:21.007Z" },
    { url = "https://files.pythonhosted.org/packages/bb/3b/8442618972c51a7affeead957995cfa8323c0c9bcf8fa5a027421f720ff4/coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399", size = 261534, upload-time = "2025-09-21T20:03:23.12Z" },
    { url = "https://files.pythonhosted.org/packages/b2/dc/101f3fa3a45146db0cb03f5b4376e24c0aac818309da23e2de0c75295a91/coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235", size = 221784, upload-time = "2025-09-21T20:03:24.769Z" },
    { url = "https://files.pythonhosted.org/packages/4c/a1/74c51803fc70a8a40d7346660379e144be772bab4ac7bb6e6b905152345c/coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d", size = 222905, upload-time = "2025-09-21T20:03:26.93Z" },
    { url = "https://files.pythonhosted.org/packages/12/65/f116a6d2127df30bcafbceef0302d8a64ba87488bf6f73a6d8eebf060873/coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a", size = 220922, upload-time = "2025-09-21T20:03:28.672Z" },
    { url = "https://files.pythonhosted.org/packages/a3/ad/d1c25053764b4c42eb294aae92ab617d2e4f803397f9c7c8295caa77a260/coverage-7.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fff7b9c3f19957020cac546c70025331113d2e61537f6e2441bc7657913de7d3", size = 217978, upload-time = "2025-09-21T20:03:30.362Z" },
    { url = "https://files.pythonhosted.org/packages/52/2f/b9f9daa39b80ece0b9548bbb723381e29bc664822d9a12c2135f8922c22b/coverage-7.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bc91b314cef27742da486d6839b677b3f2793dfe52b51bbbb7cf736d5c29281c", size = 218370, upload-time = "2025-09-21T20:03:32.147Z" },
    { url = "https://files.pythonhosted.org/packages/dd/6e/30d006c3b469e58449650642383dddf1c8fb63d44fdf92994bfd46570695/coverage-7.10.7-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:567f5c155eda8df1d3d439d40a45a6a5f029b429b06648235f1e7e51b522b396", size = 244802, upload-time = "2025-09-21T20:03:33.919Z" },
    { url = "https://files.pythonhosted.org/packages/b0/49/8a070782ce7e6b94ff6a0b6d7c65ba6bc3091d92a92cef4cd4eb0767965c/coverage-7.10.7-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2af88deffcc8a4d5974cf2d502251bc3b2db8461f0b66d80a449c33757aa9f40", size = 246625, upload-time = "2025-09-21T20:03:36.09Z" },
    { url = "https://files.pythonhosted.org/packages/6a/92/1c1c5a9e8677ce56d42b97bdaca337b2d4d9ebe703d8c174ede52dbabd5f/coverage-7.10.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7315339eae3b24c2d2fa1ed7d7a38654cba34a13ef19fbcb9425da46d3dc594", size = 248399, upload-time = "2025-09-21T20:03:38.342Z" },
    { url = "https://files.pythonhosted.org/packages/c0/54/b140edee7257e815de7426d5d9846b58505dffc29795fff2dfb7f8a1c5a0/coverage-7.10.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:912e6ebc7a6e4adfdbb1aec371ad04c68854cd3bf3608b3514e7ff9062931d8a", size = 245142, upload-time = "2025-09-21T20:03:40.591Z" },
    { url = "https://files.pythonhosted.org/packages/e4/9e/6d6b8295940b118e8b7083b29226c71f6154f7ff41e9ca431f03de2eac0d/coverage-7.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f49a05acd3dfe1ce9715b657e28d138578bc40126760efb962322c56e9ca344b", size = 246284, upload-time = "2025-09-21T20:03:42.355Z" },
    { url = "https://files.pythonhosted.org/packages/db/e5/5e957ca747d43dbe4d9714358375c7546cb3cb533007b6813fc20fce37ad/coverage-7.10.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cce2109b6219f22ece99db7644b9622f54a4e915dad65660ec435e89a3ea7cc3", size = 244353, upload-time = "2025-09-21T20:03:44.218Z" },
    { url = "https://files.pythonhosted.org/packages/9a/45/540fc5cc92536a1b783b7ef99450bd55a4b3af234aae35a18a339973ce30/coverage-7.10.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:f3c887f96407cea3916294046fc7dab611c2552beadbed4ea901cbc6a40cc7a0", size = 244430, upload-time = "2025-09-21T20:03:46.065Z" },
    { url = "https://files.pythonhosted.org/packages/75/0b/8287b2e5b38c8fe15d7e3398849bb58d382aedc0864ea0fa1820e8630491/coverage-7.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:635adb9a4507c9fd2ed65f39693fa31c9a3ee3a8e6dc64df033e8fdf52a7003f", size = 245311, upload-time = "2025-09-21T20:03:48.19Z" },
    { url = "https://files.pythonhosted.org/packages/0c/1d/29724999984740f0c86d03e6420b942439bf5bd7f54d4382cae386a9d1e9/coverage-7.10.7-cp39-cp39-win32.whl", hash = "sha256:5a02d5a850e2979b0a014c412573953995174743a3f7fa4ea5a6e9a3c5617431", size = 220500, upload-time = "2025-09-21T20:03:50.024Z" },
    { url = "https://files.pythonhosted.org/packages/43/11/4b1e6b129943f905ca54c339f343877b55b365ae2558806c1be4f7476ed5/coverage-7.10.7-cp39-cp39-win_amd64.whl", hash = "sha256:c134869d5ffe34547d14e174c866fd8fe2254918cc0a95e99052903bc1543e07", size = 221408, upload-time = "2025-09-21T20:03:51.803Z" },
    { url = "https://files.pythonhosted.org/packages/ec/16/114df1c291c22cac3b0c127a73e0af5c12ed7bbb6558d310429a0ae24023/coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260", size = 209952, upload-time = "2025-09-21T20:03:53.918Z" },
]

[package.optional-dependencies]
toml = [
    { name = "tomli", marker = "python_full_version < '3.10'" },
]

[[package]]
name = "coverage"
version = "7.13.5"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/69/33/e8c48488c29a73fd089f9d71f9653c1be7478f2ad6b5bc870db11a55d23d/coverage-7.13.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0723d2c96324561b9aa76fb982406e11d93cdb388a7a7da2b16e04719cf7ca5", size = 219255, upload-time = "2026-03-17T10:29:51.081Z" },
    { url = "https://files.pythonhosted.org/packages/da/bd/b0ebe9f677d7f4b74a3e115eec7ddd4bcf892074963a00d91e8b164a6386/coverage-7.13.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52f444e86475992506b32d4e5ca55c24fc88d73bcbda0e9745095b28ef4dc0cf", size = 219772, upload-time = "2026-03-17T10:29:52.867Z" },
    { url = "https://files.pythonhosted.org/packages/48/cc/5cb9502f4e01972f54eedd48218bb203fe81e294be606a2bc93970208013/coverage-7.13.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:704de6328e3d612a8f6c07000a878ff38181ec3263d5a11da1db294fa6a9bdf8", size = 246532, upload-time = "2026-03-17T10:29:54.688Z" },
    { url = "https://files.pythonhosted.org/packages/7d/d8/3217636d86c7e7b12e126e4f30ef1581047da73140614523af7495ed5f2d/coverage-7.13.5-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a1a6d79a14e1ec1832cabc833898636ad5f3754a678ef8bb4908515208bf84f4", size = 248333, upload-time = "2026-03-17T10:29:56.221Z" },
    { url = "https://files.pythonhosted.org/packages/2b/30/2002ac6729ba2d4357438e2ed3c447ad8562866c8c63fc16f6dfc33afe56/coverage-7.13.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79060214983769c7ba3f0cee10b54c97609dca4d478fa1aa32b914480fd5738d", size = 250211, upload-time = "2026-03-17T10:29:57.938Z" },
    { url = "https://files.pythonhosted.org/packages/6c/85/552496626d6b9359eb0e2f86f920037c9cbfba09b24d914c6e1528155f7d/coverage-7.13.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:356e76b46783a98c2a2fe81ec79df4883a1e62895ea952968fb253c114e7f930", size = 252125, upload-time = "2026-03-17T10:29:59.388Z" },
    { url = "https://files.pythonhosted.org/packages/44/21/40256eabdcbccdb6acf6b381b3016a154399a75fe39d406f790ae84d1f3c/coverage-7.13.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cef0cdec915d11254a7f549c1170afecce708d30610c6abdded1f74e581666d", size = 247219, upload-time = "2026-03-17T10:30:01.199Z" },
    { url = "https://files.pythonhosted.org/packages/b1/e8/96e2a6c3f21a0ea77d7830b254a1542d0328acc8d7bdf6a284ba7e529f77/coverage-7.13.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dc022073d063b25a402454e5712ef9e007113e3a676b96c5f29b2bda29352f40", size = 248248, upload-time = "2026-03-17T10:30:03.317Z" },
    { url = "https://files.pythonhosted.org/packages/da/ba/8477f549e554827da390ec659f3c38e4b6d95470f4daafc2d8ff94eaa9c2/coverage-7.13.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9b74db26dfea4f4e50d48a4602207cd1e78be33182bc9cbf22da94f332f99878", size = 246254, upload-time = "2026-03-17T10:30:04.832Z" },
    { url = "https://files.pythonhosted.org/packages/55/59/bc22aef0e6aa179d5b1b001e8b3654785e9adf27ef24c93dc4228ebd5d68/coverage-7.13.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ad146744ca4fd09b50c482650e3c1b1f4dfa1d4792e0a04a369c7f23336f0400", size = 250067, upload-time = "2026-03-17T10:30:06.535Z" },
    { url = "https://files.pythonhosted.org/packages/de/1b/c6a023a160806a5137dca53468fd97530d6acad24a22003b1578a9c2e429/coverage-7.13.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c555b48be1853fe3997c11c4bd521cdd9a9612352de01fa4508f16ec341e6fe0", size = 246521, upload-time = "2026-03-17T10:30:08.486Z" },
    { url = "https://files.pythonhosted.org/packages/2d/3f/3532c85a55aa2f899fa17c186f831cfa1aa434d88ff792a709636f64130e/coverage-7.13.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7034b5c56a58ae5e85f23949d52c14aca2cfc6848a31764995b7de88f13a1ea0", size = 247126, upload-time = "2026-03-17T10:30:09.966Z" },
    { url = "https://files.pythonhosted.org/packages/aa/2e/b9d56af4a24ef45dfbcda88e06870cb7d57b2b0bfa3a888d79b4c8debd76/coverage-7.13.5-cp310-cp310-win32.whl", hash = "sha256:eb7fdf1ef130660e7415e0253a01a7d5a88c9c4d158bcf75cbbd922fd65a5b58", size = 221860, upload-time = "2026-03-17T10:30:11.393Z" },
    { url = "https://files.pythonhosted.org/packages/9f/cc/d938417e7a4d7f0433ad4edee8bb2acdc60dc7ac5af19e2a07a048ecbee3/coverage-7.13.5-cp310-cp310-win_amd64.whl", hash = "sha256:3e1bb5f6c78feeb1be3475789b14a0f0a5b47d505bfc7267126ccbd50289999e", size = 222788, upload-time = "2026-03-17T10:30:12.886Z" },
    { url = "https://files.pythonhosted.org/packages/4b/37/d24c8f8220ff07b839b2c043ea4903a33b0f455abe673ae3c03bbdb7f212/coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d", size = 219381, upload-time = "2026-03-17T10:30:14.68Z" },
    { url = "https://files.pythonhosted.org/packages/35/8b/cd129b0ca4afe886a6ce9d183c44d8301acbd4ef248622e7c49a23145605/coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587", size = 219880, upload-time = "2026-03-17T10:30:16.231Z" },
    { url = "https://files.pythonhosted.org/packages/55/2f/e0e5b237bffdb5d6c530ce87cc1d413a5b7d7dfd60fb067ad6d254c35c76/coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642", size = 250303, upload-time = "2026-03-17T10:30:17.748Z" },
    { url = "https://files.pythonhosted.org/packages/92/be/b1afb692be85b947f3401375851484496134c5554e67e822c35f28bf2fbc/coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b", size = 252218, upload-time = "2026-03-17T10:30:19.804Z" },
    { url = "https://files.pythonhosted.org/packages/da/69/2f47bb6fa1b8d1e3e5d0c4be8ccb4313c63d742476a619418f85740d597b/coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686", size = 254326, upload-time = "2026-03-17T10:30:21.321Z" },
    { url = "https://files.pythonhosted.org/packages/d5/d0/79db81da58965bd29dabc8f4ad2a2af70611a57cba9d1ec006f072f30a54/coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743", size = 256267, upload-time = "2026-03-17T10:30:23.094Z" },
    { url = "https://files.pythonhosted.org/packages/e5/32/d0d7cc8168f91ddab44c0ce4806b969df5f5fdfdbb568eaca2dbc2a04936/coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75", size = 250430, upload-time = "2026-03-17T10:30:25.311Z" },
    { url = "https://files.pythonhosted.org/packages/4d/06/a055311d891ddbe231cd69fdd20ea4be6e3603ffebddf8704b8ca8e10a3c/coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209", size = 252017, upload-time = "2026-03-17T10:30:27.284Z" },
    { url = "https://files.pythonhosted.org/packages/d6/f6/d0fd2d21e29a657b5f77a2fe7082e1568158340dceb941954f776dce1b7b/coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a", size = 250080, upload-time = "2026-03-17T10:30:29.481Z" },
    { url = "https://files.pythonhosted.org/packages/4e/ab/0d7fb2efc2e9a5eb7ddcc6e722f834a69b454b7e6e5888c3a8567ecffb31/coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e", size = 253843, upload-time = "2026-03-17T10:30:31.301Z" },
    { url = "https://files.pythonhosted.org/packages/ba/6f/7467b917bbf5408610178f62a49c0ed4377bb16c1657f689cc61470da8ce/coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd", size = 249802, upload-time = "2026-03-17T10:30:33.358Z" },
    { url = "https://files.pythonhosted.org/packages/75/2c/1172fb689df92135f5bfbbd69fc83017a76d24ea2e2f3a1154007e2fb9f8/coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8", size = 250707, upload-time = "2026-03-17T10:30:35.2Z" },
    { url = "https://files.pythonhosted.org/packages/67/21/9ac389377380a07884e3b48ba7a620fcd9dbfaf1d40565facdc6b36ec9ef/coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf", size = 221880, upload-time = "2026-03-17T10:30:36.775Z" },
    { url = "https://files.pythonhosted.org/packages/af/7f/4cd8a92531253f9d7c1bbecd9fa1b472907fb54446ca768c59b531248dc5/coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9", size = 222816, upload-time = "2026-03-17T10:30:38.891Z" },
    { url = "https://files.pythonhosted.org/packages/12/a6/1d3f6155fb0010ca68eba7fe48ca6c9da7385058b77a95848710ecf189b1/coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028", size = 221483, upload-time = "2026-03-17T10:30:40.463Z" },
    { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" },
    { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" },
    { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" },
    { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" },
    { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" },
    { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" },
    { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" },
    { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" },
    { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" },
    { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" },
    { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" },
    { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" },
    { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" },
    { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" },
    { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" },
    { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" },
    { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" },
    { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" },
    { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" },
    { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" },
    { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" },
    { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" },
    { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" },
    { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" },
    { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" },
    { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" },
    { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" },
    { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" },
    { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" },
    { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" },
    { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" },
    { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" },
    { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" },
    { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" },
    { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" },
    { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" },
    { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" },
    { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" },
    { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" },
    { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" },
    { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" },
    { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" },
    { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" },
    { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" },
    { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" },
    { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" },
    { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" },
    { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" },
    { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" },
    { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" },
    { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" },
    { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" },
    { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" },
    { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" },
    { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" },
    { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" },
    { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" },
    { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" },
    { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" },
    { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" },
    { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" },
    { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" },
    { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" },
    { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" },
    { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" },
    { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" },
    { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" },
    { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" },
    { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" },
    { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" },
    { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" },
    { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" },
    { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" },
    { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" },
    { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" },
    { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" },
]

[package.optional-dependencies]
toml = [
    { name = "tomli", marker = "python_full_version >= '3.10' and python_full_version <= '3.11'" },
]

[[package]]
name = "cryptography"
version = "46.0.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
    { name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a4/ba/04b1bd4218cbc58dc90ce967106d51582371b898690f3ae0402876cc4f34/cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759", size = 750542, upload-time = "2026-03-25T23:34:53.396Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/47/23/9285e15e3bc57325b0a72e592921983a701efc1ee8f91c06c5f0235d86d9/cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8", size = 7176401, upload-time = "2026-03-25T23:33:22.096Z" },
    { url = "https://files.pythonhosted.org/packages/60/f8/e61f8f13950ab6195b31913b42d39f0f9afc7d93f76710f299b5ec286ae6/cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30", size = 4275275, upload-time = "2026-03-25T23:33:23.844Z" },
    { url = "https://files.pythonhosted.org/packages/19/69/732a736d12c2631e140be2348b4ad3d226302df63ef64d30dfdb8db7ad1c/cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a", size = 4425320, upload-time = "2026-03-25T23:33:25.703Z" },
    { url = "https://files.pythonhosted.org/packages/d4/12/123be7292674abf76b21ac1fc0e1af50661f0e5b8f0ec8285faac18eb99e/cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175", size = 4278082, upload-time = "2026-03-25T23:33:27.423Z" },
    { url = "https://files.pythonhosted.org/packages/5b/ba/d5e27f8d68c24951b0a484924a84c7cdaed7502bac9f18601cd357f8b1d2/cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463", size = 4926514, upload-time = "2026-03-25T23:33:29.206Z" },
    { url = "https://files.pythonhosted.org/packages/34/71/1ea5a7352ae516d5512d17babe7e1b87d9db5150b21f794b1377eac1edc0/cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97", size = 4457766, upload-time = "2026-03-25T23:33:30.834Z" },
    { url = "https://files.pythonhosted.org/packages/01/59/562be1e653accee4fdad92c7a2e88fced26b3fdfce144047519bbebc299e/cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c", size = 3986535, upload-time = "2026-03-25T23:33:33.02Z" },
    { url = "https://files.pythonhosted.org/packages/d6/8b/b1ebfeb788bf4624d36e45ed2662b8bd43a05ff62157093c1539c1288a18/cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507", size = 4277618, upload-time = "2026-03-25T23:33:34.567Z" },
    { url = "https://files.pythonhosted.org/packages/dd/52/a005f8eabdb28df57c20f84c44d397a755782d6ff6d455f05baa2785bd91/cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19", size = 4890802, upload-time = "2026-03-25T23:33:37.034Z" },
    { url = "https://files.pythonhosted.org/packages/ec/4d/8e7d7245c79c617d08724e2efa397737715ca0ec830ecb3c91e547302555/cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738", size = 4457425, upload-time = "2026-03-25T23:33:38.904Z" },
    { url = "https://files.pythonhosted.org/packages/1d/5c/f6c3596a1430cec6f949085f0e1a970638d76f81c3ea56d93d564d04c340/cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c", size = 4405530, upload-time = "2026-03-25T23:33:40.842Z" },
    { url = "https://files.pythonhosted.org/packages/7e/c9/9f9cea13ee2dbde070424e0c4f621c091a91ffcc504ffea5e74f0e1daeff/cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f", size = 4667896, upload-time = "2026-03-25T23:33:42.781Z" },
    { url = "https://files.pythonhosted.org/packages/ad/b5/1895bc0821226f129bc74d00eccfc6a5969e2028f8617c09790bf89c185e/cryptography-46.0.6-cp311-abi3-win32.whl", hash = "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2", size = 3026348, upload-time = "2026-03-25T23:33:45.021Z" },
    { url = "https://files.pythonhosted.org/packages/c3/f8/c9bcbf0d3e6ad288b9d9aa0b1dee04b063d19e8c4f871855a03ab3a297ab/cryptography-46.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124", size = 3483896, upload-time = "2026-03-25T23:33:46.649Z" },
    { url = "https://files.pythonhosted.org/packages/01/41/3a578f7fd5c70611c0aacba52cd13cb364a5dee895a5c1d467208a9380b0/cryptography-46.0.6-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275", size = 7117147, upload-time = "2026-03-25T23:33:48.249Z" },
    { url = "https://files.pythonhosted.org/packages/fa/87/887f35a6fca9dde90cad08e0de0c89263a8e59b2d2ff904fd9fcd8025b6f/cryptography-46.0.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4", size = 4266221, upload-time = "2026-03-25T23:33:49.874Z" },
    { url = "https://files.pythonhosted.org/packages/aa/a8/0a90c4f0b0871e0e3d1ed126aed101328a8a57fd9fd17f00fb67e82a51ca/cryptography-46.0.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b", size = 4408952, upload-time = "2026-03-25T23:33:52.128Z" },
    { url = "https://files.pythonhosted.org/packages/16/0b/b239701eb946523e4e9f329336e4ff32b1247e109cbab32d1a7b61da8ed7/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707", size = 4270141, upload-time = "2026-03-25T23:33:54.11Z" },
    { url = "https://files.pythonhosted.org/packages/0f/a8/976acdd4f0f30df7b25605f4b9d3d89295351665c2091d18224f7ad5cdbf/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361", size = 4904178, upload-time = "2026-03-25T23:33:55.725Z" },
    { url = "https://files.pythonhosted.org/packages/b1/1b/bf0e01a88efd0e59679b69f42d4afd5bced8700bb5e80617b2d63a3741af/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b", size = 4441812, upload-time = "2026-03-25T23:33:57.364Z" },
    { url = "https://files.pythonhosted.org/packages/bb/8b/11df86de2ea389c65aa1806f331cae145f2ed18011f30234cc10ca253de8/cryptography-46.0.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca", size = 3963923, upload-time = "2026-03-25T23:33:59.361Z" },
    { url = "https://files.pythonhosted.org/packages/91/e0/207fb177c3a9ef6a8108f234208c3e9e76a6aa8cf20d51932916bd43bda0/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013", size = 4269695, upload-time = "2026-03-25T23:34:00.909Z" },
    { url = "https://files.pythonhosted.org/packages/21/5e/19f3260ed1e95bced52ace7501fabcd266df67077eeb382b79c81729d2d3/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4", size = 4869785, upload-time = "2026-03-25T23:34:02.796Z" },
    { url = "https://files.pythonhosted.org/packages/10/38/cd7864d79aa1d92ef6f1a584281433419b955ad5a5ba8d1eb6c872165bcb/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a", size = 4441404, upload-time = "2026-03-25T23:34:04.35Z" },
    { url = "https://files.pythonhosted.org/packages/09/0a/4fe7a8d25fed74419f91835cf5829ade6408fd1963c9eae9c4bce390ecbb/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d", size = 4397549, upload-time = "2026-03-25T23:34:06.342Z" },
    { url = "https://files.pythonhosted.org/packages/5f/a0/7d738944eac6513cd60a8da98b65951f4a3b279b93479a7e8926d9cd730b/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736", size = 4651874, upload-time = "2026-03-25T23:34:07.916Z" },
    { url = "https://files.pythonhosted.org/packages/cb/f1/c2326781ca05208845efca38bf714f76939ae446cd492d7613808badedf1/cryptography-46.0.6-cp314-cp314t-win32.whl", hash = "sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed", size = 3001511, upload-time = "2026-03-25T23:34:09.892Z" },
    { url = "https://files.pythonhosted.org/packages/c9/57/fe4a23eb549ac9d903bd4698ffda13383808ef0876cc912bcb2838799ece/cryptography-46.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4", size = 3471692, upload-time = "2026-03-25T23:34:11.613Z" },
    { url = "https://files.pythonhosted.org/packages/c4/cc/f330e982852403da79008552de9906804568ae9230da8432f7496ce02b71/cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a", size = 7162776, upload-time = "2026-03-25T23:34:13.308Z" },
    { url = "https://files.pythonhosted.org/packages/49/b3/dc27efd8dcc4bff583b3f01d4a3943cd8b5821777a58b3a6a5f054d61b79/cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8", size = 4270529, upload-time = "2026-03-25T23:34:15.019Z" },
    { url = "https://files.pythonhosted.org/packages/e6/05/e8d0e6eb4f0d83365b3cb0e00eb3c484f7348db0266652ccd84632a3d58d/cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77", size = 4414827, upload-time = "2026-03-25T23:34:16.604Z" },
    { url = "https://files.pythonhosted.org/packages/2f/97/daba0f5d2dc6d855e2dcb70733c812558a7977a55dd4a6722756628c44d1/cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290", size = 4271265, upload-time = "2026-03-25T23:34:18.586Z" },
    { url = "https://files.pythonhosted.org/packages/89/06/fe1fce39a37ac452e58d04b43b0855261dac320a2ebf8f5260dd55b201a9/cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410", size = 4916800, upload-time = "2026-03-25T23:34:20.561Z" },
    { url = "https://files.pythonhosted.org/packages/ff/8a/b14f3101fe9c3592603339eb5d94046c3ce5f7fc76d6512a2d40efd9724e/cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d", size = 4448771, upload-time = "2026-03-25T23:34:22.406Z" },
    { url = "https://files.pythonhosted.org/packages/01/b3/0796998056a66d1973fd52ee89dc1bb3b6581960a91ad4ac705f182d398f/cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70", size = 3978333, upload-time = "2026-03-25T23:34:24.281Z" },
    { url = "https://files.pythonhosted.org/packages/c5/3d/db200af5a4ffd08918cd55c08399dc6c9c50b0bc72c00a3246e099d3a849/cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d", size = 4271069, upload-time = "2026-03-25T23:34:25.895Z" },
    { url = "https://files.pythonhosted.org/packages/d7/18/61acfd5b414309d74ee838be321c636fe71815436f53c9f0334bf19064fa/cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa", size = 4878358, upload-time = "2026-03-25T23:34:27.67Z" },
    { url = "https://files.pythonhosted.org/packages/8b/65/5bf43286d566f8171917cae23ac6add941654ccf085d739195a4eacf1674/cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58", size = 4448061, upload-time = "2026-03-25T23:34:29.375Z" },
    { url = "https://files.pythonhosted.org/packages/e0/25/7e49c0fa7205cf3597e525d156a6bce5b5c9de1fd7e8cb01120e459f205a/cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb", size = 4399103, upload-time = "2026-03-25T23:34:32.036Z" },
    { url = "https://files.pythonhosted.org/packages/44/46/466269e833f1c4718d6cd496ffe20c56c9c8d013486ff66b4f69c302a68d/cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72", size = 4659255, upload-time = "2026-03-25T23:34:33.679Z" },
    { url = "https://files.pythonhosted.org/packages/0a/09/ddc5f630cc32287d2c953fc5d32705e63ec73e37308e5120955316f53827/cryptography-46.0.6-cp38-abi3-win32.whl", hash = "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c", size = 3010660, upload-time = "2026-03-25T23:34:35.418Z" },
    { url = "https://files.pythonhosted.org/packages/1b/82/ca4893968aeb2709aacfb57a30dec6fa2ab25b10fa9f064b8882ce33f599/cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f", size = 3471160, upload-time = "2026-03-25T23:34:37.191Z" },
    { url = "https://files.pythonhosted.org/packages/2e/84/7ccff00ced5bac74b775ce0beb7d1be4e8637536b522b5df9b73ada42da2/cryptography-46.0.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:2ea0f37e9a9cf0df2952893ad145fd9627d326a59daec9b0802480fa3bcd2ead", size = 3475444, upload-time = "2026-03-25T23:34:38.944Z" },
    { url = "https://files.pythonhosted.org/packages/bc/1f/4c926f50df7749f000f20eede0c896769509895e2648db5da0ed55db711d/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a3e84d5ec9ba01f8fd03802b2147ba77f0c8f2617b2aff254cedd551844209c8", size = 4218227, upload-time = "2026-03-25T23:34:40.871Z" },
    { url = "https://files.pythonhosted.org/packages/c6/65/707be3ffbd5f786028665c3223e86e11c4cda86023adbc56bd72b1b6bab5/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:12f0fa16cc247b13c43d56d7b35287ff1569b5b1f4c5e87e92cc4fcc00cd10c0", size = 4381399, upload-time = "2026-03-25T23:34:42.609Z" },
    { url = "https://files.pythonhosted.org/packages/f3/6d/73557ed0ef7d73d04d9aba745d2c8e95218213687ee5e76b7d236a5030fc/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:50575a76e2951fe7dbd1f56d181f8c5ceeeb075e9ff88e7ad997d2f42af06e7b", size = 4217595, upload-time = "2026-03-25T23:34:44.205Z" },
    { url = "https://files.pythonhosted.org/packages/9e/c5/e1594c4eec66a567c3ac4400008108a415808be2ce13dcb9a9045c92f1a0/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:90e5f0a7b3be5f40c3a0a0eafb32c681d8d2c181fc2a1bdabe9b3f611d9f6b1a", size = 4380912, upload-time = "2026-03-25T23:34:46.328Z" },
    { url = "https://files.pythonhosted.org/packages/1a/89/843b53614b47f97fe1abc13f9a86efa5ec9e275292c457af1d4a60dc80e0/cryptography-46.0.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6728c49e3b2c180ef26f8e9f0a883a2c585638db64cf265b49c9ba10652d430e", size = 3409955, upload-time = "2026-03-25T23:34:48.465Z" },
]

[[package]]
name = "decorator"
version = "5.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" },
]

[[package]]
name = "dishka"
version = "1.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b9/97/18d4a9bd44f6baa975cd8d54ed3a1a86b341a43c9c077e647d351c9d4573/dishka-1.9.1.tar.gz", hash = "sha256:973f19dc65160a97370181106764ae076052af4489e94b0cedb3eb4e47fe13bf", size = 274962, upload-time = "2026-03-08T09:43:47.298Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/33/98/c8f80be83fbd92f5f9d4bdb5d619a9c9901fb1523c0b02a448b942e532e6/dishka-1.9.1-py3-none-any.whl", hash = "sha256:5080a46bf40bd403aee396aac81f999f679078655f9a6f2062111d62e94e7b18", size = 114327, upload-time = "2026-03-08T09:43:46.097Z" },
]

[[package]]
name = "distlib"
version = "0.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" },
]

[[package]]
name = "dnspython"
version = "2.7.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
sdist = { url = "https://files.pythonhosted.org/packages/b5/4a/263763cb2ba3816dd94b08ad3a33d5fdae34ecb856678773cc40a3605829/dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1", size = 345197, upload-time = "2024-10-05T20:14:59.362Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632, upload-time = "2024-10-05T20:14:57.687Z" },
]

[[package]]
name = "dnspython"
version = "2.8.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
]

[[package]]
name = "docker"
version = "7.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "pywin32", marker = "sys_platform == 'win32'" },
    { name = "requests", version = "2.32.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "requests", version = "2.33.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "urllib3", version = "1.26.20", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "urllib3", version = "2.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/91/9b/4a2ea29aeba62471211598dac5d96825bb49348fa07e906ea930394a83ce/docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c", size = 117834, upload-time = "2024-05-23T11:13:57.216Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" },
]

[[package]]
name = "docutils"
version = "0.21.2"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version == '3.10.*'",
    "python_full_version < '3.10'",
]
sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444, upload-time = "2024-04-23T18:57:18.24Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" },
]

[[package]]
name = "docutils"
version = "0.22.4"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
]
sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" },
]

[[package]]
name = "dogpile-cache"
version = "1.4.1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
dependencies = [
    { name = "decorator", marker = "python_full_version < '3.10'" },
    { name = "stevedore", version = "5.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "typing-extensions", marker = "python_full_version < '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/86/97/da72845c89c9aa70e3e74609b864eff5e5c2ec46366645e7bb61eaa29e9c/dogpile_cache-1.4.1.tar.gz", hash = "sha256:e25c60e677a5e28ff86124765fbf18c53257bcd7830749cd5ba350ace2a12989", size = 939952, upload-time = "2025-09-12T16:34:32.997Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/d3/f6/1d9579d7b86e7957dd626e52b65c72af0fcd47b277716efd9889990d92b4/dogpile_cache-1.4.1-py3-none-any.whl", hash = "sha256:99130ce990800c8d89c26a5a8d9923cbe1b78c8a9972c2aaa0abf3d2ef2984ad", size = 63593, upload-time = "2025-09-12T16:34:34.809Z" },
]

[[package]]
name = "dogpile-cache"
version = "1.5.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
dependencies = [
    { name = "decorator", marker = "python_full_version >= '3.10'" },
    { name = "stevedore", version = "5.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "typing-extensions", marker = "python_full_version == '3.10.*'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e7/c8/301ff89746e76745b937606df4753c032787c59ecb37dd4d4250bddc8929/dogpile_cache-1.5.0.tar.gz", hash = "sha256:849c5573c9a38f155cd4173103c702b637ede0361c12e864876877d0cd125eec", size = 947962, upload-time = "2025-10-11T17:35:36.898Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/dc/80/12235e5b75bb2c586733280854f131b86051e0bbdfb55349ff70d0f72cf9/dogpile_cache-1.5.0-py3-none-any.whl", hash = "sha256:dc7b47d37844db15e8fdc0243c1b58857a2ddc52a5118237a97127bac200e18d", size = 64447, upload-time = "2025-10-11T17:35:38.573Z" },
]

[[package]]
name = "duckdb"
version = "1.4.4"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
sdist = { url = "https://files.pythonhosted.org/packages/36/9d/ab66a06e416d71b7bdcb9904cdf8d4db3379ef632bb8e9495646702d9718/duckdb-1.4.4.tar.gz", hash = "sha256:8bba52fd2acb67668a4615ee17ee51814124223de836d9e2fdcbc4c9021b3d3c", size = 18419763, upload-time = "2026-01-26T11:50:37.68Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/a2/9f/67a75f1e88f84946909826fa7aadd0c4b0dc067f24956142751fd9d59fe6/duckdb-1.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e870a441cb1c41d556205deb665749f26347ed13b3a247b53714f5d589596977", size = 28884338, upload-time = "2026-01-26T11:48:41.591Z" },
    { url = "https://files.pythonhosted.org/packages/6b/7a/e9277d0567884c21f345ad43cc01aeaa2abe566d5fdf22e35c3861dd44fa/duckdb-1.4.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:49123b579e4a6323e65139210cd72dddc593a72d840211556b60f9703bda8526", size = 15339148, upload-time = "2026-01-26T11:48:45.343Z" },
    { url = "https://files.pythonhosted.org/packages/4a/96/3a7630d2779d2bae6f3cdf540a088ed45166adefd3c429971e5b85ce8f84/duckdb-1.4.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5e1933fac5293fea5926b0ee75a55b8cfe7f516d867310a5b251831ab61fe62b", size = 13668431, upload-time = "2026-01-26T11:48:47.864Z" },
    { url = "https://files.pythonhosted.org/packages/8e/ad/f62a3a65d200e8afc1f75cf0dd3f0aa84ef0dd07c484414a11f2abed810e/duckdb-1.4.4-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:707530f6637e91dc4b8125260595299ec9dd157c09f5d16c4186c5988bfbd09a", size = 18409546, upload-time = "2026-01-26T11:48:51.142Z" },
    { url = "https://files.pythonhosted.org/packages/a2/5f/23bd586ecb21273b41b5aa4b16fd88b7fecb53ed48d897273651c0c3d66f/duckdb-1.4.4-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:453b115f4777467f35103d8081770ac2f223fb5799178db5b06186e3ab51d1f2", size = 20407046, upload-time = "2026-01-26T11:48:55.673Z" },
    { url = "https://files.pythonhosted.org/packages/8b/d0/4ce78bf341c930d4a22a56cb686bfc2c975eaf25f653a7ac25e3929d98bb/duckdb-1.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a3c8542db7ffb128aceb7f3b35502ebaddcd4f73f1227569306cc34bad06680c", size = 12256576, upload-time = "2026-01-26T11:48:58.203Z" },
    { url = "https://files.pythonhosted.org/packages/04/68/19233412033a2bc5a144a3f531f64e3548d4487251e3f16b56c31411a06f/duckdb-1.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5ba684f498d4e924c7e8f30dd157da8da34c8479746c5011b6c0e037e9c60ad2", size = 28883816, upload-time = "2026-01-26T11:49:01.009Z" },
    { url = "https://files.pythonhosted.org/packages/b3/3e/cec70e546c298ab76d80b990109e111068d82cca67942c42328eaa7d6fdb/duckdb-1.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5536eb952a8aa6ae56469362e344d4e6403cc945a80bc8c5c2ebdd85d85eb64b", size = 15339662, upload-time = "2026-01-26T11:49:04.058Z" },
    { url = "https://files.pythonhosted.org/packages/d3/f0/cf4241a040ec4f571859a738007ec773b642fbc27df4cbcf34b0c32ea559/duckdb-1.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:47dd4162da6a2be59a0aef640eb08d6360df1cf83c317dcc127836daaf3b7f7c", size = 13670044, upload-time = "2026-01-26T11:49:06.627Z" },
    { url = "https://files.pythonhosted.org/packages/11/64/de2bb4ec1e35ec9ebf6090a95b930fc56934a0ad6f34a24c5972a14a77ef/duckdb-1.4.4-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6cb357cfa3403910e79e2eb46c8e445bb1ee2fd62e9e9588c6b999df4256abc1", size = 18409951, upload-time = "2026-01-26T11:49:09.808Z" },
    { url = "https://files.pythonhosted.org/packages/79/a2/ac0f5ee16df890d141304bcd48733516b7202c0de34cd3555634d6eb4551/duckdb-1.4.4-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c25d5b0febda02b7944e94fdae95aecf952797afc8cb920f677b46a7c251955", size = 20411739, upload-time = "2026-01-26T11:49:12.652Z" },
    { url = "https://files.pythonhosted.org/packages/37/a2/9a3402edeedaecf72de05fe9ff7f0303d701b8dfc136aea4a4be1a5f7eee/duckdb-1.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:6703dd1bb650025b3771552333d305d62ddd7ff182de121483d4e042ea6e2e00", size = 12256972, upload-time = "2026-01-26T11:49:15.468Z" },
    { url = "https://files.pythonhosted.org/packages/f6/e6/052ea6dcdf35b259fd182eff3efd8d75a071de4010c9807556098df137b9/duckdb-1.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:bf138201f56e5d6fc276a25138341b3523e2f84733613fc43f02c54465619a95", size = 13006696, upload-time = "2026-01-26T11:49:18.054Z" },
    { url = "https://files.pythonhosted.org/packages/58/33/beadaa69f8458afe466126f2c5ee48c4759cc9d5d784f8703d44e0b52c3c/duckdb-1.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ddcfd9c6ff234da603a1edd5fd8ae6107f4d042f74951b65f91bc5e2643856b3", size = 28896535, upload-time = "2026-01-26T11:49:21.232Z" },
    { url = "https://files.pythonhosted.org/packages/76/66/82413f386df10467affc87f65bac095b7c88dbd9c767584164d5f4dc4cb8/duckdb-1.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6792ca647216bd5c4ff16396e4591cfa9b4a72e5ad7cdd312cec6d67e8431a7c", size = 15349716, upload-time = "2026-01-26T11:49:23.989Z" },
    { url = "https://files.pythonhosted.org/packages/5d/8c/c13d396fd4e9bf970916dc5b4fea410c1b10fe531069aea65f1dcf849a71/duckdb-1.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1f8d55843cc940e36261689054f7dfb6ce35b1f5b0953b0d355b6adb654b0d52", size = 13672403, upload-time = "2026-01-26T11:49:26.741Z" },
    { url = "https://files.pythonhosted.org/packages/db/77/2446a0b44226bb95217748d911c7ca66a66ca10f6481d5178d9370819631/duckdb-1.4.4-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c65d15c440c31e06baaebfd2c06d71ce877e132779d309f1edf0a85d23c07e92", size = 18419001, upload-time = "2026-01-26T11:49:29.353Z" },
    { url = "https://files.pythonhosted.org/packages/2e/a3/97715bba30040572fb15d02c26f36be988d48bc00501e7ac02b1d65ef9d0/duckdb-1.4.4-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b297eff642503fd435a9de5a9cb7db4eccb6f61d61a55b30d2636023f149855f", size = 20437385, upload-time = "2026-01-26T11:49:32.302Z" },
    { url = "https://files.pythonhosted.org/packages/8b/0a/18b9167adf528cbe3867ef8a84a5f19f37bedccb606a8a9e59cfea1880c8/duckdb-1.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:d525de5f282b03aa8be6db86b1abffdceae5f1055113a03d5b50cd2fb8cf2ef8", size = 12267343, upload-time = "2026-01-26T11:49:34.985Z" },
    { url = "https://files.pythonhosted.org/packages/f8/15/37af97f5717818f3d82d57414299c293b321ac83e048c0a90bb8b6a09072/duckdb-1.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:50f2eb173c573811b44aba51176da7a4e5c487113982be6a6a1c37337ec5fa57", size = 13007490, upload-time = "2026-01-26T11:49:37.413Z" },
    { url = "https://files.pythonhosted.org/packages/7f/fe/64810fee20030f2bf96ce28b527060564864ce5b934b50888eda2cbf99dd/duckdb-1.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:337f8b24e89bc2e12dadcfe87b4eb1c00fd920f68ab07bc9b70960d6523b8bc3", size = 28899349, upload-time = "2026-01-26T11:49:40.294Z" },
    { url = "https://files.pythonhosted.org/packages/9c/9b/3c7c5e48456b69365d952ac201666053de2700f5b0144a699a4dc6854507/duckdb-1.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0509b39ea7af8cff0198a99d206dca753c62844adab54e545984c2e2c1381616", size = 15350691, upload-time = "2026-01-26T11:49:43.242Z" },
    { url = "https://files.pythonhosted.org/packages/a6/7b/64e68a7b857ed0340045501535a0da99ea5d9d5ea3708fec0afb8663eb27/duckdb-1.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fb94de6d023de9d79b7edc1ae07ee1d0b4f5fa8a9dcec799650b5befdf7aafec", size = 13672311, upload-time = "2026-01-26T11:49:46.069Z" },
    { url = "https://files.pythonhosted.org/packages/09/5b/3e7aa490841784d223de61beb2ae64e82331501bf5a415dc87a0e27b4663/duckdb-1.4.4-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d636ceda422e7babd5e2f7275f6a0d1a3405e6a01873f00d38b72118d30c10b", size = 18422740, upload-time = "2026-01-26T11:49:49.034Z" },
    { url = "https://files.pythonhosted.org/packages/53/32/256df3dbaa198c58539ad94f9a41e98c2c8ff23f126b8f5f52c7dcd0a738/duckdb-1.4.4-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7df7351328ffb812a4a289732f500d621e7de9942a3a2c9b6d4afcf4c0e72526", size = 20435578, upload-time = "2026-01-26T11:49:51.946Z" },
    { url = "https://files.pythonhosted.org/packages/a4/f0/620323fd87062ea43e527a2d5ed9e55b525e0847c17d3b307094ddab98a2/duckdb-1.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:6fb1225a9ea5877421481d59a6c556a9532c32c16c7ae6ca8d127e2b878c9389", size = 12268083, upload-time = "2026-01-26T11:49:54.615Z" },
    { url = "https://files.pythonhosted.org/packages/e5/07/a397fdb7c95388ba9c055b9a3d38dfee92093f4427bc6946cf9543b1d216/duckdb-1.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:f28a18cc790217e5b347bb91b2cab27aafc557c58d3d8382e04b4fe55d0c3f66", size = 13006123, upload-time = "2026-01-26T11:49:57.092Z" },
    { url = "https://files.pythonhosted.org/packages/97/a6/f19e2864e651b0bd8e4db2b0c455e7e0d71e0d4cd2cd9cc052f518e43eb3/duckdb-1.4.4-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:25874f8b1355e96178079e37312c3ba6d61a2354f51319dae860cf21335c3a20", size = 28909554, upload-time = "2026-01-26T11:50:00.107Z" },
    { url = "https://files.pythonhosted.org/packages/0e/93/8a24e932c67414fd2c45bed83218e62b73348996bf859eda020c224774b2/duckdb-1.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:452c5b5d6c349dc5d1154eb2062ee547296fcbd0c20e9df1ed00b5e1809089da", size = 15353804, upload-time = "2026-01-26T11:50:03.382Z" },
    { url = "https://files.pythonhosted.org/packages/62/13/e5378ff5bb1d4397655d840b34b642b1b23cdd82ae19599e62dc4b9461c9/duckdb-1.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8e5c2d8a0452df55e092959c0bfc8ab8897ac3ea0f754cb3b0ab3e165cd79aff", size = 13676157, upload-time = "2026-01-26T11:50:06.232Z" },
    { url = "https://files.pythonhosted.org/packages/2d/94/24364da564b27aeebe44481f15bd0197a0b535ec93f188a6b1b98c22f082/duckdb-1.4.4-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1af6e76fe8bd24875dc56dd8e38300d64dc708cd2e772f67b9fbc635cc3066a3", size = 18426882, upload-time = "2026-01-26T11:50:08.97Z" },
    { url = "https://files.pythonhosted.org/packages/26/0a/6ae31b2914b4dc34243279b2301554bcbc5f1a09ccc82600486c49ab71d1/duckdb-1.4.4-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0440f59e0cd9936a9ebfcf7a13312eda480c79214ffed3878d75947fc3b7d6d", size = 20435641, upload-time = "2026-01-26T11:50:12.188Z" },
    { url = "https://files.pythonhosted.org/packages/d2/b1/fd5c37c53d45efe979f67e9bd49aaceef640147bb18f0699a19edd1874d6/duckdb-1.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:59c8d76016dde854beab844935b1ec31de358d4053e792988108e995b18c08e7", size = 12762360, upload-time = "2026-01-26T11:50:14.76Z" },
    { url = "https://files.pythonhosted.org/packages/dd/2d/13e6024e613679d8a489dd922f199ef4b1d08a456a58eadd96dc2f05171f/duckdb-1.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:53cd6423136ab44383ec9955aefe7599b3fb3dd1fe006161e6396d8167e0e0d4", size = 13458633, upload-time = "2026-01-26T11:50:17.657Z" },
    { url = "https://files.pythonhosted.org/packages/00/c1/edb090813533632b0eaa315092efcf60d5f835f6b74bd25b3fee2c993810/duckdb-1.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8097201bc5fd0779d7fcc2f3f4736c349197235f4cb7171622936343a1aa8dbf", size = 28883631, upload-time = "2026-01-26T11:50:20.579Z" },
    { url = "https://files.pythonhosted.org/packages/9f/01/b19f532ee7340ef11c3363300f677074d7d2bf03af5ac76efacf03b4dd76/duckdb-1.4.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:cd1be3d48577f5b40eb9706c6b2ae10edfe18e78eb28e31a3b922dcff1183597", size = 15338844, upload-time = "2026-01-26T11:50:23.641Z" },
    { url = "https://files.pythonhosted.org/packages/08/cd/73cde196b809fc934acd39f05e730f7758e15e845486ee5219fc0513701e/duckdb-1.4.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e041f2fbd6888da090eca96ac167a7eb62d02f778385dd9155ed859f1c6b6dc8", size = 13668224, upload-time = "2026-01-26T11:50:26.151Z" },
    { url = "https://files.pythonhosted.org/packages/de/6a/1aea416dbb729c1548ce6b66c3283dd5441660939ec16077ba431bec6b42/duckdb-1.4.4-cp39-cp39-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7eec0bf271ac622e57b7f6554a27a6e7d1dd2f43d1871f7962c74bcbbede15ba", size = 18387860, upload-time = "2026-01-26T11:50:28.775Z" },
    { url = "https://files.pythonhosted.org/packages/a1/6d/697bf9688d5c5b470a7210123430661d5f9bd10c9f0aeffa54799de6712d/duckdb-1.4.4-cp39-cp39-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5cdc4126ec925edf3112bc656ac9ed23745294b854935fa7a643a216e4455af6", size = 20396661, upload-time = "2026-01-26T11:50:32.009Z" },
    { url = "https://files.pythonhosted.org/packages/23/60/8e491e199839a488cd302166defc51c62c25ea2cb36adee14156e610dcfc/duckdb-1.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:c9566a4ed834ec7999db5849f53da0a7ee83d86830c33f471bf0211a1148ca12", size = 12255531, upload-time = "2026-01-26T11:50:34.681Z" },
]

[[package]]
name = "duckdb"
version = "1.5.1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
sdist = { url = "https://files.pythonhosted.org/packages/ae/62/590caabec6c41003f46a244b6fd707d35ca2e552e0c70cbf454e08bf6685/duckdb-1.5.1.tar.gz", hash = "sha256:b370d1620a34a4538ef66524fcee9de8171fa263c701036a92bc0b4c1f2f9c6d", size = 17995082, upload-time = "2026-03-23T12:12:15.894Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/eb/63/d6477057ea6103f80ed9499580c8602183211689889ec50c32f25a935e3d/duckdb-1.5.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:46f92ada9023e59f27edc048167b31ac9a03911978b1296c845a34462a27f096", size = 30067487, upload-time = "2026-03-23T12:10:15.712Z" },
    { url = "https://files.pythonhosted.org/packages/ba/b8/22e6c605d9281df7a83653f4a60168eec0f650b23f1d4648aca940d79d00/duckdb-1.5.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:caa65e1f5bf007430bf657c37cab7ab81a4ddf8d337e3062bcc5085d17ef038b", size = 15968413, upload-time = "2026-03-23T12:10:18.978Z" },
    { url = "https://files.pythonhosted.org/packages/85/b1/88a457cd3105525cba0d4c155f847c5c32fa4f543d3ba4ee38b4fd75f82e/duckdb-1.5.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8c0088765747ae5d6c9f89987bb36f9fb83564f07090d721344ce8e1abedffea", size = 14222115, upload-time = "2026-03-23T12:10:21.662Z" },
    { url = "https://files.pythonhosted.org/packages/c5/3b/800c3f1d54ae0062b3e9b0b54fc54d6c155d731311931d748fc9c5c565f9/duckdb-1.5.1-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e56a20ab6cdb90a95b0c99652e28de3504ce77129087319c03c9098266183ae5", size = 19244994, upload-time = "2026-03-23T12:10:24.708Z" },
    { url = "https://files.pythonhosted.org/packages/3a/09/4c4dd94f521d016e0fb83cca2c203d10ce1e3f8bcc679691b5271fc98b83/duckdb-1.5.1-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:715f05ea198d20d7f8b407b9b84e0023d17f2b9096c194cea702b7840e74f1f7", size = 21347663, upload-time = "2026-03-23T12:10:27.428Z" },
    { url = "https://files.pythonhosted.org/packages/d0/b3/eb3c70be70d0b3fa6c8051d6fa4b7fb3d5787fa77b3f50b7e38d5f7cc6fd/duckdb-1.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:e878ccb7d20872065e1597935fdb5e65efa43220c8edd0d9c4a1a7ff1f3eb277", size = 13067979, upload-time = "2026-03-23T12:10:30.783Z" },
    { url = "https://files.pythonhosted.org/packages/42/3e/827ffcf58f0abc6ad6dcf826c5d24ebfc65e03ad1a20d74cad9806f91c99/duckdb-1.5.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:bc7ca6a1a40e7e4c933017e6c09ef18032add793df4e42624c6c0c87e0bebdad", size = 30067835, upload-time = "2026-03-23T12:10:34.026Z" },
    { url = "https://files.pythonhosted.org/packages/04/b5/e921ecf8a7e0cc7da2100c98bef64b3da386df9444f467d6389364851302/duckdb-1.5.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:446d500a2977c6ae2077f340c510a25956da5c77597175c316edfa87248ceda3", size = 15970464, upload-time = "2026-03-23T12:10:42.063Z" },
    { url = "https://files.pythonhosted.org/packages/dd/da/ed804006cd09ba303389d573c8b15d74220667cbd1fd990c26e98d0e0a5b/duckdb-1.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b8b0808dba0c63b7633bdaefb34e08fe0612622224f9feb0e7518904b1615101", size = 14222994, upload-time = "2026-03-23T12:10:45.162Z" },
    { url = "https://files.pythonhosted.org/packages/b3/43/c904d81a61306edab81a9d74bb37bbe65679639abb7030d4c4fec9ed84f7/duckdb-1.5.1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:553c273a6a8f140adaa6da6a6135c7f95bdc8c2e5f95252fcdf9832d758e2141", size = 19244880, upload-time = "2026-03-23T12:10:48.529Z" },
    { url = "https://files.pythonhosted.org/packages/50/db/358715d677bfe5e117d9e1f2d6cc2fc2b0bd621144d1f15335b8b59f95d7/duckdb-1.5.1-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40c5220ec93790b18ec6278da9c6ac2608d997ee6d6f7cd44c5c3992764e8e71", size = 21350874, upload-time = "2026-03-23T12:10:52.095Z" },
    { url = "https://files.pythonhosted.org/packages/3f/db/fd647ce46315347976f5576a279bacb8134d23b1f004bd0bcda7ce9cf429/duckdb-1.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:36e8e32621a9e2a9abe75dc15a4b54a3997f2d8b1e53ad754bae48a083c91130", size = 13068140, upload-time = "2026-03-23T12:10:55.622Z" },
    { url = "https://files.pythonhosted.org/packages/27/95/e29d42792707619da5867ffab338d7e7b086242c7296aa9cfc6dcf52d568/duckdb-1.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:5ae7c0d744d64e2753149634787cc4ab60f05ef1e542b060eeab719f3cdb7723", size = 13908823, upload-time = "2026-03-23T12:10:58.572Z" },
    { url = "https://files.pythonhosted.org/packages/3f/06/be4c62f812c6e23898733073ace0482eeb18dffabe0585d63a3bf38bca1e/duckdb-1.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:6f7361d66cc801d9eb4df734b139cd7b0e3c257a16f3573ebd550ddb255549e6", size = 30113703, upload-time = "2026-03-23T12:11:02.536Z" },
    { url = "https://files.pythonhosted.org/packages/44/03/1794dcdda75ff203ab0982ff7eb5232549b58b9af66f243f1b7212d6d6be/duckdb-1.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0a6acc2040bec1f05de62a2f3f68f4c12f3ec7d6012b4317d0ab1a195af26225", size = 15991802, upload-time = "2026-03-23T12:11:06.321Z" },
    { url = "https://files.pythonhosted.org/packages/87/03/293bccd838a293d42ea26dec7f4eb4f58b57b6c9ffcfabc6518a5f20a24a/duckdb-1.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed6d23a3f806898e69c77430ebd8da0c79c219f97b9acbc9a29a653e09740c59", size = 14246803, upload-time = "2026-03-23T12:11:09.624Z" },
    { url = "https://files.pythonhosted.org/packages/15/2c/7b4f11879aa2924838168b4640da999dccda1b4a033d43cb998fd6dc33ea/duckdb-1.5.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6af347debc8b721aa72e48671166282da979d5e5ae52dbc660ab417282b48e23", size = 19271654, upload-time = "2026-03-23T12:11:13.354Z" },
    { url = "https://files.pythonhosted.org/packages/6f/d6/8f9a6b1fbcc669108ec6a4d625a70be9e480b437ed9b70cd56b78cd577a6/duckdb-1.5.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8150c569b2aa4573b51ba8475e814aa41fd53a3d510c1ffb96f1139f46faf611", size = 21386100, upload-time = "2026-03-23T12:11:16.758Z" },
    { url = "https://files.pythonhosted.org/packages/c4/fe/8d02c6473273468cf8d43fd5d73c677f8cdfcd036c1e884df0613f124c2b/duckdb-1.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:054ad424b051b334052afac58cb216f3b1ebb8579fc8c641e60f0182e8725ea9", size = 13083506, upload-time = "2026-03-23T12:11:19.785Z" },
    { url = "https://files.pythonhosted.org/packages/96/0b/2be786b9c153eb263bf5d3d5f7ab621b14a715d7e70f92b24ecf8536369e/duckdb-1.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:6ba302115f63f6482c000ccfd62efdb6c41d9d182a5bcd4a90e7ab8cd13856eb", size = 13888862, upload-time = "2026-03-23T12:11:22.84Z" },
    { url = "https://files.pythonhosted.org/packages/a5/f2/af476945e3b97417945b0f660b5efa661863547c0ea104251bb6387342b1/duckdb-1.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:26e56b5f0c96189e3288d83cf7b476e23615987902f801e5788dee15ee9f24a9", size = 30113759, upload-time = "2026-03-23T12:11:26.5Z" },
    { url = "https://files.pythonhosted.org/packages/fe/9d/5a542b3933647369e601175190093597ce0ac54909aea0dd876ec51ffad4/duckdb-1.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:972d0dbf283508f9bc446ee09c3838cb7c7f114b5bdceee41753288c97fe2f7c", size = 15991463, upload-time = "2026-03-23T12:11:30.025Z" },
    { url = "https://files.pythonhosted.org/packages/53/a5/b59cff67f5e0420b8f337ad86406801cffacae219deed83961dcceefda67/duckdb-1.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:482f8a13f2600f527e427f73c42b5aa75536f9892868068f0aaf573055a0135f", size = 14246482, upload-time = "2026-03-23T12:11:33.33Z" },
    { url = "https://files.pythonhosted.org/packages/e9/12/d72a82fe502aae82b97b481bf909be8e22db5a403290799ad054b4f90eb4/duckdb-1.5.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da137802688190835b4c863cafa77fd7e29dff662ee6d905a9ffc14f00299c91", size = 19270816, upload-time = "2026-03-23T12:11:36.79Z" },
    { url = "https://files.pythonhosted.org/packages/f9/c3/ee49319b15f139e04c067378f0e763f78336fbab38ba54b0852467dd9da4/duckdb-1.5.1-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d4147422d91ccdc2d2abf6ed24196025e020259d1d267970ae20c13c2ce84b1", size = 21385695, upload-time = "2026-03-23T12:11:40.465Z" },
    { url = "https://files.pythonhosted.org/packages/a8/f5/a15498e75a27a136c791ca1889beade96d388dadf9811375db155fc96d1a/duckdb-1.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:05fc91767d0cfc4cf2fa68966ab5b479ac07561752e42dd0ae30327bd160f64a", size = 13084065, upload-time = "2026-03-23T12:11:43.763Z" },
    { url = "https://files.pythonhosted.org/packages/93/81/b3612d2bbe237f75791095e16767c61067ea5d31c76e8591c212dac13bd0/duckdb-1.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:a28531cee2a5a42d89f9ba4da53bfeb15681f12acc0263476c8705380dadce07", size = 13892892, upload-time = "2026-03-23T12:11:47.222Z" },
    { url = "https://files.pythonhosted.org/packages/ad/75/e9e7893542ca738bcde2d41d459e3438950219c71c57ad28b049dc2ae616/duckdb-1.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:eba81e0b3011c1f23df7ea47ef4ffaa8239817959ae291515b6efd068bde2161", size = 30123677, upload-time = "2026-03-23T12:11:51.511Z" },
    { url = "https://files.pythonhosted.org/packages/df/db/f7420ee7109a922124c02f377ae1c56156e9e4aa434f4726848adaef0219/duckdb-1.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:afab8b4b1f4469c3879bb049dd039f8fce402712050324e9524a43d7324c5e87", size = 15996808, upload-time = "2026-03-23T12:11:54.964Z" },
    { url = "https://files.pythonhosted.org/packages/df/57/2c4c3de1f1110417592741863ba58b4eca2f7690a421712762ddbdcd72e6/duckdb-1.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:71dddcebbc5a70e946a06c30b59b5dd7999c9833d307168f90fb4e4b672ab63e", size = 14248990, upload-time = "2026-03-23T12:11:58.576Z" },
    { url = "https://files.pythonhosted.org/packages/2b/81/e173b33ffac53124a3e39e97fb60a538f26651a0df6e393eb9bf7540126c/duckdb-1.5.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac2804043bd1bc10b5da18f8f4c706877197263a510c41be9b4c0062f5783dcc", size = 19276013, upload-time = "2026-03-23T12:12:02.034Z" },
    { url = "https://files.pythonhosted.org/packages/d4/4c/47e838393aa90d3d78549c8c04cb09452efeb14aaae0ee24dc0bd61c3a41/duckdb-1.5.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8843bd9594e1387f1e601439e19ad73abdf57356104fd1e53a708255bb95a13d", size = 21387569, upload-time = "2026-03-23T12:12:05.693Z" },
    { url = "https://files.pythonhosted.org/packages/f4/9b/ce65743e0e85f5c984d2f7e8a81bc908d0bac345d6d8b6316436b29430e7/duckdb-1.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:d68c5a01a283cb13b79eafe016fe5869aa11bff8c46e7141c70aa0aac808010f", size = 13603876, upload-time = "2026-03-23T12:12:09.344Z" },
    { url = "https://files.pythonhosted.org/packages/e6/ac/f9e4e731635192571f86f52d86234f537c7f8ca4f6917c56b29051c077ef/duckdb-1.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:a3be2072315982e232bfe49c9d3db0a59ba67b2240a537ef42656cc772a887c7", size = 14370790, upload-time = "2026-03-23T12:12:12.497Z" },
]

[[package]]
name = "duckdb-engine"
version = "0.17.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "duckdb", version = "1.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "duckdb", version = "1.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "packaging" },
    { name = "sqlalchemy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/89/d5/c0d8d0a4ca3ffea92266f33d92a375e2794820ad89f9be97cf0c9a9697d0/duckdb_engine-0.17.0.tar.gz", hash = "sha256:396b23869754e536aa80881a92622b8b488015cf711c5a40032d05d2cf08f3cf", size = 48054, upload-time = "2025-03-29T09:49:17.663Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/2a/a2/e90242f53f7ae41554419b1695b4820b364df87c8350aa420b60b20cab92/duckdb_engine-0.17.0-py3-none-any.whl", hash = "sha256:3aa72085e536b43faab635f487baf77ddc5750069c16a2f8d9c6c3cb6083e979", size = 49676, upload-time = "2025-03-29T09:49:15.564Z" },
]

[[package]]
name = "editorconfig"
version = "0.17.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/88/3a/a61d9a1f319a186b05d14df17daea42fcddea63c213bcd61a929fb3a6796/editorconfig-0.17.1.tar.gz", hash = "sha256:23c08b00e8e08cc3adcddb825251c497478df1dada6aefeb01e626ad37303745", size = 14695, upload-time = "2025-06-09T08:21:37.097Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/96/fd/a40c621ff207f3ce8e484aa0fc8ba4eb6e3ecf52e15b42ba764b457a9550/editorconfig-0.17.1-py3-none-any.whl", hash = "sha256:1eda9c2c0db8c16dbd50111b710572a5e6de934e39772de1959d41f64fc17c82", size = 16360, upload-time = "2025-06-09T08:21:35.654Z" },
]

[[package]]
name = "email-validator"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "dnspython", version = "2.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "dnspython", version = "2.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" },
]

[[package]]
name = "eval-type-backport"
version = "0.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fb/a3/cafafb4558fd638aadfe4121dc6cefb8d743368c085acb2f521df0f3d9d7/eval_type_backport-0.3.1.tar.gz", hash = "sha256:57e993f7b5b69d271e37482e62f74e76a0276c82490cf8e4f0dffeb6b332d5ed", size = 9445, upload-time = "2025-12-02T11:51:42.987Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/cf/22/fdc2e30d43ff853720042fa15baa3e6122722be1a7950a98233ebb55cd71/eval_type_backport-0.3.1-py3-none-any.whl", hash = "sha256:279ab641905e9f11129f56a8a78f493518515b83402b860f6f06dd7c011fdfa8", size = 6063, upload-time = "2025-12-02T11:51:41.665Z" },
]

[[package]]
name = "exceptiongroup"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
]

[[package]]
name = "execnet"
version = "2.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" },
]

[[package]]
name = "faker"
version = "37.12.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
dependencies = [
    { name = "tzdata", marker = "python_full_version < '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/84/e95acaa848b855e15c83331d0401ee5f84b2f60889255c2e055cb4fb6bdf/faker-37.12.0.tar.gz", hash = "sha256:7505e59a7e02fa9010f06c3e1e92f8250d4cfbb30632296140c2d6dbef09b0fa", size = 1935741, upload-time = "2025-10-24T15:19:58.764Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/8e/98/2c050dec90e295a524c9b65c4cb9e7c302386a296b2938710448cbd267d5/faker-37.12.0-py3-none-any.whl", hash = "sha256:afe7ccc038da92f2fbae30d8e16d19d91e92e242f8401ce9caf44de892bab4c4", size = 1975461, upload-time = "2025-10-24T15:19:55.739Z" },
]

[[package]]
name = "faker"
version = "40.13.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
dependencies = [
    { name = "tzdata", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/89/95/4822ffe94723553789aef783104f4f18fc20d7c4c68e1bbd633e11d09758/faker-40.13.0.tar.gz", hash = "sha256:a0751c84c3abac17327d7bb4c98e8afe70ebf7821e01dd7d0b15cd8856415525", size = 1962043, upload-time = "2026-04-06T16:44:55.68Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/da/8a/708103325edff16a0b0e004de0d37db8ba216a32713948c64d71f6d4a4c2/faker-40.13.0-py3-none-any.whl", hash = "sha256:c1298fd0d819b3688fb5fd358c4ba8f56c7c8c740b411fd3dbd8e30bf2c05019", size = 1994597, upload-time = "2026-04-06T16:44:53.698Z" },
]

[[package]]
name = "fastapi"
version = "0.128.8"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
dependencies = [
    { name = "annotated-doc", marker = "python_full_version < '3.10'" },
    { name = "pydantic", marker = "python_full_version < '3.10'" },
    { name = "starlette", version = "0.49.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "typing-extensions", marker = "python_full_version < '3.10'" },
    { name = "typing-inspection", marker = "python_full_version < '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/01/72/0df5c58c954742f31a7054e2dd1143bae0b408b7f36b59b85f928f9b456c/fastapi-0.128.8.tar.gz", hash = "sha256:3171f9f328c4a218f0a8d2ba8310ac3a55d1ee12c28c949650288aee25966007", size = 375523, upload-time = "2026-02-11T15:19:36.69Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/9f/37/37b07e276f8923c69a5df266bfcb5bac4ba8b55dfe4a126720f8c48681d1/fastapi-0.128.8-py3-none-any.whl", hash = "sha256:5618f492d0fe973a778f8fec97723f598aa9deee495040a8d51aaf3cf123ecf1", size = 103630, upload-time = "2026-02-11T15:19:35.209Z" },
]

[package.optional-dependencies]
all = [
    { name = "email-validator", marker = "python_full_version < '3.10'" },
    { name = "fastapi-cli", version = "0.0.22", source = { registry = "https://pypi.org/simple" }, extra = ["standard"], marker = "python_full_version < '3.10'" },
    { name = "httpx", marker = "python_full_version < '3.10'" },
    { name = "itsdangerous", marker = "python_full_version < '3.10'" },
    { name = "jinja2", marker = "python_full_version < '3.10'" },
    { name = "orjson", marker = "python_full_version < '3.10'" },
    { name = "pydantic-extra-types", marker = "python_full_version < '3.10'" },
    { name = "pydantic-settings", version = "2.11.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "python-multipart", version = "0.0.20", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "pyyaml", marker = "python_full_version < '3.10'" },
    { name = "ujson", version = "5.11.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "uvicorn", version = "0.39.0", source = { registry = "https://pypi.org/simple" }, extra = ["standard"], marker = "python_full_version < '3.10'" },
]

[[package]]
name = "fastapi"
version = "0.135.3"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
dependencies = [
    { name = "annotated-doc", marker = "python_full_version >= '3.10'" },
    { name = "pydantic", marker = "python_full_version >= '3.10'" },
    { name = "starlette", version = "1.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "typing-extensions", marker = "python_full_version >= '3.10'" },
    { name = "typing-inspection", marker = "python_full_version >= '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f7/e6/7adb4c5fa231e82c35b8f5741a9f2d055f520c29af5546fd70d3e8e1cd2e/fastapi-0.135.3.tar.gz", hash = "sha256:bd6d7caf1a2bdd8d676843cdcd2287729572a1ef524fc4d65c17ae002a1be654", size = 396524, upload-time = "2026-04-01T16:23:58.188Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/84/a4/5caa2de7f917a04ada20018eccf60d6cc6145b0199d55ca3711b0fc08312/fastapi-0.135.3-py3-none-any.whl", hash = "sha256:9b0f590c813acd13d0ab43dd8494138eb58e484bfac405db1f3187cfc5810d98", size = 117734, upload-time = "2026-04-01T16:23:59.328Z" },
]

[package.optional-dependencies]
all = [
    { name = "email-validator", marker = "python_full_version >= '3.10'" },
    { name = "fastapi-cli", version = "0.0.24", source = { registry = "https://pypi.org/simple" }, extra = ["standard"], marker = "python_full_version >= '3.10'" },
    { name = "httpx", marker = "python_full_version >= '3.10'" },
    { name = "itsdangerous", marker = "python_full_version >= '3.10'" },
    { name = "jinja2", marker = "python_full_version >= '3.10'" },
    { name = "pydantic-extra-types", marker = "python_full_version >= '3.10'" },
    { name = "pydantic-settings", version = "2.13.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "python-multipart", version = "0.0.24", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "pyyaml", marker = "python_full_version >= '3.10'" },
    { name = "uvicorn", version = "0.44.0", source = { registry = "https://pypi.org/simple" }, extra = ["standard"], marker = "python_full_version >= '3.10'" },
]

[[package]]
name = "fastapi-cli"
version = "0.0.22"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
dependencies = [
    { name = "rich-toolkit", marker = "python_full_version < '3.10'" },
    { name = "tomli", marker = "python_full_version < '3.10'" },
    { name = "typer", version = "0.23.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "uvicorn", version = "0.39.0", source = { registry = "https://pypi.org/simple" }, extra = ["standard"], marker = "python_full_version < '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/67/6f/eb6b193f7738145ffe903c7e763fd9d016bb6e8faaf0007d181341c380d9/fastapi_cli-0.0.22.tar.gz", hash = "sha256:e5a0e3c9e88f8a70affa144e6f85ccee14378a1706ade2001970e55418cad4f6", size = 19821, upload-time = "2026-02-16T19:13:24.508Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/ab/46/1acd9ae1cb48ef4bb4ea4633c91a213c6a8d53d326278a68a3602038fe98/fastapi_cli-0.0.22-py3-none-any.whl", hash = "sha256:74c30d81d1bf72b16fb19d32523481fded51d269082a937d0adbfc0871f580cb", size = 12392, upload-time = "2026-02-16T19:13:23.338Z" },
]

[package.optional-dependencies]
standard = [
    { name = "fastapi-cloud-cli", version = "0.12.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "uvicorn", version = "0.39.0", source = { registry = "https://pypi.org/simple" }, extra = ["standard"], marker = "python_full_version < '3.10'" },
]

[[package]]
name = "fastapi-cli"
version = "0.0.24"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
dependencies = [
    { name = "rich-toolkit", marker = "python_full_version >= '3.10'" },
    { name = "tomli", marker = "python_full_version == '3.10.*'" },
    { name = "typer", version = "0.24.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "uvicorn", version = "0.44.0", source = { registry = "https://pypi.org/simple" }, extra = ["standard"], marker = "python_full_version >= '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6e/58/74797ae9e4610cfa0c6b34c8309096d3b20bb29be3b8b5fbf1004d10fa5f/fastapi_cli-0.0.24.tar.gz", hash = "sha256:1afc9c9e21d7ebc8a3ca5e31790cd8d837742be7e4f8b9236e99cb3451f0de00", size = 19043, upload-time = "2026-02-24T10:45:10.476Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/c7/4b/68f9fe268e535d79c76910519530026a4f994ce07189ac0dded45c6af825/fastapi_cli-0.0.24-py3-none-any.whl", hash = "sha256:4a1f78ed798f106b4fee85ca93b85d8fe33c0a3570f775964d37edb80b8f0edc", size = 12304, upload-time = "2026-02-24T10:45:09.552Z" },
]

[package.optional-dependencies]
standard = [
    { name = "fastapi-cloud-cli", version = "0.15.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "uvicorn", version = "0.44.0", source = { registry = "https://pypi.org/simple" }, extra = ["standard"], marker = "python_full_version >= '3.10'" },
]

[[package]]
name = "fastapi-cloud-cli"
version = "0.12.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
dependencies = [
    { name = "fastar", marker = "python_full_version < '3.10'" },
    { name = "httpx", marker = "python_full_version < '3.10'" },
    { name = "pydantic", extra = ["email"], marker = "python_full_version < '3.10'" },
    { name = "rich-toolkit", marker = "python_full_version < '3.10'" },
    { name = "rignore", marker = "python_full_version < '3.10'" },
    { name = "sentry-sdk", marker = "python_full_version < '3.10'" },
    { name = "typer", version = "0.23.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "uvicorn", version = "0.39.0", source = { registry = "https://pypi.org/simple" }, extra = ["standard"], marker = "python_full_version < '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1b/59/3def056ec8350df78a0786b7ca40a167cbf28ac26552ced4e19e1f83e872/fastapi_cloud_cli-0.12.0.tar.gz", hash = "sha256:c897d1d5e27f5b4148ed2601076785155ec8fb385a6a62d3e8801880f929629f", size = 38508, upload-time = "2026-02-13T19:39:57.877Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/a3/6f/badabb5a21388b0af2b9cd0c2a5d81aaecfca57bf382872890e802eaed98/fastapi_cloud_cli-0.12.0-py3-none-any.whl", hash = "sha256:9c666c2ab1684cee48a5b0a29ac1ae0bd395b9a13bf6858448b4369ea68beda1", size = 27735, upload-time = "2026-02-13T19:39:58.705Z" },
]

[[package]]
name = "fastapi-cloud-cli"
version = "0.15.1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
dependencies = [
    { name = "fastar", marker = "python_full_version >= '3.10'" },
    { name = "httpx", marker = "python_full_version >= '3.10'" },
    { name = "pydantic", extra = ["email"], marker = "python_full_version >= '3.10'" },
    { name = "rich-toolkit", marker = "python_full_version >= '3.10'" },
    { name = "rignore", marker = "python_full_version >= '3.10'" },
    { name = "sentry-sdk", marker = "python_full_version >= '3.10'" },
    { name = "typer", version = "0.24.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "uvicorn", version = "0.44.0", source = { registry = "https://pypi.org/simple" }, extra = ["standard"], marker = "python_full_version >= '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7f/f2/fcd66ce245b7e3c3d84ca8717eda8896945fbc17c87a9b03f490ff06ace7/fastapi_cloud_cli-0.15.1.tar.gz", hash = "sha256:71a46f8a1d9fea295544113d6b79f620dc5768b24012887887306d151165745d", size = 43851, upload-time = "2026-03-26T10:23:12.932Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/b2/11/ecb0d5e1d114e8aaec1cdc8ee2d7b0f54292585067effe2756bde7e7a4b0/fastapi_cloud_cli-0.15.1-py3-none-any.whl", hash = "sha256:b1e8b3b26dc314e180fc0ab67dfd39d7d9fe160d3951081d09184eafaacf5649", size = 32284, upload-time = "2026-03-26T10:23:14.151Z" },
]

[[package]]
name = "fastar"
version = "0.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/dd/00/dab9ca274cf1fde19223fea7104631bea254751026e75bf99f2b6d0d1568/fastar-0.9.0.tar.gz", hash = "sha256:d49114d5f0b76c5cc242875d90fa4706de45e0456ddedf416608ecd0787fb410", size = 70124, upload-time = "2026-03-20T14:26:34.503Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/24/48/3d8e24c9ae7796e59231f50133640463c6a20b00ce684b308dc6de0e28fe/fastar-0.9.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:19a384395f26daa3ecb6c24054f3a50ce919e250e06b82614a252a0fadcbca17", size = 709092, upload-time = "2026-03-20T14:25:30.007Z" },
    { url = "https://files.pythonhosted.org/packages/d9/e5/4d7dc06f3ad5457b9a1510a75e3f9ec431ad020688fcf954012a2bcae6e8/fastar-0.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b9c82b1fef26d8fd4abad1152f4c74eeb86bc9d46c814757b695847a751b9b0b", size = 630252, upload-time = "2026-03-20T14:25:17.673Z" },
    { url = "https://files.pythonhosted.org/packages/79/d4/ebb285a263cc2070d04d39917288b5d1c7f49e1c47ed5544e86283e091c6/fastar-0.9.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:e5c91cb4527a6e634e7477a01aa52ccfbb978df1d9803172685c1e0802a2c18c", size = 869584, upload-time = "2026-03-20T14:24:52.067Z" },
    { url = "https://files.pythonhosted.org/packages/23/19/a293b6f75ea1b9e14d384859253ee65f966a73be306cea39552a557c9e34/fastar-0.9.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c6bc32f40a3e8ab12b8ebce48c4808d2bcf89bd3dac3023980b8a9b4aaf719f2", size = 762379, upload-time = "2026-03-20T14:23:47.429Z" },
    { url = "https://files.pythonhosted.org/packages/95/2f/a31f00c31f16a3bffd6f6ab3414964100fb35a79983f21283fc8b81d3cec/fastar-0.9.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ee4a1d85a58cd955a5409b221450762b851879ce6e080d6d717265fb9a4e939d", size = 759567, upload-time = "2026-03-20T14:24:00.677Z" },
    { url = "https://files.pythonhosted.org/packages/b0/46/5a4b1fb1e5c8b6cd1eb464e658ed75d667f1f53834f353e6323ca71bd113/fastar-0.9.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b72e25ec1cbad0fc2a5f93a147978cc41e054ce5789807ebd3bcece5f276c0c2", size = 925850, upload-time = "2026-03-20T14:24:13.669Z" },
    { url = "https://files.pythonhosted.org/packages/f6/ec/a5543fb1b059a82ce4c6fc571fe429390294e8150c09bb537d228471eac6/fastar-0.9.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9862ddfaf73c7388d708bcbeb75e2e336605465b88d952407621c847bab5d3cb", size = 818858, upload-time = "2026-03-20T14:24:39.431Z" },
    { url = "https://files.pythonhosted.org/packages/53/9a/af5ae6d24e1170702d096225989b4ee3470b22bbecb5c09c899e816aefd7/fastar-0.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3471fa2627b9703830d13c8b0a6ba19eeff4e2e0ff924631065ecceca56abb2b", size = 821941, upload-time = "2026-03-20T14:25:05.534Z" },
    { url = "https://files.pythonhosted.org/packages/54/3f/399d8b080f7c5fe1fa88dadaa7a30bd0bb885ad490d3c2ef2c667877c5c4/fastar-0.9.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:e3d2e68e0239ab24b65b0674f2b74ac71d8fb5ea221a3e0d0ab966292bd83e12", size = 886548, upload-time = "2026-03-20T14:24:26.209Z" },
    { url = "https://files.pythonhosted.org/packages/63/cd/034b5f61e99df67e092e1d3d538150a5f562d00c0259e6402cbcb62e15a9/fastar-0.9.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dcabfe31c48ff6a994c3dc4ddc27287b15d78a09c737beef8a6b1f210b720a6a", size = 970244, upload-time = "2026-03-20T14:25:42.928Z" },
    { url = "https://files.pythonhosted.org/packages/3e/8f/3a8b0d711050b300a3448c9d145c6d234958e148e456ab4a15daca6e4b05/fastar-0.9.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f1723bb9cc3dcd087b5dd066a0369f27529a925d467ccc896d1f6cd0212417bf", size = 1036944, upload-time = "2026-03-20T14:25:55.867Z" },
    { url = "https://files.pythonhosted.org/packages/34/11/cd5ebd16529c5fbff2431b494bd6f3f8ecafeca8f874449bf65ccf58c77b/fastar-0.9.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3ec2e699af02ba78f359b1cf1f4b3da22f41dec3a327f1cda6a1d31a43365a71", size = 1078612, upload-time = "2026-03-20T14:26:09.042Z" },
    { url = "https://files.pythonhosted.org/packages/85/c7/752c184e3c5e8de592e5d7ce3d081bf665ae5dbbe4a3df816daf38043143/fastar-0.9.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:761708eb2f6e402d4cda04ac81d0c2406b1c10375601e238083d2e885ec52a42", size = 1029368, upload-time = "2026-03-20T14:26:21.79Z" },
    { url = "https://files.pythonhosted.org/packages/68/b3/2a5551942adaecb9874ebc0d0922f3ab9dd058298b7a36a7900da93a3e68/fastar-0.9.0-cp310-cp310-win32.whl", hash = "sha256:a5ea0969c94845faed7bf681850df704da9617ad7231850dbc7ca4017080133a", size = 454507, upload-time = "2026-03-20T14:26:54.124Z" },
    { url = "https://files.pythonhosted.org/packages/23/30/7a2f25837ee7353ff5eaa815d9a6321f8704fcc39a94570a1b2d958639c0/fastar-0.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:e5646f10a747282904f2def929612ed19cace4bd702029c3d7c78205ef604abd", size = 486500, upload-time = "2026-03-20T14:26:42.142Z" },
    { url = "https://files.pythonhosted.org/packages/6f/01/4ecbe0b4938608f9c6c5c4d4f6b872975fe30152bfaa8e44fe0e3b6cbcc4/fastar-0.9.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:facc7522bd1c1e7569bedb602932fc7292408a320f415d72180634d58f661bf0", size = 708809, upload-time = "2026-03-20T14:25:31.299Z" },
    { url = "https://files.pythonhosted.org/packages/11/6a/085b3cae0e04da4d42306dc07e2cc4f95d9c8f27df4dfd1a25d0f80516cb/fastar-0.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c8ac3e8aaee57dfc822b04f570f0a963c2381a9dc8990fe0c6e965efd23fd451", size = 629764, upload-time = "2026-03-20T14:25:19.017Z" },
    { url = "https://files.pythonhosted.org/packages/3c/c2/cdd996a37837e6cc5edc4d09775d2a2bc63e9e931129db69947cf4c77148/fastar-0.9.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:d90493b4bb56db728b38eb18a551df386113d72ad4e7f1a97572f3662a9b8a85", size = 869631, upload-time = "2026-03-20T14:24:53.779Z" },
    { url = "https://files.pythonhosted.org/packages/30/d4/4a5a3c341d26197ea3ae6bed79fc9bb4ead8ddc74a93bdb74e4ee0bac18e/fastar-0.9.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:17e2c3b46408193ea13c1e1177275ca7951e88bd3dce16baccb8de4f5e0dc2e8", size = 762096, upload-time = "2026-03-20T14:23:49.175Z" },
    { url = "https://files.pythonhosted.org/packages/bc/dd/1d346cdfcd3064f6c435eff90a8d7cf0021487e3681453bdd681b9488d81/fastar-0.9.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:52f96a3d4cfbe4f06b376706fa0562f3a1d2329bc37168119af0e47e1ac21cab", size = 759627, upload-time = "2026-03-20T14:24:01.984Z" },
    { url = "https://files.pythonhosted.org/packages/02/a1/e91eb7ae1e41c0d3ead86dc199beb13a0b80101e2948d66adeb578b09e60/fastar-0.9.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:57e9b94e485713c79bb259f7ecff1213527d05e9aa43a157c3fbc88812cf163e", size = 926211, upload-time = "2026-03-20T14:24:15.218Z" },
    { url = "https://files.pythonhosted.org/packages/9b/63/9fea9604e7aecc2f062f0df5729f74712d81615a1b18fa6a1a13106184fa/fastar-0.9.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fb06d0a0cc3cf52a9c07559bb16ab99eb75afe0b3d5ce68f5c299569460851ac", size = 818748, upload-time = "2026-03-20T14:24:40.765Z" },
    { url = "https://files.pythonhosted.org/packages/b0/f8/521438041d69873bb68b144b09080ae4f1621cebb8238b1e54821057206b/fastar-0.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c75e779f72d845037d4bf6692d01ac66f014eaef965c9231d41d5cc1276b89fc", size = 822380, upload-time = "2026-03-20T14:25:06.825Z" },
    { url = "https://files.pythonhosted.org/packages/92/05/f33cc3f5f96ffb7d81a7f06c9239d4eea584527292a030a73d3218148f41/fastar-0.9.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:24b13fc4ef3f1e3c9cc2dcf07ad9445900db9d3ce09b73021547a55994d0407f", size = 886569, upload-time = "2026-03-20T14:24:27.567Z" },
    { url = "https://files.pythonhosted.org/packages/60/32/6e7cb45dce544f97b0199325084a0a5a895cb903e0539690619e78d8d7cf/fastar-0.9.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ec7852de506d022ad36ad56f4aefb10c259dd59e485bf87af827954d404ba9d5", size = 969993, upload-time = "2026-03-20T14:25:44.222Z" },
    { url = "https://files.pythonhosted.org/packages/6a/ee/04cf9374e5e6a82ddc87073d684c1fa7a9ca368bf85c2786535b1bfc38a9/fastar-0.9.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:a79c53c3003958dca88a7ec3dd805bf9c2fb2a659110039f44571d57e329e3d4", size = 1036738, upload-time = "2026-03-20T14:25:57.551Z" },
    { url = "https://files.pythonhosted.org/packages/b6/94/e6f6ad29c25c5f531a406e3a35ef5c034ea177748f9fb621073519adb3d5/fastar-0.9.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:00328ce7ae76be7f9e2faa6a221a0b41212e4115c27e2ac5e585bcf226bfc2eb", size = 1078557, upload-time = "2026-03-20T14:26:10.358Z" },
    { url = "https://files.pythonhosted.org/packages/1f/44/a1c9f6afe93d1cc1abb68a7cda2bada509d756d24e22d5d949ca86b4f45e/fastar-0.9.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5c03fad1ad9ac57cf03a4db9e18c7109c37416ff4eb9ebfca98fcd2b233a26c4", size = 1029251, upload-time = "2026-03-20T14:26:23.215Z" },
    { url = "https://files.pythonhosted.org/packages/75/31/9e77bc2af3c8b8a433b7175d14b9c75d0ab901542c7452fdf942ece5a155/fastar-0.9.0-cp311-cp311-win32.whl", hash = "sha256:163ba4c543d2112c8186be2f134d11456b593071ba9ea3faba4f155bde7c5dac", size = 454633, upload-time = "2026-03-20T14:26:55.344Z" },
    { url = "https://files.pythonhosted.org/packages/0c/d4/a78d51d1290cdce2d6d3162a18d12c736b71d3feef5a446b3fe021443eb3/fastar-0.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:2137d5d26044b44bb19197a8fc959256c772615ee959cddd0f74320b548fc966", size = 486772, upload-time = "2026-03-20T14:26:43.569Z" },
    { url = "https://files.pythonhosted.org/packages/fa/39/471aefca4c8180689cc0dc6f2f23bc283a3ca07114f713307fb947d320af/fastar-0.9.0-cp311-cp311-win_arm64.whl", hash = "sha256:ecb94de3bc96d9fae95641a7907385541517a4c17416153d3b952d37dce0a2a3", size = 463586, upload-time = "2026-03-20T14:26:35.483Z" },
    { url = "https://files.pythonhosted.org/packages/4d/9b/300bc0dafa8495718976076db216f42d57b251a582589566a63b4ed2cb82/fastar-0.9.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:7a8b5daa50d9b4c07367dffc40880467170bf1c31ca63a2286506edbe6d3d65b", size = 706914, upload-time = "2026-03-20T14:25:32.501Z" },
    { url = "https://files.pythonhosted.org/packages/95/97/f1e34c8224dc373c6fab5b33e33be0d184751fdc27013af3278b1e4e6e6c/fastar-0.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9ec841a69fea73361c6df6d9183915c09e9ce3bd96493763fa46019e79918400", size = 627422, upload-time = "2026-03-20T14:25:20.318Z" },
    { url = "https://files.pythonhosted.org/packages/a9/ad/e2499d136e24c2d896f2ec58183c91c6f8185d758177537724ed2f3e1b54/fastar-0.9.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:ad46bc23040142e9be4b4005ea366834dbf0f1b6a90b8ecdc3ec96c42dec4adf", size = 865265, upload-time = "2026-03-20T14:24:55.418Z" },
    { url = "https://files.pythonhosted.org/packages/fe/cf/b6ad68b2ab1d7b74b0d38725d817418016bdd64880b36108be80d2460b4d/fastar-0.9.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de264da9e8ef6407aa0b23c7c47ed4e34fde867e7c1f6e3cb98945a93e5f89f2", size = 760583, upload-time = "2026-03-20T14:23:50.447Z" },
    { url = "https://files.pythonhosted.org/packages/b8/96/086116ad46e3b98f6c217919d680e619f2857ffa6b5cc0d7e46e4f214b83/fastar-0.9.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:75c70be3a7da3ff9342f64c15ec3749c13ef56bc28e69075d82d03768532a8d0", size = 758000, upload-time = "2026-03-20T14:24:03.471Z" },
    { url = "https://files.pythonhosted.org/packages/9b/e6/ea642ea61eea98d609343080399a296a9ff132bd0492a6638d6e0d9e41a7/fastar-0.9.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4a734506b071d2a8844771fe735fbd6d67dd0eec80eef5f189bbe763ebe7a0b8", size = 923647, upload-time = "2026-03-20T14:24:16.875Z" },
    { url = "https://files.pythonhosted.org/packages/c6/3e/53874aad61e4a664af555a2aa7a52fe46cfadd423db0e592fa0cfe0fa668/fastar-0.9.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8eac084ab215aaf65fa406c9b9da1ac4e697c3d3a1a183e09c488e555802f62d", size = 816528, upload-time = "2026-03-20T14:24:42.048Z" },
    { url = "https://files.pythonhosted.org/packages/41/df/d663214d35380b07a24a796c48d7d7d4dc3a28ec0756edbcb7e2a81dc572/fastar-0.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acb62e2369834fb23d26327157f0a2dbec40b230c709fa85b1ce96cf010e6fbf", size = 819050, upload-time = "2026-03-20T14:25:08.352Z" },
    { url = "https://files.pythonhosted.org/packages/7c/5a/455b53f11527568100ba6d5847635430645bad62d676f0bae4173fc85c90/fastar-0.9.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:f2f399fffb74bcd9e9d4507e253ace2430b5ccf61000596bda41e90414bcf4f2", size = 885257, upload-time = "2026-03-20T14:24:28.86Z" },
    { url = "https://files.pythonhosted.org/packages/4f/dd/0a8ea7b910293b07f8c82ef4e6451262ccf2a6f2020e880f184dc4abd6c2/fastar-0.9.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:87006c8770dfc558aefe927590bbcdaf9648ca4472a9ee6d10dfb7c0bda4ce5b", size = 968135, upload-time = "2026-03-20T14:25:45.614Z" },
    { url = "https://files.pythonhosted.org/packages/6b/cb/5c7e9231d6ba00e225623947068db09ddd4e401800b0afaf39eece14bfee/fastar-0.9.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4d012644421d669d9746157193f4eafd371e8ae56ff7aef97612a4922418664c", size = 1034940, upload-time = "2026-03-20T14:25:58.893Z" },
    { url = "https://files.pythonhosted.org/packages/b5/b4/eccfcf7fe9d2a0cea6d71630acc48a762404058c9b3ae1323f74abcda005/fastar-0.9.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:094fd03b2e41b20a2602d340e2b52ad10051d82caa1263411cf247c1b1bc139f", size = 1073807, upload-time = "2026-03-20T14:26:11.694Z" },
    { url = "https://files.pythonhosted.org/packages/8b/53/6ddda28545b428d54c42f341d797046467c689616a36eae9a43ba56f2545/fastar-0.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:59bc500d7b6bdaf2ffb2b632bc6b0f97ddfb3bb7d31b54d61ceb00b5698d6484", size = 1025314, upload-time = "2026-03-20T14:26:24.624Z" },
    { url = "https://files.pythonhosted.org/packages/03/cf/71e2a67b0a69971044ad57fe7d196287ac32ab710bfc47f34745bb4a7834/fastar-0.9.0-cp312-cp312-win32.whl", hash = "sha256:25a1fd512ce23eb5aaab514742e7c6120244c211c349b86af068c3ae35792ec3", size = 452740, upload-time = "2026-03-20T14:26:56.604Z" },
    { url = "https://files.pythonhosted.org/packages/c0/c5/0ffa2fffac0d80d2283db577ff23f8d91886010ea858c657f8278c2a222c/fastar-0.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:b10a409797d01ee4062547e95e4a89f6bb52677b144076fd5a1f9d28d463ab10", size = 485282, upload-time = "2026-03-20T14:26:44.926Z" },
    { url = "https://files.pythonhosted.org/packages/14/20/999d72dc12e793a6c7889176fc42ad917d568d802c91b4126629e9be45a9/fastar-0.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea4d98fc62990986ce00d2021f08ff2aa6eae71636415c5a5f65f3a6a657dc5e", size = 461795, upload-time = "2026-03-20T14:26:36.728Z" },
    { url = "https://files.pythonhosted.org/packages/9a/26/ea9339facfe4ee224be673c6888dbf077f28b0f81185f80353966c9f4925/fastar-0.9.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7b55ae4a3a481fd90a63ac558a7e8aab652ac1dfd15d8657266e71bf65346408", size = 706740, upload-time = "2026-03-20T14:25:33.741Z" },
    { url = "https://files.pythonhosted.org/packages/77/52/f3b06867e5ca8d5b2c1c15a1563415e0037b5831f2058ee72b03960296d9/fastar-0.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f07c6bdeedfeb30ef459f21fa9ab06e2b6727f7e7653176d3abb7a85f447c400", size = 627615, upload-time = "2026-03-20T14:25:21.608Z" },
    { url = "https://files.pythonhosted.org/packages/52/32/021b0a633bca18bca4f831392c2938c15c4605de2d9895b783ad6d64679c/fastar-0.9.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:90f46492e05141089766699e95c79d470e8013192fbbb16ef16b576281f3b8ee", size = 864584, upload-time = "2026-03-20T14:24:56.941Z" },
    { url = "https://files.pythonhosted.org/packages/3f/54/e2e1b4c8512d670373047e5e585b1d1ff9ffd722b0a17647d22c9c9bd248/fastar-0.9.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:108bb46c080ca152bb331f1e0576177d36e9badba51b1d5724d2823542e0dd1f", size = 760246, upload-time = "2026-03-20T14:23:51.964Z" },
    { url = "https://files.pythonhosted.org/packages/fa/7d/1e283dd8dbb3647049594bb477bdc053045c6fff2d3f06386d2dcacce7aa/fastar-0.9.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d17d311cfbb559154ba940972b6d07a3a7ac221a2a01208f119ad03495f01d32", size = 757024, upload-time = "2026-03-20T14:24:04.69Z" },
    { url = "https://files.pythonhosted.org/packages/87/ac/82d3cb64d318ce16c5d1a26a40b8aa570fcc9b23684221aece838c4cbada/fastar-0.9.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d2ef34e7088f308e73460e1b8d9b0479a743f679816782a80db6ae87ee68714a", size = 921630, upload-time = "2026-03-20T14:24:18.155Z" },
    { url = "https://files.pythonhosted.org/packages/f7/b8/3e7892f1a25a1a2054a20de6c846c0794b8fa361e5b9d3d00915b41e97bd/fastar-0.9.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c93bf4732d0dd6adae4a8b3bbebe19af76ee1072b7688bf39c5a1d120425a772", size = 815791, upload-time = "2026-03-20T14:24:43.28Z" },
    { url = "https://files.pythonhosted.org/packages/db/5e/8fcc662db1fd0985f4f8a54e79276416565a0d1fcb8da66665b2061ead30/fastar-0.9.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a67b061b1099cf3b8b6234dd3605fa16f5078ab6b51c8d77ad7a5d11c3cf834", size = 818980, upload-time = "2026-03-20T14:25:09.545Z" },
    { url = "https://files.pythonhosted.org/packages/68/ed/37291fbd6c9b5b0905712da6191bdfc25a7dc236efbf130e3a1a7d1b9440/fastar-0.9.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:912efe3121dc1f3c05940cfa1c6b09b8868d702d24566506aa1d0d96e429923a", size = 884578, upload-time = "2026-03-20T14:24:30.584Z" },
    { url = "https://files.pythonhosted.org/packages/94/19/7b3b7af978ae4f012664781554716d67549ab19ddbcb6e6d1adc04d7a5e7/fastar-0.9.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2394980cc126a3263e115600bc4ff9e7320cddde83c99fc334ab530be5b7166e", size = 967790, upload-time = "2026-03-20T14:25:46.975Z" },
    { url = "https://files.pythonhosted.org/packages/e6/38/4cce2a8e529a7d3e99e427c9bbcccd7013ff6b3ba295613e6f1c573c9e6c/fastar-0.9.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:d0aff74ea98642784c941d3cd8c35943258d4b9626157858901c5b181683339b", size = 1033892, upload-time = "2026-03-20T14:26:00.22Z" },
    { url = "https://files.pythonhosted.org/packages/1a/3f/86f25d79b1b369c2756ee338b76d1696a9cac3a737e819459b0ad7822ede/fastar-0.9.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3e8a1deaf490f4ec15eca7e66127ff89cdefd20217f358739d4b7b1cb322f663", size = 1072969, upload-time = "2026-03-20T14:26:13.089Z" },
    { url = "https://files.pythonhosted.org/packages/10/4f/6ec0c123c15bbcb9a9b82e979dc81273789ebbfbb4a2b41a1a6941577c94/fastar-0.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c9bd8879ebf05aa247e60e454bb7568cbdd44f016b8c58e31e5398039403e61d", size = 1025768, upload-time = "2026-03-20T14:26:25.957Z" },
    { url = "https://files.pythonhosted.org/packages/5a/d1/cbdcdb78ca034ed51a9f53c2650885873d8b06727452c1cc33f56ad0c66a/fastar-0.9.0-cp313-cp313-win32.whl", hash = "sha256:11b35e6453a2da8715dd8415b3999ea57805125493e44ce41a32404bf9a510a7", size = 452742, upload-time = "2026-03-20T14:26:58.014Z" },
    { url = "https://files.pythonhosted.org/packages/74/ee/138d2f8e3504232a279afa224d3e5922c15dc7126613e6c135cfc8e10ec9/fastar-0.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:10a1e7f7bfa1c6f03e4c657fdc0a32ebe42d8e48f681403dc0c67258e1cb5bef", size = 484917, upload-time = "2026-03-20T14:26:46.135Z" },
    { url = "https://files.pythonhosted.org/packages/db/ca/f518ee9dccc45097560a2cff245590c65b7b348171c8d2f2e487cf92a69f/fastar-0.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:e5484ac1415e0ca8bc7b69231e3e3afb52887fed10b839ca676767635a13f06f", size = 461202, upload-time = "2026-03-20T14:26:37.937Z" },
    { url = "https://files.pythonhosted.org/packages/cf/00/99700dd33273c118d7d9ab7ad5db6650b430448d4cfae62aec6ef6ca4cb7/fastar-0.9.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:ccb2289f24ee6555330eb77149486d3a2ec8926450a96157dd20c636a0eec085", size = 707059, upload-time = "2026-03-20T14:25:35.086Z" },
    { url = "https://files.pythonhosted.org/packages/e9/a4/4808dcfa8dddb9d7f50d830a39a9084d9d148ed06fcac8b040620848bc24/fastar-0.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2bfee749a46666785151b33980aef8f916e6e0341c3d241bde4d3de6be23f00c", size = 627135, upload-time = "2026-03-20T14:25:23.134Z" },
    { url = "https://files.pythonhosted.org/packages/da/cb/9c92e97d760d769846cae6ce53332a5f2a9246eb07b369ac2a4ebf10480c/fastar-0.9.0-cp314-cp314-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f6096ec3f216a21fa9ac430ce509447f56c5bd979170c4c0c3b4f3cb2051c1a8", size = 864974, upload-time = "2026-03-20T14:24:58.624Z" },
    { url = "https://files.pythonhosted.org/packages/84/38/9dadebd0b7408b4f415827db35169bbd0741e726e38e3afd3e491b589c61/fastar-0.9.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7a806e54d429f7f57e35dc709e801da8c0ba9095deb7331d6574c05ae4537ea", size = 760262, upload-time = "2026-03-20T14:23:53.275Z" },
    { url = "https://files.pythonhosted.org/packages/d6/7d/7afc5721429515aa0873b268513f656f905d27ff1ca54d875af6be9e9bc6/fastar-0.9.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f9a06abf8c7f74643a75003334683eb6e94fabef05f60449b7841eeb093a47b0", size = 757575, upload-time = "2026-03-20T14:24:06.143Z" },
    { url = "https://files.pythonhosted.org/packages/fc/5d/7498842c62bd6057553aa598cd175a0db41fdfeda7bdfde48dab63ffb285/fastar-0.9.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1e9b5c155946f20ce3f999fb1362ed102876156ad6539e1b73a921f14efb758c", size = 924827, upload-time = "2026-03-20T14:24:19.364Z" },
    { url = "https://files.pythonhosted.org/packages/69/ab/13322e98fe1a00ed6efbfa5bf06fcfff8a6979804ef7fcef884b5e0c6f85/fastar-0.9.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fbdedac6a84ef9ebc1cee6d777599ad51c9e98ceb8ebb386159483dcd60d0e16", size = 816536, upload-time = "2026-03-20T14:24:44.844Z" },
    { url = "https://files.pythonhosted.org/packages/fe/fd/0aa5b9994c8dba75b73a9527be4178423cb926db9f7eca562559e27ccdfd/fastar-0.9.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51df60a2f7af09f75b2a4438b25cb903d8774e24c492acf2bca8b0863026f34c", size = 818686, upload-time = "2026-03-20T14:25:10.799Z" },
    { url = "https://files.pythonhosted.org/packages/46/d6/e000cd49ef85c11a8350e461e6c48a4345ace94fb52242ac8c1d5dad1dfc/fastar-0.9.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:15016d0da7dbc664f09145fc7db549ba8fe32628c6e44e20926655b82de10658", size = 885043, upload-time = "2026-03-20T14:24:32.231Z" },
    { url = "https://files.pythonhosted.org/packages/68/28/ee734fe273475b9b25554370d92a21fc809376cf79aa072de29d23c17518/fastar-0.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c66a8e1f7dae6357be8c1f83ce6330febbc08e49fc40a5a2e91061e7867bbcbf", size = 967965, upload-time = "2026-03-20T14:25:48.397Z" },
    { url = "https://files.pythonhosted.org/packages/c1/35/165b3a75f1ee8045af9478c8aae5b5e20913cca2d4a5adb1be445e8d015a/fastar-0.9.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1c6829be3f55d2978cb62921ef4d7c3dd58fe68ee994f81d49bd0a3c5240c977", size = 1034507, upload-time = "2026-03-20T14:26:01.518Z" },
    { url = "https://files.pythonhosted.org/packages/ba/4e/4097b5015da02484468c16543db2f8dec2fe827d321a798acbd9068e0f13/fastar-0.9.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:68db849e01d49543f31d56ef2fe15527afe2b9e0fb21794edc4d772553d83407", size = 1073388, upload-time = "2026-03-20T14:26:14.448Z" },
    { url = "https://files.pythonhosted.org/packages/07/d7/3b86af4e63a551398763a1bbbbac91e1c0754ece7ac7157218b33a065f4c/fastar-0.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5569510407c0ded580cfeec99e46ebe85ce27e199e020c5c1ea6f570e302c946", size = 1025190, upload-time = "2026-03-20T14:26:27.316Z" },
    { url = "https://files.pythonhosted.org/packages/39/07/8c50a60f03e095053306fcf57d9d99343bce0e99d5b758bf96de31aec849/fastar-0.9.0-cp314-cp314-win32.whl", hash = "sha256:3f7be0a34ffbead52ab5f4a1e445e488bf39736acb006298d3b3c5b4f2c5915e", size = 452301, upload-time = "2026-03-20T14:26:59.234Z" },
    { url = "https://files.pythonhosted.org/packages/ee/69/aa6d67b09485ba031408296d6ff844c7d83cdcb9f8fcc240422c6f83be87/fastar-0.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:cf7f68b98ed34ce628994c9bbd4f56cf6b4b175b3f7b8cbe35c884c8efec0a5b", size = 484948, upload-time = "2026-03-20T14:26:48.45Z" },
    { url = "https://files.pythonhosted.org/packages/20/6d/dba29d87ca929f95a5a7025c7d30720ad8478beed29fff482f29e1e8b045/fastar-0.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:155dae97aca4b245eabb25e23fd16bfd42a0447f9db7f7789ab1299b02d94487", size = 461170, upload-time = "2026-03-20T14:26:39.191Z" },
    { url = "https://files.pythonhosted.org/packages/96/8f/c3ea0adac50a8037987ee7f15ff94767ebb604faf6008cbd2b8efa46c372/fastar-0.9.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a63df018232623e136178953031057c7ac0dbf0acc6f0e8c1dc7dbc19e64c22f", size = 705857, upload-time = "2026-03-20T14:25:36.842Z" },
    { url = "https://files.pythonhosted.org/packages/ae/b3/e0e1aad1778065559680a73cdf982ed07b04300c2e5bf778dec8668eda6f/fastar-0.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6fb44f8675ef87087cb08f9bf4dfa15e818571a5f567ff692f3ea007cff867b5", size = 626210, upload-time = "2026-03-20T14:25:24.361Z" },
    { url = "https://files.pythonhosted.org/packages/94/f3/3c117335cbea26b3bc05382c27e6028278ed048d610b8de427c68f2fec84/fastar-0.9.0-cp314-cp314t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:81092daa991d0f095424e0e28ed589e03c81a21eeddc9b981184ddda5869bf9d", size = 864879, upload-time = "2026-03-20T14:25:00.131Z" },
    { url = "https://files.pythonhosted.org/packages/26/5d/e8d00ec3b2692d14ea111ddae25bf10e0cb60d5d79915c3d8ea393a87d5c/fastar-0.9.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e8793e2618d0d6d5a7762d6007371f57f02544364864e40e6b9d304b0f151b2", size = 759117, upload-time = "2026-03-20T14:23:54.826Z" },
    { url = "https://files.pythonhosted.org/packages/1a/61/6e080fdbc28c72dded8b6ff396035d6dc292f9b1c67b8797ac2372ca5733/fastar-0.9.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:83f7ef7056791fc95b6afa987238368c9a73ad0edcedc6bc80076f9fbd3a2a78", size = 756527, upload-time = "2026-03-20T14:24:07.494Z" },
    { url = "https://files.pythonhosted.org/packages/e8/97/2cf1a07884d171c028bd4ae5ecf7ded6f31581f79ab26711dcdad0a3d5ab/fastar-0.9.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b3a456230fcc0e560823f5d04ae8e4c867300d8ee710b14ddcdd1b316ac3dd8d", size = 921763, upload-time = "2026-03-20T14:24:20.787Z" },
    { url = "https://files.pythonhosted.org/packages/f6/e3/c1d698a45f9f5dc892ed7d64badc9c38f1e5c1667048191969c438d2b428/fastar-0.9.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a60b117ebadc46c10c87852d2158a4d6489adbfbbec37be036b4cfbeca07b449", size = 815493, upload-time = "2026-03-20T14:24:46.482Z" },
    { url = "https://files.pythonhosted.org/packages/25/38/e124a404043fba75a8cb2f755ca49e4f01e18400bb6607a5f76526e07164/fastar-0.9.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a6199b4ca0c092a7ae47f5f387492d46a0a2d82cb3b7aa0bf50d7f7d5d8d57f", size = 819166, upload-time = "2026-03-20T14:25:12.027Z" },
    { url = "https://files.pythonhosted.org/packages/85/4a/5b1ea5c8d0dbdfcec2fd1e6a243d6bb5a1c7cd55e132cc532eb8b1cbd6d9/fastar-0.9.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:34efe114caf10b4d5ea404069ff1f6cc0e55a708c7091059b0fc087f65c0a331", size = 883618, upload-time = "2026-03-20T14:24:33.552Z" },
    { url = "https://files.pythonhosted.org/packages/d3/0b/ae46e5722a67a3c2e0ff83d539b0907d6e5092f6395840c0eb6ede81c5d6/fastar-0.9.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4d44c1f8d9c5a3e4e58e6ffb77f4ca023ba9d9ddd88e7c613b3419a8feaa3db7", size = 966294, upload-time = "2026-03-20T14:25:50.024Z" },
    { url = "https://files.pythonhosted.org/packages/98/58/b161cf8711f4a50a3e57b6f89bc703c1aed282cad50434b3bc8524738b20/fastar-0.9.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d2af970a1f773965b05f1765017a417380ad080ea49590516eb25b23c039158a", size = 1033177, upload-time = "2026-03-20T14:26:02.868Z" },
    { url = "https://files.pythonhosted.org/packages/e2/76/faac7292bce9b30106a6b6a9f5ddb658fdb03abe2644688b82023c8f76b9/fastar-0.9.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:1675346d7cbdde0d21869c3b597be19b5e31a36442bdf3a48d83a49765b269dc", size = 1073620, upload-time = "2026-03-20T14:26:16.121Z" },
    { url = "https://files.pythonhosted.org/packages/b8/be/dd55ffcc302d6f0ff4aba1616a0da3edc8fcefb757869cad81de74604a35/fastar-0.9.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dc440daa28591aeb4d387c171e824f179ad2ab256ce7a315472395b8d5f80392", size = 1025147, upload-time = "2026-03-20T14:26:28.767Z" },
    { url = "https://files.pythonhosted.org/packages/4b/c7/080bbb2b3c4e739fe6486fd765a09905f6c16c1068b2fcf2bb51a5e83937/fastar-0.9.0-cp314-cp314t-win32.whl", hash = "sha256:32787880600a988d11547628034993ef948499ae4514a30509817242c4eb98b1", size = 452317, upload-time = "2026-03-20T14:27:03.243Z" },
    { url = "https://files.pythonhosted.org/packages/42/39/00553739a7e9e35f78a0c5911d181acf6b6e132337adc9bbc3575f5f6f04/fastar-0.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92fa18ec4958f33473259980685d29248ac44c96eed34026ad7550f93dd9ee23", size = 483994, upload-time = "2026-03-20T14:26:52.76Z" },
    { url = "https://files.pythonhosted.org/packages/4f/36/a7af08d233624515d9a0f5d41b7a01a51fd825b8c795e41800215a3200e7/fastar-0.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:34f646ac4f5bed3661a106ca56c1744e7146a02aacf517d47b24fd3f25dc1ff6", size = 460604, upload-time = "2026-03-20T14:26:40.771Z" },
    { url = "https://files.pythonhosted.org/packages/87/06/d3428ad721a9432c5fbdd00a57149d84c37ee6b2ceb0db0a40f7265a123b/fastar-0.9.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:c119f5c74c9e139f1b2cbe9c81aeb5d781ab39a1bd2eb7864eba10e39c1008c0", size = 711443, upload-time = "2026-03-20T14:25:39.895Z" },
    { url = "https://files.pythonhosted.org/packages/25/08/d4af03357716c17a101e0e359b2e6fda4262fcc6dda2bcc79137528910e1/fastar-0.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7fd317103baeb7543446d41a62ead691cda5b03adbf901d8f96e828e8cbaddd7", size = 634185, upload-time = "2026-03-20T14:25:27.178Z" },
    { url = "https://files.pythonhosted.org/packages/5f/43/c715d193903386f6ca1895b5e51101898a52a27f0d60f71386fe55754421/fastar-0.9.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:07ff9538d3bd642eb70c50c27ead0a799dcf27af8bcbc8bf7424ddd518d2d97f", size = 872626, upload-time = "2026-03-20T14:25:02.971Z" },
    { url = "https://files.pythonhosted.org/packages/c2/6a/21b3b741871a6c01fe190dddd1480415980d2700858862b32425c47f3910/fastar-0.9.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77898185e9866a2896d96dc0efad00c0e3e0887c436ed28b2a9304d8be2d3e70", size = 764311, upload-time = "2026-03-20T14:23:57.694Z" },
    { url = "https://files.pythonhosted.org/packages/4b/9e/17341237f6ac9c0670ffb60321dced7e70badb3aadef74d802a5a790b002/fastar-0.9.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f1eddc9faac7d6ade0ab2d44812e4e04b47894bc8fbb5e4dd24be42677c7f5a1", size = 763834, upload-time = "2026-03-20T14:24:10.358Z" },
    { url = "https://files.pythonhosted.org/packages/97/44/0e0990dbc22fc06e1be191cad355a578864c83e24c83d9202fb7064804ad/fastar-0.9.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a0f97770942a70970f2d8961694cd915ad6168683e72d55a8cb9e7fbcc1a6faf", size = 928305, upload-time = "2026-03-20T14:24:23.453Z" },
    { url = "https://files.pythonhosted.org/packages/2b/35/fca567114826e2a1469878ed87a493646143542170a9d8b0fee88433e055/fastar-0.9.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:acb45617e4a90d120ae0318c48b0d1e3e7f07d9ea2959746c499bd709decbbf8", size = 820334, upload-time = "2026-03-20T14:24:49.054Z" },
    { url = "https://files.pythonhosted.org/packages/c3/e5/34c34d07ea2ff5d57715abbaf919d8f723d069376ad952a1958380f92a04/fastar-0.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc361195c285dd16a0635f687646d76b65dea35a772dd4a421131e97bdb3f5b7", size = 824123, upload-time = "2026-03-20T14:25:14.872Z" },
    { url = "https://files.pythonhosted.org/packages/30/72/5cec1d1b3142d827d2a41047468924577c54d942e4bbe3d315f3ec362f65/fastar-0.9.0-cp39-cp39-manylinux_2_31_riscv64.whl", hash = "sha256:24234c9235c9105f7b05b6bf6f4e1fae90caa6e5fb4824a837d5cc696ac747c1", size = 888838, upload-time = "2026-03-20T14:24:36.944Z" },
    { url = "https://files.pythonhosted.org/packages/ea/f3/5cfb24784a910bef0792969fc21f63cd7495cad95ec3e6bca3d75b6f2a00/fastar-0.9.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:26efd87ef5231c2127313ff6e57bea7b474025b8eadcf5cf5c0e596c53092d4d", size = 972283, upload-time = "2026-03-20T14:25:52.761Z" },
    { url = "https://files.pythonhosted.org/packages/51/4c/2ba93cf8ecedfd323d66be25f8f5928c328b722f22031e86fb4ed8eab1ed/fastar-0.9.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:e12d845d6e4b2d3165c514b364acd5955d048f68a665038edff7c33c28b171b3", size = 1039240, upload-time = "2026-03-20T14:26:05.521Z" },
    { url = "https://files.pythonhosted.org/packages/f1/36/79f720ced6959cf3c0ce86245edbf01d420c66f6571d2a7ef8f023257268/fastar-0.9.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:c2dee9f11ec466ae9cd16387a881f94a9e4fbc94810560dd70751a85518590e5", size = 1081442, upload-time = "2026-03-20T14:26:18.907Z" },
    { url = "https://files.pythonhosted.org/packages/eb/0e/b9cc6b66a9371fbc6383040e37d4a3f068b98eb200667ca95e5c42688ed5/fastar-0.9.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a24aebf0ffa79f9e28e9626f84d7cc0addc8770e26d57bc118519a2b26e91064", size = 1032134, upload-time = "2026-03-20T14:26:31.96Z" },
    { url = "https://files.pythonhosted.org/packages/2d/59/d91d294340287105b29597ca20429eb801808924684770cd9b1c7fb9616b/fastar-0.9.0-cp39-cp39-win32.whl", hash = "sha256:5f1c9b5e64d7584c440a8de6817b80f44c8badb85087671a52786bdb04873438", size = 456451, upload-time = "2026-03-20T14:27:02.011Z" },
    { url = "https://files.pythonhosted.org/packages/fe/41/65b64bd968113e616537e28b5e5a8d9d130e6b453dd0be80de49bd305e0a/fastar-0.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:3a03e8a5123963fa4e12a19536b912d0cf3f3d69167f66ad70506ca6ba8a8de8", size = 488766, upload-time = "2026-03-20T14:26:51.5Z" },
    { url = "https://files.pythonhosted.org/packages/69/9f/4aeaa0a1ac2aca142a276ea136e651e94ba1341bd840ba455ed250d1970b/fastar-0.9.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b74ce299066288f3b90221dca8507f59c7d9e8df91387948006b9a0fea4f9bdc", size = 710738, upload-time = "2026-03-20T14:25:41.17Z" },
    { url = "https://files.pythonhosted.org/packages/d0/19/9f8fb5c0e803254c5d535c362102dd604d9bdb206d5a36150f4637cadf09/fastar-0.9.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:76be31936cabce31cbb6381128f851cf0a6da2d5c25357615cd1504b26dc31cf", size = 633000, upload-time = "2026-03-20T14:25:28.496Z" },
    { url = "https://files.pythonhosted.org/packages/ef/8d/0d1d9a87a78f1e686bb6c7c69688a4c9ad1efb65e49cc66310b97fdf900b/fastar-0.9.0-pp311-pypy311_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c4c9ea0e0d69445b0ca3b0bd80bd8237fec8a914275b0472ecca2b555c12f3a3", size = 871226, upload-time = "2026-03-20T14:25:04.351Z" },
    { url = "https://files.pythonhosted.org/packages/ef/04/366937320b1cca522570c527a45b1254bd68d057e68956baefc49eacae27/fastar-0.9.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b665c33afcd1d581b82235b690d999c5446ccc2c4d80c4a95f30df3b43d22494", size = 763872, upload-time = "2026-03-20T14:23:59.122Z" },
    { url = "https://files.pythonhosted.org/packages/c8/f2/121c5432bb152da68fc466a0d0206d66383a40a2f9beff5583d9277aceee/fastar-0.9.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d2a9a49f9217f4f60f9ba23fdd1f7f3f04fed97391145eb9460ec83ca0b4bd33", size = 762897, upload-time = "2026-03-20T14:24:11.932Z" },
    { url = "https://files.pythonhosted.org/packages/80/9e/88d3a603b997063e032f94cc0fff74031d76903f38cc30416a400395df03/fastar-0.9.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59d860e82a531e9cc67e7f500a299bffbe6e93d80bbf48401fd8f452a0c58f28", size = 927024, upload-time = "2026-03-20T14:24:24.689Z" },
    { url = "https://files.pythonhosted.org/packages/a6/17/d6dc778c45b0c7d9a279706d7a5d62122dab0a7a0cb39aac6f5ef42f13f6/fastar-0.9.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3feede2d72ec0782b5ccc18568f36cbe33816be396551aa47b3e1b73c322cdd2", size = 821265, upload-time = "2026-03-20T14:24:50.407Z" },
    { url = "https://files.pythonhosted.org/packages/e0/e0/cec25d43df7ea4b4e3e875352c6d51c848c855792ba276c546732a7170af/fastar-0.9.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d9ac410d32cbb514e966c45f0fedd0f9447b0dea9e734af714648da503603df6", size = 824024, upload-time = "2026-03-20T14:25:16.142Z" },
    { url = "https://files.pythonhosted.org/packages/52/90/c354969770d21d1b07c9281b5e23052392c288d22984a1917d30940e86cb/fastar-0.9.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:40b8c08df809e5e58d1839ccb37bafe4485deb6ee56bb7c5f0cbb72d701eb965", size = 888886, upload-time = "2026-03-20T14:24:38.229Z" },
    { url = "https://files.pythonhosted.org/packages/8c/ac/eb2a01ed94e79b72003840448d2b69644a54a47f615c7d693432a1337caa/fastar-0.9.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:d62a4fd86eda3bea7cc32efd64d43b6d0fcdbbec009558b750fc362f20142789", size = 972503, upload-time = "2026-03-20T14:25:54.207Z" },
    { url = "https://files.pythonhosted.org/packages/8d/88/f7e28100fa7ff4a26a3493ad7a5d45d70f6de858c05f5c34aca3570c5839/fastar-0.9.0-pp311-pypy311_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:7bf6958bb6f94e5ec522e4a255b8e940d3561ad973f0be5dde6115b5a0854af5", size = 1039106, upload-time = "2026-03-20T14:26:07.686Z" },
    { url = "https://files.pythonhosted.org/packages/c0/de/52c578180fdaaf0f3289de8a878f1ac070f7e3e18a0689d3fd44dd7dae2c/fastar-0.9.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:c210b839c0a33cf8d08270963ad237bcb63029dddf6d6025333f7e5ca63930bd", size = 1080754, upload-time = "2026-03-20T14:26:20.299Z" },
    { url = "https://files.pythonhosted.org/packages/a4/45/1ea024be428ad9d89e9f738c9379507e97df9f9ed97e50e4a1d10ff90fef/fastar-0.9.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:fad70e257daefb42bab68dcd68beaf2e2a99da056d65f2c9f988449a4e869306", size = 1031304, upload-time = "2026-03-20T14:26:33.294Z" },
]

[[package]]
name = "fastnanoid"
version = "0.4.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/21/f5/dd1935e7b6a7534c008b39205c6fad38c59146ef0a4987696fd770f9e1a1/fastnanoid-0.4.3.tar.gz", hash = "sha256:aa8d33896513fa02dbc1393396365ef5023c1ffb73dd344fd797cc846452b45a", size = 9981, upload-time = "2025-06-30T14:43:35.824Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/d8/33/ce5e1b7e898a8bdca55d794fabd7fe465006ca8d2121cb3f93bbdd2e8bdd/fastnanoid-0.4.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:10c9ccc8e2d2ae7fc1ae5200a0a40511938376e5285ca756f99df20ddfe5c736", size = 231872, upload-time = "2025-06-30T14:41:27.448Z" },
    { url = "https://files.pythonhosted.org/packages/8c/06/92e69995b29e54bc573baf90a34fbe3bc0d254af705607a7441715eb5a18/fastnanoid-0.4.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c84cfa23115382d46c47a803d905abfb86a44b1268ff43e4a837dc76c881f1b5", size = 241609, upload-time = "2025-06-30T14:41:41.219Z" },
    { url = "https://files.pythonhosted.org/packages/c1/1d/d62b82712203308efc010ce6e702883ae8175c3bcec3be6121b99b1ed68c/fastnanoid-0.4.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:38ebed8c9e9b541d799b457ca5348462ee2f0c1d96186ef793fe6d9c8a1ea270", size = 366937, upload-time = "2025-06-30T14:41:54.559Z" },
    { url = "https://files.pythonhosted.org/packages/5a/a4/b16506c1d781acdee49dd9c0e00ac6fd91e9771e24ace27c0de5444840d7/fastnanoid-0.4.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a87f76b8981c5d17818f32f6755298cc894c4fa2a3311160d98a70251bd68dfa", size = 261339, upload-time = "2025-06-30T14:42:07.146Z" },
    { url = "https://files.pythonhosted.org/packages/bf/ec/e7ae4a0d49e02c07e81b6975986a082b50f6e96676297f4374847f1e3ab1/fastnanoid-0.4.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d8591363d60f1327832fbfa6d951a324804bfca0705f086375dd74c2c4aeceec", size = 235731, upload-time = "2025-06-30T14:42:30.24Z" },
    { url = "https://files.pythonhosted.org/packages/dd/1c/270789f975eab5cbe47c3c99fdf213981e9722eb22760acddf194960043a/fastnanoid-0.4.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0ce4a79eeeaddc70c3b12f9a106e8c78f7ba76484c78be9b72021bbfc2967813", size = 250681, upload-time = "2025-06-30T14:42:17.599Z" },
    { url = "https://files.pythonhosted.org/packages/f5/f9/39ea727bb3ccb762a20fb4c542987ce088bf546652e9076ac06035de91b8/fastnanoid-0.4.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6d2e9596f5e4595aab72036d0f926656d0469ac1106b440417d0dd7e787a8714", size = 410740, upload-time = "2025-06-30T14:42:47.259Z" },
    { url = "https://files.pythonhosted.org/packages/32/3a/d3535332efd530d6c2d8dc10d5474f9021939278aaddf265e84e28c8b272/fastnanoid-0.4.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:dba768064601c28ee9ea7e262f0ff776fcb1270fd6ae0c0271aa2722e79c347b", size = 504169, upload-time = "2025-06-30T14:42:59.611Z" },
    { url = "https://files.pythonhosted.org/packages/18/8f/54f7c7ad9d06e4b036d659a73335e64ff73f6ac222c8b14f75e29e488ab9/fastnanoid-0.4.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:d3f72279b67a4f51b5752113ba27acb83139133222736d2d3204fc31801aa578", size = 431332, upload-time = "2025-06-30T14:43:11.046Z" },
    { url = "https://files.pythonhosted.org/packages/3a/18/d5007434acfa3263bf6ca9539bbbe7b6755a5f599eba5044c73ae19b3f8c/fastnanoid-0.4.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3e2598b31f3c7a39acaeeeb93f3b0db135ec65ce680ee7fa8237380b315b98f7", size = 406112, upload-time = "2025-06-30T14:43:23.751Z" },
    { url = "https://files.pythonhosted.org/packages/c9/08/d98e30437d1a4e116d1a8a82255c2344dec11a9403f1cbf31bcd38242468/fastnanoid-0.4.3-cp310-cp310-win32.whl", hash = "sha256:9218f3474061f9bc346a2185ac9480567f0445b75d63972bcdd6d41cda472f6f", size = 102769, upload-time = "2025-06-30T14:43:41.509Z" },
    { url = "https://files.pythonhosted.org/packages/d2/00/8b0dd74f4b929951fe7fd194435e724225171bd639349ac139113df1a933/fastnanoid-0.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:37383ffa42e47ed2afc742665b67bfc9c87703404c46475b44496f7475f40e5f", size = 109616, upload-time = "2025-06-30T14:43:36.578Z" },
    { url = "https://files.pythonhosted.org/packages/2b/b2/1a1556adc254eedf0d940267ecde9b8b2e7b683b04d403ecbc07671328e2/fastnanoid-0.4.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4468f2128ac3b203baef110185b61619a2f45492bb6a90c9f098ca4a957fc4dc", size = 216189, upload-time = "2025-06-30T14:42:44.066Z" },
    { url = "https://files.pythonhosted.org/packages/e5/d5/ac87ae976a91585c84ec260ba109d43f19e34d263df88c62381665d79558/fastnanoid-0.4.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:338c7d57e199b80b2fde8984138546351bba21ea3bac03ebc51814762d0bfb45", size = 210869, upload-time = "2025-06-30T14:42:41.078Z" },
    { url = "https://files.pythonhosted.org/packages/51/28/839d11bf48c5423cecc5117f4e06a8b5902f8bffb9e623461135aa35442a/fastnanoid-0.4.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f162bfdd52d50282a727d8193c401c8336cdd7ccd9eaa9fbb3f5b89478ac2cd", size = 231775, upload-time = "2025-06-30T14:41:28.95Z" },
    { url = "https://files.pythonhosted.org/packages/65/ae/527c847397e83d84cc035ab011202322c7b225e1e868e2c8e42c65d9b583/fastnanoid-0.4.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47b9066b4a07ddbf3a1d28d1761616a72a7fedc035c6e6659d0fcafbc40832b", size = 241388, upload-time = "2025-06-30T14:41:42.379Z" },
    { url = "https://files.pythonhosted.org/packages/fa/82/0e1cb7fd5c00f5b9a612173560f1361bce2ff292c04de33eb6559c91c772/fastnanoid-0.4.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:30ce1cc5297626f2de5a2eb274b5fb323326accaa517e5126622efc288de900e", size = 366863, upload-time = "2025-06-30T14:41:55.862Z" },
    { url = "https://files.pythonhosted.org/packages/71/b2/57e061f6d48a33c4a3f4fceea2b90674181429d32ee90f282746a2dc4c73/fastnanoid-0.4.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ba492e9e1d6e2d5e7a9e9a074c14f24f6c774dc62d145672ecbc9fc7a3425520", size = 261125, upload-time = "2025-06-30T14:42:08.228Z" },
    { url = "https://files.pythonhosted.org/packages/af/99/27346e87da58b194bc1927cf276138c4d717ea3ce95a397a1959fc54b7a9/fastnanoid-0.4.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2e1a9ade5de172b2bbf37b296aae1b867bd549087180ea2259db6ee61359c5aa", size = 235511, upload-time = "2025-06-30T14:42:32.003Z" },
    { url = "https://files.pythonhosted.org/packages/0e/97/cde7439016731a2b39c22373a014dc88feab8bfa5163ee7602cfd0ef478e/fastnanoid-0.4.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1de3bf70e41689f276140c4944beb5280449ea7eb4469517fbfb49461c2724d8", size = 250576, upload-time = "2025-06-30T14:42:18.608Z" },
    { url = "https://files.pythonhosted.org/packages/5a/95/ce648bed84345d798010afb0b9bb255e4901594b66a888b891ce4985651c/fastnanoid-0.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8a531e092d8e5125555740d9a895ed7a97c5bb00a37881d9e010456d74f1cd26", size = 410572, upload-time = "2025-06-30T14:42:48.755Z" },
    { url = "https://files.pythonhosted.org/packages/b2/e0/10ba3092ef30692f7a994fc4a946ae2dbc78aab27b5e48c4183abe8a181c/fastnanoid-0.4.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:5d77c46c67b5c211da60ea03dfcf5e460542e201cd32dae0f2f590edb28b4775", size = 503840, upload-time = "2025-06-30T14:43:00.748Z" },
    { url = "https://files.pythonhosted.org/packages/bc/7e/0cf0fb74f59189c57153a71af0064554ce6f037562aec4ad5314a1c996b3/fastnanoid-0.4.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ab68c0728a3faabf57c551912cbd9ec423e8bb9adfb8ca70ec6b916042ec7394", size = 431048, upload-time = "2025-06-30T14:43:12.16Z" },
    { url = "https://files.pythonhosted.org/packages/f2/f8/039bde05afb09ef175a4204cb8be3da9ef762f1e122639ee3b55cc006da4/fastnanoid-0.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:304f89f4d0a9a96ba0b60526fcce5b31d88be68d9348d4c1b74f864b9c42b81d", size = 405846, upload-time = "2025-06-30T14:43:25.002Z" },
    { url = "https://files.pythonhosted.org/packages/d7/9d/fe42437fb3396130b5ab370bdbb98075e8c86d73405cbabacfb31965fd93/fastnanoid-0.4.3-cp311-cp311-win32.whl", hash = "sha256:3ddd9b3b6173ceaf3a1c93e10f5ebeb5e02f952e15e2bd42d396a0ef688d8e0e", size = 103042, upload-time = "2025-06-30T14:43:42.921Z" },
    { url = "https://files.pythonhosted.org/packages/b9/a9/a6f50a25684b29095e4b64974f1845b24e4a61bbcf45b754da272b02c240/fastnanoid-0.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:64e292b40d4473f06487c35fa190a8f02925d065991d3ffb759064f899dd8577", size = 109849, upload-time = "2025-06-30T14:43:37.557Z" },
    { url = "https://files.pythonhosted.org/packages/4d/12/cfbe595d53e63a1b266a28b7f8d86c9bbf3e34b7b507854cd813f23c9bdb/fastnanoid-0.4.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:dacbeb28b27b247c94925fbccde2a5e3d64905c14d7e5f67678765a76701fc31", size = 213543, upload-time = "2025-06-30T14:42:45.189Z" },
    { url = "https://files.pythonhosted.org/packages/07/52/72208cb48880adf31633eed3577d86ed48b92d7672ff290bad130bed2530/fastnanoid-0.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:045de0ed68fd8344b77430acfe1b0264fb688d56a093d497c67a11bfd2278fd9", size = 207889, upload-time = "2025-06-30T14:42:42.129Z" },
    { url = "https://files.pythonhosted.org/packages/f7/44/5af057807407cc9e5db1c0068b3018467937a37d3fc73a5b693321242e10/fastnanoid-0.4.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f5631de9f464dbfe4ea45f97048c902bd4e4f312196bfbd6decec74610010b4", size = 231223, upload-time = "2025-06-30T14:41:32.516Z" },
    { url = "https://files.pythonhosted.org/packages/a4/d0/7208a1232024c1f45ac899f6b27598c9358277b010df1a37dcc3a24adb0d/fastnanoid-0.4.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:490d29e51caac8e2045bb2ec4422d35ba2abc1a5d59313fc20a2cddb473719fa", size = 240438, upload-time = "2025-06-30T14:41:43.783Z" },
    { url = "https://files.pythonhosted.org/packages/e6/02/249c079c9937ed88825ee46e24cfd24f759e7b93d8514e100fc7afdd9392/fastnanoid-0.4.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:860cd8d544962a01d398cd54b42fab9489ff9248b560d7ca826a6842ddb111d9", size = 370318, upload-time = "2025-06-30T14:41:57.313Z" },
    { url = "https://files.pythonhosted.org/packages/c9/d9/024d5fc19a02a6884d45012209b673416f72195424f787fe7f1d43187836/fastnanoid-0.4.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8de4a164f45891e9f1eb60ac165df5c10d2ba0269bc1ea42a4319912c5c31a12", size = 261495, upload-time = "2025-06-30T14:42:09.266Z" },
    { url = "https://files.pythonhosted.org/packages/9f/f3/be59d7166a0351cc116f963c26e589a4a1a635a02a2cbccb33e0b0864a66/fastnanoid-0.4.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8df4e3fd8fa8cd868cf14a7ee9c84da60d27583543cdc549596a83efb26f134b", size = 234486, upload-time = "2025-06-30T14:42:33.47Z" },
    { url = "https://files.pythonhosted.org/packages/ab/ca/a21feb43d6363641917bf529d9019fc2f36af7d7cba66ead2deb7f7b6a0a/fastnanoid-0.4.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:10f0acf972afc2dd51d62d7fa8077d43ae6c91cc251495fcc36a0abe2e2a21bd", size = 249336, upload-time = "2025-06-30T14:42:20.596Z" },
    { url = "https://files.pythonhosted.org/packages/fb/57/94bf417e6e8935cf282825bd7fe058adf9448e6734afb32fb7a7b44c8f44/fastnanoid-0.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:402370ef2caccd417059f2aa55df6b34e2740cdabdbf983b0c45add80d8f78c4", size = 410146, upload-time = "2025-06-30T14:42:49.875Z" },
    { url = "https://files.pythonhosted.org/packages/57/a7/be3b6d50b839bf5ccd59d2ec01dd728bc703e0d51491b52783dc872f3976/fastnanoid-0.4.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:aaf76f24a9a6278c5dd7f5680bd9633d7e57f5272d894facfa42da2fd2f2a941", size = 502822, upload-time = "2025-06-30T14:43:01.832Z" },
    { url = "https://files.pythonhosted.org/packages/db/e3/765adf90e2f4d4ba0f49351853a923f05162ed4507d011e9160746d54eac/fastnanoid-0.4.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d265def97eb3ffa0e8a27112d05038ec3025a593bae016a3074beea325f107b0", size = 430021, upload-time = "2025-06-30T14:43:13.764Z" },
    { url = "https://files.pythonhosted.org/packages/c4/df/56f0345f2b4a5cf04ba7e61e24386daed45cdb1060384e4ead78c84cf31e/fastnanoid-0.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7375f8ee7316a512b8228ba63367898d9445a4ca32e4df5f6947bae0af707ed3", size = 404709, upload-time = "2025-06-30T14:43:26.137Z" },
    { url = "https://files.pythonhosted.org/packages/cf/64/e7bb58e6d811225c2b353c7223b6f218d95a5494ef29fa30344b2cd89e31/fastnanoid-0.4.3-cp312-cp312-win32.whl", hash = "sha256:8e08f0408a392fed2d63dd356c70d50e4bcfb7fad26739a0e44fac847253f09c", size = 102717, upload-time = "2025-06-30T14:43:45.781Z" },
    { url = "https://files.pythonhosted.org/packages/ba/62/b1df24ab312cd06e4371d6c2e61d6832d1c2a623ce14ff94b23969204ee7/fastnanoid-0.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:7c73723c9ebcc5155b68b909f4ba6afb176ea01a1747fc5ae11744a1ad18b1f1", size = 109491, upload-time = "2025-06-30T14:43:38.587Z" },
    { url = "https://files.pythonhosted.org/packages/e0/01/53dad4b97e424d94ac3ec1c4c56dde95cf47a0f9dee32e5d1696422d239e/fastnanoid-0.4.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:26e621635473a2a765f5f11f4533ebf78377e7336db49f10dd082d522011a37d", size = 213706, upload-time = "2025-06-30T14:42:46.216Z" },
    { url = "https://files.pythonhosted.org/packages/4b/ae/a6691565b7398ae24fc7b8b6af1d783aa22ce0ff68fe2d2257c8e9aa17ee/fastnanoid-0.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a683290623456a2a48dbfb546e7cd6e22b87160a24d6557f01c271047a3ca107", size = 208084, upload-time = "2025-06-30T14:42:43.14Z" },
    { url = "https://files.pythonhosted.org/packages/f5/c1/143567f642467d764781679997247dd8cd17eb9b89b43468b77a601eba46/fastnanoid-0.4.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79a8af60fa4f3b729d38c6795ade55dbcdc24d2c4306804016a2f32c5e098434", size = 231229, upload-time = "2025-06-30T14:41:33.486Z" },
    { url = "https://files.pythonhosted.org/packages/59/5b/014aca65ad7a7be1eb1228c1fd980f17b453d155c5e8907879bb6f50a88c/fastnanoid-0.4.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f85cff115077cb9f55e1ee1502077fa3cfb8fcea22060165f957b2576e44f116", size = 240135, upload-time = "2025-06-30T14:41:46.718Z" },
    { url = "https://files.pythonhosted.org/packages/07/f6/616efa01caeb30bea4f224f03853e91016759de94f2354eade1414f60a4e/fastnanoid-0.4.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:716e5c1e18ae4b56a691e34485d1fb7bd0264878a94978eb3265d245626b613d", size = 367044, upload-time = "2025-06-30T14:41:58.647Z" },
    { url = "https://files.pythonhosted.org/packages/78/54/1cde771fe30a9026d289aff78fb5913d1f0fe742d27df668a68230bf685b/fastnanoid-0.4.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9a3c3c551d02cb1d3eb0a2928664fd7948400b9b86f732a11d5891325efa8657", size = 261560, upload-time = "2025-06-30T14:42:10.27Z" },
    { url = "https://files.pythonhosted.org/packages/f7/72/3989891de09135f4b6785d46566387f8e3f9428fcfa04e9edde417c2bddb/fastnanoid-0.4.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68561fd3ebe9615b1bbb52eb909c3f2dbf9295eb87ede86d63317654210e9907", size = 234308, upload-time = "2025-06-30T14:42:34.47Z" },
    { url = "https://files.pythonhosted.org/packages/be/ec/aa4e384f60d00c59e10fb3630f69255fad601f25fc69236cd23de07e572f/fastnanoid-0.4.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e7c4ca8aebadf142c8e49c3da69af991ef846e791039eb944786ed21bc106896", size = 249116, upload-time = "2025-06-30T14:42:21.703Z" },
    { url = "https://files.pythonhosted.org/packages/31/42/0fb29a5c2c06a53dd12082bcad20d61863ae1d88731dff9df8cb0d1f05b3/fastnanoid-0.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d6c83d8b358ad0a31ab59cd1a89d3698709e9fbfd4451192e04643606b53fb86", size = 410142, upload-time = "2025-06-30T14:42:50.908Z" },
    { url = "https://files.pythonhosted.org/packages/6b/aa/441100a4e3d9fa2e551272f4f408bbeec885c68b4b99784885974ff17a57/fastnanoid-0.4.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:21e5f7ef6de4d21743adf87455c0decd7fc270ae16a956be9bcf016202155449", size = 502574, upload-time = "2025-06-30T14:43:02.932Z" },
    { url = "https://files.pythonhosted.org/packages/73/fd/c37a60618c96c29e52f3e0cf7ee5b85f203a04c2f4a952fdd037c8b1c263/fastnanoid-0.4.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8bbe3cd12f624240b3d2f2573d667c80350bb13538655fd66112361534515607", size = 429760, upload-time = "2025-06-30T14:43:14.846Z" },
    { url = "https://files.pythonhosted.org/packages/d6/8d/1b7407dc0cd86fc340798c1cdd27daf751f6ebde7b3b04b6b5d60ff0c4d9/fastnanoid-0.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5e627658f2ee8ba50f20ef5e3def22d9cd57809a3c10eb8bb1477dbfe48f7086", size = 404540, upload-time = "2025-06-30T14:43:27.26Z" },
    { url = "https://files.pythonhosted.org/packages/46/bf/70a45f08e35d6fa2ac55bd5641e0489146bd7a98a6f4251f15fc5e6fc2ba/fastnanoid-0.4.3-cp313-cp313-win32.whl", hash = "sha256:f30395dba8dc5237f6036c29a53e64b644c83bcfbc938823ce364bfb8384b1f1", size = 102815, upload-time = "2025-06-30T14:43:46.746Z" },
    { url = "https://files.pythonhosted.org/packages/31/7d/171e34276c6868c2d53909fe3757cd930e0d62282cf78076ab80fac98e09/fastnanoid-0.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:4fa766a758327aff021cf93546ce2454a58f619ef2993666c5d52c4cebcd65c2", size = 109439, upload-time = "2025-06-30T14:43:39.552Z" },
    { url = "https://files.pythonhosted.org/packages/ac/17/c4a18bee948c37e2ddc029194647457d6ba1f05c101224ff6c6bdb5b6047/fastnanoid-0.4.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dbfbd6d442b2842cc6cbcdcf3d4b6db6e677dad544765ee62a4fdee43aae60ca", size = 230789, upload-time = "2025-06-30T14:41:34.993Z" },
    { url = "https://files.pythonhosted.org/packages/22/48/36f096b479ed5239b58d6243f1b696f6863b1a9fe4561a63fb7e86c51037/fastnanoid-0.4.3-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0b0a847843a58c1ee1d4d8c037a1c00cb84dc66710e03320e5782c55b28d5b07", size = 240082, upload-time = "2025-06-30T14:41:48.016Z" },
    { url = "https://files.pythonhosted.org/packages/bc/c2/b081c074f967372397980dfe3089dd1ac69afd430e260e9fdd561ba2df01/fastnanoid-0.4.3-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4078e26ccc6a001f9a70504f536f61b4e7773f5b8127791f6a0a4db51fa111c9", size = 367828, upload-time = "2025-06-30T14:42:00.101Z" },
    { url = "https://files.pythonhosted.org/packages/81/1c/bfa345ed2e0cd03f3f9429091f643e7cf42706a276eb0e1cfbff7d404e37/fastnanoid-0.4.3-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c3dbbe0b4eac58340dd522d6ece665b597c0c4af33865dc07c8b0dea8606ce25", size = 261272, upload-time = "2025-06-30T14:42:11.308Z" },
    { url = "https://files.pythonhosted.org/packages/fa/46/554bfe67aa43a9fc91e3ef076610f06c57cdc287f5754fd6af6d544028ad/fastnanoid-0.4.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:daa2e0778f941da55b01177cc811f02291a2c02c07369126328deff49556f9a3", size = 409815, upload-time = "2025-06-30T14:42:52.043Z" },
    { url = "https://files.pythonhosted.org/packages/99/25/b5820834b1b41c1d57eceb76b80743b5ccfa5d1be6880b7ae88a350e1190/fastnanoid-0.4.3-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:86b18aa85e6d5768c5a23e3e98a3731ec0f731c68393ce832cf275a1b0b3533d", size = 502414, upload-time = "2025-06-30T14:43:04.064Z" },
    { url = "https://files.pythonhosted.org/packages/2c/55/e1072cc080165b311625b080e1a69408d6bbd598575917b7a583ad485b80/fastnanoid-0.4.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:bb2d63c3c2f5224c84964e213510b68683610d6d8eea78791d8b31eac9785cfe", size = 429764, upload-time = "2025-06-30T14:43:15.918Z" },
    { url = "https://files.pythonhosted.org/packages/d5/30/0037e0d426561fdd46866b04e7214cae854aab050f8c91d4f45ddfd4034f/fastnanoid-0.4.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:b42a90da8c0c085911bef93323cd8244f3a7e6b327ecbfa2432bc3db04086014", size = 404473, upload-time = "2025-06-30T14:43:28.378Z" },
    { url = "https://files.pythonhosted.org/packages/47/a1/5b3938464cf0ba235c76b15b1d0fe2d61cfd47dac848595bc47fc1fe541e/fastnanoid-0.4.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c5833bb7a1814e105f713731f7aac4aadb06e14517139d70080cacce458026eb", size = 234388, upload-time = "2025-06-30T14:42:35.479Z" },
    { url = "https://files.pythonhosted.org/packages/59/30/6dd2f979a78783848fcff31251cc5bc45c315eb9314a5c9b485e76fc818d/fastnanoid-0.4.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:39fb393f81eb5ce37135418a9a10560b670682eeca4c062b985a1bc6fec45705", size = 249086, upload-time = "2025-06-30T14:42:22.718Z" },
    { url = "https://files.pythonhosted.org/packages/88/11/38634f8c3d469cc6cd30297317d9d5ba62df7bc3334581e83e6bac22bd68/fastnanoid-0.4.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:157555b4ecf883a4e0ce560d5ff701c05af69dfae97881076814e44c34de5044", size = 232587, upload-time = "2025-06-30T14:41:37.027Z" },
    { url = "https://files.pythonhosted.org/packages/1a/d2/d710b0d17c11f6ecb4f23a6cf3b2e1f7fd4521ec66f9a8de6c82f9b1481e/fastnanoid-0.4.3-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6f180616b7e55fdf78ad26d99f2e041b25e5c46b0a4b11ab84d372810d0c8684", size = 241477, upload-time = "2025-06-30T14:41:50.33Z" },
    { url = "https://files.pythonhosted.org/packages/ac/35/f60c61522b236b46701638842b349bdba4a81def6c320cf345f7057bd6bc/fastnanoid-0.4.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4add77f0e0cae6bd12361d9a247d6a0f518e14a3507d0192aade38414a3f6577", size = 370450, upload-time = "2025-06-30T14:42:02.63Z" },
    { url = "https://files.pythonhosted.org/packages/51/48/c8ba72e52a5eb5f401c62cbe935cd2e325a30b65e515951954ee712aa3c2/fastnanoid-0.4.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a684e5c839383675ca8b613fe069d699a5b63d9e86ea87a145f2da4fcfffd123", size = 261818, upload-time = "2025-06-30T14:42:13.378Z" },
    { url = "https://files.pythonhosted.org/packages/7f/1f/b2009781320f8c1b81a712bacff3096a44c80f4e6aeb26303a2a1b3b76c9/fastnanoid-0.4.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ce115103692b201700c791bca8cc97e321fdf1d886f0ac1c0553a3f5d50c002", size = 236326, upload-time = "2025-06-30T14:42:37.88Z" },
    { url = "https://files.pythonhosted.org/packages/5f/5b/3db8ac970894c5a674ea0d87967e077dd609f02d09b4361ff644936d3ab9/fastnanoid-0.4.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b7da3e6091fa02154e7a7a2a8422e4a7901994ea96ab5a56d7ca1fe8c137ebc2", size = 250816, upload-time = "2025-06-30T14:42:26.077Z" },
    { url = "https://files.pythonhosted.org/packages/af/63/c1ddd4853515f83e2e76307e0e82fbbcf157e28c46d669bda28187b46e1f/fastnanoid-0.4.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9827c9e52c2f6a988b5a47c30acdc5dc7cf6bc13515b65e89cc390c80622e53a", size = 411251, upload-time = "2025-06-30T14:42:54.748Z" },
    { url = "https://files.pythonhosted.org/packages/4f/32/35663e48fe03b45ef5db204cc26019ebe8814d9eade9a4c2b6b08e309d01/fastnanoid-0.4.3-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:56e2763bfd490163549d53abcec6a2223f3f22c12d75c53f2f3ba8a7c9a0bf4c", size = 503903, upload-time = "2025-06-30T14:43:06.456Z" },
    { url = "https://files.pythonhosted.org/packages/35/93/eca7bb497a1d92c75e7349b1bb0f8b0b0d5ca4607520ee7d574c996fcfee/fastnanoid-0.4.3-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:c475acaa7f3286e2cba08e308f16af530f51f8a8eb0aa91638cd2f83fc272a1a", size = 431440, upload-time = "2025-06-30T14:43:18.932Z" },
    { url = "https://files.pythonhosted.org/packages/a3/2d/0edf05d43486f5ff49fdac9e67a0803f277d5ebd8c28a661efcce0d297f2/fastnanoid-0.4.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c0103d4ddc8a34e69b8bed3a04c12cd46e9c4b998707269140d7e593245ca599", size = 406585, upload-time = "2025-06-30T14:43:30.883Z" },
    { url = "https://files.pythonhosted.org/packages/68/92/4d5cfb81c63b433602d85a5394bdb583ffaf0f8ba99efc29f1f6d4513f47/fastnanoid-0.4.3-cp39-cp39-win32.whl", hash = "sha256:065f346175660e1c3fb3bb0d0e906ea8fe23c597fd7969734d9d27a6d1cb06f1", size = 102859, upload-time = "2025-06-30T14:43:47.717Z" },
    { url = "https://files.pythonhosted.org/packages/b8/f9/ce1bcf7c6c36f2342aa1298a7c3e935c5fabb8cbccc0b387da191e5cb3a8/fastnanoid-0.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:0ca7a36fa419785b21474e92032f423c6de966bfb8b052bf0a02116b70556ea9", size = 109967, upload-time = "2025-06-30T14:43:40.512Z" },
    { url = "https://files.pythonhosted.org/packages/1f/64/de3ee84c9672f7e7392cc349da896dbc0ac9239bef1cc6c23f0607e00371/fastnanoid-0.4.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c06dffcd4ec8520c2ca5476a484275a8dce592b4707495e6a74cdc2ac0aa7922", size = 232734, upload-time = "2025-06-30T14:41:38.116Z" },
    { url = "https://files.pythonhosted.org/packages/79/ea/48907f92cf2b7fa35adcce975d5c29d086050e7a406acde503e7e0d58a60/fastnanoid-0.4.3-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:aa71ef4ba1143da200ba79b16bc82352632ad0fce36aab16710c7b4d3d5f75d6", size = 242244, upload-time = "2025-06-30T14:41:51.469Z" },
    { url = "https://files.pythonhosted.org/packages/d6/b4/9d9b6258421b3669185fc82a4569bd0af3b73db70770661578ce88317b96/fastnanoid-0.4.3-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5b3143f298528835bb1821e0da161a07631aed9ccbe93eca7c00ef0248402fd", size = 368223, upload-time = "2025-06-30T14:42:03.645Z" },
    { url = "https://files.pythonhosted.org/packages/f6/02/8ed5ada1a302a9c279b3adae2f89c37a9e5177d21a6b072f00255005a682/fastnanoid-0.4.3-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:45b8d11373bde2c0afef38b59353808948407baec33dea0df95ba00dc6dbd84c", size = 262264, upload-time = "2025-06-30T14:42:14.436Z" },
    { url = "https://files.pythonhosted.org/packages/21/b0/e18b1807d81f0a95d244b2cbc68c570518246ad713758f6cd028b4a4bd09/fastnanoid-0.4.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c7e6a71a6c9fa6f3c3443dd086d6454c71330b3a3aeb5cd09f883faf9afa0a9c", size = 236353, upload-time = "2025-06-30T14:42:38.872Z" },
    { url = "https://files.pythonhosted.org/packages/0e/8b/6ba5a5a1859758d7bdc42e72bd735b3f36ca5b78dc3768e9d253c312e13e/fastnanoid-0.4.3-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:320d3dce34b0f64c8941e7a7a1607ce773eea7b8447dfe05f45aa42297d20110", size = 251633, upload-time = "2025-06-30T14:42:27.45Z" },
    { url = "https://files.pythonhosted.org/packages/73/a2/8901e131f01c2e0b1ae4cbbda79eb75e1f4383f7fc415b2293989340bdbe/fastnanoid-0.4.3-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:aaf2e182e79665af3812f3c8f57f73c872e5c4eb89b1cb685e4cab5b8c0be848", size = 411386, upload-time = "2025-06-30T14:42:56.348Z" },
    { url = "https://files.pythonhosted.org/packages/95/fb/ed1f380a60d7f19047abcee2d310839a269f68e8bdd0fd6f0bbccaf4df4d/fastnanoid-0.4.3-pp310-pypy310_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:a6e2c9d635ae6efbe430f046ac4b748925c17343e886f4bcc0de411aa0eca569", size = 504920, upload-time = "2025-06-30T14:43:07.684Z" },
    { url = "https://files.pythonhosted.org/packages/50/04/b0478867c36dc142cc0661fa06542f42c80cd989cc025a9b9594172ab682/fastnanoid-0.4.3-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:b7e11772a44afba1a244fab80926f1a5baa2cf6dc5b857efcb62128eeda2afbf", size = 432008, upload-time = "2025-06-30T14:43:20.015Z" },
    { url = "https://files.pythonhosted.org/packages/10/b0/97e4518ac6b6e158533167e15d967005e6b86b83ca897bf3c0924db62f79/fastnanoid-0.4.3-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:c11b9c5795add802540f6ae9e0942e4ed2b1b5f74376946c8e3dc9ecee19c5ea", size = 406686, upload-time = "2025-06-30T14:43:31.972Z" },
    { url = "https://files.pythonhosted.org/packages/0a/95/36b1b47db8cd3fc66b2152e5f552f20ea70458387bf6a4f9a26de7993bf0/fastnanoid-0.4.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c931443d1a29f7a004e506b89fcbce256a31c4aab5d081dee4df0432db3d05", size = 232543, upload-time = "2025-06-30T14:41:39.179Z" },
    { url = "https://files.pythonhosted.org/packages/f9/f8/80697c7b4158317b5396779c8861dcd3cc621ddcb736ab555401e311e684/fastnanoid-0.4.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ec5a4fc039a698b8d1b99a19ce50e853d8d8fb42aa52a3beea0e80a3df30facd", size = 242101, upload-time = "2025-06-30T14:41:52.489Z" },
    { url = "https://files.pythonhosted.org/packages/01/f4/b7bf12cd7ab6d5b26d0c5a94731fa5255d188524ea15e8365f8b24293a08/fastnanoid-0.4.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7898b91478853f7034e54f276bc10aeccdecfa76bbc0385aeeba2196bfdc201d", size = 368124, upload-time = "2025-06-30T14:42:04.692Z" },
    { url = "https://files.pythonhosted.org/packages/e1/6a/41f3b688f77d4c99d36b9ab1b1bdede5a39fdecf8202c0b9c1acd41b17d4/fastnanoid-0.4.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:78828e0c2fede559299c22a4249ada58b898ea3b6fd95f3c26a77a43bfd17a99", size = 262077, upload-time = "2025-06-30T14:42:15.534Z" },
    { url = "https://files.pythonhosted.org/packages/63/42/b775efd993cea7908f5248417fa3757e6bc0c6caa9caa99f8b7a62718acf/fastnanoid-0.4.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9649a796c90c7ebcaeedd8f5decc82b83641341b60eb27e669fd28cf2530c88f", size = 236070, upload-time = "2025-06-30T14:42:39.957Z" },
    { url = "https://files.pythonhosted.org/packages/6d/53/452da9995b29ab952d3987fe3753a91ec273dfcedd65d665cad2550fec1d/fastnanoid-0.4.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:164f8c2f1b452b40652c9b692e293afbce36811b4ca4b44267719208b682b872", size = 251527, upload-time = "2025-06-30T14:42:28.874Z" },
    { url = "https://files.pythonhosted.org/packages/36/4e/9231004152677b762f6635e7bac893726a2c323cc54b5e9331739352ac3f/fastnanoid-0.4.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:3a7cfb8b2453fad0ce1406ee3b028cade32b5de5948e25afa04c97e87f088ce4", size = 411240, upload-time = "2025-06-30T14:42:57.4Z" },
    { url = "https://files.pythonhosted.org/packages/e6/69/2c71353ff382bc16b6ec902153a4d5f3199a8df1fd87fbc0b1da64066102/fastnanoid-0.4.3-pp311-pypy311_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:8e93359bff01947ed884f01cf44766d8fa4af8a26df56a212ea308fb6e93f658", size = 504674, upload-time = "2025-06-30T14:43:08.815Z" },
    { url = "https://files.pythonhosted.org/packages/88/7b/7e63ee606e97c4e18a9338b06cb96b96a82ec222b9df7a497fe8a122df9f/fastnanoid-0.4.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:db1759eb65a1fd027a88f0c6f6f63bf0b3063f8c488b9dd17f869d1ab423a387", size = 431910, upload-time = "2025-06-30T14:43:21.109Z" },
    { url = "https://files.pythonhosted.org/packages/dd/38/3844c9e3fc23708566d3e9efb3ccbeb9981e87cc431c141a67ad110b7d18/fastnanoid-0.4.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:16b8692b5a59981e1644efbb83b87feac9cea1a2b8fb0dbc19f402dae26008bc", size = 406405, upload-time = "2025-06-30T14:43:33.247Z" },
    { url = "https://files.pythonhosted.org/packages/97/91/ac61901f7da8765dd157d740433291cf8940147212458c80f0a3d69a6abb/fastnanoid-0.4.3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bfb7cefe42f32fd52f375ee4f023d5b15263e910b26dca743901c672bbf1307e", size = 232919, upload-time = "2025-06-30T14:41:40.19Z" },
    { url = "https://files.pythonhosted.org/packages/75/ad/ff570a53da80d8653dc58d2a46c3b378008dd9903a10d8d5cb6998ed01ed/fastnanoid-0.4.3-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0597a30e1dc7b036f156fe9e2dfcf42e55eece625b2ee5aec9f9da50e1516e6a", size = 241970, upload-time = "2025-06-30T14:41:53.56Z" },
    { url = "https://files.pythonhosted.org/packages/e4/6c/c536b4aa96c44ef398ad88b1990d059a7f487fa116c1434c1a9176777f5f/fastnanoid-0.4.3-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e32bd26ca9b32e5aad1121cf3e0fc163250b5ed2ee05f4355a990b577c03871f", size = 371196, upload-time = "2025-06-30T14:42:06.152Z" },
    { url = "https://files.pythonhosted.org/packages/71/02/6f1b7a185a45b8682b7ee6fd45af710a86c940ced34455509120b743f9e7/fastnanoid-0.4.3-pp39-pypy39_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ff7bad516258544e5d9d37507a69fd0a3af7d37ad0c87d14bbd4d95383e4b0e", size = 262467, upload-time = "2025-06-30T14:42:16.546Z" },
    { url = "https://files.pythonhosted.org/packages/ac/1c/7eabd68cf0c0ab0b12e8fd309c8b364fc7b673cc85f3d4056801041a925b/fastnanoid-0.4.3-pp39-pypy39_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:3c95323959066f30bcc16da1101663d12522c01e83a4bffe26569cb48dfde75c", size = 411536, upload-time = "2025-06-30T14:42:58.516Z" },
    { url = "https://files.pythonhosted.org/packages/c1/f5/5095125d49dbd06017a5df5f1ba41afbf59e2c0d8bad909e4a1cf1f35c78/fastnanoid-0.4.3-pp39-pypy39_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:d61602b26addf9da6b4083b4d90c252c394262a7808f22c9aba33a1c9223a163", size = 504679, upload-time = "2025-06-30T14:43:09.94Z" },
    { url = "https://files.pythonhosted.org/packages/4f/e5/eae60c88633bdc025dd1671bb0905cbb044e4d00eabd60e8c9ba756d1e57/fastnanoid-0.4.3-pp39-pypy39_pp73-musllinux_1_2_i686.whl", hash = "sha256:16e91a7afbff5bfcf159ca0bf3faa642cb069549d91f79f0de4b3f696b64d86c", size = 431969, upload-time = "2025-06-30T14:43:22.267Z" },
    { url = "https://files.pythonhosted.org/packages/63/4d/8b27d5daca0020dd41839a65837e74a6d264d273f0e29f85ee53a635cd5c/fastnanoid-0.4.3-pp39-pypy39_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:744686d495636f9b9e235e23308536dede26a2d564cc9ae5e7008a65b69d461c", size = 407079, upload-time = "2025-06-30T14:43:34.689Z" },
]

[[package]]
name = "filelock"
version = "3.19.1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
sdist = { url = "https://files.pythonhosted.org/packages/40/bb/0ab3e58d22305b6f5440629d20683af28959bf793d98d11950e305c1c326/filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58", size = 17687, upload-time = "2025-08-14T16:56:03.016Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988, upload-time = "2025-08-14T16:56:01.633Z" },
]

[[package]]
name = "filelock"
version = "3.25.2"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480, upload-time = "2026-03-11T20:45:38.487Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" },
]

[[package]]
name = "flask"
version = "3.1.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "blinker" },
    { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "click", version = "8.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "importlib-metadata", marker = "python_full_version < '3.10'" },
    { name = "itsdangerous" },
    { name = "jinja2" },
    { name = "markupsafe" },
    { name = "werkzeug" },
]
sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" },
]

[package.optional-dependencies]
async = [
    { name = "asgiref" },
]

[[package]]
name = "flask-sqlalchemy"
version = "3.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "flask" },
    { name = "sqlalchemy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/91/53/b0a9fcc1b1297f51e68b69ed3b7c3c40d8c45be1391d77ae198712914392/flask_sqlalchemy-3.1.1.tar.gz", hash = "sha256:e4b68bb881802dda1a7d878b2fc84c06d1ee57fb40b874d3dc97dabfa36b8312", size = 81899, upload-time = "2023-09-11T21:42:36.147Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/1d/6a/89963a5c6ecf166e8be29e0d1bf6806051ee8fe6c82e232842e3aeac9204/flask_sqlalchemy-3.1.1-py3-none-any.whl", hash = "sha256:4ba4be7f419dc72f4efd8802d69974803c37259dd42f3913b0dcf75c9447e0a0", size = 25125, upload-time = "2023-09-11T21:42:34.514Z" },
]

[[package]]
name = "frozenlist"
version = "1.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/83/4a/557715d5047da48d54e659203b9335be7bfaafda2c3f627b7c47e0b3aaf3/frozenlist-1.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011", size = 86230, upload-time = "2025-10-06T05:35:23.699Z" },
    { url = "https://files.pythonhosted.org/packages/a2/fb/c85f9fed3ea8fe8740e5b46a59cc141c23b842eca617da8876cfce5f760e/frozenlist-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565", size = 49621, upload-time = "2025-10-06T05:35:25.341Z" },
    { url = "https://files.pythonhosted.org/packages/63/70/26ca3f06aace16f2352796b08704338d74b6d1a24ca38f2771afbb7ed915/frozenlist-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad", size = 49889, upload-time = "2025-10-06T05:35:26.797Z" },
    { url = "https://files.pythonhosted.org/packages/5d/ed/c7895fd2fde7f3ee70d248175f9b6cdf792fb741ab92dc59cd9ef3bd241b/frozenlist-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2", size = 219464, upload-time = "2025-10-06T05:35:28.254Z" },
    { url = "https://files.pythonhosted.org/packages/6b/83/4d587dccbfca74cb8b810472392ad62bfa100bf8108c7223eb4c4fa2f7b3/frozenlist-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186", size = 221649, upload-time = "2025-10-06T05:35:29.454Z" },
    { url = "https://files.pythonhosted.org/packages/6a/c6/fd3b9cd046ec5fff9dab66831083bc2077006a874a2d3d9247dea93ddf7e/frozenlist-1.8.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e", size = 219188, upload-time = "2025-10-06T05:35:30.951Z" },
    { url = "https://files.pythonhosted.org/packages/ce/80/6693f55eb2e085fc8afb28cf611448fb5b90e98e068fa1d1b8d8e66e5c7d/frozenlist-1.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450", size = 231748, upload-time = "2025-10-06T05:35:32.101Z" },
    { url = "https://files.pythonhosted.org/packages/97/d6/e9459f7c5183854abd989ba384fe0cc1a0fb795a83c033f0571ec5933ca4/frozenlist-1.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef", size = 236351, upload-time = "2025-10-06T05:35:33.834Z" },
    { url = "https://files.pythonhosted.org/packages/97/92/24e97474b65c0262e9ecd076e826bfd1d3074adcc165a256e42e7b8a7249/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4", size = 218767, upload-time = "2025-10-06T05:35:35.205Z" },
    { url = "https://files.pythonhosted.org/packages/ee/bf/dc394a097508f15abff383c5108cb8ad880d1f64a725ed3b90d5c2fbf0bb/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff", size = 235887, upload-time = "2025-10-06T05:35:36.354Z" },
    { url = "https://files.pythonhosted.org/packages/40/90/25b201b9c015dbc999a5baf475a257010471a1fa8c200c843fd4abbee725/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c", size = 228785, upload-time = "2025-10-06T05:35:37.949Z" },
    { url = "https://files.pythonhosted.org/packages/84/f4/b5bc148df03082f05d2dd30c089e269acdbe251ac9a9cf4e727b2dbb8a3d/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f", size = 230312, upload-time = "2025-10-06T05:35:39.178Z" },
    { url = "https://files.pythonhosted.org/packages/db/4b/87e95b5d15097c302430e647136b7d7ab2398a702390cf4c8601975709e7/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7", size = 217650, upload-time = "2025-10-06T05:35:40.377Z" },
    { url = "https://files.pythonhosted.org/packages/e5/70/78a0315d1fea97120591a83e0acd644da638c872f142fd72a6cebee825f3/frozenlist-1.8.0-cp310-cp310-win32.whl", hash = "sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a", size = 39659, upload-time = "2025-10-06T05:35:41.863Z" },
    { url = "https://files.pythonhosted.org/packages/66/aa/3f04523fb189a00e147e60c5b2205126118f216b0aa908035c45336e27e4/frozenlist-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6", size = 43837, upload-time = "2025-10-06T05:35:43.205Z" },
    { url = "https://files.pythonhosted.org/packages/39/75/1135feecdd7c336938bd55b4dc3b0dfc46d85b9be12ef2628574b28de776/frozenlist-1.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e", size = 39989, upload-time = "2025-10-06T05:35:44.596Z" },
    { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" },
    { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" },
    { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" },
    { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" },
    { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" },
    { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" },
    { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" },
    { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" },
    { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" },
    { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" },
    { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" },
    { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" },
    { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" },
    { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" },
    { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" },
    { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" },
    { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" },
    { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" },
    { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" },
    { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" },
    { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" },
    { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" },
    { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" },
    { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" },
    { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" },
    { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" },
    { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" },
    { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" },
    { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" },
    { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" },
    { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" },
    { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" },
    { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" },
    { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" },
    { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" },
    { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" },
    { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" },
    { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" },
    { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" },
    { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" },
    { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" },
    { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" },
    { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" },
    { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" },
    { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" },
    { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" },
    { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" },
    { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" },
    { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" },
    { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" },
    { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" },
    { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" },
    { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" },
    { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" },
    { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" },
    { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" },
    { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" },
    { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" },
    { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" },
    { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" },
    { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" },
    { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" },
    { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" },
    { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" },
    { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" },
    { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" },
    { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" },
    { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" },
    { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" },
    { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" },
    { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" },
    { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" },
    { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" },
    { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" },
    { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" },
    { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" },
    { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" },
    { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" },
    { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" },
    { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" },
    { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" },
    { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" },
    { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" },
    { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" },
    { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" },
    { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" },
    { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" },
    { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" },
    { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" },
    { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" },
    { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" },
    { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" },
    { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" },
    { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" },
    { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" },
    { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" },
    { url = "https://files.pythonhosted.org/packages/c2/59/ae5cdac87a00962122ea37bb346d41b66aec05f9ce328fa2b9e216f8967b/frozenlist-1.8.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d8b7138e5cd0647e4523d6685b0eac5d4be9a184ae9634492f25c6eb38c12a47", size = 86967, upload-time = "2025-10-06T05:37:55.607Z" },
    { url = "https://files.pythonhosted.org/packages/8a/10/17059b2db5a032fd9323c41c39e9d1f5f9d0c8f04d1e4e3e788573086e61/frozenlist-1.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a6483e309ca809f1efd154b4d37dc6d9f61037d6c6a81c2dc7a15cb22c8c5dca", size = 49984, upload-time = "2025-10-06T05:37:57.049Z" },
    { url = "https://files.pythonhosted.org/packages/4b/de/ad9d82ca8e5fa8f0c636e64606553c79e2b859ad253030b62a21fe9986f5/frozenlist-1.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1b9290cf81e95e93fdf90548ce9d3c1211cf574b8e3f4b3b7cb0537cf2227068", size = 50240, upload-time = "2025-10-06T05:37:58.145Z" },
    { url = "https://files.pythonhosted.org/packages/4e/45/3dfb7767c2a67d123650122b62ce13c731b6c745bc14424eea67678b508c/frozenlist-1.8.0-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:59a6a5876ca59d1b63af8cd5e7ffffb024c3dc1e9cf9301b21a2e76286505c95", size = 219472, upload-time = "2025-10-06T05:37:59.239Z" },
    { url = "https://files.pythonhosted.org/packages/0b/bf/5bf23d913a741b960d5c1dac7c1985d8a2a1d015772b2d18ea168b08e7ff/frozenlist-1.8.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6dc4126390929823e2d2d9dc79ab4046ed74680360fc5f38b585c12c66cdf459", size = 221531, upload-time = "2025-10-06T05:38:00.521Z" },
    { url = "https://files.pythonhosted.org/packages/d0/03/27ec393f3b55860859f4b74cdc8c2a4af3dbf3533305e8eacf48a4fd9a54/frozenlist-1.8.0-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:332db6b2563333c5671fecacd085141b5800cb866be16d5e3eb15a2086476675", size = 219211, upload-time = "2025-10-06T05:38:01.842Z" },
    { url = "https://files.pythonhosted.org/packages/3a/ad/0fd00c404fa73fe9b169429e9a972d5ed807973c40ab6b3cf9365a33d360/frozenlist-1.8.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9ff15928d62a0b80bb875655c39bf517938c7d589554cbd2669be42d97c2cb61", size = 231775, upload-time = "2025-10-06T05:38:03.384Z" },
    { url = "https://files.pythonhosted.org/packages/8a/c3/86962566154cb4d2995358bc8331bfc4ea19d07db1a96f64935a1607f2b6/frozenlist-1.8.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7bf6cdf8e07c8151fba6fe85735441240ec7f619f935a5205953d58009aef8c6", size = 236631, upload-time = "2025-10-06T05:38:04.609Z" },
    { url = "https://files.pythonhosted.org/packages/ea/9e/6ffad161dbd83782d2c66dc4d378a9103b31770cb1e67febf43aea42d202/frozenlist-1.8.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:48e6d3f4ec5c7273dfe83ff27c91083c6c9065af655dc2684d2c200c94308bb5", size = 218632, upload-time = "2025-10-06T05:38:05.917Z" },
    { url = "https://files.pythonhosted.org/packages/58/b2/4677eee46e0a97f9b30735e6ad0bf6aba3e497986066eb68807ac85cf60f/frozenlist-1.8.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:1a7607e17ad33361677adcd1443edf6f5da0ce5e5377b798fba20fae194825f3", size = 235967, upload-time = "2025-10-06T05:38:07.614Z" },
    { url = "https://files.pythonhosted.org/packages/05/f3/86e75f8639c5a93745ca7addbbc9de6af56aebb930d233512b17e46f6493/frozenlist-1.8.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:5a3a935c3a4e89c733303a2d5a7c257ea44af3a56c8202df486b7f5de40f37e1", size = 228799, upload-time = "2025-10-06T05:38:08.845Z" },
    { url = "https://files.pythonhosted.org/packages/30/00/39aad3a7f0d98f5eb1d99a3c311215674ed87061aecee7851974b335c050/frozenlist-1.8.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:940d4a017dbfed9daf46a3b086e1d2167e7012ee297fef9e1c545c4d022f5178", size = 230566, upload-time = "2025-10-06T05:38:10.52Z" },
    { url = "https://files.pythonhosted.org/packages/0d/4d/aa144cac44568d137846ddc4d5210fb5d9719eb1d7ec6fa2728a54b5b94a/frozenlist-1.8.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b9be22a69a014bc47e78072d0ecae716f5eb56c15238acca0f43d6eb8e4a5bda", size = 217715, upload-time = "2025-10-06T05:38:11.832Z" },
    { url = "https://files.pythonhosted.org/packages/64/4c/8f665921667509d25a0dd72540513bc86b356c95541686f6442a3283019f/frozenlist-1.8.0-cp39-cp39-win32.whl", hash = "sha256:1aa77cb5697069af47472e39612976ed05343ff2e84a3dcf15437b232cbfd087", size = 39933, upload-time = "2025-10-06T05:38:13.061Z" },
    { url = "https://files.pythonhosted.org/packages/79/bd/bcc926f87027fad5e59926ff12d136e1082a115025d33c032d1cd69ab377/frozenlist-1.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:7398c222d1d405e796970320036b1b563892b65809d9e5261487bb2c7f7b5c6a", size = 44121, upload-time = "2025-10-06T05:38:14.572Z" },
    { url = "https://files.pythonhosted.org/packages/4c/07/9c2e4eb7584af4b705237b971b89a4155a8e57599c4483a131a39256a9a0/frozenlist-1.8.0-cp39-cp39-win_arm64.whl", hash = "sha256:b4f3b365f31c6cd4af24545ca0a244a53688cad8834e32f56831c4923b50a103", size = 40312, upload-time = "2025-10-06T05:38:15.699Z" },
    { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" },
]

[[package]]
name = "fsspec"
version = "2025.10.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
sdist = { url = "https://files.pythonhosted.org/packages/24/7f/2747c0d332b9acfa75dc84447a066fdf812b5a6b8d30472b74d309bfe8cb/fsspec-2025.10.0.tar.gz", hash = "sha256:b6789427626f068f9a83ca4e8a3cc050850b6c0f71f99ddb4f542b8266a26a59", size = 309285, upload-time = "2025-10-30T14:58:44.036Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/eb/02/a6b21098b1d5d6249b7c5ab69dde30108a71e4e819d4a9778f1de1d5b70d/fsspec-2025.10.0-py3-none-any.whl", hash = "sha256:7c7712353ae7d875407f97715f0e1ffcc21e33d5b24556cb1e090ae9409ec61d", size = 200966, upload-time = "2025-10-30T14:58:42.53Z" },
]

[package.optional-dependencies]
s3 = [
    { name = "s3fs", version = "2025.10.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
]

[[package]]
name = "fsspec"
version = "2026.3.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
sdist = { url = "https://files.pythonhosted.org/packages/e1/cf/b50ddf667c15276a9ab15a70ef5f257564de271957933ffea49d2cdbcdfb/fsspec-2026.3.0.tar.gz", hash = "sha256:1ee6a0e28677557f8c2f994e3eea77db6392b4de9cd1f5d7a9e87a0ae9d01b41", size = 313547, upload-time = "2026-03-27T19:11:14.892Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/d5/1f/5f4a3cd9e4440e9d9bc78ad0a91a1c8d46b4d429d5239ebe6793c9fe5c41/fsspec-2026.3.0-py3-none-any.whl", hash = "sha256:d2ceafaad1b3457968ed14efa28798162f1638dbb5d2a6868a2db002a5ee39a4", size = 202595, upload-time = "2026-03-27T19:11:13.595Z" },
]

[package.optional-dependencies]
s3 = [
    { name = "s3fs", version = "2026.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]

[[package]]
name = "google-api-core"
version = "2.30.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "google-auth" },
    { name = "googleapis-common-protos" },
    { name = "proto-plus" },
    { name = "protobuf" },
    { name = "requests", version = "2.32.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "requests", version = "2.33.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1a/2e/83ca41eb400eb228f9279ec14ed66f6475218b59af4c6daec2d5a509fe83/google_api_core-2.30.2.tar.gz", hash = "sha256:9a8113e1a88bdc09a7ff629707f2214d98d61c7f6ceb0ea38c42a095d02dc0f9", size = 176862, upload-time = "2026-04-02T21:23:44.876Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/84/e1/ebd5100cbb202e561c0c8b59e485ef3bd63fa9beb610f3fdcaea443f0288/google_api_core-2.30.2-py3-none-any.whl", hash = "sha256:a4c226766d6af2580577db1f1a51bf53cd262f722b49731ce7414c43068a9594", size = 173236, upload-time = "2026-04-02T21:23:06.395Z" },
]

[package.optional-dependencies]
grpc = [
    { name = "grpcio" },
    { name = "grpcio-status" },
]

[[package]]
name = "google-auth"
version = "2.49.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "cryptography" },
    { name = "pyasn1-modules" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ea/80/6a696a07d3d3b0a92488933532f03dbefa4a24ab80fb231395b9a2a1be77/google_auth-2.49.1.tar.gz", hash = "sha256:16d40da1c3c5a0533f57d268fe72e0ebb0ae1cc3b567024122651c045d879b64", size = 333825, upload-time = "2026-03-12T19:30:58.135Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/e9/eb/c6c2478d8a8d633460be40e2a8a6f8f429171997a35a96f81d3b680dec83/google_auth-2.49.1-py3-none-any.whl", hash = "sha256:195ebe3dca18eddd1b3db5edc5189b76c13e96f29e73043b923ebcf3f1a860f7", size = 240737, upload-time = "2026-03-12T19:30:53.159Z" },
]

[[package]]
name = "google-cloud-bigquery"
version = "3.41.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "google-api-core", extra = ["grpc"] },
    { name = "google-auth" },
    { name = "google-cloud-core" },
    { name = "google-resumable-media" },
    { name = "packaging" },
    { name = "python-dateutil" },
    { name = "requests", version = "2.32.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "requests", version = "2.33.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ce/13/6515c7aab55a4a0cf708ffd309fb9af5bab54c13e32dc22c5acd6497193c/google_cloud_bigquery-3.41.0.tar.gz", hash = "sha256:2217e488b47ed576360c9b2cc07d59d883a54b83167c0ef37f915c26b01a06fe", size = 513434, upload-time = "2026-03-30T22:50:55.347Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/40/33/1d3902efadef9194566d499d61507e1f038454e0b55499d2d7f8ab2a4fee/google_cloud_bigquery-3.41.0-py3-none-any.whl", hash = "sha256:2a5b5a737b401cbd824a6e5eac7554100b878668d908e6548836b5d8aaa4dcaa", size = 262343, upload-time = "2026-03-30T22:48:45.444Z" },
]

[[package]]
name = "google-cloud-core"
version = "2.5.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "google-api-core" },
    { name = "google-auth" },
]
sdist = { url = "https://files.pythonhosted.org/packages/dc/24/6ca08b0a03c7b0c620427503ab00353a4ae806b848b93bcea18b6b76fde6/google_cloud_core-2.5.1.tar.gz", hash = "sha256:3dc94bdec9d05a31d9f355045ed0f369fbc0d8c665076c734f065d729800f811", size = 36078, upload-time = "2026-03-30T22:50:08.057Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/73/d9/5bb050cb32826466aa9b25f79e2ca2879fe66cb76782d4ed798dd7506151/google_cloud_core-2.5.1-py3-none-any.whl", hash = "sha256:ea62cdf502c20e3e14be8a32c05ed02113d7bef454e40ff3fab6fe1ec9f1f4e7", size = 29452, upload-time = "2026-03-30T22:48:31.567Z" },
]

[[package]]
name = "google-cloud-monitoring"
version = "2.30.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "google-api-core", extra = ["grpc"] },
    { name = "google-auth" },
    { name = "grpcio" },
    { name = "proto-plus" },
    { name = "protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/3f/7bc306ebb006114f58fb9143aec91e1b014a11577350d8bbd6bbc38389f9/google_cloud_monitoring-2.30.0.tar.gz", hash = "sha256:a9530aa9aa246c490810dfa7be32d67e8340d19108acc99cbc02d1ed494fba76", size = 407108, upload-time = "2026-03-26T22:17:10.365Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/ad/c8/666c21c470b9d6fd62ac9ee74dc265419975228f9b16f8ad72ec22e8d98b/google_cloud_monitoring-2.30.0-py3-none-any.whl", hash = "sha256:2729f3b88a4798b7757b1d9d31b6cb562bb3544e8173765e4e5cd44d8685b1ed", size = 391367, upload-time = "2026-03-26T22:15:04.088Z" },
]

[[package]]
name = "google-cloud-spanner"
version = "3.64.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "google-api-core", extra = ["grpc"] },
    { name = "google-cloud-core" },
    { name = "google-cloud-monitoring" },
    { name = "grpc-google-iam-v1" },
    { name = "grpc-interceptor" },
    { name = "mmh3", version = "5.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "mmh3", version = "5.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "opentelemetry-api" },
    { name = "opentelemetry-resourcedetector-gcp" },
    { name = "opentelemetry-sdk" },
    { name = "opentelemetry-semantic-conventions" },
    { name = "proto-plus" },
    { name = "protobuf" },
    { name = "sqlparse" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cf/67/573b14674bd74c8f0630125e13fd52791c76e6a34f21862358913fa41742/google_cloud_spanner-3.64.0.tar.gz", hash = "sha256:02c26601eaaef6abba78efe5c55187b16550aeab0671ed0a65ab2d78bf7c019e", size = 884721, upload-time = "2026-04-01T16:14:38.479Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/23/93/0ae1f0edfb9d9a0fc85d234b085b1cd7a3c5444f5bb85f1315f76c654313/google_cloud_spanner-3.64.0-py3-none-any.whl", hash = "sha256:9dd8b268c511def6bef118f9d8d9cbea98509727d13388a8365d5b72e13acf7c", size = 607319, upload-time = "2026-04-01T16:14:36.224Z" },
]

[[package]]
name = "google-crc32c"
version = "1.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/03/41/4b9c02f99e4c5fb477122cd5437403b552873f014616ac1d19ac8221a58d/google_crc32c-1.8.0.tar.gz", hash = "sha256:a428e25fb7691024de47fecfbff7ff957214da51eddded0da0ae0e0f03a2cf79", size = 14192, upload-time = "2025-12-16T00:35:25.142Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/95/ac/6f7bc93886a823ab545948c2dd48143027b2355ad1944c7cf852b338dc91/google_crc32c-1.8.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:0470b8c3d73b5f4e3300165498e4cf25221c7eb37f1159e221d1825b6df8a7ff", size = 31296, upload-time = "2025-12-16T00:19:07.261Z" },
    { url = "https://files.pythonhosted.org/packages/f7/97/a5accde175dee985311d949cfcb1249dcbb290f5ec83c994ea733311948f/google_crc32c-1.8.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:119fcd90c57c89f30040b47c211acee231b25a45d225e3225294386f5d258288", size = 30870, upload-time = "2025-12-16T00:29:17.669Z" },
    { url = "https://files.pythonhosted.org/packages/3d/63/bec827e70b7a0d4094e7476f863c0dbd6b5f0f1f91d9c9b32b76dcdfeb4e/google_crc32c-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6f35aaffc8ccd81ba3162443fabb920e65b1f20ab1952a31b13173a67811467d", size = 33214, upload-time = "2025-12-16T00:40:19.618Z" },
    { url = "https://files.pythonhosted.org/packages/63/bc/11b70614df04c289128d782efc084b9035ef8466b3d0a8757c1b6f5cf7ac/google_crc32c-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:864abafe7d6e2c4c66395c1eb0fe12dc891879769b52a3d56499612ca93b6092", size = 33589, upload-time = "2025-12-16T00:40:20.7Z" },
    { url = "https://files.pythonhosted.org/packages/3e/00/a08a4bc24f1261cc5b0f47312d8aebfbe4b53c2e6307f1b595605eed246b/google_crc32c-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:db3fe8eaf0612fc8b20fa21a5f25bd785bc3cd5be69f8f3412b0ac2ffd49e733", size = 34437, upload-time = "2025-12-16T00:35:19.437Z" },
    { url = "https://files.pythonhosted.org/packages/5d/ef/21ccfaab3d5078d41efe8612e0ed0bfc9ce22475de074162a91a25f7980d/google_crc32c-1.8.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:014a7e68d623e9a4222d663931febc3033c5c7c9730785727de2a81f87d5bab8", size = 31298, upload-time = "2025-12-16T00:20:32.241Z" },
    { url = "https://files.pythonhosted.org/packages/c5/b8/f8413d3f4b676136e965e764ceedec904fe38ae8de0cdc52a12d8eb1096e/google_crc32c-1.8.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:86cfc00fe45a0ac7359e5214a1704e51a99e757d0272554874f419f79838c5f7", size = 30872, upload-time = "2025-12-16T00:33:58.785Z" },
    { url = "https://files.pythonhosted.org/packages/f6/fd/33aa4ec62b290477181c55bb1c9302c9698c58c0ce9a6ab4874abc8b0d60/google_crc32c-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:19b40d637a54cb71e0829179f6cb41835f0fbd9e8eb60552152a8b52c36cbe15", size = 33243, upload-time = "2025-12-16T00:40:21.46Z" },
    { url = "https://files.pythonhosted.org/packages/71/03/4820b3bd99c9653d1a5210cb32f9ba4da9681619b4d35b6a052432df4773/google_crc32c-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:17446feb05abddc187e5441a45971b8394ea4c1b6efd88ab0af393fd9e0a156a", size = 33608, upload-time = "2025-12-16T00:40:22.204Z" },
    { url = "https://files.pythonhosted.org/packages/7c/43/acf61476a11437bf9733fb2f70599b1ced11ec7ed9ea760fdd9a77d0c619/google_crc32c-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:71734788a88f551fbd6a97be9668a0020698e07b2bf5b3aa26a36c10cdfb27b2", size = 34439, upload-time = "2025-12-16T00:35:20.458Z" },
    { url = "https://files.pythonhosted.org/packages/e9/5f/7307325b1198b59324c0fa9807cafb551afb65e831699f2ce211ad5c8240/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:4b8286b659c1335172e39563ab0a768b8015e88e08329fa5321f774275fc3113", size = 31300, upload-time = "2025-12-16T00:21:56.723Z" },
    { url = "https://files.pythonhosted.org/packages/21/8e/58c0d5d86e2220e6a37befe7e6a94dd2f6006044b1a33edf1ff6d9f7e319/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:2a3dc3318507de089c5384cc74d54318401410f82aa65b2d9cdde9d297aca7cb", size = 30867, upload-time = "2025-12-16T00:38:31.302Z" },
    { url = "https://files.pythonhosted.org/packages/ce/a9/a780cc66f86335a6019f557a8aaca8fbb970728f0efd2430d15ff1beae0e/google_crc32c-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14f87e04d613dfa218d6135e81b78272c3b904e2a7053b841481b38a7d901411", size = 33364, upload-time = "2025-12-16T00:40:22.96Z" },
    { url = "https://files.pythonhosted.org/packages/21/3f/3457ea803db0198c9aaca2dd373750972ce28a26f00544b6b85088811939/google_crc32c-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb5c869c2923d56cb0c8e6bcdd73c009c36ae39b652dbe46a05eb4ef0ad01454", size = 33740, upload-time = "2025-12-16T00:40:23.96Z" },
    { url = "https://files.pythonhosted.org/packages/df/c0/87c2073e0c72515bb8733d4eef7b21548e8d189f094b5dad20b0ecaf64f6/google_crc32c-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc0c8912038065eafa603b238abf252e204accab2a704c63b9e14837a854962", size = 34437, upload-time = "2025-12-16T00:35:21.395Z" },
    { url = "https://files.pythonhosted.org/packages/d1/db/000f15b41724589b0e7bc24bc7a8967898d8d3bc8caf64c513d91ef1f6c0/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:3ebb04528e83b2634857f43f9bb8ef5b2bbe7f10f140daeb01b58f972d04736b", size = 31297, upload-time = "2025-12-16T00:23:20.709Z" },
    { url = "https://files.pythonhosted.org/packages/d7/0d/8ebed0c39c53a7e838e2a486da8abb0e52de135f1b376ae2f0b160eb4c1a/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:450dc98429d3e33ed2926fc99ee81001928d63460f8538f21a5d6060912a8e27", size = 30867, upload-time = "2025-12-16T00:43:14.628Z" },
    { url = "https://files.pythonhosted.org/packages/ce/42/b468aec74a0354b34c8cbf748db20d6e350a68a2b0912e128cabee49806c/google_crc32c-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3b9776774b24ba76831609ffbabce8cdf6fa2bd5e9df37b594221c7e333a81fa", size = 33344, upload-time = "2025-12-16T00:40:24.742Z" },
    { url = "https://files.pythonhosted.org/packages/1c/e8/b33784d6fc77fb5062a8a7854e43e1e618b87d5ddf610a88025e4de6226e/google_crc32c-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:89c17d53d75562edfff86679244830599ee0a48efc216200691de8b02ab6b2b8", size = 33694, upload-time = "2025-12-16T00:40:25.505Z" },
    { url = "https://files.pythonhosted.org/packages/92/b1/d3cbd4d988afb3d8e4db94ca953df429ed6db7282ed0e700d25e6c7bfc8d/google_crc32c-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:57a50a9035b75643996fbf224d6661e386c7162d1dfdab9bc4ca790947d1007f", size = 34435, upload-time = "2025-12-16T00:35:22.107Z" },
    { url = "https://files.pythonhosted.org/packages/21/88/8ecf3c2b864a490b9e7010c84fd203ec8cf3b280651106a3a74dd1b0ca72/google_crc32c-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:e6584b12cb06796d285d09e33f63309a09368b9d806a551d8036a4207ea43697", size = 31301, upload-time = "2025-12-16T00:24:48.527Z" },
    { url = "https://files.pythonhosted.org/packages/36/c6/f7ff6c11f5ca215d9f43d3629163727a272eabc356e5c9b2853df2bfe965/google_crc32c-1.8.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:f4b51844ef67d6cf2e9425983274da75f18b1597bb2c998e1c0a0e8d46f8f651", size = 30868, upload-time = "2025-12-16T00:48:12.163Z" },
    { url = "https://files.pythonhosted.org/packages/56/15/c25671c7aad70f8179d858c55a6ae8404902abe0cdcf32a29d581792b491/google_crc32c-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b0d1a7afc6e8e4635564ba8aa5c0548e3173e41b6384d7711a9123165f582de2", size = 33381, upload-time = "2025-12-16T00:40:26.268Z" },
    { url = "https://files.pythonhosted.org/packages/42/fa/f50f51260d7b0ef5d4898af122d8a7ec5a84e2984f676f746445f783705f/google_crc32c-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8b3f68782f3cbd1bce027e48768293072813469af6a61a86f6bb4977a4380f21", size = 33734, upload-time = "2025-12-16T00:40:27.028Z" },
    { url = "https://files.pythonhosted.org/packages/08/a5/7b059810934a09fb3ccb657e0843813c1fee1183d3bc2c8041800374aa2c/google_crc32c-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:d511b3153e7011a27ab6ee6bb3a5404a55b994dc1a7322c0b87b29606d9790e2", size = 34878, upload-time = "2025-12-16T00:35:23.142Z" },
    { url = "https://files.pythonhosted.org/packages/42/c5/4c4cde2e7e54d9cde5c3d131f54a609eb3a77e60a04ec348051f61071fc2/google_crc32c-1.8.0-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:ba6aba18daf4d36ad4412feede6221414692f44d17e5428bdd81ad3fc1eee5dc", size = 31291, upload-time = "2025-12-16T00:17:45.878Z" },
    { url = "https://files.pythonhosted.org/packages/31/1d/abae5a7ca05c07dc7f129b32f7f8cce5314172fd2300c7aec305427a637e/google_crc32c-1.8.0-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:87b0072c4ecc9505cfa16ee734b00cd7721d20a0f595be4d40d3d21b41f65ae2", size = 30862, upload-time = "2025-12-16T00:25:03.867Z" },
    { url = "https://files.pythonhosted.org/packages/1e/c4/7032f0e87ee0b0f65669ac8a1022beabd80afe5da69f4bbf49eb7fea9c40/google_crc32c-1.8.0-cp39-cp39-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d488e98b18809f5e322978d4506373599c0c13e6c5ad13e53bb44758e18d215", size = 33063, upload-time = "2025-12-16T00:40:27.789Z" },
    { url = "https://files.pythonhosted.org/packages/cc/fc/831d92dd02bc145523590db3927a73300f5121a34b56c2696e4305411b67/google_crc32c-1.8.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:01f126a5cfddc378290de52095e2c7052be2ba7656a9f0caf4bcd1bfb1833f8a", size = 33434, upload-time = "2025-12-16T00:40:28.555Z" },
    { url = "https://files.pythonhosted.org/packages/ca/ef/74accbe6e6892c3bcbe5a7ed8d650a23b3042ddcc0b301896c22e6733bea/google_crc32c-1.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:61f58b28e0b21fcb249a8247ad0db2e64114e201e2e9b4200af020f3b6242c9f", size = 34432, upload-time = "2025-12-16T00:35:24.136Z" },
    { url = "https://files.pythonhosted.org/packages/52/c5/c171e4d8c44fec1422d801a6d2e5d7ddabd733eeda505c79730ee9607f07/google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:87fa445064e7db928226b2e6f0d5304ab4cd0339e664a4e9a25029f384d9bb93", size = 28615, upload-time = "2025-12-16T00:40:29.298Z" },
    { url = "https://files.pythonhosted.org/packages/9c/97/7d75fe37a7a6ed171a2cf17117177e7aab7e6e0d115858741b41e9dd4254/google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f639065ea2042d5c034bf258a9f085eaa7af0cd250667c0635a3118e8f92c69c", size = 28800, upload-time = "2025-12-16T00:40:30.322Z" },
]

[[package]]
name = "google-resumable-media"
version = "2.8.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "google-crc32c" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3f/d1/b1ea14b93b6b78f57fc580125de44e9f593ab88dd2460f1a8a8d18f74754/google_resumable_media-2.8.2.tar.gz", hash = "sha256:f3354a182ebd193ae3f42e3ef95e6c9b10f128320de23ac7637236713b1acd70", size = 2164510, upload-time = "2026-03-30T23:34:25.369Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/5e/f8/50bfaf4658431ff9de45c5c3935af7ab01157a4903c603cd0eee6e78e087/google_resumable_media-2.8.2-py3-none-any.whl", hash = "sha256:82b6d8ccd11765268cdd2a2123f417ec806b8eef3000a9a38dfe3033da5fb220", size = 81511, upload-time = "2026-03-30T23:34:09.671Z" },
]

[[package]]
name = "googleapis-common-protos"
version = "1.74.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/20/18/a746c8344152d368a5aac738d4c857012f2c5d1fd2eac7e17b647a7861bd/googleapis_common_protos-1.74.0.tar.gz", hash = "sha256:57971e4eeeba6aad1163c1f0fc88543f965bb49129b8bb55b2b7b26ecab084f1", size = 151254, upload-time = "2026-04-02T21:23:26.679Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/b6/b0/be5d3329badb9230b765de6eea66b73abd5944bdeb5afb3562ddcd80ae84/googleapis_common_protos-1.74.0-py3-none-any.whl", hash = "sha256:702216f78610bb510e3f12ac3cafd281b7ac45cc5d86e90ad87e4d301a3426b5", size = 300743, upload-time = "2026-04-02T21:22:49.108Z" },
]

[package.optional-dependencies]
grpc = [
    { name = "grpcio" },
]

[[package]]
name = "greenlet"
version = "3.2.5"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
sdist = { url = "https://files.pythonhosted.org/packages/b0/f5/3e9eafb4030588337b2a2ae4df46212956854e9069c07b53aa3caabafd47/greenlet-3.2.5.tar.gz", hash = "sha256:c816554eb33e7ecf9ba4defcb1fd8c994e59be6b4110da15480b3e7447ea4286", size = 191501, upload-time = "2026-02-20T20:08:51.539Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/ff/d6/b3db928fc329b1b19ba32ffe143d2305f3aaafc583f5e1074c74ec445189/greenlet-3.2.5-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:34cc7cf8ab6f4b85298b01e13e881265ee7b3c1daf6bc10a2944abc15d4f87c3", size = 275803, upload-time = "2026-02-20T20:06:42.541Z" },
    { url = "https://files.pythonhosted.org/packages/b3/ff/ab0ad4ff3d9e1faa266de4f6c79763b33fccd9265995f2940192494cc0ec/greenlet-3.2.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c11fe0cfb0ce33132f0b5d27eeadd1954976a82e5e9b60909ec2c4b884a55382", size = 633556, upload-time = "2026-02-20T20:30:41.594Z" },
    { url = "https://files.pythonhosted.org/packages/da/dd/7b3ac77099a1671af8077ecedb12c9a1be1310e4c35bb69fd34c18ab6093/greenlet-3.2.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:a145f4b1c4ed7a2c94561b7f18b4beec3d3fb6f0580db22f7ed1d544e0620b34", size = 644943, upload-time = "2026-02-20T20:37:23.084Z" },
    { url = "https://files.pythonhosted.org/packages/56/f0/bea7e7909ea9045b0c5055dad1ec9b81c82b761b4567e625f4f8349acfa1/greenlet-3.2.5-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:edbf4ab9a7057ee430a678fe2ef37ea5d69125d6bdc7feb42ed8d871c737e63b", size = 640849, upload-time = "2026-02-20T20:43:57.305Z" },
    { url = "https://files.pythonhosted.org/packages/0f/36/84630e9ff1dfc8b7690957c0f77834a84eabdbd9c4977c3a2d0cbd5325c2/greenlet-3.2.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc1d01bdd67db3e5711e6246e451d7a0f75fae7bbf40adde129296a7f9aa7cc9", size = 639841, upload-time = "2026-02-20T20:07:17.473Z" },
    { url = "https://files.pythonhosted.org/packages/12/c4/6a2ee6c676dea7a05a3c3c1291fbc8ea44f26456b0accc891471293825af/greenlet-3.2.5-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd593db7ee1fa8a513a48a404f8cc4126998a48025e3f5cbbc68d51be0a6bf66", size = 588813, upload-time = "2026-02-20T20:07:56.171Z" },
    { url = "https://files.pythonhosted.org/packages/01/c0/75e75c2c993aa850292561ec80f5c263e3924e5843aa95a38716df69304c/greenlet-3.2.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ac8db07bced2c39b987bba13a3195f8157b0cfbce54488f86919321444a1cc3c", size = 1117377, upload-time = "2026-02-20T20:32:48.452Z" },
    { url = "https://files.pythonhosted.org/packages/ee/03/e38ebf9024a0873fe8f60f5b7bc36bfb3be5e13efe4d798240f2d1f0fb73/greenlet-3.2.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4544ab2cfd5912e42458b13516429e029f87d8bbcdc8d5506db772941ae12493", size = 1141246, upload-time = "2026-02-20T20:06:23.576Z" },
    { url = "https://files.pythonhosted.org/packages/d8/7b/c6e1192c795c0c12871e199237909a6bd35757d92c8472c7c019959b8637/greenlet-3.2.5-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:acabf468466d18017e2ae5fbf1a5a88b86b48983e550e1ae1437b69a83d9f4ac", size = 276916, upload-time = "2026-02-20T20:06:18.166Z" },
    { url = "https://files.pythonhosted.org/packages/3e/b6/9887b559f3e1952d23052ec352e9977e808a2246c7cb8282a38337221e88/greenlet-3.2.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:472841de62d60f2cafd60edd4fd4dd7253eb70e6eaf14b8990dcaf177f4af957", size = 636107, upload-time = "2026-02-20T20:30:43.362Z" },
    { url = "https://files.pythonhosted.org/packages/8a/be/e3e48b63bbc27d660fa1d98aecb64906b90a12e686a436169c1330ef34b2/greenlet-3.2.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d951e7d628a6e8b68af469f0fe4f100ef64c4054abeb9cdafbfaa30a920c950", size = 648240, upload-time = "2026-02-20T20:37:24.608Z" },
    { url = "https://files.pythonhosted.org/packages/17/f6/2cbe999683f759f14f598234f04ae8ba6f22953a624b3a7a630003e6bfff/greenlet-3.2.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:87b791dd0e031a574249af717ac36f7031b18c35329561c1e0368201c18caf1f", size = 644170, upload-time = "2026-02-20T20:43:59.002Z" },
    { url = "https://files.pythonhosted.org/packages/4c/ac/e731ed62576e91e533b36d0d97325adc2786674ab9e48ed8a6a24f4ef4e9/greenlet-3.2.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8317d732e2ae0935d9ed2af2ea876fa714cf6f3b887a31ca150b54329b0a6e9", size = 643313, upload-time = "2026-02-20T20:07:19.012Z" },
    { url = "https://files.pythonhosted.org/packages/70/64/99e5cdceb494bd4c1341c45b93f322601d2c8a5e1e4d1c7a2d24c5ed0570/greenlet-3.2.5-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce8aed6fdd5e07d3cbb988cbdc188266a4eb9e1a52db9ef5c6526e59962d3933", size = 591295, upload-time = "2026-02-20T20:07:57.286Z" },
    { url = "https://files.pythonhosted.org/packages/ee/e9/968e11f388c2b8792d3b8b40a57984c894a3b4745dae3662dce722653bc5/greenlet-3.2.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:60c06b502d56d5451f60ca665691da29f79ed95e247bcf8ce5024d7bbe64acb9", size = 1120277, upload-time = "2026-02-20T20:32:50.103Z" },
    { url = "https://files.pythonhosted.org/packages/cb/2c/b5f2c4c68d753dce08218dc5a6b21d82238fdfdc44309032f6fe24d285e6/greenlet-3.2.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d2a78e6f1bf3f1672df91e212a2f8314e1e7c922f065d14cbad4bc815059467", size = 1145746, upload-time = "2026-02-20T20:06:26.296Z" },
    { url = "https://files.pythonhosted.org/packages/ad/32/022b21523eee713e7550162d5ca6aed23f913cc2c6232b154b9fd9badc07/greenlet-3.2.5-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:2acb30e77042f747ca81f0a10cc153296567e92e666c5e1b117f4595afd43352", size = 278412, upload-time = "2026-02-20T20:03:15.02Z" },
    { url = "https://files.pythonhosted.org/packages/90/c5/8a3b0ed3cc34d8b988a44349437dfa0941f9c23ac108175f7b4ccea97111/greenlet-3.2.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:393c03c26c865f17f31d8db2f09603fadbe0581ad85a5d5908b131549fc38217", size = 644616, upload-time = "2026-02-20T20:30:44.823Z" },
    { url = "https://files.pythonhosted.org/packages/b1/2c/2627bea183554695016af6cae93d7474fa90f61e5a6601a84ae7841cb720/greenlet-3.2.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:04e6a202cde56043fd355fefd1552c4caa5c087528121871d950eb4f1b51fa99", size = 658813, upload-time = "2026-02-20T20:37:26.255Z" },
    { url = "https://files.pythonhosted.org/packages/44/c6/a80fc96f7cca7962dd972875d12c52dfabc94cb02bfeb19f3e7e169fca44/greenlet-3.2.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d5583b2ffa677578a384337ee13125bdf9a427485d689014b39d638a4f3d8dbe", size = 653512, upload-time = "2026-02-20T20:44:00.343Z" },
    { url = "https://files.pythonhosted.org/packages/2f/1b/75a5aeff487a26ba427a3837da6372f1fe6f2a9c6b2898e28ac99d491c11/greenlet-3.2.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:45fcea7b697b91290b36eafc12fff479aca6ba6500d98ef6f34d5634c7119cbe", size = 655426, upload-time = "2026-02-20T20:07:20.124Z" },
    { url = "https://files.pythonhosted.org/packages/53/91/9b5dfb4f3c88f8247c7a8f4c3759f0740bfa6bb0c59a9f6bf938e913df56/greenlet-3.2.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f96e2bb8a56b7e1aed1dbfbbe0050cb2ecca99c7c91892fd1771e3afab63b3e3", size = 611138, upload-time = "2026-02-20T20:07:58.966Z" },
    { url = "https://files.pythonhosted.org/packages/b4/8d/d0b086410512d9859c84e9242a9b341de9f5566011ddf3a3f6886b842b61/greenlet-3.2.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d7456e67b0be653dfe643bb37d9566cd30939c80f858e2ce6d2d54951f75b14a", size = 1126896, upload-time = "2026-02-20T20:32:52.198Z" },
    { url = "https://files.pythonhosted.org/packages/ef/37/59fe12fe456e84ced6ba71781e28cde52a3124d1dd2077bc1727021f49fd/greenlet-3.2.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5ceb29d1f74c7280befbbfa27b9bf91ba4a07a1a00b2179a5d953fc219b16c42", size = 1154779, upload-time = "2026-02-20T20:06:27.583Z" },
    { url = "https://files.pythonhosted.org/packages/dd/95/d5d332fb73affaf7a1fbe80e49c2c7eae4f17c645af24a3b3fa25736d6f0/greenlet-3.2.5-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:f2cc88b50b9006b324c1b9f5f3552f9d4564c78af57cdfb4c7baf4f0aa089146", size = 277166, upload-time = "2026-02-20T20:03:57.077Z" },
    { url = "https://files.pythonhosted.org/packages/6c/77/89458e20db5a4f1c64f9a0191561227e76d809941ca2d7529006d17d3450/greenlet-3.2.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e66872daffa360b2537170b73ad530f14fa31785b1bc78080125d92edf0a6def", size = 644674, upload-time = "2026-02-20T20:30:46.118Z" },
    { url = "https://files.pythonhosted.org/packages/90/f8/9962175d2f2eaa629a7fd7545abacc8c4deda3baa4e52c1526d2eb5f5546/greenlet-3.2.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c5445ddb7b586d870dad32ca9fc47c287d6022a528d194efdb8912093c5303ad", size = 658834, upload-time = "2026-02-20T20:37:27.466Z" },
    { url = "https://files.pythonhosted.org/packages/81/71/52c21a7106ce5218aa6fa59ec32825b2655f875a09b69f68bd3e5d01feb3/greenlet-3.2.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd904626b8779810062cb455514594776e3cba3b8c0ba4939894df9f7b384971", size = 653091, upload-time = "2026-02-20T20:44:01.927Z" },
    { url = "https://files.pythonhosted.org/packages/f5/d7/826d0e080f0a7ad5ec47c8d143bbd3ca0887657bb806595fe2434d12938a/greenlet-3.2.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:752c896a8c976548faafe8a306d446c6a4c68d4fd24699b84d4393bd9ac69a8e", size = 655760, upload-time = "2026-02-20T20:07:21.551Z" },
    { url = "https://files.pythonhosted.org/packages/41/cc/33bd4c2f816be8c8e16f71740c4130adf3a66a3dd2ba29de72b9d8dd1096/greenlet-3.2.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:499b809e7738c8af0ff9ac9d5dd821cb93f4293065a9237543217f0b252f950a", size = 614132, upload-time = "2026-02-20T20:08:00.351Z" },
    { url = "https://files.pythonhosted.org/packages/48/79/f3891dcfc59097474a53cc3c624f2f2465e431ab493bda043b8c873fb20a/greenlet-3.2.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2c7429f6e9cea7cbf2637d86d3db12806ba970f7f972fcab39d6b54b4457cbaf", size = 1125286, upload-time = "2026-02-20T20:32:54.032Z" },
    { url = "https://files.pythonhosted.org/packages/ca/47/212b47e6d2d7a04c4083db1af2fdd291bc8fe99b7e3571bfa560b65fc361/greenlet-3.2.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:a5e4b25e855800fba17713020c5c33e0a4b7a1829027719344f0c7c8870092a2", size = 1152825, upload-time = "2026-02-20T20:06:29Z" },
    { url = "https://files.pythonhosted.org/packages/f6/9d/4e9b941be05f8da7ba804c6413761d2c11cca05994cbf0a015bd729419f0/greenlet-3.2.5-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:7123b29e6bad2f3f89681be4ef316480fca798ebe8d22fbaced9cc3775007a4f", size = 277627, upload-time = "2026-02-20T20:06:04.798Z" },
    { url = "https://files.pythonhosted.org/packages/23/cb/a73625c9a35138330014ecf3740c0d62e0c2b5e7279bb7f2586b1b199fac/greenlet-3.2.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6e8fe0c72603201a86b2e038daf9b6c8570715f8779566419cff543b6ace88de", size = 690001, upload-time = "2026-02-20T20:30:47.754Z" },
    { url = "https://files.pythonhosted.org/packages/83/49/6d1531109507bce7dfb23acf57a87013627ed3ac058851176e443a6a9134/greenlet-3.2.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:050703a60603db0e817364d69e048c70af299040c13a7e67792b9e62d4571196", size = 702953, upload-time = "2026-02-20T20:37:29.125Z" },
    { url = "https://files.pythonhosted.org/packages/90/ac/6d8fff3b273fc60ad4b46f8411fe91c1e4cca064dfff68d096bc982fa6d0/greenlet-3.2.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:04633da773ae432649a3f092a8e4add390732cc9e1ab52c8ff2c91b8dc86f202", size = 698353, upload-time = "2026-02-20T20:44:03.547Z" },
    { url = "https://files.pythonhosted.org/packages/f7/38/f958ee90fab93529b30cc1e4a59b27c1112b640570043a84af84da3b3b98/greenlet-3.2.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6712bfd520530eb67331813f7112d3ee18e206f48b3d026d8a96cd2d2ad20251", size = 698995, upload-time = "2026-02-20T20:07:22.663Z" },
    { url = "https://files.pythonhosted.org/packages/51/c1/a603906e79716d61f08afedaf8aed62017661457aef233d62d6e57ecd511/greenlet-3.2.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bc06a78fa3ffbe2a75f1ebc7e040eacf6fa1050a9432953ab111fbbbf0d03c1", size = 661175, upload-time = "2026-02-20T20:08:01.477Z" },
    { url = "https://files.pythonhosted.org/packages/3f/8f/f880ff4587d236b4d06893fb34da6b299aa0d00f6c8259673f80e1b6d63c/greenlet-3.2.5-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:dbe0e81e24982bb45907ca20152b31c2e3300ca352fdc4acbd4956e4a2cbc195", size = 274946, upload-time = "2026-02-20T20:05:21.979Z" },
    { url = "https://files.pythonhosted.org/packages/3c/50/f6c78b8420187fdfe97fcf2e6d1dd243a7742d272c32fd4d4b1095474b37/greenlet-3.2.5-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:15871afc0d78ec87d15d8412b337f287fc69f8f669346e391585824970931c48", size = 631781, upload-time = "2026-02-20T20:30:48.845Z" },
    { url = "https://files.pythonhosted.org/packages/26/d6/3277f92e1961e6e9f41d9f173ea74b5c1f7065072637669f761626f26cc0/greenlet-3.2.5-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5bf0d7d62e356ef2e87e55e46a4e930ac165f9372760fb983b5631bb479e9d3a", size = 643740, upload-time = "2026-02-20T20:37:30.639Z" },
    { url = "https://files.pythonhosted.org/packages/f8/8a/c37b87659378759f158dbe03eaeb7ed002a8968f1c649b2972f5323f99b2/greenlet-3.2.5-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:e3f03ddd7142c758ab41c18089a1407b9959bd276b4e6dfbd8fd06403832c87a", size = 639098, upload-time = "2026-02-20T20:44:07.287Z" },
    { url = "https://files.pythonhosted.org/packages/2a/6a/4f79d2e7b5ef3723fc5ffea0d6cb22627e5f95e0f19c973fa12bf1cf7891/greenlet-3.2.5-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6dff6433742073e5b6ad40953a78a0e8cddcb3f6869e5ea635d29a810ca5e7d0", size = 638382, upload-time = "2026-02-20T20:07:23.883Z" },
    { url = "https://files.pythonhosted.org/packages/4d/59/7aadf33f23c65dbf4db27e7f5b60c414797a61e954352ae4a86c5c8b0553/greenlet-3.2.5-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bdd67619cefe1cc9fcab57c8853d2bb36eca9f166c0058cc0d428d471f7c785c", size = 587516, upload-time = "2026-02-20T20:08:02.841Z" },
    { url = "https://files.pythonhosted.org/packages/1d/46/b3422959f830de28a4eea447414e6bd7b980d755892f66ab52ad805da1c4/greenlet-3.2.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:3828b309dfb1f117fe54867512a8265d8d4f00f8de6908eef9b885f4d8789062", size = 1115818, upload-time = "2026-02-20T20:32:55.786Z" },
    { url = "https://files.pythonhosted.org/packages/54/4a/3d1c9728f093415637cf3696909fa10852632e33e68238fb8ca60eb90de1/greenlet-3.2.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:67725ae9fea62c95cf1aa230f1b8d4dc38f7cd14f6103d1df8a5a95657eb8e54", size = 1140219, upload-time = "2026-02-20T20:06:30.334Z" },
]

[[package]]
name = "greenlet"
version = "3.3.2"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/38/3f/9859f655d11901e7b2996c6e3d33e0caa9a1d4572c3bc61ed0faa64b2f4c/greenlet-3.3.2-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:9bc885b89709d901859cf95179ec9f6bb67a3d2bb1f0e88456461bd4b7f8fd0d", size = 277747, upload-time = "2026-02-20T20:16:21.325Z" },
    { url = "https://files.pythonhosted.org/packages/fb/07/cb284a8b5c6498dbd7cba35d31380bb123d7dceaa7907f606c8ff5993cbf/greenlet-3.3.2-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b568183cf65b94919be4438dc28416b234b678c608cafac8874dfeeb2a9bbe13", size = 579202, upload-time = "2026-02-20T20:47:28.955Z" },
    { url = "https://files.pythonhosted.org/packages/ed/45/67922992b3a152f726163b19f890a85129a992f39607a2a53155de3448b8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:527fec58dc9f90efd594b9b700662ed3fb2493c2122067ac9c740d98080a620e", size = 590620, upload-time = "2026-02-20T20:55:55.581Z" },
    { url = "https://files.pythonhosted.org/packages/03/5f/6e2a7d80c353587751ef3d44bb947f0565ec008a2e0927821c007e96d3a7/greenlet-3.3.2-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:508c7f01f1791fbc8e011bd508f6794cb95397fdb198a46cb6635eb5b78d85a7", size = 602132, upload-time = "2026-02-20T21:02:43.261Z" },
    { url = "https://files.pythonhosted.org/packages/ad/55/9f1ebb5a825215fadcc0f7d5073f6e79e3007e3282b14b22d6aba7ca6cb8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ad0c8917dd42a819fe77e6bdfcb84e3379c0de956469301d9fd36427a1ca501f", size = 591729, upload-time = "2026-02-20T20:20:58.395Z" },
    { url = "https://files.pythonhosted.org/packages/24/b4/21f5455773d37f94b866eb3cf5caed88d6cea6dd2c6e1f9c34f463cba3ec/greenlet-3.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:97245cc10e5515dbc8c3104b2928f7f02b6813002770cfaffaf9a6e0fc2b94ef", size = 1551946, upload-time = "2026-02-20T20:49:31.102Z" },
    { url = "https://files.pythonhosted.org/packages/00/68/91f061a926abead128fe1a87f0b453ccf07368666bd59ffa46016627a930/greenlet-3.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8c1fdd7d1b309ff0da81d60a9688a8bd044ac4e18b250320a96fc68d31c209ca", size = 1618494, upload-time = "2026-02-20T20:21:06.541Z" },
    { url = "https://files.pythonhosted.org/packages/ac/78/f93e840cbaef8becaf6adafbaf1319682a6c2d8c1c20224267a5c6c8c891/greenlet-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:5d0e35379f93a6d0222de929a25ab47b5eb35b5ef4721c2b9cbcc4036129ff1f", size = 230092, upload-time = "2026-02-20T20:17:09.379Z" },
    { url = "https://files.pythonhosted.org/packages/f3/47/16400cb42d18d7a6bb46f0626852c1718612e35dcb0dffa16bbaffdf5dd2/greenlet-3.3.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:c56692189a7d1c7606cb794be0a8381470d95c57ce5be03fb3d0ef57c7853b86", size = 278890, upload-time = "2026-02-20T20:19:39.263Z" },
    { url = "https://files.pythonhosted.org/packages/a3/90/42762b77a5b6aa96cd8c0e80612663d39211e8ae8a6cd47c7f1249a66262/greenlet-3.3.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ebd458fa8285960f382841da585e02201b53a5ec2bac6b156fc623b5ce4499f", size = 581120, upload-time = "2026-02-20T20:47:30.161Z" },
    { url = "https://files.pythonhosted.org/packages/bf/6f/f3d64f4fa0a9c7b5c5b3c810ff1df614540d5aa7d519261b53fba55d4df9/greenlet-3.3.2-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a443358b33c4ec7b05b79a7c8b466f5d275025e750298be7340f8fc63dff2a55", size = 594363, upload-time = "2026-02-20T20:55:56.965Z" },
    { url = "https://files.pythonhosted.org/packages/9c/8b/1430a04657735a3f23116c2e0d5eb10220928846e4537a938a41b350bed6/greenlet-3.3.2-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4375a58e49522698d3e70cc0b801c19433021b5c37686f7ce9c65b0d5c8677d2", size = 605046, upload-time = "2026-02-20T21:02:45.234Z" },
    { url = "https://files.pythonhosted.org/packages/72/83/3e06a52aca8128bdd4dcd67e932b809e76a96ab8c232a8b025b2850264c5/greenlet-3.3.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e2cd90d413acbf5e77ae41e5d3c9b3ac1d011a756d7284d7f3f2b806bbd6358", size = 594156, upload-time = "2026-02-20T20:20:59.955Z" },
    { url = "https://files.pythonhosted.org/packages/70/79/0de5e62b873e08fe3cef7dbe84e5c4bc0e8ed0c7ff131bccb8405cd107c8/greenlet-3.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:442b6057453c8cb29b4fb36a2ac689382fc71112273726e2423f7f17dc73bf99", size = 1554649, upload-time = "2026-02-20T20:49:32.293Z" },
    { url = "https://files.pythonhosted.org/packages/5a/00/32d30dee8389dc36d42170a9c66217757289e2afb0de59a3565260f38373/greenlet-3.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45abe8eb6339518180d5a7fa47fa01945414d7cca5ecb745346fc6a87d2750be", size = 1619472, upload-time = "2026-02-20T20:21:07.966Z" },
    { url = "https://files.pythonhosted.org/packages/f1/3a/efb2cf697fbccdf75b24e2c18025e7dfa54c4f31fab75c51d0fe79942cef/greenlet-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e692b2dae4cc7077cbb11b47d258533b48c8fde69a33d0d8a82e2fe8d8531d5", size = 230389, upload-time = "2026-02-20T20:17:18.772Z" },
    { url = "https://files.pythonhosted.org/packages/e1/a1/65bbc059a43a7e2143ec4fc1f9e3f673e04f9c7b371a494a101422ac4fd5/greenlet-3.3.2-cp311-cp311-win_arm64.whl", hash = "sha256:02b0a8682aecd4d3c6c18edf52bc8e51eacdd75c8eac52a790a210b06aa295fd", size = 229645, upload-time = "2026-02-20T20:18:18.695Z" },
    { url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" },
    { url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" },
    { url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" },
    { url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" },
    { url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" },
    { url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" },
    { url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" },
    { url = "https://files.pythonhosted.org/packages/9b/40/cc802e067d02af8b60b6771cea7d57e21ef5e6659912814babb42b864713/greenlet-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:34308836d8370bddadb41f5a7ce96879b72e2fdfb4e87729330c6ab52376409f", size = 231081, upload-time = "2026-02-20T20:17:28.121Z" },
    { url = "https://files.pythonhosted.org/packages/58/2e/fe7f36ff1982d6b10a60d5e0740c759259a7d6d2e1dc41da6d96de32fff6/greenlet-3.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:d3a62fa76a32b462a97198e4c9e99afb9ab375115e74e9a83ce180e7a496f643", size = 230331, upload-time = "2026-02-20T20:17:23.34Z" },
    { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" },
    { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" },
    { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" },
    { url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" },
    { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" },
    { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" },
    { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" },
    { url = "https://files.pythonhosted.org/packages/91/39/5ef5aa23bc545aa0d31e1b9b55822b32c8da93ba657295840b6b34124009/greenlet-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:a7945dd0eab63ded0a48e4dcade82939783c172290a7903ebde9e184333ca124", size = 230961, upload-time = "2026-02-20T20:16:58.461Z" },
    { url = "https://files.pythonhosted.org/packages/62/6b/a89f8456dcb06becff288f563618e9f20deed8dd29beea14f9a168aef64b/greenlet-3.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:394ead29063ee3515b4e775216cb756b2e3b4a7e55ae8fd884f17fa579e6b327", size = 230221, upload-time = "2026-02-20T20:17:37.152Z" },
    { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" },
    { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" },
    { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" },
    { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" },
    { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" },
    { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" },
    { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" },
    { url = "https://files.pythonhosted.org/packages/f3/ca/2101ca3d9223a1dc125140dbc063644dca76df6ff356531eb27bc267b446/greenlet-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492", size = 232034, upload-time = "2026-02-20T20:20:08.186Z" },
    { url = "https://files.pythonhosted.org/packages/f6/4a/ecf894e962a59dea60f04877eea0fd5724618da89f1867b28ee8b91e811f/greenlet-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71", size = 231437, upload-time = "2026-02-20T20:18:59.722Z" },
    { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" },
    { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" },
    { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" },
    { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" },
    { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" },
    { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" },
    { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" },
    { url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" },
]

[[package]]
name = "grpc-google-iam-v1"
version = "0.14.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "googleapis-common-protos", extra = ["grpc"] },
    { name = "grpcio" },
    { name = "protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/44/4f/d098419ad0bfc06c9ce440575f05aa22d8973b6c276e86ac7890093d3c37/grpc_google_iam_v1-0.14.4.tar.gz", hash = "sha256:392b3796947ed6334e61171d9ab06bf7eb357f554e5fc7556ad7aab6d0e17038", size = 23706, upload-time = "2026-04-01T01:57:49.813Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/89/22/c2dd50c09bf679bd38173656cd4402d2511e563b33bc88f90009cf50613c/grpc_google_iam_v1-0.14.4-py3-none-any.whl", hash = "sha256:412facc320fcbd94034b4df3d557662051d4d8adfa86e0ddb4dca70a3f739964", size = 32675, upload-time = "2026-04-01T01:57:47.69Z" },
]

[[package]]
name = "grpc-interceptor"
version = "0.15.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "grpcio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9f/28/57449d5567adf4c1d3e216aaca545913fbc21a915f2da6790d6734aac76e/grpc-interceptor-0.15.4.tar.gz", hash = "sha256:1f45c0bcb58b6f332f37c637632247c9b02bc6af0fdceb7ba7ce8d2ebbfb0926", size = 19322, upload-time = "2023-11-16T02:05:42.459Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/15/ac/8d53f230a7443401ce81791ec50a3b0e54924bf615ad287654fa4a2f5cdc/grpc_interceptor-0.15.4-py3-none-any.whl", hash = "sha256:0035f33228693ed3767ee49d937bac424318db173fef4d2d0170b3215f254d9d", size = 20848, upload-time = "2023-11-16T02:05:40.913Z" },
]

[[package]]
name = "grpcio"
version = "1.80.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b7/48/af6173dbca4454f4637a4678b67f52ca7e0c1ed7d5894d89d434fecede05/grpcio-1.80.0.tar.gz", hash = "sha256:29aca15edd0688c22ba01d7cc01cb000d72b2033f4a3c72a81a19b56fd143257", size = 12978905, upload-time = "2026-03-30T08:49:10.502Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/9d/cd/bb7b7e54084a344c03d68144450da7ddd5564e51a298ae1662de65f48e2d/grpcio-1.80.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:886457a7768e408cdce226ad1ca67d2958917d306523a0e21e1a2fdaa75c9c9c", size = 6050363, upload-time = "2026-03-30T08:46:20.894Z" },
    { url = "https://files.pythonhosted.org/packages/16/02/1417f5c3460dea65f7a2e3c14e8b31e77f7ffb730e9bfadd89eda7a9f477/grpcio-1.80.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:7b641fc3f1dc647bfd80bd713addc68f6d145956f64677e56d9ebafc0bd72388", size = 12026037, upload-time = "2026-03-30T08:46:25.144Z" },
    { url = "https://files.pythonhosted.org/packages/43/98/c910254eedf2cae368d78336a2de0678e66a7317d27c02522392f949b5c6/grpcio-1.80.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:33eb763f18f006dc7fee1e69831d38d23f5eccd15b2e0f92a13ee1d9242e5e02", size = 6602306, upload-time = "2026-03-30T08:46:27.593Z" },
    { url = "https://files.pythonhosted.org/packages/7c/f8/88ca4e78c077b2b2113d95da1e1ab43efd43d723c9a0397d26529c2c1a56/grpcio-1.80.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:52d143637e3872633fc7dd7c3c6a1c84e396b359f3a72e215f8bf69fd82084fc", size = 7301535, upload-time = "2026-03-30T08:46:29.556Z" },
    { url = "https://files.pythonhosted.org/packages/f9/96/f28660fe2fe0f153288bf4a04e4910b7309d442395135c88ed4f5b3b8b40/grpcio-1.80.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c51bf8ac4575af2e0678bccfb07e47321fc7acb5049b4482832c5c195e04e13a", size = 6808669, upload-time = "2026-03-30T08:46:31.984Z" },
    { url = "https://files.pythonhosted.org/packages/47/eb/3f68a5e955779c00aeef23850e019c1c1d0e032d90633ba49c01ad5a96e0/grpcio-1.80.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:50a9871536d71c4fba24ee856abc03a87764570f0c457dd8db0b4018f379fed9", size = 7409489, upload-time = "2026-03-30T08:46:34.684Z" },
    { url = "https://files.pythonhosted.org/packages/5b/a7/d2f681a4bfb881be40659a309771f3bdfbfdb1190619442816c3f0ffc079/grpcio-1.80.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:a72d84ad0514db063e21887fbacd1fd7acb4d494a564cae22227cd45c7fbf199", size = 8423167, upload-time = "2026-03-30T08:46:36.833Z" },
    { url = "https://files.pythonhosted.org/packages/97/8a/29b4589c204959aa35ce5708400a05bba72181807c45c47b3ec000c39333/grpcio-1.80.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f7691a6788ad9196872f95716df5bc643ebba13c97140b7a5ee5c8e75d1dea81", size = 7846761, upload-time = "2026-03-30T08:46:40.091Z" },
    { url = "https://files.pythonhosted.org/packages/6b/d2/ed143e097230ee121ac5848f6ff14372dba91289b10b536d54fb1b7cbae7/grpcio-1.80.0-cp310-cp310-win32.whl", hash = "sha256:46c2390b59d67f84e882694d489f5b45707c657832d7934859ceb8c33f467069", size = 4156534, upload-time = "2026-03-30T08:46:42.026Z" },
    { url = "https://files.pythonhosted.org/packages/d5/c9/df8279bb49b29409995e95efa85b72973d62f8aeff89abee58c91f393710/grpcio-1.80.0-cp310-cp310-win_amd64.whl", hash = "sha256:dc053420fc75749c961e2a4c906398d7c15725d36ccc04ae6d16093167223b58", size = 4889869, upload-time = "2026-03-30T08:46:44.219Z" },
    { url = "https://files.pythonhosted.org/packages/5d/db/1d56e5f5823257b291962d6c0ce106146c6447f405b60b234c4f222a7cde/grpcio-1.80.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:dfab85db094068ff42e2a3563f60ab3dddcc9d6488a35abf0132daec13209c8a", size = 6055009, upload-time = "2026-03-30T08:46:46.265Z" },
    { url = "https://files.pythonhosted.org/packages/6e/18/c83f3cad64c5ca63bca7e91e5e46b0d026afc5af9d0a9972472ceba294b3/grpcio-1.80.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:5c07e82e822e1161354e32da2662f741a4944ea955f9f580ec8fb409dd6f6060", size = 12035295, upload-time = "2026-03-30T08:46:49.099Z" },
    { url = "https://files.pythonhosted.org/packages/0f/8e/e14966b435be2dda99fbe89db9525ea436edc79780431a1c2875a3582644/grpcio-1.80.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ba0915d51fd4ced2db5ff719f84e270afe0e2d4c45a7bdb1e8d036e4502928c2", size = 6610297, upload-time = "2026-03-30T08:46:52.123Z" },
    { url = "https://files.pythonhosted.org/packages/cc/26/d5eb38f42ce0e3fdc8174ea4d52036ef8d58cc4426cb800f2610f625dd75/grpcio-1.80.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3cb8130ba457d2aa09fa6b7c3ed6b6e4e6a2685fce63cb803d479576c4d80e21", size = 7300208, upload-time = "2026-03-30T08:46:54.859Z" },
    { url = "https://files.pythonhosted.org/packages/25/51/bd267c989f85a17a5b3eea65a6feb4ff672af41ca614e5a0279cc0ea381c/grpcio-1.80.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:09e5e478b3d14afd23f12e49e8b44c8684ac3c5f08561c43a5b9691c54d136ab", size = 6813442, upload-time = "2026-03-30T08:46:57.056Z" },
    { url = "https://files.pythonhosted.org/packages/9e/d9/d80eef735b19e9169e30164bbf889b46f9df9127598a83d174eb13a48b26/grpcio-1.80.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:00168469238b022500e486c1c33916acf2f2a9b2c022202cf8a1885d2e3073c1", size = 7414743, upload-time = "2026-03-30T08:46:59.682Z" },
    { url = "https://files.pythonhosted.org/packages/de/f2/567f5bd5054398ed6b0509b9a30900376dcf2786bd936812098808b49d8d/grpcio-1.80.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8502122a3cc1714038e39a0b071acb1207ca7844208d5ea0d091317555ee7106", size = 8426046, upload-time = "2026-03-30T08:47:02.474Z" },
    { url = "https://files.pythonhosted.org/packages/62/29/73ef0141b4732ff5eacd68430ff2512a65c004696997f70476a83e548e7e/grpcio-1.80.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ce1794f4ea6cc3ca29463f42d665c32ba1b964b48958a66497917fe9069f26e6", size = 7851641, upload-time = "2026-03-30T08:47:05.462Z" },
    { url = "https://files.pythonhosted.org/packages/46/69/abbfa360eb229a8623bab5f5a4f8105e445bd38ce81a89514ba55d281ad0/grpcio-1.80.0-cp311-cp311-win32.whl", hash = "sha256:51b4a7189b0bef2aa30adce3c78f09c83526cf3dddb24c6a96555e3b97340440", size = 4154368, upload-time = "2026-03-30T08:47:08.027Z" },
    { url = "https://files.pythonhosted.org/packages/6f/d4/ae92206d01183b08613e846076115f5ac5991bae358d2a749fa864da5699/grpcio-1.80.0-cp311-cp311-win_amd64.whl", hash = "sha256:02e64bb0bb2da14d947a49e6f120a75e947250aebe65f9629b62bb1f5c14e6e9", size = 4894235, upload-time = "2026-03-30T08:47:10.839Z" },
    { url = "https://files.pythonhosted.org/packages/5c/e8/a2b749265eb3415abc94f2e619bbd9e9707bebdda787e61c593004ec927a/grpcio-1.80.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:c624cc9f1008361014378c9d776de7182b11fe8b2e5a81bc69f23a295f2a1ad0", size = 6015616, upload-time = "2026-03-30T08:47:13.428Z" },
    { url = "https://files.pythonhosted.org/packages/3e/97/b1282161a15d699d1e90c360df18d19165a045ce1c343c7f313f5e8a0b77/grpcio-1.80.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:f49eddcac43c3bf350c0385366a58f36bed8cc2c0ec35ef7b74b49e56552c0c2", size = 12014204, upload-time = "2026-03-30T08:47:15.873Z" },
    { url = "https://files.pythonhosted.org/packages/6e/5e/d319c6e997b50c155ac5a8cb12f5173d5b42677510e886d250d50264949d/grpcio-1.80.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d334591df610ab94714048e0d5b4f3dd5ad1bee74dfec11eee344220077a79de", size = 6563866, upload-time = "2026-03-30T08:47:18.588Z" },
    { url = "https://files.pythonhosted.org/packages/ae/f6/fdd975a2cb4d78eb67769a7b3b3830970bfa2e919f1decf724ae4445f42c/grpcio-1.80.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0cb517eb1d0d0aaf1d87af7cc5b801d686557c1d88b2619f5e31fab3c2315921", size = 7273060, upload-time = "2026-03-30T08:47:21.113Z" },
    { url = "https://files.pythonhosted.org/packages/db/f0/a3deb5feba60d9538a962913e37bd2e69a195f1c3376a3dd44fe0427e996/grpcio-1.80.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4e78c4ac0d97dc2e569b2f4bcbbb447491167cb358d1a389fc4af71ab6f70411", size = 6782121, upload-time = "2026-03-30T08:47:23.827Z" },
    { url = "https://files.pythonhosted.org/packages/ca/84/36c6dcfddc093e108141f757c407902a05085e0c328007cb090d56646cdf/grpcio-1.80.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2ed770b4c06984f3b47eb0517b1c69ad0b84ef3f40128f51448433be904634cd", size = 7383811, upload-time = "2026-03-30T08:47:26.517Z" },
    { url = "https://files.pythonhosted.org/packages/7c/ef/f3a77e3dc5b471a0ec86c564c98d6adfa3510d38f8ee99010410858d591e/grpcio-1.80.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:256507e2f524092f1473071a05e65a5b10d84b82e3ff24c5b571513cfaa61e2f", size = 8393860, upload-time = "2026-03-30T08:47:29.439Z" },
    { url = "https://files.pythonhosted.org/packages/9b/8d/9d4d27ed7f33d109c50d6b5ce578a9914aa68edab75d65869a17e630a8d1/grpcio-1.80.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9a6284a5d907c37db53350645567c522be314bac859a64a7a5ca63b77bb7958f", size = 7830132, upload-time = "2026-03-30T08:47:33.254Z" },
    { url = "https://files.pythonhosted.org/packages/14/e4/9990b41c6d7a44e1e9dee8ac11d7a9802ba1378b40d77468a7761d1ad288/grpcio-1.80.0-cp312-cp312-win32.whl", hash = "sha256:c71309cfce2f22be26aa4a847357c502db6c621f1a49825ae98aa0907595b193", size = 4140904, upload-time = "2026-03-30T08:47:35.319Z" },
    { url = "https://files.pythonhosted.org/packages/2f/2c/296f6138caca1f4b92a31ace4ae1b87dab692fc16a7a3417af3bb3c805bf/grpcio-1.80.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe648599c0e37594c4809d81a9e77bd138cc82eb8baa71b6a86af65426723ff", size = 4880944, upload-time = "2026-03-30T08:47:37.831Z" },
    { url = "https://files.pythonhosted.org/packages/2f/3a/7c3c25789e3f069e581dc342e03613c5b1cb012c4e8c7d9d5cf960a75856/grpcio-1.80.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:e9e408fc016dffd20661f0126c53d8a31c2821b5c13c5d67a0f5ed5de93319ad", size = 6017243, upload-time = "2026-03-30T08:47:40.075Z" },
    { url = "https://files.pythonhosted.org/packages/04/19/21a9806eb8240e174fd1ab0cd5b9aa948bb0e05c2f2f55f9d5d7405e6d08/grpcio-1.80.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:92d787312e613754d4d8b9ca6d3297e69994a7912a32fa38c4c4e01c272974b0", size = 12010840, upload-time = "2026-03-30T08:47:43.11Z" },
    { url = "https://files.pythonhosted.org/packages/18/3a/23347d35f76f639e807fb7a36fad3068aed100996849a33809591f26eca6/grpcio-1.80.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac393b58aa16991a2f1144ec578084d544038c12242da3a215966b512904d0f", size = 6567644, upload-time = "2026-03-30T08:47:46.806Z" },
    { url = "https://files.pythonhosted.org/packages/ff/40/96e07ecb604a6a67ae6ab151e3e35b132875d98bc68ec65f3e5ab3e781d7/grpcio-1.80.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:68e5851ac4b9afe07e7f84483803ad167852570d65326b34d54ca560bfa53fb6", size = 7277830, upload-time = "2026-03-30T08:47:49.643Z" },
    { url = "https://files.pythonhosted.org/packages/9b/e2/da1506ecea1f34a5e365964644b35edef53803052b763ca214ba3870c856/grpcio-1.80.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:873ff5d17d68992ef6605330127425d2fc4e77e612fa3c3e0ed4e668685e3140", size = 6783216, upload-time = "2026-03-30T08:47:52.817Z" },
    { url = "https://files.pythonhosted.org/packages/44/83/3b20ff58d0c3b7f6caaa3af9a4174d4023701df40a3f39f7f1c8e7c48f9d/grpcio-1.80.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2bea16af2750fd0a899bf1abd9022244418b55d1f37da2202249ba4ba673838d", size = 7385866, upload-time = "2026-03-30T08:47:55.687Z" },
    { url = "https://files.pythonhosted.org/packages/47/45/55c507599c5520416de5eefecc927d6a0d7af55e91cfffb2e410607e5744/grpcio-1.80.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba0db34f7e1d803a878284cd70e4c63cb6ae2510ba51937bf8f45ba997cefcf7", size = 8391602, upload-time = "2026-03-30T08:47:58.303Z" },
    { url = "https://files.pythonhosted.org/packages/10/bb/dd06f4c24c01db9cf11341b547d0a016b2c90ed7dbbb086a5710df7dd1d7/grpcio-1.80.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8eb613f02d34721f1acf3626dfdb3545bd3c8505b0e52bf8b5710a28d02e8aa7", size = 7826752, upload-time = "2026-03-30T08:48:01.311Z" },
    { url = "https://files.pythonhosted.org/packages/f9/1e/9d67992ba23371fd63d4527096eb8c6b76d74d52b500df992a3343fd7251/grpcio-1.80.0-cp313-cp313-win32.whl", hash = "sha256:93b6f823810720912fd131f561f91f5fed0fda372b6b7028a2681b8194d5d294", size = 4142310, upload-time = "2026-03-30T08:48:04.594Z" },
    { url = "https://files.pythonhosted.org/packages/cf/e6/283326a27da9e2c3038bc93eeea36fb118ce0b2d03922a9cda6688f53c5b/grpcio-1.80.0-cp313-cp313-win_amd64.whl", hash = "sha256:e172cf795a3ba5246d3529e4d34c53db70e888fa582a8ffebd2e6e48bc0cba50", size = 4882833, upload-time = "2026-03-30T08:48:07.363Z" },
    { url = "https://files.pythonhosted.org/packages/c5/6d/e65307ce20f5a09244ba9e9d8476e99fb039de7154f37fb85f26978b59c3/grpcio-1.80.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3d4147a97c8344d065d01bbf8b6acec2cf86fb0400d40696c8bdad34a64ffc0e", size = 6017376, upload-time = "2026-03-30T08:48:10.005Z" },
    { url = "https://files.pythonhosted.org/packages/69/10/9cef5d9650c72625a699c549940f0abb3c4bfdb5ed45a5ce431f92f31806/grpcio-1.80.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8e11f167935b3eb089ac9038e1a063e6d7dbe995c0bb4a661e614583352e76f", size = 12018133, upload-time = "2026-03-30T08:48:12.927Z" },
    { url = "https://files.pythonhosted.org/packages/04/82/983aabaad82ba26113caceeb9091706a0696b25da004fe3defb5b346e15b/grpcio-1.80.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f14b618fc30de822681ee986cfdcc2d9327229dc4c98aed16896761cacd468b9", size = 6574748, upload-time = "2026-03-30T08:48:16.386Z" },
    { url = "https://files.pythonhosted.org/packages/07/d7/031666ef155aa0bf399ed7e19439656c38bbd143779ae0861b038ce82abd/grpcio-1.80.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4ed39fbdcf9b87370f6e8df4e39ca7b38b3e5e9d1b0013c7b6be9639d6578d14", size = 7277711, upload-time = "2026-03-30T08:48:19.627Z" },
    { url = "https://files.pythonhosted.org/packages/e8/43/f437a78f7f4f1d311804189e8f11fb311a01049b2e08557c1068d470cb2e/grpcio-1.80.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2dcc70e9f0ba987526e8e8603a610fb4f460e42899e74e7a518bf3c68fe1bf05", size = 6785372, upload-time = "2026-03-30T08:48:22.373Z" },
    { url = "https://files.pythonhosted.org/packages/93/3d/f6558e9c6296cb4227faa5c43c54a34c68d32654b829f53288313d16a86e/grpcio-1.80.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:448c884b668b868562b1bda833c5fce6272d26e1926ec46747cda05741d302c1", size = 7395268, upload-time = "2026-03-30T08:48:25.638Z" },
    { url = "https://files.pythonhosted.org/packages/06/21/0fdd77e84720b08843c371a2efa6f2e19dbebf56adc72df73d891f5506f0/grpcio-1.80.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a1dc80fe55685b4a543555e6eef975303b36c8db1023b1599b094b92aa77965f", size = 8392000, upload-time = "2026-03-30T08:48:28.974Z" },
    { url = "https://files.pythonhosted.org/packages/f5/68/67f4947ed55d2e69f2cc199ab9fd85e0a0034d813bbeef84df6d2ba4d4b7/grpcio-1.80.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:31b9ac4ad1aa28ffee5503821fafd09e4da0a261ce1c1281c6c8da0423c83b6e", size = 7828477, upload-time = "2026-03-30T08:48:32.054Z" },
    { url = "https://files.pythonhosted.org/packages/44/b6/8d4096691b2e385e8271911a0de4f35f0a6c7d05aff7098e296c3de86939/grpcio-1.80.0-cp314-cp314-win32.whl", hash = "sha256:367ce30ba67d05e0592470428f0ec1c31714cab9ef19b8f2e37be1f4c7d32fae", size = 4218563, upload-time = "2026-03-30T08:48:34.538Z" },
    { url = "https://files.pythonhosted.org/packages/e5/8c/bbe6baf2557262834f2070cf668515fa308b2d38a4bbf771f8f7872a7036/grpcio-1.80.0-cp314-cp314-win_amd64.whl", hash = "sha256:3b01e1f5464c583d2f567b2e46ff0d516ef979978f72091fd81f5ab7fa6e2e7f", size = 5019457, upload-time = "2026-03-30T08:48:37.308Z" },
    { url = "https://files.pythonhosted.org/packages/08/58/7151ffa07cb3faf4bdd1a1902c067d2d162a4ba24678afd2ad5084a42382/grpcio-1.80.0-cp39-cp39-linux_armv7l.whl", hash = "sha256:aacdfb4ed3eb919ca997504d27e03d5dba403c85130b8ed450308590a738f7a4", size = 6048562, upload-time = "2026-03-30T08:48:40.068Z" },
    { url = "https://files.pythonhosted.org/packages/40/58/0287051dc65c2760155977d9775d1f3c87939e4d575a29aac40f9006b357/grpcio-1.80.0-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:a361c20ec1ccd3c3953d20fb6d7b4125093bdd10dff44c5e2bbb39e58917cedc", size = 12031536, upload-time = "2026-03-30T08:48:43.031Z" },
    { url = "https://files.pythonhosted.org/packages/7b/62/8fc355ffcc9fd8a3ca0438f007307c130dfb93949d3138cd23c8c9f434e8/grpcio-1.80.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:43168871f170d1e4ed16ae03d10cd21efa29f190e710a624cee7e5ae07da6f4f", size = 6602175, upload-time = "2026-03-30T08:48:46.099Z" },
    { url = "https://files.pythonhosted.org/packages/12/cb/3efd0b505090804dfe88bf258ed26a6fb19ccbb31889a05b9edb3ae035fe/grpcio-1.80.0-cp39-cp39-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1b97cd29a8eda100b559b455331c487a80915b6ea6bd91cf3e89836c4ee8d957", size = 7299777, upload-time = "2026-03-30T08:48:48.848Z" },
    { url = "https://files.pythonhosted.org/packages/54/b1/50fdb826acafd5ac661e10df25b089721172530f2eb4aa1f36bd3c3d4254/grpcio-1.80.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bac1d573dfa84ce59a5547073e28fa7326d53352adda6912e362da0b917fcef4", size = 6808790, upload-time = "2026-03-30T08:48:51.625Z" },
    { url = "https://files.pythonhosted.org/packages/60/29/41e9ed0bb5544836bb2685097beea972b0cabc8970aeaace0f152bfc5441/grpcio-1.80.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4560cf0e86514595dbbd330cd65b7afad4b5c4b8c4905c041cfffa138d45e6fd", size = 7410605, upload-time = "2026-03-30T08:48:54.466Z" },
    { url = "https://files.pythonhosted.org/packages/41/ad/889f0dfbc8a08050db6e23c3180dbe712b03af490352a4d7df649db26bc8/grpcio-1.80.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:ec0a592e926071b4abad50c1495cd0d0d513324b3ff5e7267067c33ba27506e4", size = 8423134, upload-time = "2026-03-30T08:48:57.71Z" },
    { url = "https://files.pythonhosted.org/packages/3d/76/f44d853f38165d26a309565da31a312587dda668e9e7b5323179b87bcab4/grpcio-1.80.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:deb10a1528473c11f72a0939eed36d83e847d7cbb63e8cc5611fb7a912d38614", size = 7846917, upload-time = "2026-03-30T08:49:00.969Z" },
    { url = "https://files.pythonhosted.org/packages/74/fe/99c56d12b48f8c8b0d28c42edfb171642eb52dd90a0fe7bc74676909fa97/grpcio-1.80.0-cp39-cp39-win32.whl", hash = "sha256:627fb7312171cdc52828bd6fac8d7028ff2a64b89f1957b6f3416caa2218d141", size = 4157647, upload-time = "2026-03-30T08:49:04.196Z" },
    { url = "https://files.pythonhosted.org/packages/e6/ff/33f6a8823f06c6a1d1f530c1531e563b76c02091525e36255c08575ae775/grpcio-1.80.0-cp39-cp39-win_amd64.whl", hash = "sha256:05d55e1798756282cddd52d56c896b3e7d673e3a8798c2f1cd05ba249a3bb4de", size = 4892359, upload-time = "2026-03-30T08:49:06.902Z" },
]

[[package]]
name = "grpcio-status"
version = "1.80.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "googleapis-common-protos" },
    { name = "grpcio" },
    { name = "protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/ed/105f619bdd00cb47a49aa2feea6232ea2bbb04199d52a22cc6a7d603b5cb/grpcio_status-1.80.0.tar.gz", hash = "sha256:df73802a4c89a3ea88aa2aff971e886fccce162bc2e6511408b3d67a144381cd", size = 13901, upload-time = "2026-03-30T08:54:34.784Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/76/80/58cd2dfc19a07d022abe44bde7c365627f6c7cb6f692ada6c65ca437d09a/grpcio_status-1.80.0-py3-none-any.whl", hash = "sha256:4b56990363af50dbf2c2ebb80f1967185c07d87aa25aa2bea45ddb75fc181dbe", size = 14638, upload-time = "2026-03-30T08:54:01.569Z" },
]

[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]

[[package]]
name = "html5tagger"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9e/02/2ae5f46d517a2c1d4a17f2b1e4834c2c7cc0fb3a69c92389172fa16ab389/html5tagger-1.3.0.tar.gz", hash = "sha256:84fa3dfb49e5c83b79bbd856ab7b1de8e2311c3bb46a8be925f119e3880a8da9", size = 14196, upload-time = "2023-03-28T05:59:34.642Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/9b/12/2f5d43ee912ea14a6baba4b3db6d309b02d932e3b7074c3339b4aded98ff/html5tagger-1.3.0-py3-none-any.whl", hash = "sha256:ce14313515edffec8ed8a36c5890d023922641171b4e6e5774ad1a74998f5351", size = 10956, upload-time = "2023-03-28T05:59:32.524Z" },
]

[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "certifi" },
    { name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]

[[package]]
name = "httptools"
version = "0.7.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/c7/e5/c07e0bcf4ec8db8164e9f6738c048b2e66aabf30e7506f440c4cc6953f60/httptools-0.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:11d01b0ff1fe02c4c32d60af61a4d613b74fad069e47e06e9067758c01e9ac78", size = 204531, upload-time = "2025-10-10T03:54:20.887Z" },
    { url = "https://files.pythonhosted.org/packages/7e/4f/35e3a63f863a659f92ffd92bef131f3e81cf849af26e6435b49bd9f6f751/httptools-0.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:84d86c1e5afdc479a6fdabf570be0d3eb791df0ae727e8dbc0259ed1249998d4", size = 109408, upload-time = "2025-10-10T03:54:22.455Z" },
    { url = "https://files.pythonhosted.org/packages/f5/71/b0a9193641d9e2471ac541d3b1b869538a5fb6419d52fd2669fa9c79e4b8/httptools-0.7.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c8c751014e13d88d2be5f5f14fc8b89612fcfa92a9cc480f2bc1598357a23a05", size = 440889, upload-time = "2025-10-10T03:54:23.753Z" },
    { url = "https://files.pythonhosted.org/packages/eb/d9/2e34811397b76718750fea44658cb0205b84566e895192115252e008b152/httptools-0.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:654968cb6b6c77e37b832a9be3d3ecabb243bbe7a0b8f65fbc5b6b04c8fcabed", size = 440460, upload-time = "2025-10-10T03:54:25.313Z" },
    { url = "https://files.pythonhosted.org/packages/01/3f/a04626ebeacc489866bb4d82362c0657b2262bef381d68310134be7f40bb/httptools-0.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b580968316348b474b020edf3988eecd5d6eec4634ee6561e72ae3a2a0e00a8a", size = 425267, upload-time = "2025-10-10T03:54:26.81Z" },
    { url = "https://files.pythonhosted.org/packages/a5/99/adcd4f66614db627b587627c8ad6f4c55f18881549bab10ecf180562e7b9/httptools-0.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d496e2f5245319da9d764296e86c5bb6fcf0cf7a8806d3d000717a889c8c0b7b", size = 424429, upload-time = "2025-10-10T03:54:28.174Z" },
    { url = "https://files.pythonhosted.org/packages/d5/72/ec8fc904a8fd30ba022dfa85f3bbc64c3c7cd75b669e24242c0658e22f3c/httptools-0.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cbf8317bfccf0fed3b5680c559d3459cccf1abe9039bfa159e62e391c7270568", size = 86173, upload-time = "2025-10-10T03:54:29.5Z" },
    { url = "https://files.pythonhosted.org/packages/9c/08/17e07e8d89ab8f343c134616d72eebfe03798835058e2ab579dcc8353c06/httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657", size = 206521, upload-time = "2025-10-10T03:54:31.002Z" },
    { url = "https://files.pythonhosted.org/packages/aa/06/c9c1b41ff52f16aee526fd10fbda99fa4787938aa776858ddc4a1ea825ec/httptools-0.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70", size = 110375, upload-time = "2025-10-10T03:54:31.941Z" },
    { url = "https://files.pythonhosted.org/packages/cc/cc/10935db22fda0ee34c76f047590ca0a8bd9de531406a3ccb10a90e12ea21/httptools-0.7.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df", size = 456621, upload-time = "2025-10-10T03:54:33.176Z" },
    { url = "https://files.pythonhosted.org/packages/0e/84/875382b10d271b0c11aa5d414b44f92f8dd53e9b658aec338a79164fa548/httptools-0.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad6b591a682dcc6cf1397c3900527f9affef1e55a06c4547264796bbd17cf5e", size = 454954, upload-time = "2025-10-10T03:54:34.226Z" },
    { url = "https://files.pythonhosted.org/packages/30/e1/44f89b280f7e46c0b1b2ccee5737d46b3bb13136383958f20b580a821ca0/httptools-0.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eb844698d11433d2139bbeeb56499102143beb582bd6c194e3ba69c22f25c274", size = 440175, upload-time = "2025-10-10T03:54:35.942Z" },
    { url = "https://files.pythonhosted.org/packages/6f/7e/b9287763159e700e335028bc1824359dc736fa9b829dacedace91a39b37e/httptools-0.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f65744d7a8bdb4bda5e1fa23e4ba16832860606fcc09d674d56e425e991539ec", size = 440310, upload-time = "2025-10-10T03:54:37.1Z" },
    { url = "https://files.pythonhosted.org/packages/b3/07/5b614f592868e07f5c94b1f301b5e14a21df4e8076215a3bccb830a687d8/httptools-0.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:135fbe974b3718eada677229312e97f3b31f8a9c8ffa3ae6f565bf808d5b6bcb", size = 86875, upload-time = "2025-10-10T03:54:38.421Z" },
    { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" },
    { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" },
    { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" },
    { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" },
    { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" },
    { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" },
    { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" },
    { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" },
    { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" },
    { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" },
    { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" },
    { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" },
    { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" },
    { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" },
    { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" },
    { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" },
    { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" },
    { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" },
    { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" },
    { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" },
    { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" },
    { url = "https://files.pythonhosted.org/packages/90/de/b1fe0e8890f0292c266117d4cd268186758a9c34e576fbd573fdf3beacff/httptools-0.7.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:ac50afa68945df63ec7a2707c506bd02239272288add34539a2ef527254626a4", size = 206454, upload-time = "2025-10-10T03:55:01.528Z" },
    { url = "https://files.pythonhosted.org/packages/57/a7/a675c90b49e550c7635ce209c01bc61daa5b08aef17da27ef4e0e78fcf3f/httptools-0.7.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de987bb4e7ac95b99b805b99e0aae0ad51ae61df4263459d36e07cf4052d8b3a", size = 110260, upload-time = "2025-10-10T03:55:02.418Z" },
    { url = "https://files.pythonhosted.org/packages/03/44/fb5ef8136e6e97f7b020e97e40c03a999f97e68574d4998fa52b0a62b01b/httptools-0.7.1-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d169162803a24425eb5e4d51d79cbf429fd7a491b9e570a55f495ea55b26f0bf", size = 441524, upload-time = "2025-10-10T03:55:03.292Z" },
    { url = "https://files.pythonhosted.org/packages/b4/62/8496a5425341867796d7e2419695f74a74607054e227bbaeabec8323e87f/httptools-0.7.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49794f9250188a57fa73c706b46cb21a313edb00d337ca4ce1a011fe3c760b28", size = 440877, upload-time = "2025-10-10T03:55:04.282Z" },
    { url = "https://files.pythonhosted.org/packages/e8/f1/26c2e5214106bf6ed04d03e518ff28ca0c6b5390c5da7b12bbf94b40ae43/httptools-0.7.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:aeefa0648362bb97a7d6b5ff770bfb774930a327d7f65f8208394856862de517", size = 425775, upload-time = "2025-10-10T03:55:05.341Z" },
    { url = "https://files.pythonhosted.org/packages/3a/34/7500a19257139725281f7939a7d1aa3701cf1ac4601a1690f9ab6f510e15/httptools-0.7.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:0d92b10dbf0b3da4823cde6a96d18e6ae358a9daa741c71448975f6a2c339cad", size = 425001, upload-time = "2025-10-10T03:55:06.389Z" },
    { url = "https://files.pythonhosted.org/packages/71/04/31a7949d645ebf33a67f56a0024109444a52a271735e0647a210264f3e61/httptools-0.7.1-cp39-cp39-win_amd64.whl", hash = "sha256:5ddbd045cfcb073db2449563dd479057f2c2b681ebc232380e63ef15edc9c023", size = 86818, upload-time = "2025-10-10T03:55:07.316Z" },
]

[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "anyio", version = "4.12.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "anyio", version = "4.13.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "certifi" },
    { name = "httpcore" },
    { name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]

[[package]]
name = "identify"
version = "2.6.15"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311, upload-time = "2025-10-02T17:43:40.631Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" },
]

[[package]]
name = "identify"
version = "2.6.18"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
sdist = { url = "https://files.pythonhosted.org/packages/46/c4/7fb4db12296cdb11893d61c92048fe617ee853f8523b9b296ac03b43757e/identify-2.6.18.tar.gz", hash = "sha256:873ac56a5e3fd63e7438a7ecbc4d91aca692eb3fefa4534db2b7913f3fc352fd", size = 99580, upload-time = "2026-03-15T18:39:50.319Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/46/33/92ef41c6fad0233e41d3d84ba8e8ad18d1780f1e5d99b3c683e6d7f98b63/identify-2.6.18-py2.py3-none-any.whl", hash = "sha256:8db9d3c8ea9079db92cafb0ebf97abdc09d52e97f4dcf773a2e694048b7cd737", size = 99394, upload-time = "2026-03-15T18:39:48.915Z" },
]

[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]

[[package]]
name = "imagesize"
version = "1.5.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
sdist = { url = "https://files.pythonhosted.org/packages/cf/59/4b0dd64676aa6fb4986a755790cb6fc558559cf0084effad516820208ec3/imagesize-1.5.0.tar.gz", hash = "sha256:8bfc5363a7f2133a89f0098451e0bcb1cd71aba4dc02bbcecb39d99d40e1b94f", size = 1281127, upload-time = "2026-03-03T01:59:54.651Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/1e/b1/a0662b03103c66cf77101a187f396ea91167cd9b7d5d3a2e465ad2c7ee9b/imagesize-1.5.0-py2.py3-none-any.whl", hash = "sha256:32677681b3f434c2cb496f00e89c5a291247b35b1f527589909e008057da5899", size = 5763, upload-time = "2026-03-03T01:59:52.343Z" },
]

[[package]]
name = "imagesize"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
sdist = { url = "https://files.pythonhosted.org/packages/6c/e6/7bf14eeb8f8b7251141944835abd42eb20a658d89084b7e1f3e5fe394090/imagesize-2.0.0.tar.gz", hash = "sha256:8e8358c4a05c304f1fccf7ff96f036e7243a189e9e42e90851993c558cfe9ee3", size = 1773045, upload-time = "2026-03-03T14:18:29.941Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96", size = 9441, upload-time = "2026-03-03T14:18:27.892Z" },
]

[[package]]
name = "importlib-metadata"
version = "8.7.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "zipp" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" },
]

[[package]]
name = "iniconfig"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
]

[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]

[[package]]
name = "itsdangerous"
version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
]

[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]

[[package]]
name = "jmespath"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" },
]

[[package]]
name = "jsbeautifier"
version = "1.15.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "editorconfig" },
    { name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ea/98/d6cadf4d5a1c03b2136837a435682418c29fdeb66be137128544cecc5b7a/jsbeautifier-1.15.4.tar.gz", hash = "sha256:5bb18d9efb9331d825735fbc5360ee8f1aac5e52780042803943aa7f854f7592", size = 75257, upload-time = "2025-02-27T17:53:53.252Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/2d/14/1c65fccf8413d5f5c6e8425f84675169654395098000d8bddc4e9d3390e1/jsbeautifier-1.15.4-py3-none-any.whl", hash = "sha256:72f65de312a3f10900d7685557f84cb61a9733c50dcc27271a39f5b0051bf528", size = 94707, upload-time = "2025-02-27T17:53:46.152Z" },
]

[[package]]
name = "librt"
version = "0.8.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/7c/5f/63f5fa395c7a8a93558c0904ba8f1c8d1b997ca6a3de61bc7659970d66bf/librt-0.8.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:81fd938344fecb9373ba1b155968c8a329491d2ce38e7ddb76f30ffb938f12dc", size = 65697, upload-time = "2026-02-17T16:11:06.903Z" },
    { url = "https://files.pythonhosted.org/packages/ff/e0/0472cf37267b5920eff2f292ccfaede1886288ce35b7f3203d8de00abfe6/librt-0.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5db05697c82b3a2ec53f6e72b2ed373132b0c2e05135f0696784e97d7f5d48e7", size = 68376, upload-time = "2026-02-17T16:11:08.395Z" },
    { url = "https://files.pythonhosted.org/packages/c8/be/8bd1359fdcd27ab897cd5963294fa4a7c83b20a8564678e4fd12157e56a5/librt-0.8.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d56bc4011975f7460bea7b33e1ff425d2f1adf419935ff6707273c77f8a4ada6", size = 197084, upload-time = "2026-02-17T16:11:09.774Z" },
    { url = "https://files.pythonhosted.org/packages/e2/fe/163e33fdd091d0c2b102f8a60cc0a61fd730ad44e32617cd161e7cd67a01/librt-0.8.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdc0f588ff4b663ea96c26d2a230c525c6fc62b28314edaaaca8ed5af931ad0", size = 207337, upload-time = "2026-02-17T16:11:11.311Z" },
    { url = "https://files.pythonhosted.org/packages/01/99/f85130582f05dcf0c8902f3d629270231d2f4afdfc567f8305a952ac7f14/librt-0.8.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97c2b54ff6717a7a563b72627990bec60d8029df17df423f0ed37d56a17a176b", size = 219980, upload-time = "2026-02-17T16:11:12.499Z" },
    { url = "https://files.pythonhosted.org/packages/6f/54/cb5e4d03659e043a26c74e08206412ac9a3742f0477d96f9761a55313b5f/librt-0.8.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f1125e6bbf2f1657d9a2f3ccc4a2c9b0c8b176965bb565dd4d86be67eddb4b6", size = 212921, upload-time = "2026-02-17T16:11:14.484Z" },
    { url = "https://files.pythonhosted.org/packages/b1/81/a3a01e4240579c30f3487f6fed01eb4bc8ef0616da5b4ebac27ca19775f3/librt-0.8.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8f4bb453f408137d7581be309b2fbc6868a80e7ef60c88e689078ee3a296ae71", size = 221381, upload-time = "2026-02-17T16:11:17.459Z" },
    { url = "https://files.pythonhosted.org/packages/08/b0/fc2d54b4b1c6fb81e77288ff31ff25a2c1e62eaef4424a984f228839717b/librt-0.8.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c336d61d2fe74a3195edc1646d53ff1cddd3a9600b09fa6ab75e5514ba4862a7", size = 216714, upload-time = "2026-02-17T16:11:19.197Z" },
    { url = "https://files.pythonhosted.org/packages/96/96/85daa73ffbd87e1fb287d7af6553ada66bf25a2a6b0de4764344a05469f6/librt-0.8.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:eb5656019db7c4deacf0c1a55a898c5bb8f989be904597fcb5232a2f4828fa05", size = 214777, upload-time = "2026-02-17T16:11:20.443Z" },
    { url = "https://files.pythonhosted.org/packages/12/9c/c3aa7a2360383f4bf4f04d98195f2739a579128720c603f4807f006a4225/librt-0.8.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c25d9e338d5bed46c1632f851babf3d13c78f49a225462017cf5e11e845c5891", size = 237398, upload-time = "2026-02-17T16:11:22.083Z" },
    { url = "https://files.pythonhosted.org/packages/61/19/d350ea89e5274665185dabc4bbb9c3536c3411f862881d316c8b8e00eb66/librt-0.8.1-cp310-cp310-win32.whl", hash = "sha256:aaab0e307e344cb28d800957ef3ec16605146ef0e59e059a60a176d19543d1b7", size = 54285, upload-time = "2026-02-17T16:11:23.27Z" },
    { url = "https://files.pythonhosted.org/packages/4f/d6/45d587d3d41c112e9543a0093d883eb57a24a03e41561c127818aa2a6bcc/librt-0.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:56e04c14b696300d47b3bc5f1d10a00e86ae978886d0cee14e5714fafb5df5d2", size = 61352, upload-time = "2026-02-17T16:11:24.207Z" },
    { url = "https://files.pythonhosted.org/packages/1d/01/0e748af5e4fee180cf7cd12bd12b0513ad23b045dccb2a83191bde82d168/librt-0.8.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:681dc2451d6d846794a828c16c22dc452d924e9f700a485b7ecb887a30aad1fd", size = 65315, upload-time = "2026-02-17T16:11:25.152Z" },
    { url = "https://files.pythonhosted.org/packages/9d/4d/7184806efda571887c798d573ca4134c80ac8642dcdd32f12c31b939c595/librt-0.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3b4350b13cc0e6f5bec8fa7caf29a8fb8cdc051a3bae45cfbfd7ce64f009965", size = 68021, upload-time = "2026-02-17T16:11:26.129Z" },
    { url = "https://files.pythonhosted.org/packages/ae/88/c3c52d2a5d5101f28d3dc89298444626e7874aa904eed498464c2af17627/librt-0.8.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ac1e7817fd0ed3d14fd7c5df91daed84c48e4c2a11ee99c0547f9f62fdae13da", size = 194500, upload-time = "2026-02-17T16:11:27.177Z" },
    { url = "https://files.pythonhosted.org/packages/d6/5d/6fb0a25b6a8906e85b2c3b87bee1d6ed31510be7605b06772f9374ca5cb3/librt-0.8.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:747328be0c5b7075cde86a0e09d7a9196029800ba75a1689332348e998fb85c0", size = 205622, upload-time = "2026-02-17T16:11:28.242Z" },
    { url = "https://files.pythonhosted.org/packages/b2/a6/8006ae81227105476a45691f5831499e4d936b1c049b0c1feb17c11b02d1/librt-0.8.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0af2bd2bc204fa27f3d6711d0f360e6b8c684a035206257a81673ab924aa11e", size = 218304, upload-time = "2026-02-17T16:11:29.344Z" },
    { url = "https://files.pythonhosted.org/packages/ee/19/60e07886ad16670aae57ef44dada41912c90906a6fe9f2b9abac21374748/librt-0.8.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d480de377f5b687b6b1bc0c0407426da556e2a757633cc7e4d2e1a057aa688f3", size = 211493, upload-time = "2026-02-17T16:11:30.445Z" },
    { url = "https://files.pythonhosted.org/packages/9c/cf/f666c89d0e861d05600438213feeb818c7514d3315bae3648b1fc145d2b6/librt-0.8.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d0ee06b5b5291f609ddb37b9750985b27bc567791bc87c76a569b3feed8481ac", size = 219129, upload-time = "2026-02-17T16:11:32.021Z" },
    { url = "https://files.pythonhosted.org/packages/8f/ef/f1bea01e40b4a879364c031476c82a0dc69ce068daad67ab96302fed2d45/librt-0.8.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9e2c6f77b9ad48ce5603b83b7da9ee3e36b3ab425353f695cba13200c5d96596", size = 213113, upload-time = "2026-02-17T16:11:33.192Z" },
    { url = "https://files.pythonhosted.org/packages/9b/80/cdab544370cc6bc1b72ea369525f547a59e6938ef6863a11ab3cd24759af/librt-0.8.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:439352ba9373f11cb8e1933da194dcc6206daf779ff8df0ed69c5e39113e6a99", size = 212269, upload-time = "2026-02-17T16:11:34.373Z" },
    { url = "https://files.pythonhosted.org/packages/9d/9c/48d6ed8dac595654f15eceab2035131c136d1ae9a1e3548e777bb6dbb95d/librt-0.8.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:82210adabbc331dbb65d7868b105185464ef13f56f7f76688565ad79f648b0fe", size = 234673, upload-time = "2026-02-17T16:11:36.063Z" },
    { url = "https://files.pythonhosted.org/packages/16/01/35b68b1db517f27a01be4467593292eb5315def8900afad29fabf56304ba/librt-0.8.1-cp311-cp311-win32.whl", hash = "sha256:52c224e14614b750c0a6d97368e16804a98c684657c7518752c356834fff83bb", size = 54597, upload-time = "2026-02-17T16:11:37.544Z" },
    { url = "https://files.pythonhosted.org/packages/71/02/796fe8f02822235966693f257bf2c79f40e11337337a657a8cfebba5febc/librt-0.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:c00e5c884f528c9932d278d5c9cbbea38a6b81eb62c02e06ae53751a83a4d52b", size = 61733, upload-time = "2026-02-17T16:11:38.691Z" },
    { url = "https://files.pythonhosted.org/packages/28/ad/232e13d61f879a42a4e7117d65e4984bb28371a34bb6fb9ca54ec2c8f54e/librt-0.8.1-cp311-cp311-win_arm64.whl", hash = "sha256:f7cdf7f26c2286ffb02e46d7bac56c94655540b26347673bea15fa52a6af17e9", size = 52273, upload-time = "2026-02-17T16:11:40.308Z" },
    { url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516, upload-time = "2026-02-17T16:11:41.604Z" },
    { url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634, upload-time = "2026-02-17T16:11:43.268Z" },
    { url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941, upload-time = "2026-02-17T16:11:44.28Z" },
    { url = "https://files.pythonhosted.org/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d", size = 209991, upload-time = "2026-02-17T16:11:45.462Z" },
    { url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476, upload-time = "2026-02-17T16:11:46.542Z" },
    { url = "https://files.pythonhosted.org/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440", size = 217518, upload-time = "2026-02-17T16:11:47.746Z" },
    { url = "https://files.pythonhosted.org/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9", size = 225116, upload-time = "2026-02-17T16:11:49.298Z" },
    { url = "https://files.pythonhosted.org/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972", size = 217751, upload-time = "2026-02-17T16:11:50.49Z" },
    { url = "https://files.pythonhosted.org/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921", size = 218378, upload-time = "2026-02-17T16:11:51.783Z" },
    { url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199, upload-time = "2026-02-17T16:11:53.561Z" },
    { url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917, upload-time = "2026-02-17T16:11:54.758Z" },
    { url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017, upload-time = "2026-02-17T16:11:55.748Z" },
    { url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441, upload-time = "2026-02-17T16:11:56.801Z" },
    { url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529, upload-time = "2026-02-17T16:11:57.809Z" },
    { url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669, upload-time = "2026-02-17T16:11:58.843Z" },
    { url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279, upload-time = "2026-02-17T16:11:59.862Z" },
    { url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288, upload-time = "2026-02-17T16:12:00.954Z" },
    { url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809, upload-time = "2026-02-17T16:12:02.108Z" },
    { url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075, upload-time = "2026-02-17T16:12:03.631Z" },
    { url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486, upload-time = "2026-02-17T16:12:04.725Z" },
    { url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219, upload-time = "2026-02-17T16:12:05.828Z" },
    { url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750, upload-time = "2026-02-17T16:12:07.09Z" },
    { url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624, upload-time = "2026-02-17T16:12:08.43Z" },
    { url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969, upload-time = "2026-02-17T16:12:09.633Z" },
    { url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000, upload-time = "2026-02-17T16:12:10.632Z" },
    { url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495, upload-time = "2026-02-17T16:12:11.633Z" },
    { url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081, upload-time = "2026-02-17T16:12:12.766Z" },
    { url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309, upload-time = "2026-02-17T16:12:13.756Z" },
    { url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804, upload-time = "2026-02-17T16:12:14.818Z" },
    { url = "https://files.pythonhosted.org/packages/59/18/5dd0d3b87b8ff9c061849fbdb347758d1f724b9a82241aa908e0ec54ccd0/librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c", size = 206907, upload-time = "2026-02-17T16:12:16.513Z" },
    { url = "https://files.pythonhosted.org/packages/d1/96/ef04902aad1424fd7299b62d1890e803e6ab4018c3044dca5922319c4b97/librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3", size = 221217, upload-time = "2026-02-17T16:12:17.906Z" },
    { url = "https://files.pythonhosted.org/packages/6d/ff/7e01f2dda84a8f5d280637a2e5827210a8acca9a567a54507ef1c75b342d/librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14", size = 214622, upload-time = "2026-02-17T16:12:19.108Z" },
    { url = "https://files.pythonhosted.org/packages/1e/8c/5b093d08a13946034fed57619742f790faf77058558b14ca36a6e331161e/librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7", size = 221987, upload-time = "2026-02-17T16:12:20.331Z" },
    { url = "https://files.pythonhosted.org/packages/d3/cc/86b0b3b151d40920ad45a94ce0171dec1aebba8a9d72bb3fa00c73ab25dd/librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6", size = 215132, upload-time = "2026-02-17T16:12:21.54Z" },
    { url = "https://files.pythonhosted.org/packages/fc/be/8588164a46edf1e69858d952654e216a9a91174688eeefb9efbb38a9c799/librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071", size = 215195, upload-time = "2026-02-17T16:12:23.073Z" },
    { url = "https://files.pythonhosted.org/packages/f5/f2/0b9279bea735c734d69344ecfe056c1ba211694a72df10f568745c899c76/librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78", size = 237946, upload-time = "2026-02-17T16:12:24.275Z" },
    { url = "https://files.pythonhosted.org/packages/e9/cc/5f2a34fbc8aeb35314a3641f9956fa9051a947424652fad9882be7a97949/librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023", size = 50689, upload-time = "2026-02-17T16:12:25.766Z" },
    { url = "https://files.pythonhosted.org/packages/a0/76/cd4d010ab2147339ca2b93e959c3686e964edc6de66ddacc935c325883d7/librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730", size = 57875, upload-time = "2026-02-17T16:12:27.465Z" },
    { url = "https://files.pythonhosted.org/packages/84/0f/2143cb3c3ca48bd3379dcd11817163ca50781927c4537345d608b5045998/librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3", size = 48058, upload-time = "2026-02-17T16:12:28.556Z" },
    { url = "https://files.pythonhosted.org/packages/d2/0e/9b23a87e37baf00311c3efe6b48d6b6c168c29902dfc3f04c338372fd7db/librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1", size = 68313, upload-time = "2026-02-17T16:12:29.659Z" },
    { url = "https://files.pythonhosted.org/packages/db/9a/859c41e5a4f1c84200a7d2b92f586aa27133c8243b6cac9926f6e54d01b9/librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee", size = 70994, upload-time = "2026-02-17T16:12:31.516Z" },
    { url = "https://files.pythonhosted.org/packages/4c/28/10605366ee599ed34223ac2bf66404c6fb59399f47108215d16d5ad751a8/librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7", size = 220770, upload-time = "2026-02-17T16:12:33.294Z" },
    { url = "https://files.pythonhosted.org/packages/af/8d/16ed8fd452dafae9c48d17a6bc1ee3e818fd40ef718d149a8eff2c9f4ea2/librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040", size = 235409, upload-time = "2026-02-17T16:12:35.443Z" },
    { url = "https://files.pythonhosted.org/packages/89/1b/7bdf3e49349c134b25db816e4a3db6b94a47ac69d7d46b1e682c2c4949be/librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e", size = 246473, upload-time = "2026-02-17T16:12:36.656Z" },
    { url = "https://files.pythonhosted.org/packages/4e/8a/91fab8e4fd2a24930a17188c7af5380eb27b203d72101c9cc000dbdfd95a/librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732", size = 238866, upload-time = "2026-02-17T16:12:37.849Z" },
    { url = "https://files.pythonhosted.org/packages/b9/e0/c45a098843fc7c07e18a7f8a24ca8496aecbf7bdcd54980c6ca1aaa79a8e/librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624", size = 250248, upload-time = "2026-02-17T16:12:39.445Z" },
    { url = "https://files.pythonhosted.org/packages/82/30/07627de23036640c952cce0c1fe78972e77d7d2f8fd54fa5ef4554ff4a56/librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4", size = 240629, upload-time = "2026-02-17T16:12:40.889Z" },
    { url = "https://files.pythonhosted.org/packages/fb/c1/55bfe1ee3542eba055616f9098eaf6eddb966efb0ca0f44eaa4aba327307/librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382", size = 239615, upload-time = "2026-02-17T16:12:42.446Z" },
    { url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001, upload-time = "2026-02-17T16:12:43.627Z" },
    { url = "https://files.pythonhosted.org/packages/b9/eb/7697f60fbe7042ab4e88f4ee6af496b7f222fffb0a4e3593ef1f29f81652/librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a", size = 51328, upload-time = "2026-02-17T16:12:45.148Z" },
    { url = "https://files.pythonhosted.org/packages/7c/72/34bf2eb7a15414a23e5e70ecb9440c1d3179f393d9349338a91e2781c0fb/librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4", size = 58722, upload-time = "2026-02-17T16:12:46.85Z" },
    { url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" },
    { url = "https://files.pythonhosted.org/packages/01/1f/c7d8b66a3ca3ca3ed8ded4b32c96ee58a45920ebbbaa934355c74adcc33e/librt-0.8.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3dff3d3ca8db20e783b1bc7de49c0a2ab0b8387f31236d6a026597d07fcd68ac", size = 65990, upload-time = "2026-02-17T16:12:48.972Z" },
    { url = "https://files.pythonhosted.org/packages/56/be/ee9ba1730052313d08457f19beaa1b878619978863fba09b40aed5b5c123/librt-0.8.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:08eec3a1fc435f0d09c87b6bf1ec798986a3544f446b864e4099633a56fcd9ed", size = 68640, upload-time = "2026-02-17T16:12:50.24Z" },
    { url = "https://files.pythonhosted.org/packages/81/27/b7309298b96f7690cec3ceee38004c1a7f60fcd96d952d3ac344a1e3e8b3/librt-0.8.1-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e3f0a41487fd5fad7e760b9e8a90e251e27c2816fbc2cff36a22a0e6bcbbd9dd", size = 196099, upload-time = "2026-02-17T16:12:52.788Z" },
    { url = "https://files.pythonhosted.org/packages/10/48/160a5aacdcb21824b10a52378c39e88c46a29bb31efdaf3910dd1f9b670e/librt-0.8.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bacdb58d9939d95cc557b4dbaa86527c9db2ac1ed76a18bc8d26f6dc8647d851", size = 206663, upload-time = "2026-02-17T16:12:55.017Z" },
    { url = "https://files.pythonhosted.org/packages/ee/65/33dd1d8caabb7c6805d87d095b143417dc96b0277c06ffa0508361422c82/librt-0.8.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6d7ab1f01aa753188605b09a51faa44a3327400b00b8cce424c71910fc0a128", size = 219318, upload-time = "2026-02-17T16:12:56.145Z" },
    { url = "https://files.pythonhosted.org/packages/09/d4/353805aa6181c7950a2462bd6e855366eeca21a501f375228d72a51547df/librt-0.8.1-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4998009e7cb9e896569f4be7004f09d0ed70d386fa99d42b6d363f6d200501ac", size = 212191, upload-time = "2026-02-17T16:12:57.326Z" },
    { url = "https://files.pythonhosted.org/packages/06/08/725b3f304d61eba56c713c251fb833a06d84bf93381caad5152366f5d2bb/librt-0.8.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2cc68eeeef5e906839c7bb0815748b5b0a974ec27125beefc0f942715785b551", size = 220672, upload-time = "2026-02-17T16:12:58.497Z" },
    { url = "https://files.pythonhosted.org/packages/0e/55/e8cdf04145872b3b97cb9b68287b22d1c08348227063f305aec11a3e6ce7/librt-0.8.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:0bf69d79a23f4f40b8673a947a234baeeb133b5078b483b7297c5916539cf5d5", size = 216172, upload-time = "2026-02-17T16:12:59.751Z" },
    { url = "https://files.pythonhosted.org/packages/8f/d8/23b1c6592d2422dd6829c672f45b1f1c257f219926b0d216fedb572d0184/librt-0.8.1-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:22b46eabd76c1986ee7d231b0765ad387d7673bbd996aa0d0d054b38ac65d8f6", size = 214116, upload-time = "2026-02-17T16:13:01.056Z" },
    { url = "https://files.pythonhosted.org/packages/c9/92/2b44fd3cc3313f44e43bdbb41343735b568fa675fa351642b408ee48d418/librt-0.8.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:237796479f4d0637d6b9cbcb926ff424a97735e68ade6facf402df4ec93375ed", size = 236664, upload-time = "2026-02-17T16:13:02.314Z" },
    { url = "https://files.pythonhosted.org/packages/00/23/92313ecdab80e142d8ea10e8dfa6297694359dbaacc9e81679bdc8cbceb6/librt-0.8.1-cp39-cp39-win32.whl", hash = "sha256:4beb04b8c66c6ae62f8c1e0b2f097c1ebad9295c929a8d5286c05eae7c2fc7dc", size = 54368, upload-time = "2026-02-17T16:13:03.549Z" },
    { url = "https://files.pythonhosted.org/packages/68/36/18f6e768afad6b55a690d38427c53251b69b7ba8795512730fd2508b31a9/librt-0.8.1-cp39-cp39-win_amd64.whl", hash = "sha256:64548cde61b692dc0dc379f4b5f59a2f582c2ebe7890d09c1ae3b9e66fa015b7", size = 61507, upload-time = "2026-02-17T16:13:04.556Z" },
]

[[package]]
name = "litestar"
version = "2.21.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "anyio", version = "4.12.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "anyio", version = "4.13.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "click", version = "8.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "exceptiongroup", marker = "python_full_version < '3.11'" },
    { name = "httpx" },
    { name = "importlib-metadata", marker = "python_full_version < '3.10'" },
    { name = "litestar-htmx" },
    { name = "msgspec" },
    { name = "multidict" },
    { name = "multipart" },
    { name = "polyfactory" },
    { name = "pyyaml" },
    { name = "rich" },
    { name = "rich-click" },
    { name = "sniffio" },
    { name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a1/fc/7ce2057ffd738be4d2abc5b69229f57181bbff8a84a4576004b021085773/litestar-2.21.1.tar.gz", hash = "sha256:28301438de7c5e77bb68a5d8684dff415b9f252b0dd8413b356e8e6794c6863a", size = 376270, upload-time = "2026-03-07T13:49:16.053Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/f3/67/139c0fa6e1dd9e558910c02a383cd0ae12c2a1d6d3f0ea0d42dbeb03d8b2/litestar-2.21.1-py3-none-any.whl", hash = "sha256:6321340195801454aeac4a12e72c28f54714a4c3e8172c33e577c593cc5982c6", size = 568342, upload-time = "2026-03-07T13:49:13.694Z" },
]

[package.optional-dependencies]
cli = [
    { name = "jsbeautifier" },
    { name = "uvicorn", version = "0.39.0", source = { registry = "https://pypi.org/simple" }, extra = ["standard"], marker = "python_full_version < '3.10'" },
    { name = "uvicorn", version = "0.44.0", source = { registry = "https://pypi.org/simple" }, extra = ["standard"], marker = "python_full_version >= '3.10'" },
]

[[package]]
name = "litestar-htmx"
version = "0.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3f/b9/7e296aa1adada25cce8e5f89a996b0e38d852d93b1b656a2058226c542a2/litestar_htmx-0.5.0.tar.gz", hash = "sha256:e02d1a3a92172c874835fa3e6749d65ae9fc626d0df46719490a16293e2146fb", size = 119755, upload-time = "2025-06-11T21:19:45.573Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/f2/24/8d99982f0aa9c1cd82073c6232b54a0dbe6797c7d63c0583a6c68ee3ddf2/litestar_htmx-0.5.0-py3-none-any.whl", hash = "sha256:92833aa47e0d0e868d2a7dbfab75261f124f4b83d4f9ad12b57b9a68f86c50e6", size = 9970, upload-time = "2025-06-11T21:19:44.465Z" },
]

[[package]]
name = "mako"
version = "1.3.10"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" },
]

[[package]]
name = "markdown-it-py"
version = "3.0.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version == '3.10.*'",
    "python_full_version < '3.10'",
]
dependencies = [
    { name = "mdurl", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" },
]

[[package]]
name = "markdown-it-py"
version = "4.0.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
]
dependencies = [
    { name = "mdurl", marker = "python_full_version >= '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
]

[[package]]
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" },
    { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" },
    { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" },
    { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" },
    { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" },
    { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" },
    { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" },
    { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" },
    { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" },
    { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" },
    { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" },
    { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" },
    { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" },
    { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" },
    { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" },
    { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" },
    { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" },
    { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" },
    { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" },
    { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" },
    { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" },
    { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" },
    { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
    { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
    { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
    { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
    { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
    { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
    { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
    { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
    { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
    { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
    { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
    { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
    { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
    { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
    { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
    { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
    { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
    { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
    { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
    { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
    { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
    { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
    { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
    { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
    { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
    { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
    { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
    { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
    { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
    { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
    { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
    { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
    { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
    { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
    { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
    { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
    { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
    { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
    { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
    { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
    { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
    { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
    { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
    { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
    { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
    { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
    { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
    { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
    { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
    { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
    { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
    { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
    { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
    { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
    { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
    { url = "https://files.pythonhosted.org/packages/56/23/0d8c13a44bde9154821586520840643467aee574d8ce79a17da539ee7fed/markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26", size = 11623, upload-time = "2025-09-27T18:37:29.296Z" },
    { url = "https://files.pythonhosted.org/packages/fd/23/07a2cb9a8045d5f3f0890a8c3bc0859d7a47bfd9a560b563899bec7b72ed/markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc", size = 12049, upload-time = "2025-09-27T18:37:30.234Z" },
    { url = "https://files.pythonhosted.org/packages/bc/e4/6be85eb81503f8e11b61c0b6369b6e077dcf0a74adbd9ebf6b349937b4e9/markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c", size = 21923, upload-time = "2025-09-27T18:37:31.177Z" },
    { url = "https://files.pythonhosted.org/packages/6f/bc/4dc914ead3fe6ddaef035341fee0fc956949bbd27335b611829292b89ee2/markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42", size = 20543, upload-time = "2025-09-27T18:37:32.168Z" },
    { url = "https://files.pythonhosted.org/packages/89/6e/5fe81fbcfba4aef4093d5f856e5c774ec2057946052d18d168219b7bd9f9/markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b", size = 20585, upload-time = "2025-09-27T18:37:33.166Z" },
    { url = "https://files.pythonhosted.org/packages/f6/f6/e0e5a3d3ae9c4020f696cd055f940ef86b64fe88de26f3a0308b9d3d048c/markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758", size = 21387, upload-time = "2025-09-27T18:37:34.185Z" },
    { url = "https://files.pythonhosted.org/packages/c8/25/651753ef4dea08ea790f4fbb65146a9a44a014986996ca40102e237aa49a/markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2", size = 20133, upload-time = "2025-09-27T18:37:35.138Z" },
    { url = "https://files.pythonhosted.org/packages/dc/0a/c3cf2b4fef5f0426e8a6d7fce3cb966a17817c568ce59d76b92a233fdbec/markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d", size = 20588, upload-time = "2025-09-27T18:37:36.096Z" },
    { url = "https://files.pythonhosted.org/packages/cd/1b/a7782984844bd519ad4ffdbebbba2671ec5d0ebbeac34736c15fb86399e8/markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7", size = 14566, upload-time = "2025-09-27T18:37:37.09Z" },
    { url = "https://files.pythonhosted.org/packages/18/1f/8d9c20e1c9440e215a44be5ab64359e207fcb4f675543f1cf9a2a7f648d0/markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e", size = 15053, upload-time = "2025-09-27T18:37:38.054Z" },
    { url = "https://files.pythonhosted.org/packages/4e/d3/fe08482b5cd995033556d45041a4f4e76e7f0521112a9c9991d40d39825f/markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8", size = 13928, upload-time = "2025-09-27T18:37:39.037Z" },
]

[[package]]
name = "mdit-py-plugins"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
dependencies = [
    { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/03/a2ecab526543b152300717cf232bb4bb8605b6edb946c845016fa9c9c9fd/mdit_py_plugins-0.4.2.tar.gz", hash = "sha256:5f2cd1fdb606ddf152d37ec30e46101a60512bc0e5fa1a7002c36647b09e26b5", size = 43542, upload-time = "2024-09-09T20:27:49.564Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/a7/f7/7782a043553ee469c1ff49cfa1cdace2d6bf99a1f333cf38676b3ddf30da/mdit_py_plugins-0.4.2-py3-none-any.whl", hash = "sha256:0c673c3f889399a33b95e88d2f0d111b4447bdfea7f237dab2d488f459835636", size = 55316, upload-time = "2024-09-09T20:27:48.397Z" },
]

[[package]]
name = "mdit-py-plugins"
version = "0.5.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
dependencies = [
    { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" },
    { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205, upload-time = "2025-08-11T07:25:47.597Z" },
]

[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]

[[package]]
name = "minio"
version = "7.2.20"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "argon2-cffi", version = "23.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "argon2-cffi", version = "25.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "certifi" },
    { name = "pycryptodome" },
    { name = "typing-extensions" },
    { name = "urllib3", version = "1.26.20", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "urllib3", version = "2.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/40/df/6dfc6540f96a74125a11653cce717603fd5b7d0001a8e847b3e54e72d238/minio-7.2.20.tar.gz", hash = "sha256:95898b7a023fbbfde375985aa77e2cd6a0762268db79cf886f002a9ea8e68598", size = 136113, upload-time = "2025-11-27T00:37:15.569Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/3e/9a/b697530a882588a84db616580f2ba5d1d515c815e11c30d219145afeec87/minio-7.2.20-py3-none-any.whl", hash = "sha256:eb33dd2fb80e04c3726a76b13241c6be3c4c46f8d81e1d58e757786f6501897e", size = 93751, upload-time = "2025-11-27T00:37:13.993Z" },
]

[[package]]
name = "mmh3"
version = "5.2.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
sdist = { url = "https://files.pythonhosted.org/packages/a7/af/f28c2c2f51f31abb4725f9a64bc7863d5f491f6539bd26aee2a1d21a649e/mmh3-5.2.0.tar.gz", hash = "sha256:1efc8fec8478e9243a78bb993422cf79f8ff85cb4cf6b79647480a31e0d950a8", size = 33582, upload-time = "2025-07-29T07:43:48.49Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/b9/2b/870f0ff5ecf312c58500f45950751f214b7068665e66e9bfd8bc2595587c/mmh3-5.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:81c504ad11c588c8629536b032940f2a359dda3b6cbfd4ad8f74cb24dcd1b0bc", size = 56119, upload-time = "2025-07-29T07:41:39.117Z" },
    { url = "https://files.pythonhosted.org/packages/3b/88/eb9a55b3f3cf43a74d6bfa8db0e2e209f966007777a1dc897c52c008314c/mmh3-5.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0b898cecff57442724a0f52bf42c2de42de63083a91008fb452887e372f9c328", size = 40634, upload-time = "2025-07-29T07:41:40.626Z" },
    { url = "https://files.pythonhosted.org/packages/d1/4c/8e4b3878bf8435c697d7ce99940a3784eb864521768069feaccaff884a17/mmh3-5.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:be1374df449465c9f2500e62eee73a39db62152a8bdfbe12ec5b5c1cd451344d", size = 40080, upload-time = "2025-07-29T07:41:41.791Z" },
    { url = "https://files.pythonhosted.org/packages/45/ac/0a254402c8c5ca424a0a9ebfe870f5665922f932830f0a11a517b6390a09/mmh3-5.2.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b0d753ad566c721faa33db7e2e0eddd74b224cdd3eaf8481d76c926603c7a00e", size = 95321, upload-time = "2025-07-29T07:41:42.659Z" },
    { url = "https://files.pythonhosted.org/packages/39/8e/29306d5eca6dfda4b899d22c95b5420db4e0ffb7e0b6389b17379654ece5/mmh3-5.2.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:dfbead5575f6470c17e955b94f92d62a03dfc3d07f2e6f817d9b93dc211a1515", size = 101220, upload-time = "2025-07-29T07:41:43.572Z" },
    { url = "https://files.pythonhosted.org/packages/49/f7/0dd1368e531e52a17b5b8dd2f379cce813bff2d0978a7748a506f1231152/mmh3-5.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7434a27754049144539d2099a6d2da5d88b8bdeedf935180bf42ad59b3607aa3", size = 103991, upload-time = "2025-07-29T07:41:44.914Z" },
    { url = "https://files.pythonhosted.org/packages/35/06/abc7122c40f4abbfcef01d2dac6ec0b77ede9757e5be8b8a40a6265b1274/mmh3-5.2.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cadc16e8ea64b5d9a47363013e2bea469e121e6e7cb416a7593aeb24f2ad122e", size = 110894, upload-time = "2025-07-29T07:41:45.849Z" },
    { url = "https://files.pythonhosted.org/packages/f4/2f/837885759afa4baccb8e40456e1cf76a4f3eac835b878c727ae1286c5f82/mmh3-5.2.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d765058da196f68dc721116cab335e696e87e76720e6ef8ee5a24801af65e63d", size = 118327, upload-time = "2025-07-29T07:41:47.224Z" },
    { url = "https://files.pythonhosted.org/packages/40/cc/5683ba20a21bcfb3f1605b1c474f46d30354f728a7412201f59f453d405a/mmh3-5.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8b0c53fe0994beade1ad7c0f13bd6fec980a0664bfbe5a6a7d64500b9ab76772", size = 101701, upload-time = "2025-07-29T07:41:48.259Z" },
    { url = "https://files.pythonhosted.org/packages/0e/24/99ab3fb940150aec8a26dbdfc39b200b5592f6aeb293ec268df93e054c30/mmh3-5.2.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:49037d417419863b222ae47ee562b2de9c3416add0a45c8d7f4e864be8dc4f89", size = 96712, upload-time = "2025-07-29T07:41:49.467Z" },
    { url = "https://files.pythonhosted.org/packages/61/04/d7c4cb18f1f001ede2e8aed0f9dbbfad03d161c9eea4fffb03f14f4523e5/mmh3-5.2.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:6ecb4e750d712abde046858ee6992b65c93f1f71b397fce7975c3860c07365d2", size = 110302, upload-time = "2025-07-29T07:41:50.387Z" },
    { url = "https://files.pythonhosted.org/packages/d8/bf/4dac37580cfda74425a4547500c36fa13ef581c8a756727c37af45e11e9a/mmh3-5.2.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:382a6bb3f8c6532ea084e7acc5be6ae0c6effa529240836d59352398f002e3fc", size = 111929, upload-time = "2025-07-29T07:41:51.348Z" },
    { url = "https://files.pythonhosted.org/packages/eb/b1/49f0a582c7a942fb71ddd1ec52b7d21d2544b37d2b2d994551346a15b4f6/mmh3-5.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7733ec52296fc1ba22e9b90a245c821adbb943e98c91d8a330a2254612726106", size = 100111, upload-time = "2025-07-29T07:41:53.139Z" },
    { url = "https://files.pythonhosted.org/packages/dc/94/ccec09f438caeb2506f4c63bb3b99aa08a9e09880f8fc047295154756210/mmh3-5.2.0-cp310-cp310-win32.whl", hash = "sha256:127c95336f2a98c51e7682341ab7cb0be3adb9df0819ab8505a726ed1801876d", size = 40783, upload-time = "2025-07-29T07:41:54.463Z" },
    { url = "https://files.pythonhosted.org/packages/ea/f4/8d39a32c8203c1cdae88fdb04d1ea4aa178c20f159df97f4c5a2eaec702c/mmh3-5.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:419005f84ba1cab47a77465a2a843562dadadd6671b8758bf179d82a15ca63eb", size = 41549, upload-time = "2025-07-29T07:41:55.295Z" },
    { url = "https://files.pythonhosted.org/packages/cc/a1/30efb1cd945e193f62574144dd92a0c9ee6463435e4e8ffce9b9e9f032f0/mmh3-5.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:d22c9dcafed659fadc605538946c041722b6d1104fe619dbf5cc73b3c8a0ded8", size = 39335, upload-time = "2025-07-29T07:41:56.194Z" },
    { url = "https://files.pythonhosted.org/packages/f7/87/399567b3796e134352e11a8b973cd470c06b2ecfad5468fe580833be442b/mmh3-5.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7901c893e704ee3c65f92d39b951f8f34ccf8e8566768c58103fb10e55afb8c1", size = 56107, upload-time = "2025-07-29T07:41:57.07Z" },
    { url = "https://files.pythonhosted.org/packages/c3/09/830af30adf8678955b247d97d3d9543dd2fd95684f3cd41c0cd9d291da9f/mmh3-5.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4a5f5536b1cbfa72318ab3bfc8a8188b949260baed186b75f0abc75b95d8c051", size = 40635, upload-time = "2025-07-29T07:41:57.903Z" },
    { url = "https://files.pythonhosted.org/packages/07/14/eaba79eef55b40d653321765ac5e8f6c9ac38780b8a7c2a2f8df8ee0fb72/mmh3-5.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cedac4f4054b8f7859e5aed41aaa31ad03fce6851901a7fdc2af0275ac533c10", size = 40078, upload-time = "2025-07-29T07:41:58.772Z" },
    { url = "https://files.pythonhosted.org/packages/bb/26/83a0f852e763f81b2265d446b13ed6d49ee49e1fc0c47b9655977e6f3d81/mmh3-5.2.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eb756caf8975882630ce4e9fbbeb9d3401242a72528230422c9ab3a0d278e60c", size = 97262, upload-time = "2025-07-29T07:41:59.678Z" },
    { url = "https://files.pythonhosted.org/packages/00/7d/b7133b10d12239aeaebf6878d7eaf0bf7d3738c44b4aba3c564588f6d802/mmh3-5.2.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:097e13c8b8a66c5753c6968b7640faefe85d8e38992703c1f666eda6ef4c3762", size = 103118, upload-time = "2025-07-29T07:42:01.197Z" },
    { url = "https://files.pythonhosted.org/packages/7b/3e/62f0b5dce2e22fd5b7d092aba285abd7959ea2b17148641e029f2eab1ffa/mmh3-5.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a7c0c7845566b9686480e6a7e9044db4afb60038d5fabd19227443f0104eeee4", size = 106072, upload-time = "2025-07-29T07:42:02.601Z" },
    { url = "https://files.pythonhosted.org/packages/66/84/ea88bb816edfe65052c757a1c3408d65c4201ddbd769d4a287b0f1a628b2/mmh3-5.2.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:61ac226af521a572700f863d6ecddc6ece97220ce7174e311948ff8c8919a363", size = 112925, upload-time = "2025-07-29T07:42:03.632Z" },
    { url = "https://files.pythonhosted.org/packages/2e/13/c9b1c022807db575fe4db806f442d5b5784547e2e82cff36133e58ea31c7/mmh3-5.2.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:582f9dbeefe15c32a5fa528b79b088b599a1dfe290a4436351c6090f90ddebb8", size = 120583, upload-time = "2025-07-29T07:42:04.991Z" },
    { url = "https://files.pythonhosted.org/packages/8a/5f/0e2dfe1a38f6a78788b7eb2b23432cee24623aeabbc907fed07fc17d6935/mmh3-5.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2ebfc46b39168ab1cd44670a32ea5489bcbc74a25795c61b6d888c5c2cf654ed", size = 99127, upload-time = "2025-07-29T07:42:05.929Z" },
    { url = "https://files.pythonhosted.org/packages/77/27/aefb7d663b67e6a0c4d61a513c83e39ba2237e8e4557fa7122a742a23de5/mmh3-5.2.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1556e31e4bd0ac0c17eaf220be17a09c171d7396919c3794274cb3415a9d3646", size = 98544, upload-time = "2025-07-29T07:42:06.87Z" },
    { url = "https://files.pythonhosted.org/packages/ab/97/a21cc9b1a7c6e92205a1b5fa030cdf62277d177570c06a239eca7bd6dd32/mmh3-5.2.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:81df0dae22cd0da87f1c978602750f33d17fb3d21fb0f326c89dc89834fea79b", size = 106262, upload-time = "2025-07-29T07:42:07.804Z" },
    { url = "https://files.pythonhosted.org/packages/43/18/db19ae82ea63c8922a880e1498a75342311f8aa0c581c4dd07711473b5f7/mmh3-5.2.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:eba01ec3bd4a49b9ac5ca2bc6a73ff5f3af53374b8556fcc2966dd2af9eb7779", size = 109824, upload-time = "2025-07-29T07:42:08.735Z" },
    { url = "https://files.pythonhosted.org/packages/9f/f5/41dcf0d1969125fc6f61d8618b107c79130b5af50b18a4651210ea52ab40/mmh3-5.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e9a011469b47b752e7d20de296bb34591cdfcbe76c99c2e863ceaa2aa61113d2", size = 97255, upload-time = "2025-07-29T07:42:09.706Z" },
    { url = "https://files.pythonhosted.org/packages/32/b3/cce9eaa0efac1f0e735bb178ef9d1d2887b4927fe0ec16609d5acd492dda/mmh3-5.2.0-cp311-cp311-win32.whl", hash = "sha256:bc44fc2b886243d7c0d8daeb37864e16f232e5b56aaec27cc781d848264cfd28", size = 40779, upload-time = "2025-07-29T07:42:10.546Z" },
    { url = "https://files.pythonhosted.org/packages/7c/e9/3fa0290122e6d5a7041b50ae500b8a9f4932478a51e48f209a3879fe0b9b/mmh3-5.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:8ebf241072cf2777a492d0e09252f8cc2b3edd07dfdb9404b9757bffeb4f2cee", size = 41549, upload-time = "2025-07-29T07:42:11.399Z" },
    { url = "https://files.pythonhosted.org/packages/3a/54/c277475b4102588e6f06b2e9095ee758dfe31a149312cdbf62d39a9f5c30/mmh3-5.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:b5f317a727bba0e633a12e71228bc6a4acb4f471a98b1c003163b917311ea9a9", size = 39336, upload-time = "2025-07-29T07:42:12.209Z" },
    { url = "https://files.pythonhosted.org/packages/bf/6a/d5aa7edb5c08e0bd24286c7d08341a0446f9a2fbbb97d96a8a6dd81935ee/mmh3-5.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:384eda9361a7bf83a85e09447e1feafe081034af9dd428893701b959230d84be", size = 56141, upload-time = "2025-07-29T07:42:13.456Z" },
    { url = "https://files.pythonhosted.org/packages/08/49/131d0fae6447bc4a7299ebdb1a6fb9d08c9f8dcf97d75ea93e8152ddf7ab/mmh3-5.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c9da0d568569cc87315cb063486d761e38458b8ad513fedd3dc9263e1b81bcd", size = 40681, upload-time = "2025-07-29T07:42:14.306Z" },
    { url = "https://files.pythonhosted.org/packages/8f/6f/9221445a6bcc962b7f5ff3ba18ad55bba624bacdc7aa3fc0a518db7da8ec/mmh3-5.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86d1be5d63232e6eb93c50881aea55ff06eb86d8e08f9b5417c8c9b10db9db96", size = 40062, upload-time = "2025-07-29T07:42:15.08Z" },
    { url = "https://files.pythonhosted.org/packages/1e/d4/6bb2d0fef81401e0bb4c297d1eb568b767de4ce6fc00890bc14d7b51ecc4/mmh3-5.2.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bf7bee43e17e81671c447e9c83499f53d99bf440bc6d9dc26a841e21acfbe094", size = 97333, upload-time = "2025-07-29T07:42:16.436Z" },
    { url = "https://files.pythonhosted.org/packages/44/e0/ccf0daff8134efbb4fbc10a945ab53302e358c4b016ada9bf97a6bdd50c1/mmh3-5.2.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7aa18cdb58983ee660c9c400b46272e14fa253c675ed963d3812487f8ca42037", size = 103310, upload-time = "2025-07-29T07:42:17.796Z" },
    { url = "https://files.pythonhosted.org/packages/02/63/1965cb08a46533faca0e420e06aff8bbaf9690a6f0ac6ae6e5b2e4544687/mmh3-5.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae9d032488fcec32d22be6542d1a836f00247f40f320844dbb361393b5b22773", size = 106178, upload-time = "2025-07-29T07:42:19.281Z" },
    { url = "https://files.pythonhosted.org/packages/c2/41/c883ad8e2c234013f27f92061200afc11554ea55edd1bcf5e1accd803a85/mmh3-5.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1861fb6b1d0453ed7293200139c0a9011eeb1376632e048e3766945b13313c5", size = 113035, upload-time = "2025-07-29T07:42:20.356Z" },
    { url = "https://files.pythonhosted.org/packages/df/b5/1ccade8b1fa625d634a18bab7bf08a87457e09d5ec8cf83ca07cbea9d400/mmh3-5.2.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:99bb6a4d809aa4e528ddfe2c85dd5239b78b9dd14be62cca0329db78505e7b50", size = 120784, upload-time = "2025-07-29T07:42:21.377Z" },
    { url = "https://files.pythonhosted.org/packages/77/1c/919d9171fcbdcdab242e06394464ccf546f7d0f3b31e0d1e3a630398782e/mmh3-5.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1f8d8b627799f4e2fcc7c034fed8f5f24dc7724ff52f69838a3d6d15f1ad4765", size = 99137, upload-time = "2025-07-29T07:42:22.344Z" },
    { url = "https://files.pythonhosted.org/packages/66/8a/1eebef5bd6633d36281d9fc83cf2e9ba1ba0e1a77dff92aacab83001cee4/mmh3-5.2.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b5995088dd7023d2d9f310a0c67de5a2b2e06a570ecfd00f9ff4ab94a67cde43", size = 98664, upload-time = "2025-07-29T07:42:23.269Z" },
    { url = "https://files.pythonhosted.org/packages/13/41/a5d981563e2ee682b21fb65e29cc0f517a6734a02b581359edd67f9d0360/mmh3-5.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1a5f4d2e59d6bba8ef01b013c472741835ad961e7c28f50c82b27c57748744a4", size = 106459, upload-time = "2025-07-29T07:42:24.238Z" },
    { url = "https://files.pythonhosted.org/packages/24/31/342494cd6ab792d81e083680875a2c50fa0c5df475ebf0b67784f13e4647/mmh3-5.2.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fd6e6c3d90660d085f7e73710eab6f5545d4854b81b0135a3526e797009dbda3", size = 110038, upload-time = "2025-07-29T07:42:25.629Z" },
    { url = "https://files.pythonhosted.org/packages/28/44/efda282170a46bb4f19c3e2b90536513b1d821c414c28469a227ca5a1789/mmh3-5.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c4a2f3d83879e3de2eb8cbf562e71563a8ed15ee9b9c2e77ca5d9f73072ac15c", size = 97545, upload-time = "2025-07-29T07:42:27.04Z" },
    { url = "https://files.pythonhosted.org/packages/68/8f/534ae319c6e05d714f437e7206f78c17e66daca88164dff70286b0e8ea0c/mmh3-5.2.0-cp312-cp312-win32.whl", hash = "sha256:2421b9d665a0b1ad724ec7332fb5a98d075f50bc51a6ff854f3a1882bd650d49", size = 40805, upload-time = "2025-07-29T07:42:28.032Z" },
    { url = "https://files.pythonhosted.org/packages/b8/f6/f6abdcfefcedab3c964868048cfe472764ed358c2bf6819a70dd4ed4ed3a/mmh3-5.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d80005b7634a3a2220f81fbeb94775ebd12794623bb2e1451701ea732b4aa3", size = 41597, upload-time = "2025-07-29T07:42:28.894Z" },
    { url = "https://files.pythonhosted.org/packages/15/fd/f7420e8cbce45c259c770cac5718badf907b302d3a99ec587ba5ce030237/mmh3-5.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:3d6bfd9662a20c054bc216f861fa330c2dac7c81e7fb8307b5e32ab5b9b4d2e0", size = 39350, upload-time = "2025-07-29T07:42:29.794Z" },
    { url = "https://files.pythonhosted.org/packages/d8/fa/27f6ab93995ef6ad9f940e96593c5dd24744d61a7389532b0fec03745607/mmh3-5.2.0-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:e79c00eba78f7258e5b354eccd4d7907d60317ced924ea4a5f2e9d83f5453065", size = 40874, upload-time = "2025-07-29T07:42:30.662Z" },
    { url = "https://files.pythonhosted.org/packages/11/9c/03d13bcb6a03438bc8cac3d2e50f80908d159b31a4367c2e1a7a077ded32/mmh3-5.2.0-cp313-cp313-android_21_x86_64.whl", hash = "sha256:956127e663d05edbeec54df38885d943dfa27406594c411139690485128525de", size = 42012, upload-time = "2025-07-29T07:42:31.539Z" },
    { url = "https://files.pythonhosted.org/packages/4e/78/0865d9765408a7d504f1789944e678f74e0888b96a766d578cb80b040999/mmh3-5.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:c3dca4cb5b946ee91b3d6bb700d137b1cd85c20827f89fdf9c16258253489044", size = 39197, upload-time = "2025-07-29T07:42:32.374Z" },
    { url = "https://files.pythonhosted.org/packages/3e/12/76c3207bd186f98b908b6706c2317abb73756d23a4e68ea2bc94825b9015/mmh3-5.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:e651e17bfde5840e9e4174b01e9e080ce49277b70d424308b36a7969d0d1af73", size = 39840, upload-time = "2025-07-29T07:42:33.227Z" },
    { url = "https://files.pythonhosted.org/packages/5d/0d/574b6cce5555c9f2b31ea189ad44986755eb14e8862db28c8b834b8b64dc/mmh3-5.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:9f64bf06f4bf623325fda3a6d02d36cd69199b9ace99b04bb2d7fd9f89688504", size = 40644, upload-time = "2025-07-29T07:42:34.099Z" },
    { url = "https://files.pythonhosted.org/packages/52/82/3731f8640b79c46707f53ed72034a58baad400be908c87b0088f1f89f986/mmh3-5.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ddc63328889bcaee77b743309e5c7d2d52cee0d7d577837c91b6e7cc9e755e0b", size = 56153, upload-time = "2025-07-29T07:42:35.031Z" },
    { url = "https://files.pythonhosted.org/packages/4f/34/e02dca1d4727fd9fdeaff9e2ad6983e1552804ce1d92cc796e5b052159bb/mmh3-5.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:bb0fdc451fb6d86d81ab8f23d881b8d6e37fc373a2deae1c02d27002d2ad7a05", size = 40684, upload-time = "2025-07-29T07:42:35.914Z" },
    { url = "https://files.pythonhosted.org/packages/8f/36/3dee40767356e104967e6ed6d102ba47b0b1ce2a89432239b95a94de1b89/mmh3-5.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b29044e1ffdb84fe164d0a7ea05c7316afea93c00f8ed9449cf357c36fc4f814", size = 40057, upload-time = "2025-07-29T07:42:36.755Z" },
    { url = "https://files.pythonhosted.org/packages/31/58/228c402fccf76eb39a0a01b8fc470fecf21965584e66453b477050ee0e99/mmh3-5.2.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:58981d6ea9646dbbf9e59a30890cbf9f610df0e4a57dbfe09215116fd90b0093", size = 97344, upload-time = "2025-07-29T07:42:37.675Z" },
    { url = "https://files.pythonhosted.org/packages/34/82/fc5ce89006389a6426ef28e326fc065b0fbaaed230373b62d14c889f47ea/mmh3-5.2.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7e5634565367b6d98dc4aa2983703526ef556b3688ba3065edb4b9b90ede1c54", size = 103325, upload-time = "2025-07-29T07:42:38.591Z" },
    { url = "https://files.pythonhosted.org/packages/09/8c/261e85777c6aee1ebd53f2f17e210e7481d5b0846cd0b4a5c45f1e3761b8/mmh3-5.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0271ac12415afd3171ab9a3c7cbfc71dee2c68760a7dc9d05bf8ed6ddfa3a7a", size = 106240, upload-time = "2025-07-29T07:42:39.563Z" },
    { url = "https://files.pythonhosted.org/packages/70/73/2f76b3ad8a3d431824e9934403df36c0ddacc7831acf82114bce3c4309c8/mmh3-5.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:45b590e31bc552c6f8e2150ff1ad0c28dd151e9f87589e7eaf508fbdd8e8e908", size = 113060, upload-time = "2025-07-29T07:42:40.585Z" },
    { url = "https://files.pythonhosted.org/packages/9f/b9/7ea61a34e90e50a79a9d87aa1c0b8139a7eaf4125782b34b7d7383472633/mmh3-5.2.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bdde97310d59604f2a9119322f61b31546748499a21b44f6715e8ced9308a6c5", size = 120781, upload-time = "2025-07-29T07:42:41.618Z" },
    { url = "https://files.pythonhosted.org/packages/0f/5b/ae1a717db98c7894a37aeedbd94b3f99e6472a836488f36b6849d003485b/mmh3-5.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fc9c5f280438cf1c1a8f9abb87dc8ce9630a964120cfb5dd50d1e7ce79690c7a", size = 99174, upload-time = "2025-07-29T07:42:42.587Z" },
    { url = "https://files.pythonhosted.org/packages/e3/de/000cce1d799fceebb6d4487ae29175dd8e81b48e314cba7b4da90bcf55d7/mmh3-5.2.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c903e71fd8debb35ad2a4184c1316b3cb22f64ce517b4e6747f25b0a34e41266", size = 98734, upload-time = "2025-07-29T07:42:43.996Z" },
    { url = "https://files.pythonhosted.org/packages/79/19/0dc364391a792b72fbb22becfdeacc5add85cc043cd16986e82152141883/mmh3-5.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:eed4bba7ff8a0d37106ba931ab03bdd3915fbb025bcf4e1f0aa02bc8114960c5", size = 106493, upload-time = "2025-07-29T07:42:45.07Z" },
    { url = "https://files.pythonhosted.org/packages/3c/b1/bc8c28e4d6e807bbb051fefe78e1156d7f104b89948742ad310612ce240d/mmh3-5.2.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:1fdb36b940e9261aff0b5177c5b74a36936b902f473180f6c15bde26143681a9", size = 110089, upload-time = "2025-07-29T07:42:46.122Z" },
    { url = "https://files.pythonhosted.org/packages/3b/a2/d20f3f5c95e9c511806686c70d0a15479cc3941c5f322061697af1c1ff70/mmh3-5.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7303aab41e97adcf010a09efd8f1403e719e59b7705d5e3cfed3dd7571589290", size = 97571, upload-time = "2025-07-29T07:42:47.18Z" },
    { url = "https://files.pythonhosted.org/packages/7b/23/665296fce4f33488deec39a750ffd245cfc07aafb0e3ef37835f91775d14/mmh3-5.2.0-cp313-cp313-win32.whl", hash = "sha256:03e08c6ebaf666ec1e3d6ea657a2d363bb01effd1a9acfe41f9197decaef0051", size = 40806, upload-time = "2025-07-29T07:42:48.166Z" },
    { url = "https://files.pythonhosted.org/packages/59/b0/92e7103f3b20646e255b699e2d0327ce53a3f250e44367a99dc8be0b7c7a/mmh3-5.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:7fddccd4113e7b736706e17a239a696332360cbaddf25ae75b57ba1acce65081", size = 41600, upload-time = "2025-07-29T07:42:49.371Z" },
    { url = "https://files.pythonhosted.org/packages/99/22/0b2bd679a84574647de538c5b07ccaa435dbccc37815067fe15b90fe8dad/mmh3-5.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa0c966ee727aad5406d516375593c5f058c766b21236ab8985693934bb5085b", size = 39349, upload-time = "2025-07-29T07:42:50.268Z" },
    { url = "https://files.pythonhosted.org/packages/f7/ca/a20db059a8a47048aaf550da14a145b56e9c7386fb8280d3ce2962dcebf7/mmh3-5.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:e5015f0bb6eb50008bed2d4b1ce0f2a294698a926111e4bb202c0987b4f89078", size = 39209, upload-time = "2025-07-29T07:42:51.559Z" },
    { url = "https://files.pythonhosted.org/packages/98/dd/e5094799d55c7482d814b979a0fd608027d0af1b274bfb4c3ea3e950bfd5/mmh3-5.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:e0f3ed828d709f5b82d8bfe14f8856120718ec4bd44a5b26102c3030a1e12501", size = 39843, upload-time = "2025-07-29T07:42:52.536Z" },
    { url = "https://files.pythonhosted.org/packages/f4/6b/7844d7f832c85400e7cc89a1348e4e1fdd38c5a38415bb5726bbb8fcdb6c/mmh3-5.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:f35727c5118aba95f0397e18a1a5b8405425581bfe53e821f0fb444cbdc2bc9b", size = 40648, upload-time = "2025-07-29T07:42:53.392Z" },
    { url = "https://files.pythonhosted.org/packages/1f/bf/71f791f48a21ff3190ba5225807cbe4f7223360e96862c376e6e3fb7efa7/mmh3-5.2.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3bc244802ccab5220008cb712ca1508cb6a12f0eb64ad62997156410579a1770", size = 56164, upload-time = "2025-07-29T07:42:54.267Z" },
    { url = "https://files.pythonhosted.org/packages/70/1f/f87e3d34d83032b4f3f0f528c6d95a98290fcacf019da61343a49dccfd51/mmh3-5.2.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ff3d50dc3fe8a98059f99b445dfb62792b5d006c5e0b8f03c6de2813b8376110", size = 40692, upload-time = "2025-07-29T07:42:55.234Z" },
    { url = "https://files.pythonhosted.org/packages/a6/e2/db849eaed07117086f3452feca8c839d30d38b830ac59fe1ce65af8be5ad/mmh3-5.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:37a358cc881fe796e099c1db6ce07ff757f088827b4e8467ac52b7a7ffdca647", size = 40068, upload-time = "2025-07-29T07:42:56.158Z" },
    { url = "https://files.pythonhosted.org/packages/df/6b/209af927207af77425b044e32f77f49105a0b05d82ff88af6971d8da4e19/mmh3-5.2.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b9a87025121d1c448f24f27ff53a5fe7b6ef980574b4a4f11acaabe702420d63", size = 97367, upload-time = "2025-07-29T07:42:57.037Z" },
    { url = "https://files.pythonhosted.org/packages/ca/e0/78adf4104c425606a9ce33fb351f790c76a6c2314969c4a517d1ffc92196/mmh3-5.2.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ba55d6ca32eeef8b2625e1e4bfc3b3db52bc63014bd7e5df8cc11bf2b036b12", size = 103306, upload-time = "2025-07-29T07:42:58.522Z" },
    { url = "https://files.pythonhosted.org/packages/a3/79/c2b89f91b962658b890104745b1b6c9ce38d50a889f000b469b91eeb1b9e/mmh3-5.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9ff37ba9f15637e424c2ab57a1a590c52897c845b768e4e0a4958084ec87f22", size = 106312, upload-time = "2025-07-29T07:42:59.552Z" },
    { url = "https://files.pythonhosted.org/packages/4b/14/659d4095528b1a209be90934778c5ffe312177d51e365ddcbca2cac2ec7c/mmh3-5.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a094319ec0db52a04af9fdc391b4d39a1bc72bc8424b47c4411afb05413a44b5", size = 113135, upload-time = "2025-07-29T07:43:00.745Z" },
    { url = "https://files.pythonhosted.org/packages/8d/6f/cd7734a779389a8a467b5c89a48ff476d6f2576e78216a37551a97e9e42a/mmh3-5.2.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c5584061fd3da584659b13587f26c6cad25a096246a481636d64375d0c1f6c07", size = 120775, upload-time = "2025-07-29T07:43:02.124Z" },
    { url = "https://files.pythonhosted.org/packages/1d/ca/8256e3b96944408940de3f9291d7e38a283b5761fe9614d4808fcf27bd62/mmh3-5.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecbfc0437ddfdced5e7822d1ce4855c9c64f46819d0fdc4482c53f56c707b935", size = 99178, upload-time = "2025-07-29T07:43:03.182Z" },
    { url = "https://files.pythonhosted.org/packages/8a/32/39e2b3cf06b6e2eb042c984dab8680841ac2a0d3ca6e0bea30db1f27b565/mmh3-5.2.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:7b986d506a8e8ea345791897ba5d8ba0d9d8820cd4fc3e52dbe6de19388de2e7", size = 98738, upload-time = "2025-07-29T07:43:04.207Z" },
    { url = "https://files.pythonhosted.org/packages/61/d3/7bbc8e0e8cf65ebbe1b893ffa0467b7ecd1bd07c3bbf6c9db4308ada22ec/mmh3-5.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:38d899a156549da8ef6a9f1d6f7ef231228d29f8f69bce2ee12f5fba6d6fd7c5", size = 106510, upload-time = "2025-07-29T07:43:05.656Z" },
    { url = "https://files.pythonhosted.org/packages/10/99/b97e53724b52374e2f3859046f0eb2425192da356cb19784d64bc17bb1cf/mmh3-5.2.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d86651fa45799530885ba4dab3d21144486ed15285e8784181a0ab37a4552384", size = 110053, upload-time = "2025-07-29T07:43:07.204Z" },
    { url = "https://files.pythonhosted.org/packages/ac/62/3688c7d975ed195155671df68788c83fed6f7909b6ec4951724c6860cb97/mmh3-5.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c463d7c1c4cfc9d751efeaadd936bbba07b5b0ed81a012b3a9f5a12f0872bd6e", size = 97546, upload-time = "2025-07-29T07:43:08.226Z" },
    { url = "https://files.pythonhosted.org/packages/ca/3b/c6153250f03f71a8b7634cded82939546cdfba02e32f124ff51d52c6f991/mmh3-5.2.0-cp314-cp314-win32.whl", hash = "sha256:bb4fe46bdc6104fbc28db7a6bacb115ee6368ff993366bbd8a2a7f0076e6f0c0", size = 41422, upload-time = "2025-07-29T07:43:09.216Z" },
    { url = "https://files.pythonhosted.org/packages/74/01/a27d98bab083a435c4c07e9d1d720d4c8a578bf4c270bae373760b1022be/mmh3-5.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:7c7f0b342fd06044bedd0b6e72177ddc0076f54fd89ee239447f8b271d919d9b", size = 42135, upload-time = "2025-07-29T07:43:10.183Z" },
    { url = "https://files.pythonhosted.org/packages/cb/c9/dbba5507e95429b8b380e2ba091eff5c20a70a59560934dff0ad8392b8c8/mmh3-5.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:3193752fc05ea72366c2b63ff24b9a190f422e32d75fdeae71087c08fff26115", size = 39879, upload-time = "2025-07-29T07:43:11.106Z" },
    { url = "https://files.pythonhosted.org/packages/b5/d1/c8c0ef839c17258b9de41b84f663574fabcf8ac2007b7416575e0f65ff6e/mmh3-5.2.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:69fc339d7202bea69ef9bd7c39bfdf9fdabc8e6822a01eba62fb43233c1b3932", size = 57696, upload-time = "2025-07-29T07:43:11.989Z" },
    { url = "https://files.pythonhosted.org/packages/2f/55/95e2b9ff201e89f9fe37036037ab61a6c941942b25cdb7b6a9df9b931993/mmh3-5.2.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:12da42c0a55c9d86ab566395324213c319c73ecb0c239fad4726324212b9441c", size = 41421, upload-time = "2025-07-29T07:43:13.269Z" },
    { url = "https://files.pythonhosted.org/packages/77/79/9be23ad0b7001a4b22752e7693be232428ecc0a35068a4ff5c2f14ef8b20/mmh3-5.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f7f9034c7cf05ddfaac8d7a2e63a3c97a840d4615d0a0e65ba8bdf6f8576e3be", size = 40853, upload-time = "2025-07-29T07:43:14.888Z" },
    { url = "https://files.pythonhosted.org/packages/ac/1b/96b32058eda1c1dee8264900c37c359a7325c1f11f5ff14fd2be8e24eff9/mmh3-5.2.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:11730eeb16dfcf9674fdea9bb6b8e6dd9b40813b7eb839bc35113649eef38aeb", size = 109694, upload-time = "2025-07-29T07:43:15.816Z" },
    { url = "https://files.pythonhosted.org/packages/8d/6f/a2ae44cd7dad697b6dea48390cbc977b1e5ca58fda09628cbcb2275af064/mmh3-5.2.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:932a6eec1d2e2c3c9e630d10f7128d80e70e2d47fe6b8c7ea5e1afbd98733e65", size = 117438, upload-time = "2025-07-29T07:43:16.865Z" },
    { url = "https://files.pythonhosted.org/packages/a0/08/bfb75451c83f05224a28afeaf3950c7b793c0b71440d571f8e819cfb149a/mmh3-5.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ca975c51c5028947bbcfc24966517aac06a01d6c921e30f7c5383c195f87991", size = 120409, upload-time = "2025-07-29T07:43:18.207Z" },
    { url = "https://files.pythonhosted.org/packages/9f/ea/8b118b69b2ff8df568f742387d1a159bc654a0f78741b31437dd047ea28e/mmh3-5.2.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5b0b58215befe0f0e120b828f7645e97719bbba9f23b69e268ed0ac7adde8645", size = 125909, upload-time = "2025-07-29T07:43:19.39Z" },
    { url = "https://files.pythonhosted.org/packages/3e/11/168cc0b6a30650032e351a3b89b8a47382da541993a03af91e1ba2501234/mmh3-5.2.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29c2b9ce61886809d0492a274a5a53047742dea0f703f9c4d5d223c3ea6377d3", size = 135331, upload-time = "2025-07-29T07:43:20.435Z" },
    { url = "https://files.pythonhosted.org/packages/31/05/e3a9849b1c18a7934c64e831492c99e67daebe84a8c2f2c39a7096a830e3/mmh3-5.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a367d4741ac0103f8198c82f429bccb9359f543ca542b06a51f4f0332e8de279", size = 110085, upload-time = "2025-07-29T07:43:21.92Z" },
    { url = "https://files.pythonhosted.org/packages/d9/d5/a96bcc306e3404601418b2a9a370baec92af84204528ba659fdfe34c242f/mmh3-5.2.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:5a5dba98e514fb26241868f6eb90a7f7ca0e039aed779342965ce24ea32ba513", size = 111195, upload-time = "2025-07-29T07:43:23.066Z" },
    { url = "https://files.pythonhosted.org/packages/af/29/0fd49801fec5bff37198684e0849b58e0dab3a2a68382a357cfffb0fafc3/mmh3-5.2.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:941603bfd75a46023807511c1ac2f1b0f39cccc393c15039969806063b27e6db", size = 116919, upload-time = "2025-07-29T07:43:24.178Z" },
    { url = "https://files.pythonhosted.org/packages/2d/04/4f3c32b0a2ed762edca45d8b46568fc3668e34f00fb1e0a3b5451ec1281c/mmh3-5.2.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:132dd943451a7c7546978863d2f5a64977928410782e1a87d583cb60eb89e667", size = 123160, upload-time = "2025-07-29T07:43:25.26Z" },
    { url = "https://files.pythonhosted.org/packages/91/76/3d29eaa38821730633d6a240d36fa8ad2807e9dfd432c12e1a472ed211eb/mmh3-5.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f698733a8a494466432d611a8f0d1e026f5286dee051beea4b3c3146817e35d5", size = 110206, upload-time = "2025-07-29T07:43:26.699Z" },
    { url = "https://files.pythonhosted.org/packages/44/1c/ccf35892684d3a408202e296e56843743e0b4fb1629e59432ea88cdb3909/mmh3-5.2.0-cp314-cp314t-win32.whl", hash = "sha256:6d541038b3fc360ec538fc116de87462627944765a6750308118f8b509a8eec7", size = 41970, upload-time = "2025-07-29T07:43:27.666Z" },
    { url = "https://files.pythonhosted.org/packages/75/b2/b9e4f1e5adb5e21eb104588fcee2cd1eaa8308255173481427d5ecc4284e/mmh3-5.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e912b19cf2378f2967d0c08e86ff4c6c360129887f678e27e4dde970d21b3f4d", size = 43063, upload-time = "2025-07-29T07:43:28.582Z" },
    { url = "https://files.pythonhosted.org/packages/6a/fc/0e61d9a4e29c8679356795a40e48f647b4aad58d71bfc969f0f8f56fb912/mmh3-5.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:e7884931fe5e788163e7b3c511614130c2c59feffdc21112290a194487efb2e9", size = 40455, upload-time = "2025-07-29T07:43:29.563Z" },
    { url = "https://files.pythonhosted.org/packages/f2/11/4bad09e880b648eeb55393a644c08efbd7da302fc405c8d2f6555521bb98/mmh3-5.2.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3c6041fd9d5fb5fcac57d5c80f521a36b74aea06b8566431c63e4ffc49aced51", size = 56117, upload-time = "2025-07-29T07:43:30.955Z" },
    { url = "https://files.pythonhosted.org/packages/b2/43/97cacd1fa2994b4ec110334388e126fe000ddf041829721e2e59e46b0a7c/mmh3-5.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:58477cf9ef16664d1ce2b038f87d2dc96d70fe50733a34a7f07da6c9a5e3538c", size = 40634, upload-time = "2025-07-29T07:43:31.917Z" },
    { url = "https://files.pythonhosted.org/packages/e9/03/2a52e464b0e23f9838267adf75f942c5addc2c1f009a48d1ef5c331084fb/mmh3-5.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:be7d3dca9358e01dab1bad881fb2b4e8730cec58d36dd44482bc068bfcd3bc65", size = 40075, upload-time = "2025-07-29T07:43:32.9Z" },
    { url = "https://files.pythonhosted.org/packages/b3/d3/c0c00f7eb436a0adf64d8a877673ac76096bf86aca57b6a2c80786d69242/mmh3-5.2.0-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:931d47e08c9c8a67bf75d82f0ada8399eac18b03388818b62bfa42882d571d72", size = 95112, upload-time = "2025-07-29T07:43:33.815Z" },
    { url = "https://files.pythonhosted.org/packages/9b/f3/116cc1171bcb41a9cec10c46ee1d8bb5185d70c15848ff66d15ab7afb6fd/mmh3-5.2.0-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:dd966df3489ec13848d6c6303429bbace94a153f43d1ae2a55115fd36fd5ca5d", size = 101006, upload-time = "2025-07-29T07:43:34.876Z" },
    { url = "https://files.pythonhosted.org/packages/41/34/b38a0c5c323666e632cc07d4fd337c4af0b300619c7b8b7a1d9a2db1ac1a/mmh3-5.2.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c677d78887244bf3095020b73c42b505b700f801c690f8eaa90ad12d3179612f", size = 103782, upload-time = "2025-07-29T07:43:35.987Z" },
    { url = "https://files.pythonhosted.org/packages/25/d6/42b5ae7219ec87f756ffafcf7471b7fd3386e352653522d155f4897e06d0/mmh3-5.2.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:63830f846797187c5d3e2dae50f0848fdc86032f5bfdc58ae352f02f857e9025", size = 110660, upload-time = "2025-07-29T07:43:37.103Z" },
    { url = "https://files.pythonhosted.org/packages/8f/55/daea1ee478328f7ed3b5422f080a3f892e02bc1542f0bc5a1be083a05758/mmh3-5.2.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c3f563e8901960e2eaa64c8e8821895818acabeb41c96f2efbb936f65dbe486c", size = 118107, upload-time = "2025-07-29T07:43:38.173Z" },
    { url = "https://files.pythonhosted.org/packages/46/f1/930d3395a0aaef49db41019e94a7b46ac35b9a64c213a620eacac34078c0/mmh3-5.2.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:96f1e1ac44cbb42bcc406e509f70c9af42c594e72ccc7b1257f97554204445f0", size = 101448, upload-time = "2025-07-29T07:43:39.199Z" },
    { url = "https://files.pythonhosted.org/packages/cc/e4/543bf2622a1645fa560c26fe5dc2919c8c9eb2f9ac129778ce6acc9848fc/mmh3-5.2.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:7bbb0df897944b5ec830f3ad883e32c5a7375370a521565f5fe24443bfb2c4f7", size = 96474, upload-time = "2025-07-29T07:43:41.025Z" },
    { url = "https://files.pythonhosted.org/packages/16/d8/9c552bd64c86bb03fba08d4b702efd65b09ed54c6969df0d1ec7fa8c0ae4/mmh3-5.2.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:1fae471339ae1b9c641f19cf46dfe6ffd7f64b1fba7c4333b99fa3dd7f21ae0a", size = 110049, upload-time = "2025-07-29T07:43:42.106Z" },
    { url = "https://files.pythonhosted.org/packages/6b/47/8a012b9c4d9c9b704ffcd71cad861ef120b2bd417d081bdb3aaa9e396fe6/mmh3-5.2.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:aa6e5d31fdc5ed9e3e95f9873508615a778fe9b523d52c17fc770a3eb39ab6e4", size = 111683, upload-time = "2025-07-29T07:43:43.228Z" },
    { url = "https://files.pythonhosted.org/packages/2c/fc/4ad1bd01976484d0568a7d18d5a8597da1e65e76ac763114573dcd09d225/mmh3-5.2.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:746a5ee71c6d1103d9b560fa147881b5e68fd35da56e54e03d5acefad0e7c055", size = 99883, upload-time = "2025-07-29T07:43:44.304Z" },
    { url = "https://files.pythonhosted.org/packages/ed/1d/4fbd0f74c7e9c35f5f70eb77509b7a706ef76ee86957a79e228f47cf037f/mmh3-5.2.0-cp39-cp39-win32.whl", hash = "sha256:10983c10f5c77683bd845751905ba535ec47409874acc759d5ce3ff7ef34398a", size = 40790, upload-time = "2025-07-29T07:43:45.296Z" },
    { url = "https://files.pythonhosted.org/packages/a0/61/0f593606dbd3a4259301ffb61678433656dc4a2c6da022fa7a122de7ffb4/mmh3-5.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:fdfd3fb739f4e22746e13ad7ba0c6eedf5f454b18d11249724a388868e308ee4", size = 41563, upload-time = "2025-07-29T07:43:46.599Z" },
    { url = "https://files.pythonhosted.org/packages/07/e6/ff066b72d86f0a19d3e4b6f3af073a9a328cb3cb4b068e25972866fcd517/mmh3-5.2.0-cp39-cp39-win_arm64.whl", hash = "sha256:33576136c06b46a7046b6d83a3d75fbca7d25f84cec743f1ae156362608dc6d2", size = 39340, upload-time = "2025-07-29T07:43:47.512Z" },
]

[[package]]
name = "mmh3"
version = "5.2.1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
sdist = { url = "https://files.pythonhosted.org/packages/91/1a/edb23803a168f070ded7a3014c6d706f63b90c84ccc024f89d794a3b7a6d/mmh3-5.2.1.tar.gz", hash = "sha256:bbea5b775f0ac84945191fb83f845a6fd9a21a03ea7f2e187defac7e401616ad", size = 33775, upload-time = "2026-03-05T15:55:57.716Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/a6/bb/88ee54afa5644b0f35ab5b435f208394feb963e5bb47c4e404deb625ffa4/mmh3-5.2.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5d87a3584093e1a89987e3d36d82c98d9621b2cb944e22a420aa1401e096758f", size = 56080, upload-time = "2026-03-05T15:53:40.452Z" },
    { url = "https://files.pythonhosted.org/packages/cc/bf/5404c2fd6ac84819e8ff1b7e34437b37cf55a2b11318894909e7bb88de3f/mmh3-5.2.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:30e4d2084df019880d55f6f7bea35328d9b464ebee090baa372c096dc77556fb", size = 40462, upload-time = "2026-03-05T15:53:41.751Z" },
    { url = "https://files.pythonhosted.org/packages/de/0b/52bffad0b52ae4ea53e222b594bd38c08ecac1fc410323220a7202e43da5/mmh3-5.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0bbc17250b10d3466875a40a52520a6bac3c02334ca709207648abd3c223ed5c", size = 40077, upload-time = "2026-03-05T15:53:42.753Z" },
    { url = "https://files.pythonhosted.org/packages/a0/9e/326c93d425b9fa4cbcdc71bc32aaba520db37577d632a24d25d927594eca/mmh3-5.2.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:76219cd1eefb9bf4af7856e3ae563d15158efa145c0aab01e9933051a1954045", size = 95302, upload-time = "2026-03-05T15:53:43.867Z" },
    { url = "https://files.pythonhosted.org/packages/c6/b1/e20d5f0d19c4c0f3df213fa7dcfa0942c4fb127d38e11f398ae8ddf6cccc/mmh3-5.2.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb9d44c25244e11c8be3f12c938ca8ba8404620ef8092245d2093c6ab3df260f", size = 101174, upload-time = "2026-03-05T15:53:45.194Z" },
    { url = "https://files.pythonhosted.org/packages/7f/4a/1a9bb3e33c18b1e1cee2c249a3053c4d4d9c93ecb30738f39a62249a7e86/mmh3-5.2.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d5d542bf2abd0fd0361e8017d03f7cb5786214ceb4a40eef1539d6585d93386", size = 103979, upload-time = "2026-03-05T15:53:46.334Z" },
    { url = "https://files.pythonhosted.org/packages/ff/8d/dab9ee7545429e7acdd38d23d0104471d31de09a0c695f1b751e0ff34532/mmh3-5.2.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:08043f7cb1fb9467c3fbbbaea7896986e7fbc81f4d3fd9289a73d9110ab6207a", size = 110898, upload-time = "2026-03-05T15:53:47.443Z" },
    { url = "https://files.pythonhosted.org/packages/72/08/408f11af7fe9e76b883142bb06536007cc7f237be2a5e9ad4e837716e627/mmh3-5.2.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:add7ac388d1e0bf57259afbcf9ed05621a3bf11ce5ee337e7536f1e1aaf056b0", size = 118308, upload-time = "2026-03-05T15:53:49.1Z" },
    { url = "https://files.pythonhosted.org/packages/86/2d/0551be7fe0000736d9ad12ffa1f130d7a0c17b49193d6dc41c82bd9404c6/mmh3-5.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:41105377f6282e8297f182e393a79cfffd521dde37ace52b106373bdcd9ca5cb", size = 101671, upload-time = "2026-03-05T15:53:50.317Z" },
    { url = "https://files.pythonhosted.org/packages/44/17/6e4f80c4e6ad590139fa2017c3aeca54e7cc9ef68e08aa142a0c90f40a97/mmh3-5.2.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3cb61db880ec11e984348227b333259994c2c85caa775eb7875decb3768db890", size = 96682, upload-time = "2026-03-05T15:53:51.48Z" },
    { url = "https://files.pythonhosted.org/packages/ad/a7/b82fccd38c1fa815de72e94ebe9874562964a10e21e6c1bc3b01d3f15a0e/mmh3-5.2.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e8b5378de2b139c3a830f0209c1e91f7705919a4b3e563a10955104f5097a70a", size = 110287, upload-time = "2026-03-05T15:53:52.68Z" },
    { url = "https://files.pythonhosted.org/packages/a8/a1/2644069031c8cec0be46f0346f568a53f42fddd843f03cc890306699c1e2/mmh3-5.2.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e904f2417f0d6f6d514f3f8b836416c360f306ddaee1f84de8eef1e722d212e5", size = 111899, upload-time = "2026-03-05T15:53:53.791Z" },
    { url = "https://files.pythonhosted.org/packages/51/7b/6614f3eb8fb33f931fa7616c6d477247e48ec6c5082b02eeeee998cffa94/mmh3-5.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f1fbb0a99125b1287c6d9747f937dc66621426836d1a2d50d05aecfc81911b57", size = 100078, upload-time = "2026-03-05T15:53:55.234Z" },
    { url = "https://files.pythonhosted.org/packages/27/9a/dd4d5a5fb893e64f71b42b69ecae97dd78db35075412488b24036bc5599c/mmh3-5.2.1-cp310-cp310-win32.whl", hash = "sha256:b4cce60d0223074803c9dbe0721ad3fa51dafe7d462fee4b656a1aa01ee07518", size = 40756, upload-time = "2026-03-05T15:53:56.319Z" },
    { url = "https://files.pythonhosted.org/packages/c9/34/0b25889450f8aeffcec840aa73251e853f059c1b72ed1d1c027b956f95f5/mmh3-5.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:6f01f044112d43a20be2f13a11683666d87151542ad627fe41a18b9791d2802f", size = 41519, upload-time = "2026-03-05T15:53:57.41Z" },
    { url = "https://files.pythonhosted.org/packages/fd/31/8fd42e3c526d0bcb1db7f569c0de6729e180860a0495e387a53af33c2043/mmh3-5.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:7501e9be34cb21e72fcfe672aafd0eee65c16ba2afa9dcb5500a587d3a0580f0", size = 39285, upload-time = "2026-03-05T15:53:58.697Z" },
    { url = "https://files.pythonhosted.org/packages/65/d7/3312a59df3c1cdd783f4cf0c4ee8e9decff9c5466937182e4cc7dbbfe6c5/mmh3-5.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:dae0f0bd7d30c0ad61b9a504e8e272cb8391eed3f1587edf933f4f6b33437450", size = 56082, upload-time = "2026-03-05T15:53:59.702Z" },
    { url = "https://files.pythonhosted.org/packages/61/96/6f617baa098ca0d2989bfec6d28b5719532cd8d8848782662f5b755f657f/mmh3-5.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9aeaf53eaa075dd63e81512522fd180097312fb2c9f476333309184285c49ce0", size = 40458, upload-time = "2026-03-05T15:54:01.548Z" },
    { url = "https://files.pythonhosted.org/packages/c1/b4/9cd284bd6062d711e13d26c04d4778ab3f690c1c38a4563e3c767ec8802e/mmh3-5.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0634581290e6714c068f4aa24020acf7880927d1f0084fa753d9799ae9610082", size = 40079, upload-time = "2026-03-05T15:54:02.743Z" },
    { url = "https://files.pythonhosted.org/packages/f6/09/a806334ce1d3d50bf782b95fcee8b3648e1e170327d4bb7b4bad2ad7d956/mmh3-5.2.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e080c0637aea036f35507e803a4778f119a9b436617694ae1c5c366805f1e997", size = 97242, upload-time = "2026-03-05T15:54:04.536Z" },
    { url = "https://files.pythonhosted.org/packages/ee/93/723e317dd9e041c4dc4566a2eb53b01ad94de31750e0b834f1643905e97c/mmh3-5.2.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db0562c5f71d18596dcd45e854cf2eeba27d7543e1a3acdafb7eef728f7fe85d", size = 103082, upload-time = "2026-03-05T15:54:06.387Z" },
    { url = "https://files.pythonhosted.org/packages/61/b5/f96121e69cc48696075071531cf574f112e1ffd08059f4bffb41210e6fc5/mmh3-5.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d9f9a3ce559a5267014b04b82956993270f63ec91765e13e9fd73daf2d2738e", size = 106054, upload-time = "2026-03-05T15:54:07.506Z" },
    { url = "https://files.pythonhosted.org/packages/82/49/192b987ec48d0b2aecf8ac285a9b11fbc00030f6b9c694664ae923458dde/mmh3-5.2.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:960b1b3efa39872ac8b6cc3a556edd6fb90ed74f08c9c45e028f1005b26aa55d", size = 112910, upload-time = "2026-03-05T15:54:09.403Z" },
    { url = "https://files.pythonhosted.org/packages/cf/a1/03e91fd334ed0144b83343a76eb11f17434cd08f746401488cfeafb2d241/mmh3-5.2.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d30b650595fdbe32366b94cb14f30bb2b625e512bd4e1df00611f99dc5c27fd4", size = 120551, upload-time = "2026-03-05T15:54:10.587Z" },
    { url = "https://files.pythonhosted.org/packages/93/b9/b89a71d2ff35c3a764d1c066c7313fc62c7cc48fa48a4b3b0304a4a0146f/mmh3-5.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:82f3802bfc4751f420d591c5c864de538b71cea117fce67e4595c2afede08a15", size = 99096, upload-time = "2026-03-05T15:54:11.76Z" },
    { url = "https://files.pythonhosted.org/packages/36/b5/613772c1c6ed5f7b63df55eb131e887cc43720fec392777b95a79d34e640/mmh3-5.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:915e7a2418f10bd1151b1953df06d896db9783c9cfdb9a8ee1f9b3a4331ab503", size = 98524, upload-time = "2026-03-05T15:54:13.122Z" },
    { url = "https://files.pythonhosted.org/packages/5e/0e/1524566fe8eaf871e4f7bc44095929fcd2620488f402822d848df19d679c/mmh3-5.2.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:fc78739b5ec6e4fb02301984a3d442a91406e7700efbe305071e7fd1c78278f2", size = 106239, upload-time = "2026-03-05T15:54:14.601Z" },
    { url = "https://files.pythonhosted.org/packages/04/94/21adfa7d90a7a697137ad6de33eeff6445420ca55e433a5d4919c79bc3b5/mmh3-5.2.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:41aac7002a749f08727cb91babff1daf8deac317c0b1f317adc69be0e6c375d1", size = 109797, upload-time = "2026-03-05T15:54:15.819Z" },
    { url = "https://files.pythonhosted.org/packages/b5/e6/1aacc3a219e1aa62fa65669995d4a3562b35be5200ec03680c7e4bec9676/mmh3-5.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9d8089d853c7963a8ce87fff93e2a67075c0bc08684a08ea6ad13577c38ffc38", size = 97228, upload-time = "2026-03-05T15:54:16.992Z" },
    { url = "https://files.pythonhosted.org/packages/f1/b9/5e4cca8dcccf298add0a27f3c357bc8cf8baf821d35cdc6165e4bd5a48b0/mmh3-5.2.1-cp311-cp311-win32.whl", hash = "sha256:baeb47635cb33375dee4924cd93d7f5dcaa786c740b08423b0209b824a1ee728", size = 40751, upload-time = "2026-03-05T15:54:18.714Z" },
    { url = "https://files.pythonhosted.org/packages/72/fc/5b11d49247f499bcda591171e9cf3b6ee422b19e70aa2cef2e0ae65ca3b9/mmh3-5.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:1e4ecee40ba19e6975e1120829796770325841c2f153c0e9aecca927194c6a2a", size = 41517, upload-time = "2026-03-05T15:54:19.764Z" },
    { url = "https://files.pythonhosted.org/packages/8a/5f/2a511ee8a1c2a527c77726d5231685b72312c5a1a1b7639ad66a9652aa84/mmh3-5.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:c302245fd6c33d96bd169c7ccf2513c20f4c1e417c07ce9dce107c8bc3f8411f", size = 39287, upload-time = "2026-03-05T15:54:20.904Z" },
    { url = "https://files.pythonhosted.org/packages/92/94/bc5c3b573b40a328c4d141c20e399039ada95e5e2a661df3425c5165fd84/mmh3-5.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0cc21533878e5586b80d74c281d7f8da7932bc8ace50b8d5f6dbf7e3935f63f1", size = 56087, upload-time = "2026-03-05T15:54:21.92Z" },
    { url = "https://files.pythonhosted.org/packages/f6/80/64a02cc3e95c3af0aaa2590849d9ed24a9f14bb93537addde688e039b7c3/mmh3-5.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4eda76074cfca2787c8cf1bec603eaebdddd8b061ad5502f85cddae998d54f00", size = 40500, upload-time = "2026-03-05T15:54:22.953Z" },
    { url = "https://files.pythonhosted.org/packages/8b/72/e6d6602ce18adf4ddcd0e48f2e13590cc92a536199e52109f46f259d3c46/mmh3-5.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:eee884572b06bbe8a2b54f424dbd996139442cf83c76478e1ec162512e0dd2c7", size = 40034, upload-time = "2026-03-05T15:54:23.943Z" },
    { url = "https://files.pythonhosted.org/packages/59/c2/bf4537a8e58e21886ef16477041238cab5095c836496e19fafc34b7445d2/mmh3-5.2.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0d0b7e803191db5f714d264044e06189c8ccd3219e936cc184f07106bd17fd7b", size = 97292, upload-time = "2026-03-05T15:54:25.335Z" },
    { url = "https://files.pythonhosted.org/packages/e5/e2/51ed62063b44d10b06d975ac87af287729eeb5e3ed9772f7584a17983e90/mmh3-5.2.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e6c219e375f6341d0959af814296372d265a8ca1af63825f65e2e87c618f006", size = 103274, upload-time = "2026-03-05T15:54:26.44Z" },
    { url = "https://files.pythonhosted.org/packages/75/ce/12a7524dca59eec92e5b31fdb13ede1e98eda277cf2b786cf73bfbc24e81/mmh3-5.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26fb5b9c3946bf7f1daed7b37e0c03898a6f062149127570f8ede346390a0825", size = 106158, upload-time = "2026-03-05T15:54:28.578Z" },
    { url = "https://files.pythonhosted.org/packages/86/1f/d3ba6dd322d01ab5d44c46c8f0c38ab6bbbf9b5e20e666dfc05bf4a23604/mmh3-5.2.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3c38d142c706201db5b2345166eeef1e7740e3e2422b470b8ba5c8727a9b4c7a", size = 113005, upload-time = "2026-03-05T15:54:29.767Z" },
    { url = "https://files.pythonhosted.org/packages/b6/a9/15d6b6f913294ea41b44d901741298e3718e1cb89ee626b3694625826a43/mmh3-5.2.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50885073e2909251d4718634a191c49ae5f527e5e1736d738e365c3e8be8f22b", size = 120744, upload-time = "2026-03-05T15:54:30.931Z" },
    { url = "https://files.pythonhosted.org/packages/76/b3/70b73923fd0284c439860ff5c871b20210dfdbe9a6b9dd0ee6496d77f174/mmh3-5.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b3f99e1756fc48ad507b95e5d86f2fb21b3d495012ff13e6592ebac14033f166", size = 99111, upload-time = "2026-03-05T15:54:32.353Z" },
    { url = "https://files.pythonhosted.org/packages/dd/38/99f7f75cd27d10d8b899a1caafb9d531f3903e4d54d572220e3d8ac35e89/mmh3-5.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62815d2c67f2dd1be76a253d88af4e1da19aeaa1820146dec52cf8bee2958b16", size = 98623, upload-time = "2026-03-05T15:54:33.801Z" },
    { url = "https://files.pythonhosted.org/packages/fd/68/6e292c0853e204c44d2f03ea5f090be3317a0e2d9417ecb62c9eb27687df/mmh3-5.2.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8f767ba0911602ddef289404e33835a61168314ebd3c729833db2ed685824211", size = 106437, upload-time = "2026-03-05T15:54:35.177Z" },
    { url = "https://files.pythonhosted.org/packages/dd/c6/fedd7284c459cfb58721d461fcf5607a4c1f5d9ab195d113d51d10164d16/mmh3-5.2.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:67e41a497bac88cc1de96eeba56eeb933c39d54bc227352f8455aa87c4ca4000", size = 110002, upload-time = "2026-03-05T15:54:36.673Z" },
    { url = "https://files.pythonhosted.org/packages/3b/ac/ca8e0c19a34f5b71390171d2ff0b9f7f187550d66801a731bb68925126a4/mmh3-5.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3d74a03fb57757ece25aa4b3c1c60157a1cece37a020542785f942e2f827eed5", size = 97507, upload-time = "2026-03-05T15:54:37.804Z" },
    { url = "https://files.pythonhosted.org/packages/df/94/6ebb9094cfc7ac5e7950776b9d13a66bb4a34f83814f32ba2abc9494fc68/mmh3-5.2.1-cp312-cp312-win32.whl", hash = "sha256:7374d6e3ef72afe49697ecd683f3da12f4fc06af2d75433d0580c6746d2fa025", size = 40773, upload-time = "2026-03-05T15:54:40.077Z" },
    { url = "https://files.pythonhosted.org/packages/5b/3c/cd3527198cf159495966551c84a5f36805a10ac17b294f41f67b83f6a4d6/mmh3-5.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:3a9fed49c6ce4ed7e73f13182760c65c816da006debe67f37635580dfb0fae00", size = 41560, upload-time = "2026-03-05T15:54:41.148Z" },
    { url = "https://files.pythonhosted.org/packages/15/96/6fe5ebd0f970a076e3ed5512871ce7569447b962e96c125528a2f9724470/mmh3-5.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:bbfcb95d9a744e6e2827dfc66ad10e1020e0cac255eb7f85652832d5a264c2fc", size = 39313, upload-time = "2026-03-05T15:54:42.171Z" },
    { url = "https://files.pythonhosted.org/packages/25/a5/9daa0508a1569a54130f6198d5462a92deda870043624aa3ea72721aa765/mmh3-5.2.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:723b2681ed4cc07d3401bbea9c201ad4f2a4ca6ba8cddaff6789f715dd2b391e", size = 40832, upload-time = "2026-03-05T15:54:43.212Z" },
    { url = "https://files.pythonhosted.org/packages/0a/6b/3230c6d80c1f4b766dedf280a92c2241e99f87c1504ff74205ec8cebe451/mmh3-5.2.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:3619473a0e0d329fd4aec8075628f8f616be2da41605300696206d6f36920c3d", size = 41964, upload-time = "2026-03-05T15:54:44.204Z" },
    { url = "https://files.pythonhosted.org/packages/62/fb/648bfddb74a872004b6ee751551bfdda783fe6d70d2e9723bad84dbe5311/mmh3-5.2.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:e48d4dbe0f88e53081da605ae68644e5182752803bbc2beb228cca7f1c4454d6", size = 39114, upload-time = "2026-03-05T15:54:45.205Z" },
    { url = "https://files.pythonhosted.org/packages/95/c2/ab7901f87af438468b496728d11264cb397b3574d41506e71b92128e0373/mmh3-5.2.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a482ac121de6973897c92c2f31defc6bafb11c83825109275cffce54bb64933f", size = 39819, upload-time = "2026-03-05T15:54:46.509Z" },
    { url = "https://files.pythonhosted.org/packages/2f/ed/6f88dda0df67de1612f2e130ffea34cf84aaee5bff5b0aff4dbff2babe34/mmh3-5.2.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:17fbb47f0885ace8327ce1235d0416dc86a211dcd8cc1e703f41523be32cfec8", size = 40330, upload-time = "2026-03-05T15:54:47.864Z" },
    { url = "https://files.pythonhosted.org/packages/3d/66/7516d23f53cdf90f43fce24ab80c28f45e6851d78b46bef8c02084edf583/mmh3-5.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d51fde50a77f81330523562e3c2734ffdca9c4c9e9d355478117905e1cfe16c6", size = 56078, upload-time = "2026-03-05T15:54:48.9Z" },
    { url = "https://files.pythonhosted.org/packages/bc/34/4d152fdf4a91a132cb226b671f11c6b796eada9ab78080fb5ce1e95adaab/mmh3-5.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:19bbd3b841174ae6ed588536ab5e1b1fe83d046e668602c20266547298d939a9", size = 40498, upload-time = "2026-03-05T15:54:49.942Z" },
    { url = "https://files.pythonhosted.org/packages/d4/4c/8e3af1b6d85a299767ec97bd923f12b06267089c1472c27c1696870d1175/mmh3-5.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be77c402d5e882b6fbacfd90823f13da8e0a69658405a39a569c6b58fdb17b03", size = 40033, upload-time = "2026-03-05T15:54:50.994Z" },
    { url = "https://files.pythonhosted.org/packages/8b/f2/966ea560e32578d453c9e9db53d602cbb1d0da27317e232afa7c38ceba11/mmh3-5.2.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fd96476f04db5ceba1cfa0f21228f67c1f7402296f0e73fee3513aa680ad237b", size = 97320, upload-time = "2026-03-05T15:54:52.072Z" },
    { url = "https://files.pythonhosted.org/packages/bb/0d/2c5f9893b38aeb6b034d1a44ecd55a010148054f6a516abe53b5e4057297/mmh3-5.2.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:707151644085dd0f20fe4f4b573d28e5130c4aaa5f587e95b60989c5926653b5", size = 103299, upload-time = "2026-03-05T15:54:53.569Z" },
    { url = "https://files.pythonhosted.org/packages/1c/fc/2ebaef4a4d4376f89761274dc274035ffd96006ab496b4ee5af9b08f21a9/mmh3-5.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3737303ca9ea0f7cb83028781148fcda4f1dac7821db0c47672971dabcf63593", size = 106222, upload-time = "2026-03-05T15:54:55.092Z" },
    { url = "https://files.pythonhosted.org/packages/57/09/ea7ffe126d0ba0406622602a2d05e1e1a6841cc92fc322eb576c95b27fad/mmh3-5.2.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2778fed822d7db23ac5008b181441af0c869455b2e7d001f4019636ac31b6fe4", size = 113048, upload-time = "2026-03-05T15:54:56.305Z" },
    { url = "https://files.pythonhosted.org/packages/85/57/9447032edf93a64aa9bef4d9aa596400b1756f40411890f77a284f6293ca/mmh3-5.2.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d57dea657357230cc780e13920d7fa7db059d58fe721c80020f94476da4ca0a1", size = 120742, upload-time = "2026-03-05T15:54:57.453Z" },
    { url = "https://files.pythonhosted.org/packages/53/82/a86cc87cc88c92e9e1a598fee509f0409435b57879a6129bf3b3e40513c7/mmh3-5.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:169e0d178cb59314456ab30772429a802b25d13227088085b0d49b9fe1533104", size = 99132, upload-time = "2026-03-05T15:54:58.583Z" },
    { url = "https://files.pythonhosted.org/packages/54/f7/6b16eb1b40ee89bb740698735574536bc20d6cdafc65ae702ea235578e05/mmh3-5.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7e4e1f580033335c6f76d1e0d6b56baf009d1a64d6a4816347e4271ba951f46d", size = 98686, upload-time = "2026-03-05T15:55:00.078Z" },
    { url = "https://files.pythonhosted.org/packages/e8/88/a601e9f32ad1410f438a6d0544298ea621f989bd34a0731a7190f7dec799/mmh3-5.2.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:2bd9f19f7f1fcebd74e830f4af0f28adad4975d40d80620be19ffb2b2af56c9f", size = 106479, upload-time = "2026-03-05T15:55:01.532Z" },
    { url = "https://files.pythonhosted.org/packages/d6/5c/ce29ae3dfc4feec4007a437a1b7435fb9507532a25147602cd5b52be86db/mmh3-5.2.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:c88653877aeb514c089d1b3d473451677b8b9a6d1497dbddf1ae7934518b06d2", size = 110030, upload-time = "2026-03-05T15:55:02.934Z" },
    { url = "https://files.pythonhosted.org/packages/13/30/ae444ef2ff87c805d525da4fa63d27cda4fe8a48e77003a036b8461cfd5c/mmh3-5.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fceef7fe67c81e1585198215e42ad3fdba3a25644beda8fbdaf85f4d7b93175a", size = 97536, upload-time = "2026-03-05T15:55:04.135Z" },
    { url = "https://files.pythonhosted.org/packages/4b/f9/dc3787ee5c813cc27fe79f45ad4500d9b5437f23a7402435cc34e07c7718/mmh3-5.2.1-cp313-cp313-win32.whl", hash = "sha256:54b64fb2433bc71488e7a449603bf8bd31fbcf9cb56fbe1eb6d459e90b86c37b", size = 40769, upload-time = "2026-03-05T15:55:05.277Z" },
    { url = "https://files.pythonhosted.org/packages/43/67/850e0b5a1e97799822ebfc4ca0e8c6ece3ed8baf7dcdf64de817dfdda2ca/mmh3-5.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:cae6383181f1e345317742d2ddd88f9e7d2682fa4c9432e3a74e47d92dce0229", size = 41563, upload-time = "2026-03-05T15:55:06.283Z" },
    { url = "https://files.pythonhosted.org/packages/c0/cc/98c90b28e1da5458e19fbfaf4adb5289208d3bfccd45dd14eab216a2f0bb/mmh3-5.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:022aa1a528604e6c83d0a7705fdef0b5355d897a9e0fa3a8d26709ceaa06965d", size = 39310, upload-time = "2026-03-05T15:55:07.323Z" },
    { url = "https://files.pythonhosted.org/packages/63/b4/65bc1fb2bb7f83e91c30865023b1847cf89a5f237165575e8c83aa536584/mmh3-5.2.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:d771f085fcdf4035786adfb1d8db026df1eb4b41dac1c3d070d1e49512843227", size = 40794, upload-time = "2026-03-05T15:55:09.773Z" },
    { url = "https://files.pythonhosted.org/packages/c4/86/7168b3d83be8eb553897b1fac9da8bbb06568e5cfe555ffc329ebb46f59d/mmh3-5.2.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:7f196cd7910d71e9d9860da0ff7a77f64d22c1ad931f1dd18559a06e03109fc0", size = 41923, upload-time = "2026-03-05T15:55:10.924Z" },
    { url = "https://files.pythonhosted.org/packages/bf/9b/b653ab611c9060ce8ff0ba25c0226757755725e789292f3ca138a58082cd/mmh3-5.2.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b1f12bd684887a0a5d55e6363ca87056f361e45451105012d329b86ec19dbe0b", size = 39131, upload-time = "2026-03-05T15:55:11.961Z" },
    { url = "https://files.pythonhosted.org/packages/9b/b4/5a2e0d34ab4d33543f01121e832395ea510132ea8e52cdf63926d9d81754/mmh3-5.2.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d106493a60dcb4aef35a0fac85105e150a11cf8bc2b0d388f5a33272d756c966", size = 39825, upload-time = "2026-03-05T15:55:13.013Z" },
    { url = "https://files.pythonhosted.org/packages/bd/69/81699a8f39a3f8d368bec6443435c0c392df0d200ad915bf0d222b588e03/mmh3-5.2.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:44983e45310ee5b9f73397350251cdf6e63a466406a105f1d16cb5baa659270b", size = 40344, upload-time = "2026-03-05T15:55:14.026Z" },
    { url = "https://files.pythonhosted.org/packages/0c/b3/71c8c775807606e8fd8acc5c69016e1caf3200d50b50b6dd4b40ce10b76c/mmh3-5.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:368625fb01666655985391dbad3860dc0ba7c0d6b9125819f3121ee7292b4ac8", size = 56291, upload-time = "2026-03-05T15:55:15.137Z" },
    { url = "https://files.pythonhosted.org/packages/6f/75/2c24517d4b2ce9e4917362d24f274d3d541346af764430249ddcc4cb3a08/mmh3-5.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:72d1cc63bcc91e14933f77d51b3df899d6a07d184ec515ea7f56bff659e124d7", size = 40575, upload-time = "2026-03-05T15:55:16.518Z" },
    { url = "https://files.pythonhosted.org/packages/bf/b9/e4a360164365ac9f07a25f0f7928e3a66eb9ecc989384060747aa170e6aa/mmh3-5.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e8b4b5580280b9265af3e0409974fb79c64cf7523632d03fbf11df18f8b0181e", size = 40052, upload-time = "2026-03-05T15:55:17.735Z" },
    { url = "https://files.pythonhosted.org/packages/97/ca/120d92223a7546131bbbc31c9174168ee7a73b1366f5463ffe69d9e691fe/mmh3-5.2.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4cbbde66f1183db040daede83dd86c06d663c5bb2af6de1142b7c8c37923dd74", size = 97311, upload-time = "2026-03-05T15:55:18.959Z" },
    { url = "https://files.pythonhosted.org/packages/b6/71/c1a60c1652b8813ef9de6d289784847355417ee0f2980bca002fe87f4ae5/mmh3-5.2.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8ff038d52ef6aa0f309feeba00c5095c9118d0abf787e8e8454d6048db2037fc", size = 103279, upload-time = "2026-03-05T15:55:20.448Z" },
    { url = "https://files.pythonhosted.org/packages/48/29/ad97f4be1509cdcb28ae32c15593ce7c415db47ace37f8fad35b493faa9a/mmh3-5.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4130d0b9ce5fad6af07421b1aecc7e079519f70d6c05729ab871794eded8617", size = 106290, upload-time = "2026-03-05T15:55:21.6Z" },
    { url = "https://files.pythonhosted.org/packages/77/29/1f86d22e281bd8827ba373600a4a8b0c0eae5ca6aa55b9a8c26d2a34decc/mmh3-5.2.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6e0bfe77d238308839699944164b96a2eeccaf55f2af400f54dc20669d8d5f2", size = 113116, upload-time = "2026-03-05T15:55:22.826Z" },
    { url = "https://files.pythonhosted.org/packages/a7/7c/339971ea7ed4c12d98f421f13db3ea576a9114082ccb59d2d1a0f00ccac1/mmh3-5.2.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f963eafc0a77a6c0562397da004f5876a9bcf7265a7bcc3205e29636bc4a1312", size = 120740, upload-time = "2026-03-05T15:55:24.3Z" },
    { url = "https://files.pythonhosted.org/packages/e4/92/3c7c4bdb8e926bb3c972d1e2907d77960c1c4b250b41e8366cf20c6e4373/mmh3-5.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:92883836caf50d5255be03d988d75bc93e3f86ba247b7ca137347c323f731deb", size = 99143, upload-time = "2026-03-05T15:55:25.456Z" },
    { url = "https://files.pythonhosted.org/packages/df/0a/33dd8706e732458c8375eae63c981292de07a406bad4ec03e5269654aa2c/mmh3-5.2.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:57b52603e89355ff318025dd55158f6e71396c0f1f609d548e9ea9c94cc6ce0a", size = 98703, upload-time = "2026-03-05T15:55:26.723Z" },
    { url = "https://files.pythonhosted.org/packages/51/04/76bbce05df76cbc3d396f13b2ea5b1578ef02b6a5187e132c6c33f99d596/mmh3-5.2.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f40a95186a72fa0b67d15fef0f157bfcda00b4f59c8a07cbe5530d41ac35d105", size = 106484, upload-time = "2026-03-05T15:55:28.214Z" },
    { url = "https://files.pythonhosted.org/packages/d3/8f/c6e204a2c70b719c1f62ffd9da27aef2dddcba875ea9c31ca0e87b975a46/mmh3-5.2.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:58370d05d033ee97224c81263af123dea3d931025030fd34b61227a768a8858a", size = 110012, upload-time = "2026-03-05T15:55:29.532Z" },
    { url = "https://files.pythonhosted.org/packages/e3/37/7181efd8e39db386c1ebc3e6b7d1f702a09d7c1197a6f2742ed6b5c16597/mmh3-5.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7be6dfb49e48fd0a7d91ff758a2b51336f1cd21f9d44b20f6801f072bd080cdd", size = 97508, upload-time = "2026-03-05T15:55:31.01Z" },
    { url = "https://files.pythonhosted.org/packages/42/0f/afa7ca2615fd85e1469474bb860e381443d0b868c083b62b41cb1d7ca32f/mmh3-5.2.1-cp314-cp314-win32.whl", hash = "sha256:54fe8518abe06a4c3852754bfd498b30cc58e667f376c513eac89a244ce781a4", size = 41387, upload-time = "2026-03-05T15:55:32.403Z" },
    { url = "https://files.pythonhosted.org/packages/71/0d/46d42a260ee1357db3d486e6c7a692e303c017968e14865e00efa10d09fc/mmh3-5.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:3f796b535008708846044c43302719c6956f39ca2d93f2edda5319e79a29efbb", size = 42101, upload-time = "2026-03-05T15:55:33.646Z" },
    { url = "https://files.pythonhosted.org/packages/a4/7b/848a8378059d96501a41159fca90d6a99e89736b0afbe8e8edffeac8c74b/mmh3-5.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:cd471ede0d802dd936b6fab28188302b2d497f68436025857ca72cd3810423fe", size = 39836, upload-time = "2026-03-05T15:55:35.026Z" },
    { url = "https://files.pythonhosted.org/packages/27/61/1dabea76c011ba8547c25d30c91c0ec22544487a8750997a27a0c9e1180b/mmh3-5.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:5174a697ce042fa77c407e05efe41e03aa56dae9ec67388055820fb48cf4c3ba", size = 57727, upload-time = "2026-03-05T15:55:36.162Z" },
    { url = "https://files.pythonhosted.org/packages/b7/32/731185950d1cf2d5e28979cc8593016ba1619a295faba10dda664a4931b5/mmh3-5.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0a3984146e414684a6be2862d84fcb1035f4984851cb81b26d933bab6119bf00", size = 41308, upload-time = "2026-03-05T15:55:37.254Z" },
    { url = "https://files.pythonhosted.org/packages/76/aa/66c76801c24b8c9418b4edde9b5e57c75e72c94e29c48f707e3962534f18/mmh3-5.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bd6e7d363aa93bd3421b30b6af97064daf47bc96005bddba67c5ffbc6df426b8", size = 40758, upload-time = "2026-03-05T15:55:38.61Z" },
    { url = "https://files.pythonhosted.org/packages/9e/bb/79a1f638a02f0ae389f706d13891e2fbf7d8c0a22ecde67ba828951bb60a/mmh3-5.2.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:113f78e7463a36dbbcea05bfe688efd7fa759d0f0c56e73c974d60dcfec3dfcc", size = 109670, upload-time = "2026-03-05T15:55:40.13Z" },
    { url = "https://files.pythonhosted.org/packages/26/94/8cd0e187a288985bcfc79bf5144d1d712df9dee74365f59d26e3a1865be6/mmh3-5.2.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7e8ec5f606e0809426d2440e0683509fb605a8820a21ebd120dcdba61b74ef7f", size = 117399, upload-time = "2026-03-05T15:55:42.076Z" },
    { url = "https://files.pythonhosted.org/packages/42/94/dfea6059bd5c5beda565f58a4096e43f4858fb6d2862806b8bbd12cbb284/mmh3-5.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22b0f9971ec4e07e8223f2beebe96a6cfc779d940b6f27d26604040dd74d3a44", size = 120386, upload-time = "2026-03-05T15:55:43.481Z" },
    { url = "https://files.pythonhosted.org/packages/47/cb/f9c45e62aaa67220179f487772461d891bb582bb2f9783c944832c60efd9/mmh3-5.2.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:85ffc9920ffc39c5eee1e3ac9100c913a0973996fbad5111f939bbda49204bb7", size = 125924, upload-time = "2026-03-05T15:55:44.638Z" },
    { url = "https://files.pythonhosted.org/packages/a5/83/fe54a4a7c11bc9f623dfc1707decd034245602b076dfc1dcc771a4163170/mmh3-5.2.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7aec798c2b01aaa65a55f1124f3405804184373abb318a3091325aece235f67c", size = 135280, upload-time = "2026-03-05T15:55:45.866Z" },
    { url = "https://files.pythonhosted.org/packages/97/67/fe7e9e9c143daddd210cd22aef89cbc425d58ecf238d2b7d9eb0da974105/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:55dbbd8ffbc40d1697d5e2d0375b08599dae8746b0b08dea05eee4ce81648fac", size = 110050, upload-time = "2026-03-05T15:55:47.074Z" },
    { url = "https://files.pythonhosted.org/packages/43/c4/6d4b09fcbef80794de447c9378e39eefc047156b290fa3dd2d5257ca8227/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6c85c38a279ca9295a69b9b088a2e48aa49737bb1b34e6a9dc6297c110e8d912", size = 111158, upload-time = "2026-03-05T15:55:48.239Z" },
    { url = "https://files.pythonhosted.org/packages/81/a6/ca51c864bdb30524beb055a6d8826db3906af0834ec8c41d097a6e8573d5/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:6290289fa5fb4c70fd7f72016e03633d60388185483ff3b162912c81205ae2cf", size = 116890, upload-time = "2026-03-05T15:55:49.405Z" },
    { url = "https://files.pythonhosted.org/packages/cc/04/5a1fe2e2ad843d03e89af25238cbc4f6840a8bb6c4329a98ab694c71deda/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:4fc6cd65dc4d2fdb2625e288939a3566e36127a84811a4913f02f3d5931da52d", size = 123121, upload-time = "2026-03-05T15:55:50.61Z" },
    { url = "https://files.pythonhosted.org/packages/af/4d/3c820c6f4897afd25905270a9f2330a23f77a207ea7356f7aadace7273c0/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:623f938f6a039536cc02b7582a07a080f13fdfd48f87e63201d92d7e34d09a18", size = 110187, upload-time = "2026-03-05T15:55:52.143Z" },
    { url = "https://files.pythonhosted.org/packages/21/54/1d71cd143752361c0aebef16ad3f55926a6faf7b112d355745c1f8a25f7f/mmh3-5.2.1-cp314-cp314t-win32.whl", hash = "sha256:29bc3973676ae334412efdd367fcd11d036b7be3efc1ce2407ef8676dabfeb82", size = 41934, upload-time = "2026-03-05T15:55:53.564Z" },
    { url = "https://files.pythonhosted.org/packages/9d/e4/63a2a88f31d93dea03947cccc2a076946857e799ea4f7acdecbf43b324aa/mmh3-5.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:28cfab66577000b9505a0d068c731aee7ca85cd26d4d63881fab17857e0fe1fb", size = 43036, upload-time = "2026-03-05T15:55:55.252Z" },
    { url = "https://files.pythonhosted.org/packages/a0/0f/59204bf136d1201f8d7884cfbaf7498c5b4674e87a4c693f9bde63741ce1/mmh3-5.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:dfd51b4c56b673dfbc43d7d27ef857dd91124801e2806c69bb45585ce0fa019b", size = 40391, upload-time = "2026-03-05T15:55:56.697Z" },
]

[[package]]
name = "msgspec"
version = "0.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ea/9c/bfbd12955a49180cbd234c5d29ec6f74fe641698f0cd9df154a854fc8a15/msgspec-0.20.0.tar.gz", hash = "sha256:692349e588fde322875f8d3025ac01689fead5901e7fb18d6870a44519d62a29", size = 317862, upload-time = "2025-11-24T03:56:28.934Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/e3/5e/151883ba2047cca9db8ed2f86186b054ad200bc231352df15b0c1dd75b1f/msgspec-0.20.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:23a6ec2a3b5038c233b04740a545856a068bc5cb8db184ff493a58e08c994fbf", size = 195191, upload-time = "2025-11-24T03:55:08.549Z" },
    { url = "https://files.pythonhosted.org/packages/50/88/a795647672f547c983eff0823b82aaa35db922c767e1b3693e2dcf96678d/msgspec-0.20.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cde2c41ed3eaaef6146365cb0d69580078a19f974c6cb8165cc5dcd5734f573e", size = 188513, upload-time = "2025-11-24T03:55:10.008Z" },
    { url = "https://files.pythonhosted.org/packages/4b/91/eb0abb0e0de142066cebfe546dc9140c5972ea824aa6ff507ad0b6a126ac/msgspec-0.20.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5da0daa782f95d364f0d95962faed01e218732aa1aa6cad56b25a5d2092e75a4", size = 216370, upload-time = "2025-11-24T03:55:11.566Z" },
    { url = "https://files.pythonhosted.org/packages/15/2a/48e41d9ef0a24b1c6e67cbd94a676799e0561bfbc163be1aaaff5ca853f5/msgspec-0.20.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9369d5266144bef91be2940a3821e03e51a93c9080fde3ef72728c3f0a3a8bb7", size = 222653, upload-time = "2025-11-24T03:55:13.159Z" },
    { url = "https://files.pythonhosted.org/packages/90/c9/14b825df203d980f82a623450d5f39e7f7a09e6e256c52b498ea8f29d923/msgspec-0.20.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:90fb865b306ca92c03964a5f3d0cd9eb1adda14f7e5ac7943efd159719ea9f10", size = 222337, upload-time = "2025-11-24T03:55:14.777Z" },
    { url = "https://files.pythonhosted.org/packages/8b/d7/39a5c3ddd294f587d6fb8efccc8361b6aa5089974015054071e665c9d24b/msgspec-0.20.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e8112cd48b67dfc0cfa49fc812b6ce7eb37499e1d95b9575061683f3428975d3", size = 225565, upload-time = "2025-11-24T03:55:16.4Z" },
    { url = "https://files.pythonhosted.org/packages/98/bd/5db3c14d675ee12842afb9b70c94c64f2c873f31198c46cbfcd7dffafab0/msgspec-0.20.0-cp310-cp310-win_amd64.whl", hash = "sha256:666b966d503df5dc27287675f525a56b6e66a2b8e8ccd2877b0c01328f19ae6c", size = 188412, upload-time = "2025-11-24T03:55:17.747Z" },
    { url = "https://files.pythonhosted.org/packages/76/c7/06cc218bc0c86f0c6c6f34f7eeea6cfb8b835070e8031e3b0ef00f6c7c69/msgspec-0.20.0-cp310-cp310-win_arm64.whl", hash = "sha256:099e3e85cd5b238f2669621be65f0728169b8c7cb7ab07f6137b02dc7feea781", size = 173951, upload-time = "2025-11-24T03:55:19.335Z" },
    { url = "https://files.pythonhosted.org/packages/03/59/fdcb3af72f750a8de2bcf39d62ada70b5eb17b06d7f63860e0a679cb656b/msgspec-0.20.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:09e0efbf1ac641fedb1d5496c59507c2f0dc62a052189ee62c763e0aae217520", size = 193345, upload-time = "2025-11-24T03:55:20.613Z" },
    { url = "https://files.pythonhosted.org/packages/5a/15/3c225610da9f02505d37d69a77f4a2e7daae2a125f99d638df211ba84e59/msgspec-0.20.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23ee3787142e48f5ee746b2909ce1b76e2949fbe0f97f9f6e70879f06c218b54", size = 186867, upload-time = "2025-11-24T03:55:22.4Z" },
    { url = "https://files.pythonhosted.org/packages/81/36/13ab0c547e283bf172f45491edfdea0e2cecb26ae61e3a7b1ae6058b326d/msgspec-0.20.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:81f4ac6f0363407ac0465eff5c7d4d18f26870e00674f8fcb336d898a1e36854", size = 215351, upload-time = "2025-11-24T03:55:23.958Z" },
    { url = "https://files.pythonhosted.org/packages/6b/96/5c095b940de3aa6b43a71ec76275ac3537b21bd45c7499b5a17a429110fa/msgspec-0.20.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb4d873f24ae18cd1334f4e37a178ed46c9d186437733351267e0a269bdf7e53", size = 219896, upload-time = "2025-11-24T03:55:25.356Z" },
    { url = "https://files.pythonhosted.org/packages/98/7a/81a7b5f01af300761087b114dafa20fb97aed7184d33aab64d48874eb187/msgspec-0.20.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b92b8334427b8393b520c24ff53b70f326f79acf5f74adb94fd361bcff8a1d4e", size = 220389, upload-time = "2025-11-24T03:55:26.99Z" },
    { url = "https://files.pythonhosted.org/packages/70/c0/3d0cce27db9a9912421273d49eab79ce01ecd2fed1a2f1b74af9b445f33c/msgspec-0.20.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:562c44b047c05cc0384e006fae7a5e715740215c799429e0d7e3e5adf324285a", size = 223348, upload-time = "2025-11-24T03:55:28.311Z" },
    { url = "https://files.pythonhosted.org/packages/89/5e/406b7d578926b68790e390d83a1165a9bfc2d95612a1a9c1c4d5c72ea815/msgspec-0.20.0-cp311-cp311-win_amd64.whl", hash = "sha256:d1dcc93a3ce3d3195985bfff18a48274d0b5ffbc96fa1c5b89da6f0d9af81b29", size = 188713, upload-time = "2025-11-24T03:55:29.553Z" },
    { url = "https://files.pythonhosted.org/packages/47/87/14fe2316624ceedf76a9e94d714d194cbcb699720b210ff189f89ca4efd7/msgspec-0.20.0-cp311-cp311-win_arm64.whl", hash = "sha256:aa387aa330d2e4bd69995f66ea8fdc87099ddeedf6fdb232993c6a67711e7520", size = 174229, upload-time = "2025-11-24T03:55:31.107Z" },
    { url = "https://files.pythonhosted.org/packages/d9/6f/1e25eee957e58e3afb2a44b94fa95e06cebc4c236193ed0de3012fff1e19/msgspec-0.20.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2aba22e2e302e9231e85edc24f27ba1f524d43c223ef5765bd8624c7df9ec0a5", size = 196391, upload-time = "2025-11-24T03:55:32.677Z" },
    { url = "https://files.pythonhosted.org/packages/7f/ee/af51d090ada641d4b264992a486435ba3ef5b5634bc27e6eb002f71cef7d/msgspec-0.20.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:716284f898ab2547fedd72a93bb940375de9fbfe77538f05779632dc34afdfde", size = 188644, upload-time = "2025-11-24T03:55:33.934Z" },
    { url = "https://files.pythonhosted.org/packages/49/d6/9709ee093b7742362c2934bfb1bbe791a1e09bed3ea5d8a18ce552fbfd73/msgspec-0.20.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:558ed73315efa51b1538fa8f1d3b22c8c5ff6d9a2a62eff87d25829b94fc5054", size = 218852, upload-time = "2025-11-24T03:55:35.575Z" },
    { url = "https://files.pythonhosted.org/packages/5c/a2/488517a43ccf5a4b6b6eca6dd4ede0bd82b043d1539dd6bb908a19f8efd3/msgspec-0.20.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:509ac1362a1d53aa66798c9b9fd76872d7faa30fcf89b2fba3bcbfd559d56eb0", size = 224937, upload-time = "2025-11-24T03:55:36.859Z" },
    { url = "https://files.pythonhosted.org/packages/d5/e8/49b832808aa23b85d4f090d1d2e48a4e3834871415031ed7c5fe48723156/msgspec-0.20.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1353c2c93423602e7dea1aa4c92f3391fdfc25ff40e0bacf81d34dbc68adb870", size = 222858, upload-time = "2025-11-24T03:55:38.187Z" },
    { url = "https://files.pythonhosted.org/packages/9f/56/1dc2fa53685dca9c3f243a6cbecd34e856858354e455b77f47ebd76cf5bf/msgspec-0.20.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cb33b5eb5adb3c33d749684471c6a165468395d7aa02d8867c15103b81e1da3e", size = 227248, upload-time = "2025-11-24T03:55:39.496Z" },
    { url = "https://files.pythonhosted.org/packages/5a/51/aba940212c23b32eedce752896205912c2668472ed5b205fc33da28a6509/msgspec-0.20.0-cp312-cp312-win_amd64.whl", hash = "sha256:fb1d934e435dd3a2b8cf4bbf47a8757100b4a1cfdc2afdf227541199885cdacb", size = 190024, upload-time = "2025-11-24T03:55:40.829Z" },
    { url = "https://files.pythonhosted.org/packages/41/ad/3b9f259d94f183daa9764fef33fdc7010f7ecffc29af977044fa47440a83/msgspec-0.20.0-cp312-cp312-win_arm64.whl", hash = "sha256:00648b1e19cf01b2be45444ba9dc961bd4c056ffb15706651e64e5d6ec6197b7", size = 175390, upload-time = "2025-11-24T03:55:42.05Z" },
    { url = "https://files.pythonhosted.org/packages/8a/d1/b902d38b6e5ba3bdddbec469bba388d647f960aeed7b5b3623a8debe8a76/msgspec-0.20.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9c1ff8db03be7598b50dd4b4a478d6fe93faae3bd54f4f17aa004d0e46c14c46", size = 196463, upload-time = "2025-11-24T03:55:43.405Z" },
    { url = "https://files.pythonhosted.org/packages/57/b6/eff0305961a1d9447ec2b02f8c73c8946f22564d302a504185b730c9a761/msgspec-0.20.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f6532369ece217fd37c5ebcfd7e981f2615628c21121b7b2df9d3adcf2fd69b8", size = 188650, upload-time = "2025-11-24T03:55:44.761Z" },
    { url = "https://files.pythonhosted.org/packages/99/93/f2ec1ae1de51d3fdee998a1ede6b2c089453a2ee82b5c1b361ed9095064a/msgspec-0.20.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9a1697da2f85a751ac3cc6a97fceb8e937fc670947183fb2268edaf4016d1ee", size = 218834, upload-time = "2025-11-24T03:55:46.441Z" },
    { url = "https://files.pythonhosted.org/packages/28/83/36557b04cfdc317ed8a525c4993b23e43a8fbcddaddd78619112ca07138c/msgspec-0.20.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7fac7e9c92eddcd24c19d9e5f6249760941485dff97802461ae7c995a2450111", size = 224917, upload-time = "2025-11-24T03:55:48.06Z" },
    { url = "https://files.pythonhosted.org/packages/8f/56/362037a1ed5be0b88aced59272442c4b40065c659700f4b195a7f4d0ac88/msgspec-0.20.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f953a66f2a3eb8d5ea64768445e2bb301d97609db052628c3e1bcb7d87192a9f", size = 222821, upload-time = "2025-11-24T03:55:49.388Z" },
    { url = "https://files.pythonhosted.org/packages/92/75/fa2370ec341cedf663731ab7042e177b3742645c5dd4f64dc96bd9f18a6b/msgspec-0.20.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:247af0313ae64a066d3aea7ba98840f6681ccbf5c90ba9c7d17f3e39dbba679c", size = 227227, upload-time = "2025-11-24T03:55:51.125Z" },
    { url = "https://files.pythonhosted.org/packages/f1/25/5e8080fe0117f799b1b68008dc29a65862077296b92550632de015128579/msgspec-0.20.0-cp313-cp313-win_amd64.whl", hash = "sha256:67d5e4dfad52832017018d30a462604c80561aa62a9d548fc2bd4e430b66a352", size = 189966, upload-time = "2025-11-24T03:55:52.458Z" },
    { url = "https://files.pythonhosted.org/packages/79/b6/63363422153937d40e1cb349c5081338401f8529a5a4e216865decd981bf/msgspec-0.20.0-cp313-cp313-win_arm64.whl", hash = "sha256:91a52578226708b63a9a13de287b1ec3ed1123e4a088b198143860c087770458", size = 175378, upload-time = "2025-11-24T03:55:53.721Z" },
    { url = "https://files.pythonhosted.org/packages/bb/18/62dc13ab0260c7d741dda8dc7f481495b93ac9168cd887dda5929880eef8/msgspec-0.20.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:eead16538db1b3f7ec6e3ed1f6f7c5dec67e90f76e76b610e1ffb5671815633a", size = 196407, upload-time = "2025-11-24T03:55:55.001Z" },
    { url = "https://files.pythonhosted.org/packages/dd/1d/b9949e4ad6953e9f9a142c7997b2f7390c81e03e93570c7c33caf65d27e1/msgspec-0.20.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:703c3bb47bf47801627fb1438f106adbfa2998fe586696d1324586a375fca238", size = 188889, upload-time = "2025-11-24T03:55:56.311Z" },
    { url = "https://files.pythonhosted.org/packages/1e/19/f8bb2dc0f1bfe46cc7d2b6b61c5e9b5a46c62298e8f4d03bbe499c926180/msgspec-0.20.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6cdb227dc585fb109305cee0fd304c2896f02af93ecf50a9c84ee54ee67dbb42", size = 219691, upload-time = "2025-11-24T03:55:57.908Z" },
    { url = "https://files.pythonhosted.org/packages/b8/8e/6b17e43f6eb9369d9858ee32c97959fcd515628a1df376af96c11606cf70/msgspec-0.20.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27d35044dd8818ac1bd0fedb2feb4fbdff4e3508dd7c5d14316a12a2d96a0de0", size = 224918, upload-time = "2025-11-24T03:55:59.322Z" },
    { url = "https://files.pythonhosted.org/packages/1c/db/0e833a177db1a4484797adba7f429d4242585980b90882cc38709e1b62df/msgspec-0.20.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b4296393a29ee42dd25947981c65506fd4ad39beaf816f614146fa0c5a6c91ae", size = 223436, upload-time = "2025-11-24T03:56:00.716Z" },
    { url = "https://files.pythonhosted.org/packages/c3/30/d2ee787f4c918fd2b123441d49a7707ae9015e0e8e1ab51aa7967a97b90e/msgspec-0.20.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:205fbdadd0d8d861d71c8f3399fe1a82a2caf4467bc8ff9a626df34c12176980", size = 227190, upload-time = "2025-11-24T03:56:02.371Z" },
    { url = "https://files.pythonhosted.org/packages/ff/37/9c4b58ff11d890d788e700b827db2366f4d11b3313bf136780da7017278b/msgspec-0.20.0-cp314-cp314-win_amd64.whl", hash = "sha256:7dfebc94fe7d3feec6bc6c9df4f7e9eccc1160bb5b811fbf3e3a56899e398a6b", size = 193950, upload-time = "2025-11-24T03:56:03.668Z" },
    { url = "https://files.pythonhosted.org/packages/e9/4e/cab707bf2fa57408e2934e5197fc3560079db34a1e3cd2675ff2e47e07de/msgspec-0.20.0-cp314-cp314-win_arm64.whl", hash = "sha256:2ad6ae36e4a602b24b4bf4eaf8ab5a441fec03e1f1b5931beca8ebda68f53fc0", size = 179018, upload-time = "2025-11-24T03:56:05.038Z" },
    { url = "https://files.pythonhosted.org/packages/4c/06/3da3fc9aaa55618a8f43eb9052453cfe01f82930bca3af8cea63a89f3a11/msgspec-0.20.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f84703e0e6ef025663dd1de828ca028774797b8155e070e795c548f76dde65d5", size = 200389, upload-time = "2025-11-24T03:56:06.375Z" },
    { url = "https://files.pythonhosted.org/packages/83/3b/cc4270a5ceab40dfe1d1745856951b0a24fd16ac8539a66ed3004a60c91e/msgspec-0.20.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7c83fc24dd09cf1275934ff300e3951b3adc5573f0657a643515cc16c7dee131", size = 193198, upload-time = "2025-11-24T03:56:07.742Z" },
    { url = "https://files.pythonhosted.org/packages/cd/ae/4c7905ac53830c8e3c06fdd60e3cdcfedc0bbc993872d1549b84ea21a1bd/msgspec-0.20.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f13ccb1c335a124e80c4562573b9b90f01ea9521a1a87f7576c2e281d547f56", size = 225973, upload-time = "2025-11-24T03:56:09.18Z" },
    { url = "https://files.pythonhosted.org/packages/d9/da/032abac1de4d0678d99eaeadb1323bd9d247f4711c012404ba77ed6f15ca/msgspec-0.20.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17c2b5ca19f19306fc83c96d85e606d2cc107e0caeea85066b5389f664e04846", size = 229509, upload-time = "2025-11-24T03:56:10.898Z" },
    { url = "https://files.pythonhosted.org/packages/69/52/fdc7bdb7057a166f309e0b44929e584319e625aaba4771b60912a9321ccd/msgspec-0.20.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d931709355edabf66c2dd1a756b2d658593e79882bc81aae5964969d5a291b63", size = 230434, upload-time = "2025-11-24T03:56:12.48Z" },
    { url = "https://files.pythonhosted.org/packages/cb/fe/1dfd5f512b26b53043884e4f34710c73e294e7cc54278c3fe28380e42c37/msgspec-0.20.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:565f915d2e540e8a0c93a01ff67f50aebe1f7e22798c6a25873f9fda8d1325f8", size = 231758, upload-time = "2025-11-24T03:56:13.765Z" },
    { url = "https://files.pythonhosted.org/packages/97/f6/9ba7121b8e0c4e0beee49575d1dbc804e2e72467692f0428cf39ceba1ea5/msgspec-0.20.0-cp314-cp314t-win_amd64.whl", hash = "sha256:726f3e6c3c323f283f6021ebb6c8ccf58d7cd7baa67b93d73bfbe9a15c34ab8d", size = 206540, upload-time = "2025-11-24T03:56:15.029Z" },
    { url = "https://files.pythonhosted.org/packages/c8/3e/c5187de84bb2c2ca334ab163fcacf19a23ebb1d876c837f81a1b324a15bf/msgspec-0.20.0-cp314-cp314t-win_arm64.whl", hash = "sha256:93f23528edc51d9f686808a361728e903d6f2be55c901d6f5c92e44c6d546bfc", size = 183011, upload-time = "2025-11-24T03:56:16.442Z" },
    { url = "https://files.pythonhosted.org/packages/b2/30/55eb8645bf11ea84bc1dafa670d068348b08b84660c4c9240ff05296e707/msgspec-0.20.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:eee56472ced14602245ac47516e179d08c6c892d944228796f239e983de7449c", size = 195293, upload-time = "2025-11-24T03:56:17.763Z" },
    { url = "https://files.pythonhosted.org/packages/b1/c2/78c66d69beb45c311ba6ad0021f31ddfe6f19fe1b46cf295175fbb41430d/msgspec-0.20.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:19395e9a08cc5bd0e336909b3e13b4ae5ee5e47b82e98f8b7801d5a13806bb6f", size = 188572, upload-time = "2025-11-24T03:56:19.431Z" },
    { url = "https://files.pythonhosted.org/packages/44/14/9d6f685a277e4d3417f103c4d228cb7ea83fdd776c739570f233917f5fd2/msgspec-0.20.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d5bb7ce84fe32f6ce9f62aa7e7109cb230ad542cc5bc9c46e587f1dac4afc48e", size = 216219, upload-time = "2025-11-24T03:56:20.823Z" },
    { url = "https://files.pythonhosted.org/packages/98/24/e50ea4080656a711bee9fe3d846de3b0e74f03c1dc620284b82e1757fdb0/msgspec-0.20.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8c6da9ae2d76d11181fbb0ea598f6e1d558ef597d07ec46d689d17f68133769f", size = 222573, upload-time = "2025-11-24T03:56:22.17Z" },
    { url = "https://files.pythonhosted.org/packages/d1/4b/2d9415a935ebd6e5f34fd5cad7be6b8525d8353bf5ed6eb77e706863f3b0/msgspec-0.20.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:84d88bd27d906c471a5ca232028671db734111996ed1160e37171a8d1f07a599", size = 222097, upload-time = "2025-11-24T03:56:23.553Z" },
    { url = "https://files.pythonhosted.org/packages/b3/56/2cc277def0d43625dd14ab6ee0e3a5198175725198122d707fa139ebbdd1/msgspec-0.20.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:03907bf733f94092a6b4c5285b274f79947cad330bd8a9d8b45c0369e1a3c7f0", size = 225419, upload-time = "2025-11-24T03:56:24.953Z" },
    { url = "https://files.pythonhosted.org/packages/42/1d/e9401b352aa399af5efa35f1f130651698e65f919ecb9221b925b2236948/msgspec-0.20.0-cp39-cp39-win_amd64.whl", hash = "sha256:9fbcb660632a2f5c247c0dc820212bf3a423357ac6241ff6dc6cfc6f72584016", size = 188527, upload-time = "2025-11-24T03:56:26.193Z" },
    { url = "https://files.pythonhosted.org/packages/02/59/079f33cd092ee42c9b97a59daa2115e7550a7eba98781ef6657e3d710d56/msgspec-0.20.0-cp39-cp39-win_arm64.whl", hash = "sha256:f7cd0e89b86a16005745cb99bd1858e8050fc17f63de571504492b267bca188a", size = 173927, upload-time = "2025-11-24T03:56:27.52Z" },
]

[[package]]
name = "multidict"
version = "6.7.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/84/0b/19348d4c98980c4851d2f943f8ebafdece2ae7ef737adcfa5994ce8e5f10/multidict-6.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5", size = 77176, upload-time = "2026-01-26T02:42:59.784Z" },
    { url = "https://files.pythonhosted.org/packages/ef/04/9de3f8077852e3d438215c81e9b691244532d2e05b4270e89ce67b7d103c/multidict-6.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8", size = 44996, upload-time = "2026-01-26T02:43:01.674Z" },
    { url = "https://files.pythonhosted.org/packages/31/5c/08c7f7fe311f32e83f7621cd3f99d805f45519cd06fafb247628b861da7d/multidict-6.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cdea2e7b2456cfb6694fb113066fd0ec7ea4d67e3a35e1f4cbeea0b448bf5872", size = 44631, upload-time = "2026-01-26T02:43:03.169Z" },
    { url = "https://files.pythonhosted.org/packages/b7/7f/0e3b1390ae772f27501199996b94b52ceeb64fe6f9120a32c6c3f6b781be/multidict-6.7.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17207077e29342fdc2c9a82e4b306f1127bf1ea91f8b71e02d4798a70bb99991", size = 242561, upload-time = "2026-01-26T02:43:04.733Z" },
    { url = "https://files.pythonhosted.org/packages/dd/f4/8719f4f167586af317b69dd3e90f913416c91ca610cac79a45c53f590312/multidict-6.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4f49cb5661344764e4c7c7973e92a47a59b8fc19b6523649ec9dc4960e58a03", size = 242223, upload-time = "2026-01-26T02:43:06.695Z" },
    { url = "https://files.pythonhosted.org/packages/47/ab/7c36164cce64a6ad19c6d9a85377b7178ecf3b89f8fd589c73381a5eedfd/multidict-6.7.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a9fc4caa29e2e6ae408d1c450ac8bf19892c5fca83ee634ecd88a53332c59981", size = 222322, upload-time = "2026-01-26T02:43:08.472Z" },
    { url = "https://files.pythonhosted.org/packages/f5/79/a25add6fb38035b5337bc5734f296d9afc99163403bbcf56d4170f97eb62/multidict-6.7.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c5f0c21549ab432b57dcc82130f388d84ad8179824cc3f223d5e7cfbfd4143f6", size = 254005, upload-time = "2026-01-26T02:43:10.127Z" },
    { url = "https://files.pythonhosted.org/packages/4a/7b/64a87cf98e12f756fc8bd444b001232ffff2be37288f018ad0d3f0aae931/multidict-6.7.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7dfb78d966b2c906ae1d28ccf6e6712a3cd04407ee5088cd276fe8cb42186190", size = 251173, upload-time = "2026-01-26T02:43:11.731Z" },
    { url = "https://files.pythonhosted.org/packages/4b/ac/b605473de2bb404e742f2cc3583d12aedb2352a70e49ae8fce455b50c5aa/multidict-6.7.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b0d9b91d1aa44db9c1f1ecd0d9d2ae610b2f4f856448664e01a3b35899f3f92", size = 243273, upload-time = "2026-01-26T02:43:13.063Z" },
    { url = "https://files.pythonhosted.org/packages/03/65/11492d6a0e259783720f3bc1d9ea55579a76f1407e31ed44045c99542004/multidict-6.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dd96c01a9dcd4889dcfcf9eb5544ca0c77603f239e3ffab0524ec17aea9a93ee", size = 238956, upload-time = "2026-01-26T02:43:14.843Z" },
    { url = "https://files.pythonhosted.org/packages/5f/a7/7ee591302af64e7c196fb63fe856c788993c1372df765102bd0448e7e165/multidict-6.7.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:067343c68cd6612d375710f895337b3a98a033c94f14b9a99eff902f205424e2", size = 233477, upload-time = "2026-01-26T02:43:16.025Z" },
    { url = "https://files.pythonhosted.org/packages/9c/99/c109962d58756c35fd9992fed7f2355303846ea2ff054bb5f5e9d6b888de/multidict-6.7.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5884a04f4ff56c6120f6ccf703bdeb8b5079d808ba604d4d53aec0d55dc33568", size = 243615, upload-time = "2026-01-26T02:43:17.84Z" },
    { url = "https://files.pythonhosted.org/packages/d5/5f/1973e7c771c86e93dcfe1c9cc55a5481b610f6614acfc28c0d326fe6bfad/multidict-6.7.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8affcf1c98b82bc901702eb73b6947a1bfa170823c153fe8a47b5f5f02e48e40", size = 249930, upload-time = "2026-01-26T02:43:19.06Z" },
    { url = "https://files.pythonhosted.org/packages/5d/a5/f170fc2268c3243853580203378cd522446b2df632061e0a5409817854c7/multidict-6.7.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0d17522c37d03e85c8098ec8431636309b2682cf12e58f4dbc76121fb50e4962", size = 243807, upload-time = "2026-01-26T02:43:20.286Z" },
    { url = "https://files.pythonhosted.org/packages/de/01/73856fab6d125e5bc652c3986b90e8699a95e84b48d72f39ade6c0e74a8c/multidict-6.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:24c0cf81544ca5e17cfcb6e482e7a82cd475925242b308b890c9452a074d4505", size = 239103, upload-time = "2026-01-26T02:43:21.508Z" },
    { url = "https://files.pythonhosted.org/packages/e7/46/f1220bd9944d8aa40d8ccff100eeeee19b505b857b6f603d6078cb5315b0/multidict-6.7.1-cp310-cp310-win32.whl", hash = "sha256:d82dd730a95e6643802f4454b8fdecdf08667881a9c5670db85bc5a56693f122", size = 41416, upload-time = "2026-01-26T02:43:22.703Z" },
    { url = "https://files.pythonhosted.org/packages/68/00/9b38e272a770303692fc406c36e1a4c740f401522d5787691eb38a8925a8/multidict-6.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cf37cbe5ced48d417ba045aca1b21bafca67489452debcde94778a576666a1df", size = 46022, upload-time = "2026-01-26T02:43:23.77Z" },
    { url = "https://files.pythonhosted.org/packages/64/65/d8d42490c02ee07b6bbe00f7190d70bb4738b3cce7629aaf9f213ef730dd/multidict-6.7.1-cp310-cp310-win_arm64.whl", hash = "sha256:59bc83d3f66b41dac1e7460aac1d196edc70c9ba3094965c467715a70ecb46db", size = 43238, upload-time = "2026-01-26T02:43:24.882Z" },
    { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" },
    { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" },
    { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" },
    { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" },
    { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" },
    { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" },
    { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" },
    { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" },
    { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" },
    { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" },
    { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" },
    { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" },
    { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" },
    { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" },
    { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" },
    { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" },
    { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" },
    { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" },
    { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" },
    { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" },
    { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" },
    { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" },
    { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" },
    { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" },
    { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" },
    { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" },
    { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" },
    { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" },
    { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" },
    { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" },
    { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" },
    { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" },
    { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" },
    { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" },
    { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" },
    { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" },
    { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" },
    { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" },
    { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" },
    { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" },
    { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" },
    { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" },
    { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" },
    { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" },
    { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" },
    { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" },
    { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" },
    { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" },
    { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" },
    { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" },
    { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" },
    { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" },
    { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" },
    { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" },
    { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" },
    { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" },
    { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" },
    { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" },
    { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" },
    { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" },
    { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" },
    { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" },
    { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" },
    { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" },
    { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" },
    { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" },
    { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" },
    { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" },
    { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" },
    { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" },
    { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" },
    { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" },
    { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" },
    { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" },
    { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" },
    { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" },
    { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" },
    { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" },
    { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" },
    { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" },
    { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" },
    { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" },
    { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" },
    { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" },
    { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" },
    { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" },
    { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" },
    { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" },
    { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" },
    { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" },
    { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" },
    { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" },
    { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" },
    { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" },
    { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" },
    { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" },
    { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" },
    { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" },
    { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" },
    { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" },
    { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" },
    { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" },
    { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" },
    { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" },
    { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" },
    { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" },
    { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" },
    { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" },
    { url = "https://files.pythonhosted.org/packages/9e/ee/74525ebe3eb5fddcd6735fc03cbea3feeed4122b53bc798ac32d297ac9ae/multidict-6.7.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:65573858d27cdeaca41893185677dc82395159aa28875a8867af66532d413a8f", size = 77107, upload-time = "2026-01-26T02:46:12.608Z" },
    { url = "https://files.pythonhosted.org/packages/f0/9a/ce8744e777a74b3050b1bf56be3eed1053b3457302ea055f1ea437200a23/multidict-6.7.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c524c6fb8fc342793708ab111c4dbc90ff9abd568de220432500e47e990c0358", size = 44943, upload-time = "2026-01-26T02:46:14.016Z" },
    { url = "https://files.pythonhosted.org/packages/83/9c/1d2a283d9c6f31e260cb6c2fccadc3edcf6c4c14ee0929cd2af4d2606dd7/multidict-6.7.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:aa23b001d968faef416ff70dc0f1ab045517b9b42a90edd3e9bcdb06479e31d5", size = 44603, upload-time = "2026-01-26T02:46:15.391Z" },
    { url = "https://files.pythonhosted.org/packages/87/9d/3b186201671583d8e8d6d79c07481a5aafd0ba7575e3d8566baec80c1e82/multidict-6.7.1-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6704fa2b7453b2fb121740555fa1ee20cd98c4d011120caf4d2b8d4e7c76eec0", size = 240573, upload-time = "2026-01-26T02:46:16.783Z" },
    { url = "https://files.pythonhosted.org/packages/42/7d/a52f5d4d0754311d1ac78478e34dff88de71259a8585e05ee14e5f877caf/multidict-6.7.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:121a34e5bfa410cdf2c8c49716de160de3b1dbcd86b49656f5681e4543bcd1a8", size = 240106, upload-time = "2026-01-26T02:46:18.432Z" },
    { url = "https://files.pythonhosted.org/packages/84/9f/d80118e6c30ff55b7d171bdc5520aad4b9626e657520b8d7c8ca8c2fad12/multidict-6.7.1-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:026d264228bcd637d4e060844e39cdc60f86c479e463d49075dedc21b18fbbe0", size = 219418, upload-time = "2026-01-26T02:46:20.526Z" },
    { url = "https://files.pythonhosted.org/packages/c7/bd/896e60b3457f194de77c7de64f9acce9f75da0518a5230ce1df534f6747b/multidict-6.7.1-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0e697826df7eb63418ee190fd06ce9f1803593bb4b9517d08c60d9b9a7f69d8f", size = 252124, upload-time = "2026-01-26T02:46:22.157Z" },
    { url = "https://files.pythonhosted.org/packages/f4/de/ba6b30447c36a37078d0ba604aa12c1a52887af0c355236ca6e0a9d5286f/multidict-6.7.1-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb08271280173720e9fea9ede98e5231defcbad90f1624bea26f32ec8a956e2f", size = 249402, upload-time = "2026-01-26T02:46:23.718Z" },
    { url = "https://files.pythonhosted.org/packages/c2/b2/50a383c96230e432895a2fd3bcfe1b65785899598259d871d5de6b93180c/multidict-6.7.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6b3228e1d80af737b72925ce5fb4daf5a335e49cd7ab77ed7b9fdfbf58c526e", size = 240346, upload-time = "2026-01-26T02:46:25.393Z" },
    { url = "https://files.pythonhosted.org/packages/89/37/16d391fd8da544b1489306e38a46785fa41dd0f0ef766837ed7d4676dde0/multidict-6.7.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3943debf0fbb57bdde5901695c11094a9a36723e5c03875f87718ee15ca2f4d2", size = 237010, upload-time = "2026-01-26T02:46:27.408Z" },
    { url = "https://files.pythonhosted.org/packages/b0/24/3152ee026eda86d5d3e3685182911e6951af7a016579da931080ce6ac9ad/multidict-6.7.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:98c5787b0a0d9a41d9311eae44c3b76e6753def8d8870ab501320efe75a6a5f8", size = 232018, upload-time = "2026-01-26T02:46:29.941Z" },
    { url = "https://files.pythonhosted.org/packages/9c/1f/48d3c27a72be7fd23a55d8847193c459959bf35a5bb5844530dab00b739b/multidict-6.7.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:08ccb2a6dc72009093ebe7f3f073e5ec5964cba9a706fa94b1a1484039b87941", size = 241498, upload-time = "2026-01-26T02:46:32.052Z" },
    { url = "https://files.pythonhosted.org/packages/1a/45/413643ae2952d0decdf6c1250f86d08a43e143271441e81027e38d598bd7/multidict-6.7.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:eb351f72c26dc9abe338ca7294661aa22969ad8ffe7ef7d5541d19f368dc854a", size = 247957, upload-time = "2026-01-26T02:46:33.666Z" },
    { url = "https://files.pythonhosted.org/packages/50/f8/f1d0ac23df15e0470776388bdb261506f63af1f81d28bacb5e262d6e12b6/multidict-6.7.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ac1c665bad8b5d762f5f85ebe4d94130c26965f11de70c708c75671297c776de", size = 241651, upload-time = "2026-01-26T02:46:35.7Z" },
    { url = "https://files.pythonhosted.org/packages/2c/c9/1a2a18f383cf129add66b6c36b75c3911a7ba95cf26cb141482de085cc12/multidict-6.7.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1fa6609d0364f4f6f58351b4659a1f3e0e898ba2a8c5cac04cb2c7bc556b0bc5", size = 236371, upload-time = "2026-01-26T02:46:37.37Z" },
    { url = "https://files.pythonhosted.org/packages/bb/aa/77d87e3fca31325b87e0eb72d5fe9a7472dcb51391a42df7ac1f3842f6c0/multidict-6.7.1-cp39-cp39-win32.whl", hash = "sha256:6f77ce314a29263e67adadc7e7c1bc699fcb3a305059ab973d038f87caa42ed0", size = 41426, upload-time = "2026-01-26T02:46:39.026Z" },
    { url = "https://files.pythonhosted.org/packages/e3/b3/e8863e6a2da15a9d7e98976ff402e871b7352c76566df6c18d0378e0d9cf/multidict-6.7.1-cp39-cp39-win_amd64.whl", hash = "sha256:f537b55778cd3cbee430abe3131255d3a78202e0f9ea7ffc6ada893a4bcaeea4", size = 46180, upload-time = "2026-01-26T02:46:40.422Z" },
    { url = "https://files.pythonhosted.org/packages/93/d3/dd4fa951ad5b5fa216bf30054d705683d13405eea7459833d78f31b74c9c/multidict-6.7.1-cp39-cp39-win_arm64.whl", hash = "sha256:749aa54f578f2e5f439538706a475aa844bfa8ef75854b1401e6e528e4937cf9", size = 43231, upload-time = "2026-01-26T02:46:41.945Z" },
    { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" },
]

[[package]]
name = "multipart"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8e/d6/9c4f366d6f9bb8f8fb5eae3acac471335c39510c42b537fd515213d7d8c3/multipart-1.3.1.tar.gz", hash = "sha256:211d7cfc1a7a43e75c4d24ee0e8e0f4f61d522f1a21575303ae85333dea687bf", size = 38929, upload-time = "2026-02-27T10:17:13.7Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/19/ed/e1f03200ee1f0bf4a2b9b72709afefbf5319b68df654e0b84b35c65613ee/multipart-1.3.1-py3-none-any.whl", hash = "sha256:a82b59e1befe74d3d30b3d3f70efd5a2eba4d938f845dcff9faace968888ff29", size = 15061, upload-time = "2026-02-27T10:17:11.943Z" },
]

[[package]]
name = "mypy"
version = "1.19.1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
dependencies = [
    { name = "librt", marker = "python_full_version < '3.10' and platform_python_implementation != 'PyPy'" },
    { name = "mypy-extensions", marker = "python_full_version < '3.10'" },
    { name = "pathspec", marker = "python_full_version < '3.10'" },
    { name = "tomli", marker = "python_full_version < '3.10'" },
    { name = "typing-extensions", marker = "python_full_version < '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/2f/63/e499890d8e39b1ff2df4c0c6ce5d371b6844ee22b8250687a99fd2f657a8/mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec", size = 13101333, upload-time = "2025-12-15T05:03:03.28Z" },
    { url = "https://files.pythonhosted.org/packages/72/4b/095626fc136fba96effc4fd4a82b41d688ab92124f8c4f7564bffe5cf1b0/mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b", size = 12164102, upload-time = "2025-12-15T05:02:33.611Z" },
    { url = "https://files.pythonhosted.org/packages/0c/5b/952928dd081bf88a83a5ccd49aaecfcd18fd0d2710c7ff07b8fb6f7032b9/mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6", size = 12765799, upload-time = "2025-12-15T05:03:28.44Z" },
    { url = "https://files.pythonhosted.org/packages/2a/0d/93c2e4a287f74ef11a66fb6d49c7a9f05e47b0a4399040e6719b57f500d2/mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74", size = 13522149, upload-time = "2025-12-15T05:02:36.011Z" },
    { url = "https://files.pythonhosted.org/packages/7b/0e/33a294b56aaad2b338d203e3a1d8b453637ac36cb278b45005e0901cf148/mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1", size = 13810105, upload-time = "2025-12-15T05:02:40.327Z" },
    { url = "https://files.pythonhosted.org/packages/0e/fd/3e82603a0cb66b67c5e7abababce6bf1a929ddf67bf445e652684af5c5a0/mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac", size = 10057200, upload-time = "2025-12-15T05:02:51.012Z" },
    { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" },
    { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" },
    { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" },
    { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" },
    { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" },
    { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" },
    { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" },
    { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" },
    { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" },
    { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" },
    { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" },
    { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" },
    { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" },
    { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" },
    { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" },
    { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" },
    { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" },
    { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" },
    { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" },
    { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" },
    { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" },
    { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" },
    { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" },
    { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" },
    { url = "https://files.pythonhosted.org/packages/b5/f7/88436084550ca9af5e610fa45286be04c3b63374df3e021c762fe8c4369f/mypy-1.19.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7bcfc336a03a1aaa26dfce9fff3e287a3ba99872a157561cbfcebe67c13308e3", size = 13102606, upload-time = "2025-12-15T05:02:46.833Z" },
    { url = "https://files.pythonhosted.org/packages/ca/a5/43dfad311a734b48a752790571fd9e12d61893849a01bff346a54011957f/mypy-1.19.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b7951a701c07ea584c4fe327834b92a30825514c868b1f69c30445093fdd9d5a", size = 12164496, upload-time = "2025-12-15T05:03:41.947Z" },
    { url = "https://files.pythonhosted.org/packages/88/f0/efbfa391395cce2f2771f937e0620cfd185ec88f2b9cd88711028a768e96/mypy-1.19.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b13cfdd6c87fc3efb69ea4ec18ef79c74c3f98b4e5498ca9b85ab3b2c2329a67", size = 12772068, upload-time = "2025-12-15T05:02:53.689Z" },
    { url = "https://files.pythonhosted.org/packages/25/05/58b3ba28f5aed10479e899a12d2120d582ba9fa6288851b20bf1c32cbb4f/mypy-1.19.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f28f99c824ecebcdaa2e55d82953e38ff60ee5ec938476796636b86afa3956e", size = 13520385, upload-time = "2025-12-15T05:02:38.328Z" },
    { url = "https://files.pythonhosted.org/packages/c5/a0/c006ccaff50b31e542ae69b92fe7e2f55d99fba3a55e01067dd564325f85/mypy-1.19.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c608937067d2fc5a4dd1a5ce92fd9e1398691b8c5d012d66e1ddd430e9244376", size = 13796221, upload-time = "2025-12-15T05:03:22.147Z" },
    { url = "https://files.pythonhosted.org/packages/b2/ff/8bdb051cd710f01b880472241bd36b3f817a8e1c5d5540d0b761675b6de2/mypy-1.19.1-cp39-cp39-win_amd64.whl", hash = "sha256:409088884802d511ee52ca067707b90c883426bd95514e8cfda8281dc2effe24", size = 10055456, upload-time = "2025-12-15T05:03:35.169Z" },
    { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" },
]

[[package]]
name = "mypy"
version = "1.20.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
dependencies = [
    { name = "librt", marker = "python_full_version >= '3.10' and platform_python_implementation != 'PyPy'" },
    { name = "mypy-extensions", marker = "python_full_version >= '3.10'" },
    { name = "pathspec", marker = "python_full_version >= '3.10'" },
    { name = "tomli", marker = "python_full_version == '3.10.*'" },
    { name = "typing-extensions", marker = "python_full_version >= '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/b0089fe7fef0a994ae5ee07029ced0526082c6cfaaa4c10d40a10e33b097/mypy-1.20.0.tar.gz", hash = "sha256:eb96c84efcc33f0b5e0e04beacf00129dd963b67226b01c00b9dfc8affb464c3", size = 3815028, upload-time = "2026-03-31T16:55:14.959Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/4d/a2/a965c8c3fcd4fa8b84ba0d46606181b0d0a1d50f274c67877f3e9ed4882c/mypy-1.20.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d99f515f95fd03a90875fdb2cca12ff074aa04490db4d190905851bdf8a549a8", size = 14430138, upload-time = "2026-03-31T16:52:37.843Z" },
    { url = "https://files.pythonhosted.org/packages/53/6e/043477501deeb8eabbab7f1a2f6cac62cfb631806dc1d6862a04a7f5011b/mypy-1.20.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bd0212976dc57a5bfeede7c219e7cd66568a32c05c9129686dd487c059c1b88a", size = 13311282, upload-time = "2026-03-31T16:55:11.021Z" },
    { url = "https://files.pythonhosted.org/packages/65/aa/bd89b247b83128197a214f29f0632ff3c14f54d4cd70d144d157bd7d7d6e/mypy-1.20.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8426d4d75d68714abc17a4292d922f6ba2cfb984b72c2278c437f6dae797865", size = 13750889, upload-time = "2026-03-31T16:52:02.909Z" },
    { url = "https://files.pythonhosted.org/packages/fa/9d/2860be7355c45247ccc0be1501c91176318964c2a137bd4743f58ce6200e/mypy-1.20.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02cca0761c75b42a20a2757ae58713276605eb29a08dd8a6e092aa347c4115ca", size = 14619788, upload-time = "2026-03-31T16:50:48.928Z" },
    { url = "https://files.pythonhosted.org/packages/75/7f/3ef3e360c91f3de120f205c8ce405e9caf9fc52ef14b65d37073e322c114/mypy-1.20.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b3a49064504be59e59da664c5e149edc1f26c67c4f8e8456f6ba6aba55033018", size = 14918849, upload-time = "2026-03-31T16:51:10.478Z" },
    { url = "https://files.pythonhosted.org/packages/ae/72/af970dfe167ef788df7c5e6109d2ed0229f164432ce828bc9741a4250e64/mypy-1.20.0-cp310-cp310-win_amd64.whl", hash = "sha256:ebea00201737ad4391142808ed16e875add5c17f676e0912b387739f84991e13", size = 10822007, upload-time = "2026-03-31T16:50:25.268Z" },
    { url = "https://files.pythonhosted.org/packages/93/94/ba9065c2ebe5421619aff684b793d953e438a8bfe31a320dd6d1e0706e81/mypy-1.20.0-cp310-cp310-win_arm64.whl", hash = "sha256:e80cf77847d0d3e6e3111b7b25db32a7f8762fd4b9a3a72ce53fe16a2863b281", size = 9756158, upload-time = "2026-03-31T16:48:36.213Z" },
    { url = "https://files.pythonhosted.org/packages/6e/1c/74cb1d9993236910286865679d1c616b136b2eae468493aa939431eda410/mypy-1.20.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4525e7010b1b38334516181c5b81e16180b8e149e6684cee5a727c78186b4e3b", size = 14343972, upload-time = "2026-03-31T16:49:04.887Z" },
    { url = "https://files.pythonhosted.org/packages/d5/0d/01399515eca280386e308cf57901e68d3a52af18691941b773b3380c1df8/mypy-1.20.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a17c5d0bdcca61ce24a35beb828a2d0d323d3fcf387d7512206888c900193367", size = 13225007, upload-time = "2026-03-31T16:50:08.151Z" },
    { url = "https://files.pythonhosted.org/packages/56/ac/b4ba5094fb2d7fe9d2037cd8d18bbe02bcf68fd22ab9ff013f55e57ba095/mypy-1.20.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f75ff57defcd0f1d6e006d721ccdec6c88d4f6a7816eb92f1c4890d979d9ee62", size = 13663752, upload-time = "2026-03-31T16:49:26.064Z" },
    { url = "https://files.pythonhosted.org/packages/db/a7/460678d3cf7da252d2288dad0c602294b6ec22a91932ec368cc11e44bb6e/mypy-1.20.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b503ab55a836136b619b5fc21c8803d810c5b87551af8600b72eecafb0059cb0", size = 14532265, upload-time = "2026-03-31T16:53:55.077Z" },
    { url = "https://files.pythonhosted.org/packages/a3/3e/051cca8166cf0438ae3ea80e0e7c030d7a8ab98dffc93f80a1aa3f23c1a2/mypy-1.20.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1973868d2adbb4584a3835780b27436f06d1dc606af5be09f187aaa25be1070f", size = 14768476, upload-time = "2026-03-31T16:50:34.587Z" },
    { url = "https://files.pythonhosted.org/packages/be/66/8e02ec184f852ed5c4abb805583305db475930854e09964b55e107cdcbc4/mypy-1.20.0-cp311-cp311-win_amd64.whl", hash = "sha256:2fcedb16d456106e545b2bfd7ef9d24e70b38ec252d2a629823a4d07ebcdb69e", size = 10818226, upload-time = "2026-03-31T16:53:15.624Z" },
    { url = "https://files.pythonhosted.org/packages/13/4b/383ad1924b28f41e4879a74151e7a5451123330d45652da359f9183bcd45/mypy-1.20.0-cp311-cp311-win_arm64.whl", hash = "sha256:379edf079ce44ac8d2805bcf9b3dd7340d4f97aad3a5e0ebabbf9d125b84b442", size = 9750091, upload-time = "2026-03-31T16:54:12.162Z" },
    { url = "https://files.pythonhosted.org/packages/be/dd/3afa29b58c2e57c79116ed55d700721c3c3b15955e2b6251dd165d377c0e/mypy-1.20.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:002b613ae19f4ac7d18b7e168ffe1cb9013b37c57f7411984abbd3b817b0a214", size = 14509525, upload-time = "2026-03-31T16:55:01.824Z" },
    { url = "https://files.pythonhosted.org/packages/54/eb/227b516ab8cad9f2a13c5e7a98d28cd6aa75e9c83e82776ae6c1c4c046c7/mypy-1.20.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9336b5e6712f4adaf5afc3203a99a40b379049104349d747eb3e5a3aa23ac2e", size = 13326469, upload-time = "2026-03-31T16:51:41.23Z" },
    { url = "https://files.pythonhosted.org/packages/57/d4/1ddb799860c1b5ac6117ec307b965f65deeb47044395ff01ab793248a591/mypy-1.20.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f13b3e41bce9d257eded794c0f12878af3129d80aacd8a3ee0dee51f3a978651", size = 13705953, upload-time = "2026-03-31T16:48:55.69Z" },
    { url = "https://files.pythonhosted.org/packages/c5/b7/54a720f565a87b893182a2a393370289ae7149e4715859e10e1c05e49154/mypy-1.20.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9804c3ad27f78e54e58b32e7cb532d128b43dbfb9f3f9f06262b821a0f6bd3f5", size = 14710363, upload-time = "2026-03-31T16:53:26.948Z" },
    { url = "https://files.pythonhosted.org/packages/b2/2a/74810274848d061f8a8ea4ac23aaad43bd3d8c1882457999c2e568341c57/mypy-1.20.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:697f102c5c1d526bdd761a69f17c6070f9892eebcb94b1a5963d679288c09e78", size = 14947005, upload-time = "2026-03-31T16:50:17.591Z" },
    { url = "https://files.pythonhosted.org/packages/77/91/21b8ba75f958bcda75690951ce6fa6b7138b03471618959529d74b8544e2/mypy-1.20.0-cp312-cp312-win_amd64.whl", hash = "sha256:0ecd63f75fdd30327e4ad8b5704bd6d91fc6c1b2e029f8ee14705e1207212489", size = 10880616, upload-time = "2026-03-31T16:52:19.986Z" },
    { url = "https://files.pythonhosted.org/packages/8a/15/3d8198ef97c1ca03aea010cce4f1d4f3bc5d9849e8c0140111ca2ead9fdd/mypy-1.20.0-cp312-cp312-win_arm64.whl", hash = "sha256:f194db59657c58593a3c47c6dfd7bad4ef4ac12dbc94d01b3a95521f78177e33", size = 9813091, upload-time = "2026-03-31T16:53:44.385Z" },
    { url = "https://files.pythonhosted.org/packages/d6/a7/f64ea7bd592fa431cb597418b6dec4a47f7d0c36325fec7ac67bc8402b94/mypy-1.20.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b20c8b0fd5877abdf402e79a3af987053de07e6fb208c18df6659f708b535134", size = 14485344, upload-time = "2026-03-31T16:49:16.78Z" },
    { url = "https://files.pythonhosted.org/packages/bb/72/8927d84cfc90c6abea6e96663576e2e417589347eb538749a464c4c218a0/mypy-1.20.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:367e5c993ba34d5054d11937d0485ad6dfc60ba760fa326c01090fc256adf15c", size = 13327400, upload-time = "2026-03-31T16:53:08.02Z" },
    { url = "https://files.pythonhosted.org/packages/ab/4a/11ab99f9afa41aa350178d24a7d2da17043228ea10f6456523f64b5a6cf6/mypy-1.20.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f799d9db89fc00446f03281f84a221e50018fc40113a3ba9864b132895619ebe", size = 13706384, upload-time = "2026-03-31T16:52:28.577Z" },
    { url = "https://files.pythonhosted.org/packages/42/79/694ca73979cfb3535ebfe78733844cd5aff2e63304f59bf90585110d975a/mypy-1.20.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:555658c611099455b2da507582ea20d2043dfdfe7f5ad0add472b1c6238b433f", size = 14700378, upload-time = "2026-03-31T16:48:45.527Z" },
    { url = "https://files.pythonhosted.org/packages/84/24/a022ccab3a46e3d2cdf2e0e260648633640eb396c7e75d5a42818a8d3971/mypy-1.20.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:efe8d70949c3023698c3fca1e94527e7e790a361ab8116f90d11221421cd8726", size = 14932170, upload-time = "2026-03-31T16:49:36.038Z" },
    { url = "https://files.pythonhosted.org/packages/d8/9b/549228d88f574d04117e736f55958bd4908f980f9f5700a07aeb85df005b/mypy-1.20.0-cp313-cp313-win_amd64.whl", hash = "sha256:f49590891d2c2f8a9de15614e32e459a794bcba84693c2394291a2038bbaaa69", size = 10888526, upload-time = "2026-03-31T16:50:59.827Z" },
    { url = "https://files.pythonhosted.org/packages/91/17/15095c0e54a8bc04d22d4ff06b2139d5f142c2e87520b4e39010c4862771/mypy-1.20.0-cp313-cp313-win_arm64.whl", hash = "sha256:76a70bf840495729be47510856b978f1b0ec7d08f257ca38c9d932720bf6b43e", size = 9816456, upload-time = "2026-03-31T16:49:59.537Z" },
    { url = "https://files.pythonhosted.org/packages/4e/0e/6ca4a84cbed9e62384bc0b2974c90395ece5ed672393e553996501625fc5/mypy-1.20.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0f42dfaab7ec1baff3b383ad7af562ab0de573c5f6edb44b2dab016082b89948", size = 14483331, upload-time = "2026-03-31T16:52:57.999Z" },
    { url = "https://files.pythonhosted.org/packages/7d/c5/5fe9d8a729dd9605064691816243ae6c49fde0bd28f6e5e17f6a24203c43/mypy-1.20.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:31b5dbb55293c1bd27c0fc813a0d2bb5ceef9d65ac5afa2e58f829dab7921fd5", size = 13342047, upload-time = "2026-03-31T16:54:21.555Z" },
    { url = "https://files.pythonhosted.org/packages/4c/33/e18bcfa338ca4e6b2771c85d4c5203e627d0c69d9de5c1a2cf2ba13320ba/mypy-1.20.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49d11c6f573a5a08f77fad13faff2139f6d0730ebed2cfa9b3d2702671dd7188", size = 13719585, upload-time = "2026-03-31T16:51:53.89Z" },
    { url = "https://files.pythonhosted.org/packages/6b/8d/93491ff7b79419edc7eabf95cb3b3f7490e2e574b2855c7c7e7394ff933f/mypy-1.20.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d3243c406773185144527f83be0e0aefc7bf4601b0b2b956665608bf7c98a83", size = 14685075, upload-time = "2026-03-31T16:54:04.464Z" },
    { url = "https://files.pythonhosted.org/packages/b5/9d/d924b38a4923f8d164bf2b4ec98bf13beaf6e10a5348b4b137eadae40a6e/mypy-1.20.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a79c1eba7ac4209f2d850f0edd0a2f8bba88cbfdfefe6fb76a19e9d4fe5e71a2", size = 14919141, upload-time = "2026-03-31T16:54:51.785Z" },
    { url = "https://files.pythonhosted.org/packages/59/98/1da9977016678c0b99d43afe52ed00bb3c1a0c4c995d3e6acca1a6ebb9b4/mypy-1.20.0-cp314-cp314-win_amd64.whl", hash = "sha256:00e047c74d3ec6e71a2eb88e9ea551a2edb90c21f993aefa9e0d2a898e0bb732", size = 11050925, upload-time = "2026-03-31T16:51:30.758Z" },
    { url = "https://files.pythonhosted.org/packages/5e/e3/ba0b7a3143e49a9c4f5967dde6ea4bf8e0b10ecbbcca69af84027160ee89/mypy-1.20.0-cp314-cp314-win_arm64.whl", hash = "sha256:931a7630bba591593dcf6e97224a21ff80fb357e7982628d25e3c618e7f598ef", size = 10001089, upload-time = "2026-03-31T16:49:43.632Z" },
    { url = "https://files.pythonhosted.org/packages/12/28/e617e67b3be9d213cda7277913269c874eb26472489f95d09d89765ce2d8/mypy-1.20.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:26c8b52627b6552f47ff11adb4e1509605f094e29815323e487fc0053ebe93d1", size = 15534710, upload-time = "2026-03-31T16:52:12.506Z" },
    { url = "https://files.pythonhosted.org/packages/6e/0c/3b5f2d3e45dc7169b811adce8451679d9430399d03b168f9b0489f43adaa/mypy-1.20.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:39362cdb4ba5f916e7976fccecaab1ba3a83e35f60fa68b64e9a70e221bb2436", size = 14393013, upload-time = "2026-03-31T16:54:41.186Z" },
    { url = "https://files.pythonhosted.org/packages/a3/49/edc8b0aa145cc09c1c74f7ce2858eead9329931dcbbb26e2ad40906daa4e/mypy-1.20.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34506397dbf40c15dc567635d18a21d33827e9ab29014fb83d292a8f4f8953b6", size = 15047240, upload-time = "2026-03-31T16:54:31.955Z" },
    { url = "https://files.pythonhosted.org/packages/42/37/a946bb416e37a57fa752b3100fd5ede0e28df94f92366d1716555d47c454/mypy-1.20.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:555493c44a4f5a1b58d611a43333e71a9981c6dbe26270377b6f8174126a0526", size = 15858565, upload-time = "2026-03-31T16:53:36.997Z" },
    { url = "https://files.pythonhosted.org/packages/2f/99/7690b5b5b552db1bd4ff362e4c0eb3107b98d680835e65823fbe888c8b78/mypy-1.20.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2721f0ce49cb74a38f00c50da67cb7d36317b5eda38877a49614dc018e91c787", size = 16087874, upload-time = "2026-03-31T16:52:48.313Z" },
    { url = "https://files.pythonhosted.org/packages/aa/76/53e893a498138066acd28192b77495c9357e5a58cc4be753182846b43315/mypy-1.20.0-cp314-cp314t-win_amd64.whl", hash = "sha256:47781555a7aa5fedcc2d16bcd72e0dc83eb272c10dd657f9fb3f9cc08e2e6abb", size = 12572380, upload-time = "2026-03-31T16:49:52.454Z" },
    { url = "https://files.pythonhosted.org/packages/76/9c/6dbdae21f01b7aacddc2c0bbf3c5557aa547827fdf271770fe1e521e7093/mypy-1.20.0-cp314-cp314t-win_arm64.whl", hash = "sha256:c70380fe5d64010f79fb863b9081c7004dd65225d2277333c219d93a10dad4dd", size = 10381174, upload-time = "2026-03-31T16:51:20.179Z" },
    { url = "https://files.pythonhosted.org/packages/21/66/4d734961ce167f0fd8380769b3b7c06dbdd6ff54c2190f3f2ecd22528158/mypy-1.20.0-py3-none-any.whl", hash = "sha256:a6e0641147cbfa7e4e94efdb95c2dab1aff8cfc159ded13e07f308ddccc8c48e", size = 2636365, upload-time = "2026-03-31T16:51:44.911Z" },
]

[[package]]
name = "mypy-extensions"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
]

[[package]]
name = "mysql-connector-python"
version = "9.4.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
sdist = { url = "https://files.pythonhosted.org/packages/02/77/2b45e6460d05b1f1b7a4c8eb79a50440b4417971973bb78c9ef6cad630a6/mysql_connector_python-9.4.0.tar.gz", hash = "sha256:d111360332ae78933daf3d48ff497b70739aa292ab0017791a33e826234e743b", size = 12185532, upload-time = "2025-07-22T08:02:05.788Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/a2/ef/1a35d9ebfaf80cf5aa238be471480e16a69a494d276fb07b889dc9a5cfc3/mysql_connector_python-9.4.0-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:3c2603e00516cf4208c6266e85c5c87d5f4d0ac79768106d50de42ccc8414c05", size = 17501678, upload-time = "2025-07-22T07:57:23.237Z" },
    { url = "https://files.pythonhosted.org/packages/3c/39/09ae7082c77a978f2d72d94856e2e57906165c645693bc3a940bcad3a32d/mysql_connector_python-9.4.0-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:47884fcb050112b8bef3458e17eac47cc81a6cbbf3524e3456146c949772d9b4", size = 18369526, upload-time = "2025-07-22T07:57:27.569Z" },
    { url = "https://files.pythonhosted.org/packages/40/56/1bea00f5129550bcd0175781b9cd467e865d4aea4a6f38f700f34d95dcb8/mysql_connector_python-9.4.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:f14b6936cd326e212fc9ab5f666dea3efea654f0cb644460334e60e22986e735", size = 33508525, upload-time = "2025-07-22T07:57:32.935Z" },
    { url = "https://files.pythonhosted.org/packages/0f/ec/86dfefd3e6c0fca13085bc28b7f9baae3fce9f6af243d8693729f6b5063c/mysql_connector_python-9.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:0f5ad70355720e64b72d7c068e858c9fd1f69b671d9575f857f235a10f878939", size = 33911834, upload-time = "2025-07-22T07:57:38.203Z" },
    { url = "https://files.pythonhosted.org/packages/2c/11/6907d53349b11478f72c8f22e38368d18262fbffc27e0f30e365d76dad93/mysql_connector_python-9.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:7106670abce510e440d393e27fc3602b8cf21e7a8a80216cc9ad9a68cd2e4595", size = 16393044, upload-time = "2025-07-22T07:57:42.053Z" },
    { url = "https://files.pythonhosted.org/packages/fe/0c/4365a802129be9fa63885533c38be019f1c6b6f5bcf8844ac53902314028/mysql_connector_python-9.4.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:7df1a8ddd182dd8adc914f6dc902a986787bf9599705c29aca7b2ce84e79d361", size = 17501627, upload-time = "2025-07-22T07:57:45.416Z" },
    { url = "https://files.pythonhosted.org/packages/c0/bf/ca596c00d7a6eaaf8ef2f66c9b23cd312527f483073c43ffac7843049cb4/mysql_connector_python-9.4.0-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:3892f20472e13e63b1fb4983f454771dd29f211b09724e69a9750e299542f2f8", size = 18369494, upload-time = "2025-07-22T07:57:49.714Z" },
    { url = "https://files.pythonhosted.org/packages/25/14/6510a11ed9f80d77f743dc207773092c4ab78d5efa454b39b48480315d85/mysql_connector_python-9.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:d3e87142103d71c4df647ece30f98e85e826652272ed1c74822b56f6acdc38e7", size = 33516187, upload-time = "2025-07-22T07:57:55.294Z" },
    { url = "https://files.pythonhosted.org/packages/16/a8/4f99d80f1cf77733ce9a44b6adb7f0dd7079e7afa51ca4826515ef0c3e16/mysql_connector_python-9.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:b27fcd403436fe83bafb2fe7fcb785891e821e639275c4ad3b3bd1e25f533206", size = 33917818, upload-time = "2025-07-22T07:58:00.523Z" },
    { url = "https://files.pythonhosted.org/packages/15/9c/127f974ca9d5ee25373cb5433da06bb1f36e05f2a6b7436da1fe9c6346b0/mysql_connector_python-9.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:fd6ff5afb9c324b0bbeae958c93156cce4168c743bf130faf224d52818d1f0ee", size = 16392378, upload-time = "2025-07-22T07:58:04.669Z" },
    { url = "https://files.pythonhosted.org/packages/03/7c/a543fb17c2dfa6be8548dfdc5879a0c7924cd5d1c79056c48472bb8fe858/mysql_connector_python-9.4.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:4efa3898a24aba6a4bfdbf7c1f5023c78acca3150d72cc91199cca2ccd22f76f", size = 17503693, upload-time = "2025-07-22T07:58:08.96Z" },
    { url = "https://files.pythonhosted.org/packages/cb/6e/c22fbee05f5cfd6ba76155b6d45f6261d8d4c1e36e23de04e7f25fbd01a4/mysql_connector_python-9.4.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:665c13e7402235162e5b7a2bfdee5895192121b64ea455c90a81edac6a48ede5", size = 18371987, upload-time = "2025-07-22T07:58:13.273Z" },
    { url = "https://files.pythonhosted.org/packages/b4/fd/f426f5f35a3d3180c7f84d1f96b4631be2574df94ca1156adab8618b236c/mysql_connector_python-9.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:815aa6cad0f351c1223ef345781a538f2e5e44ef405fdb3851eb322bd9c4ca2b", size = 33516214, upload-time = "2025-07-22T07:58:18.967Z" },
    { url = "https://files.pythonhosted.org/packages/45/5a/1b053ae80b43cd3ccebc4bb99a98826969b3b0f8adebdcc2530750ad76ed/mysql_connector_python-9.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b3436a2c8c0ec7052932213e8d01882e6eb069dbab33402e685409084b133a1c", size = 33918565, upload-time = "2025-07-22T07:58:25.28Z" },
    { url = "https://files.pythonhosted.org/packages/cb/69/36b989de675d98ba8ff7d45c96c30c699865c657046f2e32db14e78f13d9/mysql_connector_python-9.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:57b0c224676946b70548c56798d5023f65afa1ba5b8ac9f04a143d27976c7029", size = 16392563, upload-time = "2025-07-22T07:58:29.623Z" },
    { url = "https://files.pythonhosted.org/packages/79/e2/13036479cd1070d1080cee747de6c96bd6fbb021b736dd3ccef2b19016c8/mysql_connector_python-9.4.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:fde3bbffb5270a4b02077029914e6a9d2ec08f67d8375b4111432a2778e7540b", size = 17503749, upload-time = "2025-07-22T07:58:33.649Z" },
    { url = "https://files.pythonhosted.org/packages/31/df/b89e6551b91332716d384dcc3223e1f8065902209dcd9e477a3df80154f7/mysql_connector_python-9.4.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:25f77ad7d845df3b5a5a3a6a8d1fed68248dc418a6938a371d1ddaaab6b9a8e3", size = 18372145, upload-time = "2025-07-22T07:58:37.384Z" },
    { url = "https://files.pythonhosted.org/packages/07/bd/af0de40a01d5cb4df19318cc018e64666f2b7fa89bffa1ab5b35337aae2c/mysql_connector_python-9.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:227dd420c71e6d4788d52d98f298e563f16b6853577e5ade4bd82d644257c812", size = 33516503, upload-time = "2025-07-22T07:58:41.987Z" },
    { url = "https://files.pythonhosted.org/packages/d1/9b/712053216fcbe695e519ecb1035ffd767c2de9f51ccba15078537c99d6fa/mysql_connector_python-9.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5163381a312d38122eded2197eb5cd7ccf1a5c5881d4e7a6de10d6ea314d088e", size = 33918904, upload-time = "2025-07-22T07:58:46.796Z" },
    { url = "https://files.pythonhosted.org/packages/64/15/cbd996d425c59811849f3c1d1b1dae089a1ae18c4acd4d8de2b847b772df/mysql_connector_python-9.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:c727cb1f82b40c9aaa7a15ab5cf0a7f87c5d8dce32eab5ff2530a4aa6054e7df", size = 16392566, upload-time = "2025-07-22T07:58:50.223Z" },
    { url = "https://files.pythonhosted.org/packages/6d/36/b32635b69729f144d45c0cbcd135cfd6c480a62160ac015ca71ebf68fca7/mysql_connector_python-9.4.0-cp39-cp39-macosx_14_0_arm64.whl", hash = "sha256:20f8154ab5c0ed444f8ef8e5fa91e65215037db102c137b5f995ebfffd309b78", size = 17501675, upload-time = "2025-07-22T07:58:53.049Z" },
    { url = "https://files.pythonhosted.org/packages/a0/23/65e801f74b3fcc2a6944242d64f0d623af48497e4d9cf55419c2c6d6439b/mysql_connector_python-9.4.0-cp39-cp39-macosx_14_0_x86_64.whl", hash = "sha256:7b8976d89d67c8b0dc452471cb557d9998ed30601fb69a876bf1f0ecaa7954a4", size = 18369579, upload-time = "2025-07-22T07:58:55.995Z" },
    { url = "https://files.pythonhosted.org/packages/86/e9/dc31eeffe33786016e1370be72f339544ee00034cb702c0b4a3c6f5c1585/mysql_connector_python-9.4.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:4ee4fe1b067e243aae21981e4b9f9d300a3104814b8274033ca8fc7a89b1729e", size = 33506513, upload-time = "2025-07-22T07:58:59.341Z" },
    { url = "https://files.pythonhosted.org/packages/dd/c7/aa6f4cc2e5e3fb68b5a6bba680429b761e387b8a040cf16a5f17e0b09df6/mysql_connector_python-9.4.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:1c6b95404e80d003cd452e38674e91528e2b3a089fe505c882f813b564e64f9d", size = 33909982, upload-time = "2025-07-22T07:59:02.832Z" },
    { url = "https://files.pythonhosted.org/packages/0c/a4/b1e2adc65121e7eabed06d09bed87638e7f9a51e9b5dbb1cfb17b58b1181/mysql_connector_python-9.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:a8f820c111335f225d63367307456eb7e10494f87e7a94acded3bb762e55a6d4", size = 16393051, upload-time = "2025-07-22T07:59:05.983Z" },
    { url = "https://files.pythonhosted.org/packages/36/34/b6165e15fd45a8deb00932d8e7d823de7650270873b4044c4db6688e1d8f/mysql_connector_python-9.4.0-py2.py3-none-any.whl", hash = "sha256:56e679169c704dab279b176fab2a9ee32d2c632a866c0f7cd48a8a1e2cf802c4", size = 406574, upload-time = "2025-07-22T07:59:08.394Z" },
]

[[package]]
name = "mysql-connector-python"
version = "9.5.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
sdist = { url = "https://files.pythonhosted.org/packages/39/33/b332b001bc8c5ee09255a0d4b09a254da674450edd6a3e5228b245ca82a0/mysql_connector_python-9.5.0.tar.gz", hash = "sha256:92fb924285a86d8c146ebd63d94f9eaefa548da7813bc46271508fdc6cc1d596", size = 12251077, upload-time = "2025-10-22T09:05:45.423Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/53/5d/30210fcf7ba98d1e03de0c47a58218ab5313d82f2e01ae53b47f45c36b9d/mysql_connector_python-9.5.0-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:77d14c9fde90726de22443e8c5ba0912a4ebb632cc1ade52a349dacbac47b140", size = 17579085, upload-time = "2025-10-22T09:01:27.388Z" },
    { url = "https://files.pythonhosted.org/packages/77/92/ea79a0875436665330a81e82b4b73a6d52aebcfb1cf4d97f4ad4bd4dedf5/mysql_connector_python-9.5.0-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:4d603b55de310b9689bb3cb5e57fe97e98756e36d62f8f308f132f2c724f62b8", size = 18445098, upload-time = "2025-10-22T09:01:29.721Z" },
    { url = "https://files.pythonhosted.org/packages/5f/f2/4578b5093f46985c659035e880e70e8b0bed44d4a59ad4e83df5d49b9c69/mysql_connector_python-9.5.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:48ffa71ba748afaae5c45ed9a085a72604368ce611fe81c3fdc146ef60181d51", size = 33660118, upload-time = "2025-10-22T09:01:32.048Z" },
    { url = "https://files.pythonhosted.org/packages/c5/60/63135610ae0cee1260ce64874c1ddbf08e7fb560c21a3d9cce88b0ddc266/mysql_connector_python-9.5.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:77c71df48293d3c08713ff7087cf483804c8abf41a4bb4aefea7317b752c8e9a", size = 34096212, upload-time = "2025-10-22T09:01:36.306Z" },
    { url = "https://files.pythonhosted.org/packages/3e/b1/78dc693552cfbb45076b3638ca4c402fae52209af8f276370d02d78367a0/mysql_connector_python-9.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:4f8d2d9d586c34dc9508a44d19cf30ccafabbbd12d7f8ab58da3af118636843c", size = 16512395, upload-time = "2025-10-22T09:01:38.602Z" },
    { url = "https://files.pythonhosted.org/packages/05/03/77347d58b0027ce93a41858477e08422e498c6ebc24348b1f725ed7a67ae/mysql_connector_python-9.5.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:653e70cd10cf2d18dd828fae58dff5f0f7a5cf7e48e244f2093314dddf84a4b9", size = 17578984, upload-time = "2025-10-22T09:01:41.213Z" },
    { url = "https://files.pythonhosted.org/packages/a5/bb/0f45c7ee55ebc56d6731a593d85c0e7f25f83af90a094efebfd5be9fe010/mysql_connector_python-9.5.0-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:5add93f60b3922be71ea31b89bc8a452b876adbb49262561bd559860dae96b3f", size = 18445067, upload-time = "2025-10-22T09:01:43.215Z" },
    { url = "https://files.pythonhosted.org/packages/1c/ec/054de99d4aa50d851a37edca9039280f7194cc1bfd30aab38f5bd6977ebe/mysql_connector_python-9.5.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:20950a5e44896c03e3dc93ceb3a5e9b48c9acae18665ca6e13249b3fe5b96811", size = 33668029, upload-time = "2025-10-22T09:01:45.74Z" },
    { url = "https://files.pythonhosted.org/packages/90/a2/e6095dc3a7ad5c959fe4a65681db63af131f572e57cdffcc7816bc84e3ad/mysql_connector_python-9.5.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:7fdd3205b9242c284019310fa84437f3357b13f598e3f9b5d80d337d4a6406b8", size = 34101687, upload-time = "2025-10-22T09:01:48.462Z" },
    { url = "https://files.pythonhosted.org/packages/9c/88/bc13c33fca11acaf808bd1809d8602d78f5bb84f7b1e7b1a288c383a14fd/mysql_connector_python-9.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:c021d8b0830958b28712c70c53b206b4cf4766948dae201ea7ca588a186605e0", size = 16511749, upload-time = "2025-10-22T09:01:51.032Z" },
    { url = "https://files.pythonhosted.org/packages/02/89/167ebee82f4b01ba7339c241c3cc2518886a2be9f871770a1efa81b940a0/mysql_connector_python-9.5.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a72c2ef9d50b84f3c567c31b3bf30901af740686baa2a4abead5f202e0b7ea61", size = 17581904, upload-time = "2025-10-22T09:01:53.21Z" },
    { url = "https://files.pythonhosted.org/packages/67/46/630ca969ce10b30fdc605d65dab4a6157556d8cc3b77c724f56c2d83cb79/mysql_connector_python-9.5.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:bd9ba5a946cfd3b3b2688a75135357e862834b0321ed936fd968049be290872b", size = 18448195, upload-time = "2025-10-22T09:01:55.378Z" },
    { url = "https://files.pythonhosted.org/packages/f6/87/4c421f41ad169d8c9065ad5c46673c7af889a523e4899c1ac1d6bfd37262/mysql_connector_python-9.5.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:5ef7accbdf8b5f6ec60d2a1550654b7e27e63bf6f7b04020d5fb4191fb02bc4d", size = 33668638, upload-time = "2025-10-22T09:01:57.896Z" },
    { url = "https://files.pythonhosted.org/packages/a6/01/67cf210d50bfefbb9224b9a5c465857c1767388dade1004c903c8e22a991/mysql_connector_python-9.5.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:a6e0a4a0274d15e3d4c892ab93f58f46431222117dba20608178dfb2cc4d5fd8", size = 34102899, upload-time = "2025-10-22T09:02:00.291Z" },
    { url = "https://files.pythonhosted.org/packages/cd/ef/3d1a67d503fff38cc30e11d111cf28f0976987fb175f47b10d44494e1080/mysql_connector_python-9.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:b6c69cb37600b7e22f476150034e2afbd53342a175e20aea887f8158fc5e3ff6", size = 16512684, upload-time = "2025-10-22T09:02:02.411Z" },
    { url = "https://files.pythonhosted.org/packages/72/18/f221aeac49ce94ac119a427afbd51fe1629d48745b571afc0de49647b528/mysql_connector_python-9.5.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:1f5f7346b0d5edb2e994c1bd77b3f5eed88b0ca368ad6788d1012c7e56d7bf68", size = 17581933, upload-time = "2025-10-22T09:02:04.396Z" },
    { url = "https://files.pythonhosted.org/packages/de/8e/14d44db7353350006a12e46d61c3a995bba06acd7547fc78f9bb32611e0c/mysql_connector_python-9.5.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:07bf52591b4215cb4318b4617c327a6d84c31978c11e3255f01a627bcda2618e", size = 18448446, upload-time = "2025-10-22T09:02:06.399Z" },
    { url = "https://files.pythonhosted.org/packages/6b/f5/ab306f292a99bff3544ff44ad53661a031dc1a11e5b1ad64b9e5b5290ef9/mysql_connector_python-9.5.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:8972c1f960b30d487f34f9125ec112ea2b3200bd02c53e5e32ee7a43be6d64c1", size = 33668933, upload-time = "2025-10-22T09:02:08.785Z" },
    { url = "https://files.pythonhosted.org/packages/e8/ee/d146d2642552ebb5811cf551f06aca7da536c80b18fb6c75bdbc29723388/mysql_connector_python-9.5.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:f6d32d7aa514d2f6f8709ba1e018314f82ab2acea2e6af30d04c1906fe9171b9", size = 34103214, upload-time = "2025-10-22T09:02:11.657Z" },
    { url = "https://files.pythonhosted.org/packages/e7/f8/5e88e5eda1fe58f7d146b73744f691d85dce76fb42e7ce3de53e49911da3/mysql_connector_python-9.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:edd47048eb65c196b28aa9d2c0c6a017d8ca084a9a7041cd317301c829eb5a05", size = 16512689, upload-time = "2025-10-22T09:02:14.167Z" },
    { url = "https://files.pythonhosted.org/packages/14/42/52bef145028af1b8e633eb77773278a04b2cd9f824117209aba093018445/mysql_connector_python-9.5.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:6effda35df1a96d9a096f04468d40f2324ea36b34d0e9632e81daae8be97b308", size = 17581903, upload-time = "2025-10-22T09:02:16.441Z" },
    { url = "https://files.pythonhosted.org/packages/f4/a6/bd800b42bde86bf2e9468dfabcbd7538c66daff9d1a9fc97d2cc897f96fa/mysql_connector_python-9.5.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:fd057bd042464eedbf5337d1ceea7f2a4ab075a1cf6d1d62ffd5184966a656dd", size = 18448394, upload-time = "2025-10-22T09:02:18.436Z" },
    { url = "https://files.pythonhosted.org/packages/4a/21/a1a3247775d0dfee094499cb915560755eaa1013ac3b03e34a98b0e16e49/mysql_connector_python-9.5.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:2797dd7bbefb1d1669d984cfb284ea6b34401bbd9c1b3bf84e646d0bd3a82197", size = 33669845, upload-time = "2025-10-22T09:02:20.966Z" },
    { url = "https://files.pythonhosted.org/packages/58/b7/dcab48349ab8abafd6f40f113101549e0cf107e43dd9c7e1fae79799604b/mysql_connector_python-9.5.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:a5fff063ed48281b7374a4da6b9ef4293d390c153f79b1589ee547ea08c92310", size = 34104103, upload-time = "2025-10-22T09:02:23.469Z" },
    { url = "https://files.pythonhosted.org/packages/21/3a/be129764fe5f5cd89a5aa3f58e7a7471284715f4af71097a980d24ebec0a/mysql_connector_python-9.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:56104693478fd447886c470a6d0558ded0fe2577df44c18232a6af6a2bbdd3e9", size = 17001255, upload-time = "2025-10-22T09:02:25.765Z" },
    { url = "https://files.pythonhosted.org/packages/95/e1/45373c06781340c7b74fe9b88b85278ac05321889a307eaa5be079a997d4/mysql_connector_python-9.5.0-py2.py3-none-any.whl", hash = "sha256:ace137b88eb6fdafa1e5b2e03ac76ce1b8b1844b3a4af1192a02ae7c1a45bdee", size = 479047, upload-time = "2025-10-22T09:02:27.809Z" },
]

[[package]]
name = "myst-parser"
version = "3.0.1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
dependencies = [
    { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "jinja2", marker = "python_full_version < '3.10'" },
    { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "mdit-py-plugins", version = "0.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "pyyaml", marker = "python_full_version < '3.10'" },
    { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/49/64/e2f13dac02f599980798c01156393b781aec983b52a6e4057ee58f07c43a/myst_parser-3.0.1.tar.gz", hash = "sha256:88f0cb406cb363b077d176b51c476f62d60604d68a8dcdf4832e080441301a87", size = 92392, upload-time = "2024-04-28T20:22:42.116Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/e2/de/21aa8394f16add8f7427f0a1326ccd2b3a2a8a3245c9252bc5ac034c6155/myst_parser-3.0.1-py3-none-any.whl", hash = "sha256:6457aaa33a5d474aca678b8ead9b3dc298e89c68e67012e73146ea6fd54babf1", size = 83163, upload-time = "2024-04-28T20:22:39.985Z" },
]

[[package]]
name = "myst-parser"
version = "4.0.1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version == '3.10.*'",
]
dependencies = [
    { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" },
    { name = "jinja2", marker = "python_full_version == '3.10.*'" },
    { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" },
    { name = "mdit-py-plugins", version = "0.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" },
    { name = "pyyaml", marker = "python_full_version == '3.10.*'" },
    { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/a5/9626ba4f73555b3735ad86247a8077d4603aa8628537687c839ab08bfe44/myst_parser-4.0.1.tar.gz", hash = "sha256:5cfea715e4f3574138aecbf7d54132296bfd72bb614d31168f48c477a830a7c4", size = 93985, upload-time = "2025-02-12T10:53:03.833Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/5f/df/76d0321c3797b54b60fef9ec3bd6f4cfd124b9e422182156a1dd418722cf/myst_parser-4.0.1-py3-none-any.whl", hash = "sha256:9134e88959ec3b5780aedf8a99680ea242869d012e8821db3126d427edc9c95d", size = 84579, upload-time = "2025-02-12T10:53:02.078Z" },
]

[[package]]
name = "myst-parser"
version = "5.0.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
]
dependencies = [
    { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
    { name = "jinja2", marker = "python_full_version >= '3.11'" },
    { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
    { name = "mdit-py-plugins", version = "0.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
    { name = "pyyaml", marker = "python_full_version >= '3.11'" },
    { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
    { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/33/fa/7b45eef11b7971f0beb29d27b7bfe0d747d063aa29e170d9edd004733c8a/myst_parser-5.0.0.tar.gz", hash = "sha256:f6f231452c56e8baa662cc352c548158f6a16fcbd6e3800fc594978002b94f3a", size = 98535, upload-time = "2026-01-15T09:08:18.036Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/d3/ac/686789b9145413f1a61878c407210e41bfdb097976864e0913078b24098c/myst_parser-5.0.0-py3-none-any.whl", hash = "sha256:ab31e516024918296e169139072b81592336f2fef55b8986aa31c9f04b5f7211", size = 84533, upload-time = "2026-01-15T09:08:16.788Z" },
]

[[package]]
name = "nodeenv"
version = "1.10.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
]

[[package]]
name = "numpy"
version = "2.0.2"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
sdist = { url = "https://files.pythonhosted.org/packages/a9/75/10dd1f8116a8b796cb2c737b674e02d02e80454bda953fa7e65d8c12b016/numpy-2.0.2.tar.gz", hash = "sha256:883c987dee1880e2a864ab0dc9892292582510604156762362d9326444636e78", size = 18902015, upload-time = "2024-08-26T20:19:40.945Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/21/91/3495b3237510f79f5d81f2508f9f13fea78ebfdf07538fc7444badda173d/numpy-2.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:51129a29dbe56f9ca83438b706e2e69a39892b5eda6cedcb6b0c9fdc9b0d3ece", size = 21165245, upload-time = "2024-08-26T20:04:14.625Z" },
    { url = "https://files.pythonhosted.org/packages/05/33/26178c7d437a87082d11019292dce6d3fe6f0e9026b7b2309cbf3e489b1d/numpy-2.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f15975dfec0cf2239224d80e32c3170b1d168335eaedee69da84fbe9f1f9cd04", size = 13738540, upload-time = "2024-08-26T20:04:36.784Z" },
    { url = "https://files.pythonhosted.org/packages/ec/31/cc46e13bf07644efc7a4bf68df2df5fb2a1a88d0cd0da9ddc84dc0033e51/numpy-2.0.2-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:8c5713284ce4e282544c68d1c3b2c7161d38c256d2eefc93c1d683cf47683e66", size = 5300623, upload-time = "2024-08-26T20:04:46.491Z" },
    { url = "https://files.pythonhosted.org/packages/6e/16/7bfcebf27bb4f9d7ec67332ffebee4d1bf085c84246552d52dbb548600e7/numpy-2.0.2-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:becfae3ddd30736fe1889a37f1f580e245ba79a5855bff5f2a29cb3ccc22dd7b", size = 6901774, upload-time = "2024-08-26T20:04:58.173Z" },
    { url = "https://files.pythonhosted.org/packages/f9/a3/561c531c0e8bf082c5bef509d00d56f82e0ea7e1e3e3a7fc8fa78742a6e5/numpy-2.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2da5960c3cf0df7eafefd806d4e612c5e19358de82cb3c343631188991566ccd", size = 13907081, upload-time = "2024-08-26T20:05:19.098Z" },
    { url = "https://files.pythonhosted.org/packages/fa/66/f7177ab331876200ac7563a580140643d1179c8b4b6a6b0fc9838de2a9b8/numpy-2.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:496f71341824ed9f3d2fd36cf3ac57ae2e0165c143b55c3a035ee219413f3318", size = 19523451, upload-time = "2024-08-26T20:05:47.479Z" },
    { url = "https://files.pythonhosted.org/packages/25/7f/0b209498009ad6453e4efc2c65bcdf0ae08a182b2b7877d7ab38a92dc542/numpy-2.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a61ec659f68ae254e4d237816e33171497e978140353c0c2038d46e63282d0c8", size = 19927572, upload-time = "2024-08-26T20:06:17.137Z" },
    { url = "https://files.pythonhosted.org/packages/3e/df/2619393b1e1b565cd2d4c4403bdd979621e2c4dea1f8532754b2598ed63b/numpy-2.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d731a1c6116ba289c1e9ee714b08a8ff882944d4ad631fd411106a30f083c326", size = 14400722, upload-time = "2024-08-26T20:06:39.16Z" },
    { url = "https://files.pythonhosted.org/packages/22/ad/77e921b9f256d5da36424ffb711ae79ca3f451ff8489eeca544d0701d74a/numpy-2.0.2-cp310-cp310-win32.whl", hash = "sha256:984d96121c9f9616cd33fbd0618b7f08e0cfc9600a7ee1d6fd9b239186d19d97", size = 6472170, upload-time = "2024-08-26T20:06:50.361Z" },
    { url = "https://files.pythonhosted.org/packages/10/05/3442317535028bc29cf0c0dd4c191a4481e8376e9f0db6bcf29703cadae6/numpy-2.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:c7b0be4ef08607dd04da4092faee0b86607f111d5ae68036f16cc787e250a131", size = 15905558, upload-time = "2024-08-26T20:07:13.881Z" },
    { url = "https://files.pythonhosted.org/packages/8b/cf/034500fb83041aa0286e0fb16e7c76e5c8b67c0711bb6e9e9737a717d5fe/numpy-2.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:49ca4decb342d66018b01932139c0961a8f9ddc7589611158cb3c27cbcf76448", size = 21169137, upload-time = "2024-08-26T20:07:45.345Z" },
    { url = "https://files.pythonhosted.org/packages/4a/d9/32de45561811a4b87fbdee23b5797394e3d1504b4a7cf40c10199848893e/numpy-2.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:11a76c372d1d37437857280aa142086476136a8c0f373b2e648ab2c8f18fb195", size = 13703552, upload-time = "2024-08-26T20:08:06.666Z" },
    { url = "https://files.pythonhosted.org/packages/c1/ca/2f384720020c7b244d22508cb7ab23d95f179fcfff33c31a6eeba8d6c512/numpy-2.0.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:807ec44583fd708a21d4a11d94aedf2f4f3c3719035c76a2bbe1fe8e217bdc57", size = 5298957, upload-time = "2024-08-26T20:08:15.83Z" },
    { url = "https://files.pythonhosted.org/packages/0e/78/a3e4f9fb6aa4e6fdca0c5428e8ba039408514388cf62d89651aade838269/numpy-2.0.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8cafab480740e22f8d833acefed5cc87ce276f4ece12fdaa2e8903db2f82897a", size = 6905573, upload-time = "2024-08-26T20:08:27.185Z" },
    { url = "https://files.pythonhosted.org/packages/a0/72/cfc3a1beb2caf4efc9d0b38a15fe34025230da27e1c08cc2eb9bfb1c7231/numpy-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a15f476a45e6e5a3a79d8a14e62161d27ad897381fecfa4a09ed5322f2085669", size = 13914330, upload-time = "2024-08-26T20:08:48.058Z" },
    { url = "https://files.pythonhosted.org/packages/ba/a8/c17acf65a931ce551fee11b72e8de63bf7e8a6f0e21add4c937c83563538/numpy-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13e689d772146140a252c3a28501da66dfecd77490b498b168b501835041f951", size = 19534895, upload-time = "2024-08-26T20:09:16.536Z" },
    { url = "https://files.pythonhosted.org/packages/ba/86/8767f3d54f6ae0165749f84648da9dcc8cd78ab65d415494962c86fac80f/numpy-2.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9ea91dfb7c3d1c56a0e55657c0afb38cf1eeae4544c208dc465c3c9f3a7c09f9", size = 19937253, upload-time = "2024-08-26T20:09:46.263Z" },
    { url = "https://files.pythonhosted.org/packages/df/87/f76450e6e1c14e5bb1eae6836478b1028e096fd02e85c1c37674606ab752/numpy-2.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c1c9307701fec8f3f7a1e6711f9089c06e6284b3afbbcd259f7791282d660a15", size = 14414074, upload-time = "2024-08-26T20:10:08.483Z" },
    { url = "https://files.pythonhosted.org/packages/5c/ca/0f0f328e1e59f73754f06e1adfb909de43726d4f24c6a3f8805f34f2b0fa/numpy-2.0.2-cp311-cp311-win32.whl", hash = "sha256:a392a68bd329eafac5817e5aefeb39038c48b671afd242710b451e76090e81f4", size = 6470640, upload-time = "2024-08-26T20:10:19.732Z" },
    { url = "https://files.pythonhosted.org/packages/eb/57/3a3f14d3a759dcf9bf6e9eda905794726b758819df4663f217d658a58695/numpy-2.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:286cd40ce2b7d652a6f22efdfc6d1edf879440e53e76a75955bc0c826c7e64dc", size = 15910230, upload-time = "2024-08-26T20:10:43.413Z" },
    { url = "https://files.pythonhosted.org/packages/45/40/2e117be60ec50d98fa08c2f8c48e09b3edea93cfcabd5a9ff6925d54b1c2/numpy-2.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:df55d490dea7934f330006d0f81e8551ba6010a5bf035a249ef61a94f21c500b", size = 20895803, upload-time = "2024-08-26T20:11:13.916Z" },
    { url = "https://files.pythonhosted.org/packages/46/92/1b8b8dee833f53cef3e0a3f69b2374467789e0bb7399689582314df02651/numpy-2.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8df823f570d9adf0978347d1f926b2a867d5608f434a7cff7f7908c6570dcf5e", size = 13471835, upload-time = "2024-08-26T20:11:34.779Z" },
    { url = "https://files.pythonhosted.org/packages/7f/19/e2793bde475f1edaea6945be141aef6c8b4c669b90c90a300a8954d08f0a/numpy-2.0.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9a92ae5c14811e390f3767053ff54eaee3bf84576d99a2456391401323f4ec2c", size = 5038499, upload-time = "2024-08-26T20:11:43.902Z" },
    { url = "https://files.pythonhosted.org/packages/e3/ff/ddf6dac2ff0dd50a7327bcdba45cb0264d0e96bb44d33324853f781a8f3c/numpy-2.0.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:a842d573724391493a97a62ebbb8e731f8a5dcc5d285dfc99141ca15a3302d0c", size = 6633497, upload-time = "2024-08-26T20:11:55.09Z" },
    { url = "https://files.pythonhosted.org/packages/72/21/67f36eac8e2d2cd652a2e69595a54128297cdcb1ff3931cfc87838874bd4/numpy-2.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05e238064fc0610c840d1cf6a13bf63d7e391717d247f1bf0318172e759e692", size = 13621158, upload-time = "2024-08-26T20:12:14.95Z" },
    { url = "https://files.pythonhosted.org/packages/39/68/e9f1126d757653496dbc096cb429014347a36b228f5a991dae2c6b6cfd40/numpy-2.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0123ffdaa88fa4ab64835dcbde75dcdf89c453c922f18dced6e27c90d1d0ec5a", size = 19236173, upload-time = "2024-08-26T20:12:44.049Z" },
    { url = "https://files.pythonhosted.org/packages/d1/e9/1f5333281e4ebf483ba1c888b1d61ba7e78d7e910fdd8e6499667041cc35/numpy-2.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:96a55f64139912d61de9137f11bf39a55ec8faec288c75a54f93dfd39f7eb40c", size = 19634174, upload-time = "2024-08-26T20:13:13.634Z" },
    { url = "https://files.pythonhosted.org/packages/71/af/a469674070c8d8408384e3012e064299f7a2de540738a8e414dcfd639996/numpy-2.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec9852fb39354b5a45a80bdab5ac02dd02b15f44b3804e9f00c556bf24b4bded", size = 14099701, upload-time = "2024-08-26T20:13:34.851Z" },
    { url = "https://files.pythonhosted.org/packages/d0/3d/08ea9f239d0e0e939b6ca52ad403c84a2bce1bde301a8eb4888c1c1543f1/numpy-2.0.2-cp312-cp312-win32.whl", hash = "sha256:671bec6496f83202ed2d3c8fdc486a8fc86942f2e69ff0e986140339a63bcbe5", size = 6174313, upload-time = "2024-08-26T20:13:45.653Z" },
    { url = "https://files.pythonhosted.org/packages/b2/b5/4ac39baebf1fdb2e72585c8352c56d063b6126be9fc95bd2bb5ef5770c20/numpy-2.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:cfd41e13fdc257aa5778496b8caa5e856dc4896d4ccf01841daee1d96465467a", size = 15606179, upload-time = "2024-08-26T20:14:08.786Z" },
    { url = "https://files.pythonhosted.org/packages/43/c1/41c8f6df3162b0c6ffd4437d729115704bd43363de0090c7f913cfbc2d89/numpy-2.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9059e10581ce4093f735ed23f3b9d283b9d517ff46009ddd485f1747eb22653c", size = 21169942, upload-time = "2024-08-26T20:14:40.108Z" },
    { url = "https://files.pythonhosted.org/packages/39/bc/fd298f308dcd232b56a4031fd6ddf11c43f9917fbc937e53762f7b5a3bb1/numpy-2.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:423e89b23490805d2a5a96fe40ec507407b8ee786d66f7328be214f9679df6dd", size = 13711512, upload-time = "2024-08-26T20:15:00.985Z" },
    { url = "https://files.pythonhosted.org/packages/96/ff/06d1aa3eeb1c614eda245c1ba4fb88c483bee6520d361641331872ac4b82/numpy-2.0.2-cp39-cp39-macosx_14_0_arm64.whl", hash = "sha256:2b2955fa6f11907cf7a70dab0d0755159bca87755e831e47932367fc8f2f2d0b", size = 5306976, upload-time = "2024-08-26T20:15:10.876Z" },
    { url = "https://files.pythonhosted.org/packages/2d/98/121996dcfb10a6087a05e54453e28e58694a7db62c5a5a29cee14c6e047b/numpy-2.0.2-cp39-cp39-macosx_14_0_x86_64.whl", hash = "sha256:97032a27bd9d8988b9a97a8c4d2c9f2c15a81f61e2f21404d7e8ef00cb5be729", size = 6906494, upload-time = "2024-08-26T20:15:22.055Z" },
    { url = "https://files.pythonhosted.org/packages/15/31/9dffc70da6b9bbf7968f6551967fc21156207366272c2a40b4ed6008dc9b/numpy-2.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e795a8be3ddbac43274f18588329c72939870a16cae810c2b73461c40718ab1", size = 13912596, upload-time = "2024-08-26T20:15:42.452Z" },
    { url = "https://files.pythonhosted.org/packages/b9/14/78635daab4b07c0930c919d451b8bf8c164774e6a3413aed04a6d95758ce/numpy-2.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b258c385842546006213344c50655ff1555a9338e2e5e02a0756dc3e803dd", size = 19526099, upload-time = "2024-08-26T20:16:11.048Z" },
    { url = "https://files.pythonhosted.org/packages/26/4c/0eeca4614003077f68bfe7aac8b7496f04221865b3a5e7cb230c9d055afd/numpy-2.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5fec9451a7789926bcf7c2b8d187292c9f93ea30284802a0ab3f5be8ab36865d", size = 19932823, upload-time = "2024-08-26T20:16:40.171Z" },
    { url = "https://files.pythonhosted.org/packages/f1/46/ea25b98b13dccaebddf1a803f8c748680d972e00507cd9bc6dcdb5aa2ac1/numpy-2.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9189427407d88ff25ecf8f12469d4d39d35bee1db5d39fc5c168c6f088a6956d", size = 14404424, upload-time = "2024-08-26T20:17:02.604Z" },
    { url = "https://files.pythonhosted.org/packages/c8/a6/177dd88d95ecf07e722d21008b1b40e681a929eb9e329684d449c36586b2/numpy-2.0.2-cp39-cp39-win32.whl", hash = "sha256:905d16e0c60200656500c95b6b8dca5d109e23cb24abc701d41c02d74c6b3afa", size = 6476809, upload-time = "2024-08-26T20:17:13.553Z" },
    { url = "https://files.pythonhosted.org/packages/ea/2b/7fc9f4e7ae5b507c1a3a21f0f15ed03e794c1242ea8a242ac158beb56034/numpy-2.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:a3f4ab0caa7f053f6797fcd4e1e25caee367db3112ef2b6ef82d749530768c73", size = 15911314, upload-time = "2024-08-26T20:17:36.72Z" },
    { url = "https://files.pythonhosted.org/packages/8f/3b/df5a870ac6a3be3a86856ce195ef42eec7ae50d2a202be1f5a4b3b340e14/numpy-2.0.2-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7f0a0c6f12e07fa94133c8a67404322845220c06a9e80e85999afe727f7438b8", size = 21025288, upload-time = "2024-08-26T20:18:07.732Z" },
    { url = "https://files.pythonhosted.org/packages/2c/97/51af92f18d6f6f2d9ad8b482a99fb74e142d71372da5d834b3a2747a446e/numpy-2.0.2-pp39-pypy39_pp73-macosx_14_0_x86_64.whl", hash = "sha256:312950fdd060354350ed123c0e25a71327d3711584beaef30cdaa93320c392d4", size = 6762793, upload-time = "2024-08-26T20:18:19.125Z" },
    { url = "https://files.pythonhosted.org/packages/12/46/de1fbd0c1b5ccaa7f9a005b66761533e2f6a3e560096682683a223631fe9/numpy-2.0.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26df23238872200f63518dd2aa984cfca675d82469535dc7162dc2ee52d9dd5c", size = 19334885, upload-time = "2024-08-26T20:18:47.237Z" },
    { url = "https://files.pythonhosted.org/packages/cc/dc/d330a6faefd92b446ec0f0dfea4c3207bb1fef3c4771d19cf4543efd2c78/numpy-2.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a46288ec55ebbd58947d31d72be2c63cbf839f0a63b49cb755022310792a3385", size = 15828784, upload-time = "2024-08-26T20:19:11.19Z" },
]

[[package]]
name = "numpy"
version = "2.2.6"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version == '3.10.*'",
]
sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" },
    { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" },
    { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" },
    { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" },
    { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" },
    { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" },
    { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" },
    { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" },
    { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" },
    { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" },
    { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" },
    { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" },
    { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" },
    { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" },
    { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" },
    { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" },
    { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" },
    { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" },
    { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" },
    { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" },
    { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" },
    { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" },
    { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" },
    { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" },
    { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" },
    { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" },
    { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" },
    { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" },
    { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" },
    { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" },
    { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" },
    { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" },
    { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" },
    { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" },
    { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" },
    { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" },
    { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" },
    { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" },
    { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" },
    { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" },
    { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" },
    { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" },
    { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" },
    { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" },
    { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" },
    { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" },
    { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" },
    { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" },
    { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" },
    { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" },
    { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" },
    { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" },
    { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" },
    { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" },
]

[[package]]
name = "numpy"
version = "2.4.4"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
]
sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/ef/c6/4218570d8c8ecc9704b5157a3348e486e84ef4be0ed3e38218ab473c83d2/numpy-2.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f983334aea213c99992053ede6168500e5f086ce74fbc4acc3f2b00f5762e9db", size = 16976799, upload-time = "2026-03-29T13:18:15.438Z" },
    { url = "https://files.pythonhosted.org/packages/dd/92/b4d922c4a5f5dab9ed44e6153908a5c665b71acf183a83b93b690996e39b/numpy-2.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:72944b19f2324114e9dc86a159787333b77874143efcf89a5167ef83cfee8af0", size = 14971552, upload-time = "2026-03-29T13:18:18.606Z" },
    { url = "https://files.pythonhosted.org/packages/8a/dc/df98c095978fa6ee7b9a9387d1d58cbb3d232d0e69ad169a4ce784bde4fd/numpy-2.4.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:86b6f55f5a352b48d7fbfd2dbc3d5b780b2d79f4d3c121f33eb6efb22e9a2015", size = 5476566, upload-time = "2026-03-29T13:18:21.532Z" },
    { url = "https://files.pythonhosted.org/packages/28/34/b3fdcec6e725409223dd27356bdf5a3c2cc2282e428218ecc9cb7acc9763/numpy-2.4.4-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:ba1f4fc670ed79f876f70082eff4f9583c15fb9a4b89d6188412de4d18ae2f40", size = 6806482, upload-time = "2026-03-29T13:18:23.634Z" },
    { url = "https://files.pythonhosted.org/packages/68/62/63417c13aa35d57bee1337c67446761dc25ea6543130cf868eace6e8157b/numpy-2.4.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a87ec22c87be071b6bdbd27920b129b94f2fc964358ce38f3822635a3e2e03d", size = 15973376, upload-time = "2026-03-29T13:18:26.677Z" },
    { url = "https://files.pythonhosted.org/packages/cf/c5/9fcb7e0e69cef59cf10c746b84f7d58b08bc66a6b7d459783c5a4f6101a6/numpy-2.4.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df3775294accfdd75f32c74ae39fcba920c9a378a2fc18a12b6820aa8c1fb502", size = 16925137, upload-time = "2026-03-29T13:18:30.14Z" },
    { url = "https://files.pythonhosted.org/packages/7e/43/80020edacb3f84b9efdd1591120a4296462c23fd8db0dde1666f6ef66f13/numpy-2.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0d4e437e295f18ec29bc79daf55e8a47a9113df44d66f702f02a293d93a2d6dd", size = 17329414, upload-time = "2026-03-29T13:18:33.733Z" },
    { url = "https://files.pythonhosted.org/packages/fd/06/af0658593b18a5f73532d377188b964f239eb0894e664a6c12f484472f97/numpy-2.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6aa3236c78803afbcb255045fbef97a9e25a1f6c9888357d205ddc42f4d6eba5", size = 18658397, upload-time = "2026-03-29T13:18:37.511Z" },
    { url = "https://files.pythonhosted.org/packages/e6/ce/13a09ed65f5d0ce5c7dd0669250374c6e379910f97af2c08c57b0608eee4/numpy-2.4.4-cp311-cp311-win32.whl", hash = "sha256:30caa73029a225b2d40d9fae193e008e24b2026b7ee1a867b7ee8d96ca1a448e", size = 6239499, upload-time = "2026-03-29T13:18:40.372Z" },
    { url = "https://files.pythonhosted.org/packages/bd/63/05d193dbb4b5eec1eca73822d80da98b511f8328ad4ae3ca4caf0f4db91d/numpy-2.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:6bbe4eb67390b0a0265a2c25458f6b90a409d5d069f1041e6aff1e27e3d9a79e", size = 12614257, upload-time = "2026-03-29T13:18:42.95Z" },
    { url = "https://files.pythonhosted.org/packages/87/c5/8168052f080c26fa984c413305012be54741c9d0d74abd7fbeeccae3889f/numpy-2.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:fcfe2045fd2e8f3cb0ce9d4ba6dba6333b8fa05bb8a4939c908cd43322d14c7e", size = 10486775, upload-time = "2026-03-29T13:18:45.835Z" },
    { url = "https://files.pythonhosted.org/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b", size = 16689272, upload-time = "2026-03-29T13:18:49.223Z" },
    { url = "https://files.pythonhosted.org/packages/c5/f3/a983d28637bfcd763a9c7aafdb6d5c0ebf3d487d1e1459ffdb57e2f01117/numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e", size = 14699573, upload-time = "2026-03-29T13:18:52.629Z" },
    { url = "https://files.pythonhosted.org/packages/9b/fd/e5ecca1e78c05106d98028114f5c00d3eddb41207686b2b7de3e477b0e22/numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842", size = 5204782, upload-time = "2026-03-29T13:18:55.579Z" },
    { url = "https://files.pythonhosted.org/packages/de/2f/702a4594413c1a8632092beae8aba00f1d67947389369b3777aed783fdca/numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8", size = 6552038, upload-time = "2026-03-29T13:18:57.769Z" },
    { url = "https://files.pythonhosted.org/packages/7f/37/eed308a8f56cba4d1fdf467a4fc67ef4ff4bf1c888f5fc980481890104b1/numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121", size = 15670666, upload-time = "2026-03-29T13:19:00.341Z" },
    { url = "https://files.pythonhosted.org/packages/0a/0d/0e3ecece05b7a7e87ab9fb587855548da437a061326fff64a223b6dcb78a/numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e", size = 16645480, upload-time = "2026-03-29T13:19:03.63Z" },
    { url = "https://files.pythonhosted.org/packages/34/49/f2312c154b82a286758ee2f1743336d50651f8b5195db18cdb63675ff649/numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44", size = 17020036, upload-time = "2026-03-29T13:19:07.428Z" },
    { url = "https://files.pythonhosted.org/packages/7b/e9/736d17bd77f1b0ec4f9901aaec129c00d59f5d84d5e79bba540ef12c2330/numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d", size = 18368643, upload-time = "2026-03-29T13:19:10.775Z" },
    { url = "https://files.pythonhosted.org/packages/63/f6/d417977c5f519b17c8a5c3bc9e8304b0908b0e21136fe43bf628a1343914/numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827", size = 5961117, upload-time = "2026-03-29T13:19:13.464Z" },
    { url = "https://files.pythonhosted.org/packages/2d/5b/e1deebf88ff431b01b7406ca3583ab2bbb90972bbe1c568732e49c844f7e/numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a", size = 12320584, upload-time = "2026-03-29T13:19:16.155Z" },
    { url = "https://files.pythonhosted.org/packages/58/89/e4e856ac82a68c3ed64486a544977d0e7bdd18b8da75b78a577ca31c4395/numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec", size = 10221450, upload-time = "2026-03-29T13:19:18.994Z" },
    { url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" },
    { url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" },
    { url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" },
    { url = "https://files.pythonhosted.org/packages/cd/da/464d551604320d1491bc345efed99b4b7034143a85787aab78d5691d5a0e/numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", size = 6547539, upload-time = "2026-03-29T13:19:30.97Z" },
    { url = "https://files.pythonhosted.org/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", size = 15668806, upload-time = "2026-03-29T13:19:33.887Z" },
    { url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682, upload-time = "2026-03-29T13:19:37.336Z" },
    { url = "https://files.pythonhosted.org/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810, upload-time = "2026-03-29T13:19:40.963Z" },
    { url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394, upload-time = "2026-03-29T13:19:44.859Z" },
    { url = "https://files.pythonhosted.org/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556, upload-time = "2026-03-29T13:19:47.661Z" },
    { url = "https://files.pythonhosted.org/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311, upload-time = "2026-03-29T13:19:50.67Z" },
    { url = "https://files.pythonhosted.org/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060, upload-time = "2026-03-29T13:19:54.229Z" },
    { url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302, upload-time = "2026-03-29T13:19:57.585Z" },
    { url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407, upload-time = "2026-03-29T13:20:00.601Z" },
    { url = "https://files.pythonhosted.org/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631, upload-time = "2026-03-29T13:20:02.855Z" },
    { url = "https://files.pythonhosted.org/packages/f4/da/477731acbd5a58a946c736edfdabb2ac5b34c3d08d1ba1a7b437fa0884df/numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", size = 15727691, upload-time = "2026-03-29T13:20:06.004Z" },
    { url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241, upload-time = "2026-03-29T13:20:09.417Z" },
    { url = "https://files.pythonhosted.org/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767, upload-time = "2026-03-29T13:20:13.126Z" },
    { url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169, upload-time = "2026-03-29T13:20:17.096Z" },
    { url = "https://files.pythonhosted.org/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477, upload-time = "2026-03-29T13:20:20.195Z" },
    { url = "https://files.pythonhosted.org/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487, upload-time = "2026-03-29T13:20:22.946Z" },
    { url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" },
    { url = "https://files.pythonhosted.org/packages/6e/06/c54062f85f673dd5c04cbe2f14c3acb8c8b95e3384869bb8cc9bff8cb9df/numpy-2.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0", size = 16684353, upload-time = "2026-03-29T13:20:29.504Z" },
    { url = "https://files.pythonhosted.org/packages/4c/39/8a320264a84404c74cc7e79715de85d6130fa07a0898f67fb5cd5bd79908/numpy-2.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a", size = 14704914, upload-time = "2026-03-29T13:20:33.547Z" },
    { url = "https://files.pythonhosted.org/packages/91/fb/287076b2614e1d1044235f50f03748f31fa287e3dbe6abeb35cdfa351eca/numpy-2.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a", size = 5210005, upload-time = "2026-03-29T13:20:36.45Z" },
    { url = "https://files.pythonhosted.org/packages/63/eb/fcc338595309910de6ecabfcef2419a9ce24399680bfb149421fa2df1280/numpy-2.4.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b", size = 6544974, upload-time = "2026-03-29T13:20:39.014Z" },
    { url = "https://files.pythonhosted.org/packages/44/5d/e7e9044032a716cdfaa3fba27a8e874bf1c5f1912a1ddd4ed071bf8a14a6/numpy-2.4.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a", size = 15684591, upload-time = "2026-03-29T13:20:42.146Z" },
    { url = "https://files.pythonhosted.org/packages/98/7c/21252050676612625449b4807d6b695b9ce8a7c9e1c197ee6216c8a65c7c/numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d", size = 16637700, upload-time = "2026-03-29T13:20:46.204Z" },
    { url = "https://files.pythonhosted.org/packages/b1/29/56d2bbef9465db24ef25393383d761a1af4f446a1df9b8cded4fe3a5a5d7/numpy-2.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252", size = 17035781, upload-time = "2026-03-29T13:20:50.242Z" },
    { url = "https://files.pythonhosted.org/packages/e3/2b/a35a6d7589d21f44cea7d0a98de5ddcbb3d421b2622a5c96b1edf18707c3/numpy-2.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f", size = 18362959, upload-time = "2026-03-29T13:20:54.019Z" },
    { url = "https://files.pythonhosted.org/packages/64/c9/d52ec581f2390e0f5f85cbfd80fb83d965fc15e9f0e1aec2195faa142cde/numpy-2.4.4-cp314-cp314-win32.whl", hash = "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc", size = 6008768, upload-time = "2026-03-29T13:20:56.912Z" },
    { url = "https://files.pythonhosted.org/packages/fa/22/4cc31a62a6c7b74a8730e31a4274c5dc80e005751e277a2ce38e675e4923/numpy-2.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74", size = 12449181, upload-time = "2026-03-29T13:20:59.548Z" },
    { url = "https://files.pythonhosted.org/packages/70/2e/14cda6f4d8e396c612d1bf97f22958e92148801d7e4f110cabebdc0eef4b/numpy-2.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb", size = 10496035, upload-time = "2026-03-29T13:21:02.524Z" },
    { url = "https://files.pythonhosted.org/packages/b1/e8/8fed8c8d848d7ecea092dc3469643f9d10bc3a134a815a3b033da1d2039b/numpy-2.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e", size = 14824958, upload-time = "2026-03-29T13:21:05.671Z" },
    { url = "https://files.pythonhosted.org/packages/05/1a/d8007a5138c179c2bf33ef44503e83d70434d2642877ee8fbb230e7c0548/numpy-2.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113", size = 5330020, upload-time = "2026-03-29T13:21:08.635Z" },
    { url = "https://files.pythonhosted.org/packages/99/64/ffb99ac6ae93faf117bcbd5c7ba48a7f45364a33e8e458545d3633615dda/numpy-2.4.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d", size = 6650758, upload-time = "2026-03-29T13:21:10.949Z" },
    { url = "https://files.pythonhosted.org/packages/6e/6e/795cc078b78a384052e73b2f6281ff7a700e9bf53bcce2ee579d4f6dd879/numpy-2.4.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d", size = 15729948, upload-time = "2026-03-29T13:21:14.047Z" },
    { url = "https://files.pythonhosted.org/packages/5f/86/2acbda8cc2af5f3d7bfc791192863b9e3e19674da7b5e533fded124d1299/numpy-2.4.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f", size = 16679325, upload-time = "2026-03-29T13:21:17.561Z" },
    { url = "https://files.pythonhosted.org/packages/bc/59/cafd83018f4aa55e0ac6fa92aa066c0a1877b77a615ceff1711c260ffae8/numpy-2.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0", size = 17084883, upload-time = "2026-03-29T13:21:21.106Z" },
    { url = "https://files.pythonhosted.org/packages/f0/85/a42548db84e65ece46ab2caea3d3f78b416a47af387fcbb47ec28e660dc2/numpy-2.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150", size = 18403474, upload-time = "2026-03-29T13:21:24.828Z" },
    { url = "https://files.pythonhosted.org/packages/ed/ad/483d9e262f4b831000062e5d8a45e342166ec8aaa1195264982bca267e62/numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871", size = 6155500, upload-time = "2026-03-29T13:21:28.205Z" },
    { url = "https://files.pythonhosted.org/packages/c7/03/2fc4e14c7bd4ff2964b74ba90ecb8552540b6315f201df70f137faa5c589/numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e", size = 12637755, upload-time = "2026-03-29T13:21:31.107Z" },
    { url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" },
    { url = "https://files.pythonhosted.org/packages/6b/33/8fae8f964a4f63ed528264ddf25d2b683d0b663e3cba26961eb838a7c1bd/numpy-2.4.4-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:58c8b5929fcb8287cbd6f0a3fae19c6e03a5c48402ae792962ac465224a629a4", size = 16854491, upload-time = "2026-03-29T13:21:38.03Z" },
    { url = "https://files.pythonhosted.org/packages/bc/d0/1aabee441380b981cf8cdda3ae7a46aa827d1b5a8cce84d14598bc94d6d9/numpy-2.4.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:eea7ac5d2dce4189771cedb559c738a71512768210dc4e4753b107a2048b3d0e", size = 14895830, upload-time = "2026-03-29T13:21:41.509Z" },
    { url = "https://files.pythonhosted.org/packages/a5/b8/aafb0d1065416894fccf4df6b49ef22b8db045187949545bced89c034b8e/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:51fc224f7ca4d92656d5a5eb315f12eb5fe2c97a66249aa7b5f562528a3be38c", size = 5400927, upload-time = "2026-03-29T13:21:44.747Z" },
    { url = "https://files.pythonhosted.org/packages/d6/77/063baa20b08b431038c7f9ff5435540c7b7265c78cf56012a483019ca72d/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:28a650663f7314afc3e6ec620f44f333c386aad9f6fc472030865dc0ebb26ee3", size = 6715557, upload-time = "2026-03-29T13:21:47.406Z" },
    { url = "https://files.pythonhosted.org/packages/c7/a8/379542d45a14f149444c5c4c4e7714707239ce9cc1de8c2803958889da14/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19710a9ca9992d7174e9c52f643d4272dcd1558c5f7af7f6f8190f633bd651a7", size = 15804253, upload-time = "2026-03-29T13:21:50.753Z" },
    { url = "https://files.pythonhosted.org/packages/a2/c8/f0a45426d6d21e7ea3310a15cf90c43a14d9232c31a837702dba437f3373/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b2aec6af35c113b05695ebb5749a787acd63cafc83086a05771d1e1cd1e555f", size = 16753552, upload-time = "2026-03-29T13:21:54.344Z" },
    { url = "https://files.pythonhosted.org/packages/04/74/f4c001f4714c3ad9ce037e18cf2b9c64871a84951eaa0baf683a9ca9301c/numpy-2.4.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2cf083b324a467e1ab358c105f6cad5ea950f50524668a80c486ff1db24e119", size = 12509075, upload-time = "2026-03-29T13:21:57.644Z" },
]

[[package]]
name = "obstore"
version = "0.8.2"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
dependencies = [
    { name = "typing-extensions", marker = "python_full_version < '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/8c/9ec984edd0f3b72226adfaa19b1c61b15823b35b52f311ca4af36d009d15/obstore-0.8.2.tar.gz", hash = "sha256:a467bc4e97169e2ba749981b4fd0936015428d9b8f3fb83a5528536b1b6f377f", size = 168852, upload-time = "2025-09-16T15:34:55.786Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/e1/e9/0a1e340ef262f225ad71f556ccba257896f85ca197f02cd228fe5e20b45a/obstore-0.8.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:49104c0d72688c180af015b02c691fbb6cf6a45b03a9d71b84059ed92dbec704", size = 3622821, upload-time = "2025-09-16T15:32:53.79Z" },
    { url = "https://files.pythonhosted.org/packages/24/86/2b53e8b0a838dbbf89ef5dfddde888770bc1a993c691698dae411a407228/obstore-0.8.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c49776abd416e4d80d003213522d82ad48ed3517bee27a6cf8ce0f0cf4e6337e", size = 3356349, upload-time = "2025-09-16T15:32:55.715Z" },
    { url = "https://files.pythonhosted.org/packages/e8/79/1ba6dc854d7de7704a2c474d723ffeb01b6884f72eea7cbe128efc472f4a/obstore-0.8.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1636372b5e171a98369612d122ea20b955661daafa6519ed8322f4f0cb43ff74", size = 3454842, upload-time = "2025-09-16T15:32:57.072Z" },
    { url = "https://files.pythonhosted.org/packages/ca/03/ca67ccc9b9e63cfc0cd069b84437807fed4ef880be1e445b3f29d11518e0/obstore-0.8.2-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2efed0d86ad4ebffcbe3d0c4d84f26c2c6b20287484a0a748499c169a8e1f2c4", size = 3688363, upload-time = "2025-09-16T15:32:58.164Z" },
    { url = "https://files.pythonhosted.org/packages/a7/2f/c78eb4352d8be64a072934fe3ff2af79a1d06f4571af7c70d96f9741766b/obstore-0.8.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00c5542616dc5608de82ab6f6820633c9dbab6ff048e770fb8a5fcd1d30cd656", size = 3960133, upload-time = "2025-09-16T15:32:59.614Z" },
    { url = "https://files.pythonhosted.org/packages/4f/34/9e828d19194e227fd9f1d2dd70710da99c2bd2cd728686d59ea80be10b7c/obstore-0.8.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d9df46aaf25ce80fff48c53382572adc67b6410611660b798024450281a3129", size = 3925493, upload-time = "2025-09-16T15:33:00.923Z" },
    { url = "https://files.pythonhosted.org/packages/5f/7d/9ec5967f3e2915fbc441f72c3892a7f0fb3618e3ae5c8a44181ce4aa641c/obstore-0.8.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8ccf0f03a7fe453fb8640611c922bce19f021c6aaeee6ee44d6d8fb57db6be48", size = 3769401, upload-time = "2025-09-16T15:33:02.373Z" },
    { url = "https://files.pythonhosted.org/packages/85/bf/00b65013068bde630a7369610a2dae4579315cd6ce82d30e3d23315cf308/obstore-0.8.2-cp310-cp310-manylinux_2_24_aarch64.whl", hash = "sha256:ddfbfadc88c5e9740b687ef0833384329a56cea07b34f44e1c4b00a0e97d94a9", size = 3534383, upload-time = "2025-09-16T15:33:03.903Z" },
    { url = "https://files.pythonhosted.org/packages/52/39/1b684fd96c9a33974fc52f417c52b42c1d50df40b44e588853c4a14d9ab1/obstore-0.8.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:53ad53bb16e64102f39559ec470efd78a5272b5e3b84c53aa0423993ac5575c1", size = 3697939, upload-time = "2025-09-16T15:33:05.355Z" },
    { url = "https://files.pythonhosted.org/packages/85/58/93a2c78935f17fde7e22842598a6373e46a9c32d0243ec3b26b5da92df27/obstore-0.8.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:b0b905b46354db0961ab818cad762b9c1ac154333ae5d341934c90635a6bd7ab", size = 3681746, upload-time = "2025-09-16T15:33:09.344Z" },
    { url = "https://files.pythonhosted.org/packages/38/90/225c2972338d18f92e7a56f71e34df6935b0b1bd7458bb6a0d2bd4d48f92/obstore-0.8.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fee235694406ebb2dc4178752cf5587f471d6662659b082e9786c716a0a9465c", size = 3765156, upload-time = "2025-09-16T15:33:10.457Z" },
    { url = "https://files.pythonhosted.org/packages/79/eb/aca27e895bfcbbcd2bf05ea6a2538a94b718e6f6d72986e16ab158b753ec/obstore-0.8.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6c36faf7ace17dd0832aa454118a63ea21862e3d34f71b9297d0c788d00f4985", size = 3941190, upload-time = "2025-09-16T15:33:11.59Z" },
    { url = "https://files.pythonhosted.org/packages/33/ce/c8251a397e7507521768f05bc355b132a0daaff3739e861e51fa6abd821e/obstore-0.8.2-cp310-cp310-win_amd64.whl", hash = "sha256:948a1db1d34f88cfc7ab7e0cccdcfd84cf3977365634599c95ba03b4ef80d1c4", size = 3970041, upload-time = "2025-09-16T15:33:13.035Z" },
    { url = "https://files.pythonhosted.org/packages/2f/c4/018f90701f1e5ea3fbd57f61463f42e1ef5218e548d3adcf12b6be021c34/obstore-0.8.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:2edaa97687c191c5324bb939d72f6fe86a7aa8191c410f1648c14e8296d05c1c", size = 3622568, upload-time = "2025-09-16T15:33:14.196Z" },
    { url = "https://files.pythonhosted.org/packages/a8/62/72dd1e7d52fc554bb1fdb1a9499bda219cf3facea5865a1d97fdc00b3a1b/obstore-0.8.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c4fb7ef8108f08d14edc8bec9e9a6a2e5c4d14eddb8819f5d0da498aff6e8888", size = 3356109, upload-time = "2025-09-16T15:33:15.315Z" },
    { url = "https://files.pythonhosted.org/packages/e0/ae/089fe5b9207091252fe5ce352551214f04560f85eb8f2cc4f716a6a1a57e/obstore-0.8.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fda8f658c0edf799ab1e264f9b12c7c184cd09a5272dc645d42e987810ff2772", size = 3454588, upload-time = "2025-09-16T15:33:16.421Z" },
    { url = "https://files.pythonhosted.org/packages/ea/10/1865ae2d1ba45e8ae85fb0c1aada2dc9533baf60c4dfe74dab905348d74a/obstore-0.8.2-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:87fe2bc15ce4051ecb56abd484feca323c2416628beb62c1c7b6712114564d6e", size = 3688627, upload-time = "2025-09-16T15:33:17.604Z" },
    { url = "https://files.pythonhosted.org/packages/a6/09/5d7ba6d0aeac563ea5f5586401c677bace4f782af83522b1fdf15430e152/obstore-0.8.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2482aa2562ab6a4ca40250b26bea33f8375b59898a9b5615fd412cab81098123", size = 3959896, upload-time = "2025-09-16T15:33:18.789Z" },
    { url = "https://files.pythonhosted.org/packages/16/15/2b3eda59914761a9ff4d840e2daec5697fd29b293bd18d3dc11c593aed06/obstore-0.8.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4153b928f5d2e9c6cb645e83668a53e0b42253d1e8bcb4e16571fc0a1434599a", size = 3933162, upload-time = "2025-09-16T15:33:19.935Z" },
    { url = "https://files.pythonhosted.org/packages/14/7a/5fc63b41526587067537fb1498c59a210884664c65ccf0d1f8f823b0875a/obstore-0.8.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dbfa9c38620cc191be98c8b5558c62071e495dc6b1cc724f38293ee439aa9f92", size = 3769605, upload-time = "2025-09-16T15:33:21.389Z" },
    { url = "https://files.pythonhosted.org/packages/77/4e/2208ab6e1fc021bf8b7e117249a10ab75d0ed24e0f2de1a8d7cd67d885b5/obstore-0.8.2-cp311-cp311-manylinux_2_24_aarch64.whl", hash = "sha256:0822836eae8d52499f10daef17f26855b4c123119c6eb984aa4f2d525ec2678d", size = 3534396, upload-time = "2025-09-16T15:33:22.574Z" },
    { url = "https://files.pythonhosted.org/packages/1d/8f/a0e2882edd6bd285c82b8a5851c4ecf386c93fe75b6e340d5d9d30e809fc/obstore-0.8.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8ef6435dfd586d83b4f778e7927a5d5b0d8b771e9ba914bc809a13d7805410e6", size = 3697777, upload-time = "2025-09-16T15:33:23.723Z" },
    { url = "https://files.pythonhosted.org/packages/94/78/ebf0c33bed5c9a8eed3b00eefafbcc0a687eeb1e05451c76fcf199d29ff8/obstore-0.8.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:0f2cba91f4271ca95a932a51aa8dda1537160342b33f7836c75e1eb9d40621a2", size = 3681546, upload-time = "2025-09-16T15:33:24.935Z" },
    { url = "https://files.pythonhosted.org/packages/af/21/9bf4fb9e53fd5f01af580b6538de2eae857e31d24b0ebfc4d916c306a1e4/obstore-0.8.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:23c876d603af0627627808d19a58d43eb5d8bfd02eecd29460bc9a58030fed55", size = 3765336, upload-time = "2025-09-16T15:33:26.069Z" },
    { url = "https://files.pythonhosted.org/packages/dd/3c/7f6895c23719482d231b2d6ed328e3223fdf99785f6850fba8d2fc5a86ee/obstore-0.8.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ff3c4b5d07629b70b9dee494cd6b94fff8465c3864752181a1cb81a77190fe42", size = 3941142, upload-time = "2025-09-16T15:33:27.275Z" },
    { url = "https://files.pythonhosted.org/packages/93/a4/56ccdb756161595680a28f4b0def2c04f7048ffacf128029be8394367b26/obstore-0.8.2-cp311-cp311-win_amd64.whl", hash = "sha256:aadb2cb72de7227d07f4570f82729625ffc77522fadca5cf13c3a37fbe8c8de9", size = 3970172, upload-time = "2025-09-16T15:33:28.393Z" },
    { url = "https://files.pythonhosted.org/packages/2b/dc/60fefbb5736e69eab56657bca04ca64dc07fdeccb3814164a31b62ad066b/obstore-0.8.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bb70ce297a47392b1d9a3e310f18d59cd5ebbb9453428210fef02ed60e4d75d1", size = 3612955, upload-time = "2025-09-16T15:33:29.527Z" },
    { url = "https://files.pythonhosted.org/packages/d2/8b/844e8f382e5a12b8a3796a05d76a03e12c7aedc13d6900419e39207d7868/obstore-0.8.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1619bf618428abf1f607e0b219b2e230a966dcf697b717deccfa0983dd91f646", size = 3346564, upload-time = "2025-09-16T15:33:30.698Z" },
    { url = "https://files.pythonhosted.org/packages/89/73/8537f99e09a38a54a6a15ede907aa25d4da089f767a808f0b2edd9c03cec/obstore-0.8.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a4605c3ed7c9515aeb4c619b5f7f2c9986ed4a79fe6045e536b5e59b804b1476", size = 3460809, upload-time = "2025-09-16T15:33:31.837Z" },
    { url = "https://files.pythonhosted.org/packages/b4/99/7714dec721e43f521d6325a82303a002cddad089437640f92542b84e9cc8/obstore-0.8.2-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ce42670417876dd8668cbb8659e860e9725e5f26bbc86449fd259970e2dd9d18", size = 3692081, upload-time = "2025-09-16T15:33:33.028Z" },
    { url = "https://files.pythonhosted.org/packages/ec/bd/4ac4175fe95a24c220a96021c25c432bcc0c0212f618be0737184eebbaad/obstore-0.8.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4a3e893b2a06585f651c541c1972fe1e3bf999ae2a5fda052ee55eb7e6516f5", size = 3957466, upload-time = "2025-09-16T15:33:34.528Z" },
    { url = "https://files.pythonhosted.org/packages/4e/04/caa288fb735484fc5cb019bdf3d896eaccfae0ac4622e520d05692c46790/obstore-0.8.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08462b32f95a9948ed56ed63e88406e2e5a4cae1fde198f9682e0fb8487100ed", size = 3951293, upload-time = "2025-09-16T15:33:35.733Z" },
    { url = "https://files.pythonhosted.org/packages/44/2f/d380239da2d6a1fda82e17df5dae600a404e8a93a065784518ff8325d5f6/obstore-0.8.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a0bf7763292a8fc47d01cd66e6f19002c5c6ad4b3ed4e6b2729f5e190fa8a0d", size = 3766199, upload-time = "2025-09-16T15:33:36.904Z" },
    { url = "https://files.pythonhosted.org/packages/28/41/d391be069d3da82969b54266948b2582aeca5dd735abeda4d63dba36e07b/obstore-0.8.2-cp312-cp312-manylinux_2_24_aarch64.whl", hash = "sha256:bcd47f8126cb192cbe86942b8f73b1c45a651ce7e14c9a82c5641dfbf8be7603", size = 3529678, upload-time = "2025-09-16T15:33:38.221Z" },
    { url = "https://files.pythonhosted.org/packages/b9/4c/4862fdd1a3abde459ee8eea699b1797df638a460af235b18ca82c8fffb72/obstore-0.8.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:57eda9fd8c757c3b4fe36cf3918d7e589cc1286591295cc10b34122fa36dd3fd", size = 3698079, upload-time = "2025-09-16T15:33:39.696Z" },
    { url = "https://files.pythonhosted.org/packages/68/ca/014e747bc53b570059c27e3565b2316fbe5c107d4134551f4cd3e24aa667/obstore-0.8.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ea44442aad8992166baa69f5069750979e4c5d9ffce772e61565945eea5774b9", size = 3687154, upload-time = "2025-09-16T15:33:40.92Z" },
    { url = "https://files.pythonhosted.org/packages/6f/89/6db5f8edd93028e5b8bfbeee15e6bd3e56f72106107d31cb208b57659de4/obstore-0.8.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:41496a3ab8527402db4142aaaf0d42df9d7d354b13ba10d9c33e0e48dd49dd96", size = 3773444, upload-time = "2025-09-16T15:33:42.123Z" },
    { url = "https://files.pythonhosted.org/packages/26/e5/c9e2cc540689c873beb61246e1615d6e38301e6a34dec424f5a5c63c1afd/obstore-0.8.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:43da209803f052df96c7c3cbec512d310982efd2407e4a435632841a51143170", size = 3939315, upload-time = "2025-09-16T15:33:43.252Z" },
    { url = "https://files.pythonhosted.org/packages/4d/c9/bb53280ca50103c1ffda373cdc9b0f835431060039c2897cbc87ddd92e42/obstore-0.8.2-cp312-cp312-win_amd64.whl", hash = "sha256:1836f5dcd49f9f2950c75889ab5c51fb290d3ea93cdc39a514541e0be3af016e", size = 3978234, upload-time = "2025-09-16T15:33:44.393Z" },
    { url = "https://files.pythonhosted.org/packages/f0/5d/8c3316cc958d386d5e6ab03e9db9ddc27f8e2141cee4a6777ae5b92f3aac/obstore-0.8.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:212f033e53fe6e53d64957923c5c88949a400e9027f7038c705ec2e9038be563", size = 3612027, upload-time = "2025-09-16T15:33:45.6Z" },
    { url = "https://files.pythonhosted.org/packages/ea/4d/699359774ce6330130536d008bfc32827fab0c25a00238d015a5974a3d1d/obstore-0.8.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bee21fa4ba148d08fa90e47a96df11161661ed31e09c056a373cb2154b0f2852", size = 3344686, upload-time = "2025-09-16T15:33:47.185Z" },
    { url = "https://files.pythonhosted.org/packages/82/37/55437341f10512906e02fd9fa69a8a95ad3f2f6a916d3233fda01763d110/obstore-0.8.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4c66594b59832ff1ced4c72575d9beb8b5f9b4e404ac1150a42bfb226617fd50", size = 3459860, upload-time = "2025-09-16T15:33:48.382Z" },
    { url = "https://files.pythonhosted.org/packages/7a/51/4245a616c94ee4851965e33f7a563ab4090cc81f52cc73227ff9ceca2e46/obstore-0.8.2-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:089f33af5c2fe132d00214a0c1f40601b28f23a38e24ef9f79fb0576f2730b74", size = 3691648, upload-time = "2025-09-16T15:33:49.524Z" },
    { url = "https://files.pythonhosted.org/packages/4e/f1/4e2fb24171e3ca3641a4653f006be826e7e17634b11688a5190553b00b83/obstore-0.8.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d87f658dfd340d5d9ea2d86a7c90d44da77a0db9e00c034367dca335735110cf", size = 3956867, upload-time = "2025-09-16T15:33:51.082Z" },
    { url = "https://files.pythonhosted.org/packages/42/f5/b703115361c798c9c1744e1e700d5908d904a8c2e2bd38bec759c9ffb469/obstore-0.8.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6e2e4fa92828c4fbc2d487f3da2d3588701a1b67d9f6ca3c97cc2afc912e9c63", size = 3950599, upload-time = "2025-09-16T15:33:52.173Z" },
    { url = "https://files.pythonhosted.org/packages/53/20/08c6dc0f20c1394e2324b9344838e4e7af770cdcb52c30757a475f50daeb/obstore-0.8.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab440e89c5c37a8ec230857dd65147d4b923e0cada33297135d05e0f937d696a", size = 3765865, upload-time = "2025-09-16T15:33:53.291Z" },
    { url = "https://files.pythonhosted.org/packages/77/20/77907765e29b2eba6bd8821872284d91170d7084f670855b2dfcb249ea14/obstore-0.8.2-cp313-cp313-manylinux_2_24_aarch64.whl", hash = "sha256:b9beed107c5c9cd995d4a73263861fcfbc414d58773ed65c14f80eb18258a932", size = 3529807, upload-time = "2025-09-16T15:33:54.535Z" },
    { url = "https://files.pythonhosted.org/packages/a5/f5/f629d39cc30d050f52b1bf927e4d65c1cc7d7ffbb8a635cd546b5c5219a0/obstore-0.8.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b75b4e7746292c785e31edcd5aadc8b758238372a19d4c5e394db5c305d7d175", size = 3693629, upload-time = "2025-09-16T15:33:56.016Z" },
    { url = "https://files.pythonhosted.org/packages/30/ff/106763fd10f2a1cb47f2ef1162293c78ad52f4e73223d8d43fc6b755445d/obstore-0.8.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:f33e6c366869d05ab0b7f12efe63269e631c5450d95d6b4ba4c5faf63f69de70", size = 3686176, upload-time = "2025-09-16T15:33:57.247Z" },
    { url = "https://files.pythonhosted.org/packages/ce/0c/d2ccb6f32feeca906d5a7c4255340df5262af8838441ca06c9e4e37b67d5/obstore-0.8.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:12c885a9ce5ceb09d13cc186586c0c10b62597eff21b985f6ce8ff9dab963ad3", size = 3773081, upload-time = "2025-09-16T15:33:58.475Z" },
    { url = "https://files.pythonhosted.org/packages/fa/79/40d1cc504cefc89c9b3dd8874287f3fddc7d963a8748d6dffc5880222013/obstore-0.8.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4accc883b93349a81c9931e15dd318cc703b02bbef2805d964724c73d006d00e", size = 3938589, upload-time = "2025-09-16T15:33:59.734Z" },
    { url = "https://files.pythonhosted.org/packages/14/dd/916c6777222db3271e9fb3cf9a97ed92b3a9b3e465bdeec96de9ab809d53/obstore-0.8.2-cp313-cp313-win_amd64.whl", hash = "sha256:ec850adf9980e5788a826ccfd5819989724e2a2f712bfa3258e85966c8d9981e", size = 3977768, upload-time = "2025-09-16T15:34:01.25Z" },
    { url = "https://files.pythonhosted.org/packages/f1/61/66f8dc98bbf5613bbfe5bf21747b4c8091442977f4bd897945895ab7325c/obstore-0.8.2-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1431e40e9bb4773a261e51b192ea6489d0799b9d4d7dbdf175cdf813eb8c0503", size = 3623364, upload-time = "2025-09-16T15:34:02.957Z" },
    { url = "https://files.pythonhosted.org/packages/1a/66/6d527b3027e42f625c8fc816ac7d19b0d6228f95bfe7666e4d6b081d2348/obstore-0.8.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ddb39d4da303f50b959da000aa42734f6da7ac0cc0be2d5a7838b62c97055bb9", size = 3347764, upload-time = "2025-09-16T15:34:04.236Z" },
    { url = "https://files.pythonhosted.org/packages/0d/79/c00103302b620192ea447a948921ad3fed031ce3d19e989f038e1183f607/obstore-0.8.2-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e01f4e13783db453e17e005a4a3ceff09c41c262e44649ba169d253098c775e8", size = 3460981, upload-time = "2025-09-16T15:34:05.595Z" },
    { url = "https://files.pythonhosted.org/packages/3d/d9/bfe4ed4b1aebc45b56644dd5b943cf8e1673505cccb352e66878a457e807/obstore-0.8.2-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:df0fc2d0bc17caff9b538564ddc26d7616f7e8b7c65b1a3c90b5048a8ad2e797", size = 3692711, upload-time = "2025-09-16T15:34:06.796Z" },
    { url = "https://files.pythonhosted.org/packages/13/47/cd6c2cbb18e1f40c77e7957a4a03d2d83f1859a2e876a408f1ece81cad4c/obstore-0.8.2-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e439d06c99a140348f046c9f598ee349cc2dcd9105c15540a4b231f9cc48bbae", size = 3958362, upload-time = "2025-09-16T15:34:08.277Z" },
    { url = "https://files.pythonhosted.org/packages/3d/ea/5ee82bf23abd71c7d6a3f2d008197ae8f8f569d41314c26a8f75318245be/obstore-0.8.2-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e37d9046669fcc59522d0faf1d105fcbfd09c84cccaaa1e809227d8e030f32c", size = 3957082, upload-time = "2025-09-16T15:34:09.477Z" },
    { url = "https://files.pythonhosted.org/packages/cb/ee/46650405e50fdaa8d95f30375491f9c91fac9517980e8a28a4a6af66927f/obstore-0.8.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2646fdcc4bbe92dc2bb5bcdff15574da1211f5806c002b66d514cee2a23c7cb8", size = 3775539, upload-time = "2025-09-16T15:34:10.726Z" },
    { url = "https://files.pythonhosted.org/packages/35/d6/348a7ebebe2ca3d94dfc75344ea19675ae45472823e372c1852844078307/obstore-0.8.2-cp314-cp314-manylinux_2_24_aarch64.whl", hash = "sha256:e31a7d37675056d93dfc244605089dee67f5bba30f37c88436623c8c5ad9ba9d", size = 3535048, upload-time = "2025-09-16T15:34:12.076Z" },
    { url = "https://files.pythonhosted.org/packages/41/07/b7a16cc0da91a4b902d47880ad24016abfe7880c63f7cdafda45d89a2f91/obstore-0.8.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:656313dd8170dde0f0cd471433283337a63912e8e790a121f7cc7639c83e3816", size = 3699035, upload-time = "2025-09-16T15:34:13.331Z" },
    { url = "https://files.pythonhosted.org/packages/7f/74/3269a3a58347e0b019742d888612c4b765293c9c75efa44e144b1e884c0d/obstore-0.8.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329038c9645d6d1741e77fe1a53e28a14b1a5c1461cfe4086082ad39ebabf981", size = 3687307, upload-time = "2025-09-16T15:34:14.501Z" },
    { url = "https://files.pythonhosted.org/packages/01/f9/4fd4819ad6a49d2f462a45be453561f4caebded0dc40112deeffc34b89b1/obstore-0.8.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:1e4df99b369790c97c752d126b286dc86484ea49bff5782843a265221406566f", size = 3776076, upload-time = "2025-09-16T15:34:16.207Z" },
    { url = "https://files.pythonhosted.org/packages/14/dd/7c4f958fa0b9fc4778fb3d232e38b37db8c6b260f641022fbba48b049d7e/obstore-0.8.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9e1c65c65e20cc990414a8a9af88209b1bbc0dd9521b5f6b0293c60e19439bb7", size = 3947445, upload-time = "2025-09-16T15:34:17.423Z" },
    { url = "https://files.pythonhosted.org/packages/fb/25/e50fcd26ad42aec68dcf0b328041c9ae5ee62b25a035d7d6f881647372c5/obstore-0.8.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:2ca19d5310ba2736a3052d756e682cc1aafbcc4069e62c05b7222b7d8434b543", size = 3624240, upload-time = "2025-09-16T15:34:18.702Z" },
    { url = "https://files.pythonhosted.org/packages/2c/86/b370512250c1e145f9d5c192144452976ab4ef71a4679fbbd1bb3c3223be/obstore-0.8.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e5f3df9b64c683e288fa1e47fac237c6a1e1021e7c8cadcc75f1bcb3098e824d", size = 3357769, upload-time = "2025-09-16T15:34:19.826Z" },
    { url = "https://files.pythonhosted.org/packages/74/2a/72287ef76b9e21df7ba3f2183c23a336eb1f664771bdf8b3372f542ac0f5/obstore-0.8.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0cd293ace46ee175b50e21c0d8c94f606de6cd68f2f199877c55fe8837c585a5", size = 3456380, upload-time = "2025-09-16T15:34:21.001Z" },
    { url = "https://files.pythonhosted.org/packages/55/3f/fc57774211926123e8b40e9fa01c433b5587969371003cbdba7b5fc0a4df/obstore-0.8.2-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f5a39750feedf5b95b4f62bacaded0b95a53be047d9462d6b24dc8f8b6fc6ec8", size = 3689375, upload-time = "2025-09-16T15:34:22.302Z" },
    { url = "https://files.pythonhosted.org/packages/fc/81/e9adc1751f846f5f485d7b8f639290d3fe7cc875d95631704d4dba4720c3/obstore-0.8.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cb76517cca57f6ee9d74be18074a1c0f5ff0e62b4c6e1e0f893993dda93ebbfc", size = 3960873, upload-time = "2025-09-16T15:34:23.488Z" },
    { url = "https://files.pythonhosted.org/packages/f0/bd/6ba0842505c5e3a77f9175f3e08408ba7df67a7c5e0e51fe566f216a70c3/obstore-0.8.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7cd653932bbb7afe611786388cdb403a4b19b13205e0e43d8b0e4890e0accfd0", size = 3927698, upload-time = "2025-09-16T15:34:24.744Z" },
    { url = "https://files.pythonhosted.org/packages/74/e1/d02e330e29f71767756921b1790bb9fb068c5e466001ebc10b846c525658/obstore-0.8.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4952d69843bb78c73c9a81258f448003f74ff7b298a60899f015788db98a1cd1", size = 3770826, upload-time = "2025-09-16T15:34:25.934Z" },
    { url = "https://files.pythonhosted.org/packages/7b/fb/987ed1f6e5d05211a691f7f011b0109baffe763f960572b7d10718cb92ab/obstore-0.8.2-cp39-cp39-manylinux_2_24_aarch64.whl", hash = "sha256:2e3cd6d0822888b7e79c92c1258997289ebf0224598aad8f46ada17405666852", size = 3536530, upload-time = "2025-09-16T15:34:27.16Z" },
    { url = "https://files.pythonhosted.org/packages/58/1f/87c1d8a2ae978262f5ce8cea5714dff565c6251001f8f50bfa3b17fe5472/obstore-0.8.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:feb4a6e5a3f2d323b3f61356d4ef99dd3f430aaacdaf5607ced5f857d992d2d4", size = 3699687, upload-time = "2025-09-16T15:34:28.62Z" },
    { url = "https://files.pythonhosted.org/packages/4f/d7/1f6012f030af83b977939bd6121468388283170b539d4fa54a9537c471dc/obstore-0.8.2-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:61e29fd6a27df284027c23dc49851dbeeacb2d40cb3d945bd3d6ec6cb0650450", size = 3682807, upload-time = "2025-09-16T15:34:29.993Z" },
    { url = "https://files.pythonhosted.org/packages/e9/80/5d41bcaa12e77d36cb805fc1a2401b26c967e00133b5d87d17c99a86aa5b/obstore-0.8.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:8f9e18ff6c32997bd9a9fd636a98439bcbd3f44f13bae350243eacfb75803161", size = 3767014, upload-time = "2025-09-16T15:34:31.246Z" },
    { url = "https://files.pythonhosted.org/packages/b4/c7/28119d6da47cb5a49fb93e4639ffa134257654a59b1677f663cda688b9f7/obstore-0.8.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:6ebc814302485d453b61df956c09662ebb33471684add5bbc321de7ba265b723", size = 3942657, upload-time = "2025-09-16T15:34:32.642Z" },
    { url = "https://files.pythonhosted.org/packages/5c/0a/f0fd85dc7fb627d16ad52e7408339b95ae34ea1a5ba3dfbb0e5c18bdd227/obstore-0.8.2-cp39-cp39-win_amd64.whl", hash = "sha256:36478c16fd7c7f880f28ece352251eec1fc6f6b69dbf2b78cec9754eb80a4b41", size = 3969143, upload-time = "2025-09-16T15:34:33.897Z" },
    { url = "https://files.pythonhosted.org/packages/c3/37/14bae1f5bf4369027abc5315cdba2428ad4c16e2fd3bd5d35b7ee584aa0c/obstore-0.8.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:6ea04118980a9c22fc8581225ff4507b6a161baf8949d728d96e68326ebaab59", size = 3624857, upload-time = "2025-09-16T15:34:35.601Z" },
    { url = "https://files.pythonhosted.org/packages/1a/c4/8cba91629aa20479ba86a57c2c2b3bc0a54fc6a31a4594014213603efae6/obstore-0.8.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5f33a7570b6001b54252260fbec18c3f6d21e25d3ec57e9b6c5e7330e8290eb2", size = 3355999, upload-time = "2025-09-16T15:34:36.954Z" },
    { url = "https://files.pythonhosted.org/packages/f2/10/3e40557d6d9c38c5a0f7bac1508209b9dbb8c4da918ddfa9326ba9a1de3f/obstore-0.8.2-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:11fa78dfb749edcf5a041cd6db20eae95b3e8b09dfdd9b38d14939da40e7c115", size = 3457322, upload-time = "2025-09-16T15:34:38.143Z" },
    { url = "https://files.pythonhosted.org/packages/1d/01/dcf7988350c286683698cbdd8c15498aec43cbca72eaabad06fd77f0f34a/obstore-0.8.2-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:872bc0921ff88305884546ba05e258ccd95672a03d77db123f0d0563fd3c000b", size = 3689452, upload-time = "2025-09-16T15:34:39.638Z" },
    { url = "https://files.pythonhosted.org/packages/97/02/643eb2ede58933e47bdbc92786058c83d9aa569826d5bf6e83362d24a27a/obstore-0.8.2-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:72556a2fbf018edd921286283e5c7eec9f69a21c6d12516d8a44108eceaa526a", size = 3961171, upload-time = "2025-09-16T15:34:41.232Z" },
    { url = "https://files.pythonhosted.org/packages/d8/5d/c0b515df6089d0f54109de8031a6f6ed31271361948bee90ab8271d22f79/obstore-0.8.2-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75fa1abf21499dfcfb0328941a175f89a9aa58245bf00e3318fe928e4b10d297", size = 3935988, upload-time = "2025-09-16T15:34:42.501Z" },
    { url = "https://files.pythonhosted.org/packages/7b/97/114d7bc172bb846472181d6fa3e950172ee1b1ccd11291777303c499dbdd/obstore-0.8.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f54f72f30cd608c4399679781c884bf8a0e816c1977a2fac993bf5e1fb30609f", size = 3771781, upload-time = "2025-09-16T15:34:44.405Z" },
    { url = "https://files.pythonhosted.org/packages/c3/43/4aa6de6dc406ef5e109b21a5614c34999575de638254deb456703fae24aa/obstore-0.8.2-pp310-pypy310_pp73-manylinux_2_24_aarch64.whl", hash = "sha256:b044ebf1bf7b8f7b0ca309375c1cd9e140be79e072ae8c70bbd5d9b2ad1f7678", size = 3536689, upload-time = "2025-09-16T15:34:45.649Z" },
    { url = "https://files.pythonhosted.org/packages/06/a5/870ce541aa1a9ee1d9c3e99c2187049bf5a4d278ee9678cc449aae0a4e68/obstore-0.8.2-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:b1326cd2288b64d6fe8857cc22d3a8003b802585fc0741eff2640a8dc35e8449", size = 3700560, upload-time = "2025-09-16T15:34:47.252Z" },
    { url = "https://files.pythonhosted.org/packages/7d/93/76a5fc3833aaa833b4152950d9cdfd328493a48316c24e32ddefe9b8870f/obstore-0.8.2-pp310-pypy310_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:ba6863230648a9b0e11502d2745d881cf74262720238bc0093c3eabd22a3b24c", size = 3683450, upload-time = "2025-09-16T15:34:49.589Z" },
    { url = "https://files.pythonhosted.org/packages/15/3c/4c389362c187630c42f61ef9214e67fc336e44b8aafc47cf49ba9ab8007d/obstore-0.8.2-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:887615da9eeefeb2df849d87c380e04877487aa29dbeb367efc3f17f667470d3", size = 3766628, upload-time = "2025-09-16T15:34:51.937Z" },
    { url = "https://files.pythonhosted.org/packages/03/12/08547e63edf2239ec6660af434602208ab6f394955ef660a6edda13a0bee/obstore-0.8.2-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:4eec1fb32ffa4fb9fe9ad584611ff031927a5c22732b56075ee7204f0e35ebdf", size = 3944069, upload-time = "2025-09-16T15:34:54.108Z" },
]

[[package]]
name = "obstore"
version = "0.9.2"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
dependencies = [
    { name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/24/18/cab734edaeb495a861cfbdced9fecdc0866ed1a85aa5a9202ec77cf4723e/obstore-0.9.2.tar.gz", hash = "sha256:7ef94323127a971c9dea2484109d6c706eb2b2594a2df13c2dd0a6d21a9a69ae", size = 123731, upload-time = "2026-03-11T19:10:18.19Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/18/8d/567efc2f49b6f9212f36c35759cfe95deca22f2b0cf5a9fa98dca14975e6/obstore-0.9.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:f710b981071fce35ba57b0a47e4b13c9be0f22ad7e345707f0e798ff4814851f", size = 4101752, upload-time = "2026-03-11T19:08:47.094Z" },
    { url = "https://files.pythonhosted.org/packages/3c/36/dc5a541aecf450c18b12f1ea2f0b6b0e9c0d28045a05e4ed4a708381b1af/obstore-0.9.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:242adf90709097d664bbf9f4eb207a7f6eda06b58fa1e9c2adc7774eead6fe59", size = 3882599, upload-time = "2026-03-11T19:08:49.109Z" },
    { url = "https://files.pythonhosted.org/packages/60/d0/7ee2bb0f25138beae9e37f5829fe6976995a3e51bf55551c338dfa8c8bd1/obstore-0.9.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:44b05f531ddbf5b23c8673755070cf41bf07b38e25c0a48534a38b98d5ac7c43", size = 4043233, upload-time = "2026-03-11T19:08:50.493Z" },
    { url = "https://files.pythonhosted.org/packages/5b/0f/c09cb1c1da5799de86c24cf4aa1b55afc79f924fdc550c39731805507c02/obstore-0.9.2-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c8a7b26e5085647d2225323e86ee011414ce59ecfbf7f6def28cee371cf1d1e3", size = 4142247, upload-time = "2026-03-11T19:08:52.024Z" },
    { url = "https://files.pythonhosted.org/packages/96/0f/3fed38d308bb6c4e8976f37e78d087925b4eed2b391bee064d557d6f957b/obstore-0.9.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f4eea11208b4cbd3974ac8fc3c63a9d516d7e1e18eb94f6c396923b8cd024134", size = 4422649, upload-time = "2026-03-11T19:08:53.253Z" },
    { url = "https://files.pythonhosted.org/packages/d7/79/94d303fc52009417551728e835326af0f2b9e4edd63181e607db9a868130/obstore-0.9.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:badcc28413fd54bdff7101a33dc9bee73d70e006b77f1464c562986680ddaf4b", size = 4347110, upload-time = "2026-03-11T19:08:54.742Z" },
    { url = "https://files.pythonhosted.org/packages/f8/b6/d2ffaf0ef4556f18e7a75c5aec797f34093c00dc3aedab05cb041eb27535/obstore-0.9.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a26065607c51b15b452f3344d85ea88857253724c4f3a520612f92111b2cd63e", size = 4227249, upload-time = "2026-03-11T19:08:56.33Z" },
    { url = "https://files.pythonhosted.org/packages/2f/b1/6b5db701e64569ef5906fb2121fafe71bf4cf48f3304aad7c1037bdae05b/obstore-0.9.2-cp310-cp310-manylinux_2_24_aarch64.whl", hash = "sha256:9409e3b1520d27c53bb14f16b693fafe3f57194a967ff2b1b8dee3b7cf3dada1", size = 4109922, upload-time = "2026-03-11T19:08:57.591Z" },
    { url = "https://files.pythonhosted.org/packages/9b/08/ee270efd365b6c95fb29f7c45652a186569c8123ad90684dd7605a69a3f2/obstore-0.9.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c0a337e59d8dab3be761a3f2d3648155bcd618b32cbfe2875d65a93c33b846cc", size = 4297577, upload-time = "2026-03-11T19:08:58.849Z" },
    { url = "https://files.pythonhosted.org/packages/1c/8c/5cceba7ad82bedafa93c9a14d7f26336dff8c9705c6f0382e395cb478d88/obstore-0.9.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:1d58bd7529432c5d9dd96a660d9abe21d74394a1c2e51e86843a2e6059d1ad4f", size = 4275910, upload-time = "2026-03-11T19:09:00.151Z" },
    { url = "https://files.pythonhosted.org/packages/c2/40/2ac9cb4d7fa73b0680c0f36ef3da5b8952fafab038b7ab06a408ebc10af3/obstore-0.9.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:878a7672059e07d55b4cdb32b36bbd29803a30f98b3ddadea0cc43c25676e052", size = 4263175, upload-time = "2026-03-11T19:09:02.35Z" },
    { url = "https://files.pythonhosted.org/packages/3e/59/6a6edcfed719c17feb4603d1b16271ffed13dcf3b78538c6a437a08da231/obstore-0.9.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:38db958d9b9b5ede9a1d834254edda2182e3dce64ec5cbf668790457ff365e82", size = 4446061, upload-time = "2026-03-11T19:09:03.849Z" },
    { url = "https://files.pythonhosted.org/packages/26/90/495f0d257ddae8094e0aae58a59457cdf7933d5dcda6d3f466a1a9f98bb3/obstore-0.9.2-cp310-cp310-win_amd64.whl", hash = "sha256:fb3f3843f1cbf3aab4de968c13c2e97c9d1d771b73575d2f56fd7385d3f79357", size = 4185108, upload-time = "2026-03-11T19:09:05.319Z" },
    { url = "https://files.pythonhosted.org/packages/9c/d2/b98058a552849719df56d59a53f7d97e6507b37fca0399a866534800f9fa/obstore-0.9.2-cp311-abi3-macosx_10_12_x86_64.whl", hash = "sha256:50d9c9d6de601ad4805a5a76a1a3d731f7b899383f96ef57276f97bc35202f95", size = 4105494, upload-time = "2026-03-11T19:09:06.573Z" },
    { url = "https://files.pythonhosted.org/packages/ec/55/4386622b94fd028cb2298b4780d5a8e2d959fc4c71e599fb63be869aa83d/obstore-0.9.2-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:4c6dcd9b76b802a2278e1cd88ad7305caf3c3c16f800b2bf5f86a606e9e83d96", size = 3878429, upload-time = "2026-03-11T19:09:07.962Z" },
    { url = "https://files.pythonhosted.org/packages/91/8d/0bfad11f1ee5fb1fbdb7833607212ad2586dbd1824b30cf328af63fe92fc/obstore-0.9.2-cp311-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8d46e629beb47565fa67b6ef05919434258d72ef848efa340f911af5de2536da", size = 4041157, upload-time = "2026-03-11T19:09:09.278Z" },
    { url = "https://files.pythonhosted.org/packages/eb/98/bfde825f61a8b2541be9185cd6a4ddbb820de94c79750edc32f9f9dfb795/obstore-0.9.2-cp311-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:350d8cc1cd9564369291396e160ebfa133d705ec349d8c0d444a39158d6ef3e7", size = 4144757, upload-time = "2026-03-11T19:09:10.938Z" },
    { url = "https://files.pythonhosted.org/packages/19/35/1c101f6660ef91e5280c824677d8b5ab11ee25ed52e59b075cd795a86e69/obstore-0.9.2-cp311-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dddd38c9f98fd8eaf11a9805464f0bec7e57d8e04a5e0b0cb17582ec58d2fe41", size = 4427897, upload-time = "2026-03-11T19:09:12.137Z" },
    { url = "https://files.pythonhosted.org/packages/fb/eb/a9bdb64474d4e0ab4e4c0105c959090d6bd7ce38d4a945cae3679ead8c52/obstore-0.9.2-cp311-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ca872e88e5c719faf1581632e348a6b01331b4f838d7ac29aff226107088dc35", size = 4336227, upload-time = "2026-03-11T19:09:13.822Z" },
    { url = "https://files.pythonhosted.org/packages/b2/ec/e6d39aa311afec2241adb6f2067d7d6ca2eb4e0aab5a95c47796edadd524/obstore-0.9.2-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8ee61ac2af5c32c5282fc13b9eba7ffa332f268cb65bc29134ad8ac45e069871", size = 4229010, upload-time = "2026-03-11T19:09:15.503Z" },
    { url = "https://files.pythonhosted.org/packages/1c/fb/a24fd972b66b2d83829e2e89ccf236a759a82f881f909bf4fbe0b6c398ae/obstore-0.9.2-cp311-abi3-manylinux_2_24_aarch64.whl", hash = "sha256:2f430cf8af76985e7ebb8d5f20c8ccef858c608103af6ea95c870f5380cd62f7", size = 4103835, upload-time = "2026-03-11T19:09:16.729Z" },
    { url = "https://files.pythonhosted.org/packages/d0/d4/c8cc60c8afc597712bf6c5059d629e050de521d901dad0f554b268c2d77f/obstore-0.9.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1df403f80feef7ac483ed66a2a5a964a469f3756ded533935640c4baf986dd49", size = 4292174, upload-time = "2026-03-11T19:09:18.461Z" },
    { url = "https://files.pythonhosted.org/packages/a7/80/dcf8f31814f25c390aa5501a95b78b9f6456d30cd4625109c2a6a5105ad1/obstore-0.9.2-cp311-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c20f62b7c2f57c6f449215c36af4a8d502082ced2185c0b28f07a5e7c9698181", size = 4276266, upload-time = "2026-03-11T19:09:19.787Z" },
    { url = "https://files.pythonhosted.org/packages/16/71/5f5369fba652c5f83b44381d9e7a3cfe00793301d01802059b52b8663f2c/obstore-0.9.2-cp311-abi3-musllinux_1_2_i686.whl", hash = "sha256:c296e7d60ee132babb7fd01eab946396fa28eb0d88264b9e60320922174e6010", size = 4264118, upload-time = "2026-03-11T19:09:21.081Z" },
    { url = "https://files.pythonhosted.org/packages/c5/50/a5bd1948f2b2efb1039852542829a33a198be0586da7d4247996d3f15d26/obstore-0.9.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:76f274a170731a4461d0fe3eefde38f3bdaf346011ae020c94a0bd18bfd3c4bc", size = 4446876, upload-time = "2026-03-11T19:09:22.401Z" },
    { url = "https://files.pythonhosted.org/packages/3e/d6/bcc266e391403163ed12dd8cab53012f4db8f5020fb49e3b0a505d7a1bba/obstore-0.9.2-cp311-abi3-win_amd64.whl", hash = "sha256:f644fef2a91973b6c055623692524baf830abb1f8bb3ad348611f0e25224e160", size = 4190639, upload-time = "2026-03-11T19:09:23.637Z" },
    { url = "https://files.pythonhosted.org/packages/9a/da/ea7c5095cf15c026819958f74d3ab7b69aff7ce5bf74188e5df5bba4c252/obstore-0.9.2-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:7161a977e94a94dfd2c4ef66846371bdff46bb8b5f9b91dc29c912deb88a5bb2", size = 4087051, upload-time = "2026-03-11T19:09:24.944Z" },
    { url = "https://files.pythonhosted.org/packages/0d/9f/16d6f41ab87e75a6400959a4708343eaca782b78a5f9de7846c70e2b1381/obstore-0.9.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e3a31fbd68bbe7e061272420337d5ccaf2df7927c2b44ff768531dda02196746", size = 3869338, upload-time = "2026-03-11T19:09:26.404Z" },
    { url = "https://files.pythonhosted.org/packages/99/61/5f13cc91b054d8c93db77e9113ca4924c4320e988284840c8a98238709e6/obstore-0.9.2-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:928da0d131ea33d0b88aa8c3a0dd3f7423261e0c9495444cc14ce0cf62808558", size = 4037703, upload-time = "2026-03-11T19:09:27.743Z" },
    { url = "https://files.pythonhosted.org/packages/58/a2/669620821881559819b8911c4820defa3ffc30a9e49e9d5aca05bd57da45/obstore-0.9.2-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:79667de1f0c7eed64b658b3e696bb0565fba4069f6134db502bf4f5f5835aeee", size = 4135488, upload-time = "2026-03-11T19:09:29.232Z" },
    { url = "https://files.pythonhosted.org/packages/9f/12/019e523e97415b4fcfc35b230b270d452fdf5578a7612034c8043c8f2cbf/obstore-0.9.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7318253bc8d03b64473150dad31e611f5bd70a3cc945e3e1d6ac59a901f397c0", size = 4412922, upload-time = "2026-03-11T19:09:30.462Z" },
    { url = "https://files.pythonhosted.org/packages/a6/52/d4a8c1bf588a10bfd17a5a11ebc6af834850fe174a0369648d534a2acb81/obstore-0.9.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:133507229632fde08bc202ca2c81119b2314662dab7a96f8348e97f8e97ae36a", size = 4337193, upload-time = "2026-03-11T19:09:31.773Z" },
    { url = "https://files.pythonhosted.org/packages/aa/59/46c1bdaeae2904bb1edddbfc78e35cb0521ab7c58fe92b147a981873fcdc/obstore-0.9.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1c73f208abcddcd3edb7a739d5cac777bdb6fac12a358c9b251654ec7df7866", size = 4221641, upload-time = "2026-03-11T19:09:33.067Z" },
    { url = "https://files.pythonhosted.org/packages/44/9c/b0203594666d11da31e4a7f25ace0718cb1591792e3c1de5225fbd7c8246/obstore-0.9.2-cp313-cp313t-manylinux_2_24_aarch64.whl", hash = "sha256:857b2e7d78c8fb36dcb7c6f1fa89401429667195186ced746a500e54a6aaecdb", size = 4103500, upload-time = "2026-03-11T19:09:34.687Z" },
    { url = "https://files.pythonhosted.org/packages/95/bc/b215712ef24a21247d6e8a4049a76d95e2dca517b8b24efb496600c333c7/obstore-0.9.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:24c24fdba5080524ce79b36782a11563ea40d9ae5aa26bb6b81a6d089184e4eb", size = 4290492, upload-time = "2026-03-11T19:09:35.936Z" },
    { url = "https://files.pythonhosted.org/packages/ad/28/5aa0ecdc6c01b6e020f1ff8efcca35493e0c6091a0b72ec1bbb16b5b18a8/obstore-0.9.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:778785266aaaf3a73d44ee15e33b72c7ecf0585efeaf8745a1889cc02930ae59", size = 4272220, upload-time = "2026-03-11T19:09:37.223Z" },
    { url = "https://files.pythonhosted.org/packages/06/65/c47b0f972bc7acd64385a964dfbc2efc7361207f490b4d16da789da26fd5/obstore-0.9.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:305c415fdb2230a1e096f6f290cf524d030329ad5c5e1c9c41f121e7d2fb27d7", size = 4256524, upload-time = "2026-03-11T19:09:38.592Z" },
    { url = "https://files.pythonhosted.org/packages/e6/1d/9f826fd49cd17cdbc8d2a7a75698d1cc9d731ca98d645f1ca9366ac93781/obstore-0.9.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1a544aad84ae774fac339c686f8a4d7b187c4927b6e33ebb9758c58991d4f27f", size = 4440986, upload-time = "2026-03-11T19:09:40.231Z" },
    { url = "https://files.pythonhosted.org/packages/b9/24/0af1af62239c539975b6c9095428f7597e8f5f9617e897e58dbf7b63f1c5/obstore-0.9.2-cp313-cp313t-win_amd64.whl", hash = "sha256:52da6bd719c4962fdfb3c7504e790a89a9b5d27703ee872db01e2075162706fd", size = 4175182, upload-time = "2026-03-11T19:09:41.617Z" },
    { url = "https://files.pythonhosted.org/packages/fa/63/02ca0378938efd1111aa5d689b527c6f3f0c59f4ee440a7b0bf36c528f46/obstore-0.9.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:1bd4790eaa2bb384b58e1c430b2c8816edd7e60216e813c8120014f742e5d280", size = 4087916, upload-time = "2026-03-11T19:09:43.162Z" },
    { url = "https://files.pythonhosted.org/packages/86/9b/604bfb0ec9f117dbb8e936d64e45d95cd9a1fcb63640453566fb3dc66e9d/obstore-0.9.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e6417ac0b5cb32498490ceb7034ea357ea2ea965c855590496d64b2d7808a621", size = 3869703, upload-time = "2026-03-11T19:09:44.673Z" },
    { url = "https://files.pythonhosted.org/packages/44/6a/04bcb394f2a6bb12c4325e6ff3f7ead24592582a593c70669d9cdb5b4e9c/obstore-0.9.2-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dc07d71e2f9cd30d2db6ac15c2b162d5b14f6a0e7f575ad66676335c256b1a80", size = 4038164, upload-time = "2026-03-11T19:09:45.922Z" },
    { url = "https://files.pythonhosted.org/packages/34/39/2cc1c2c2a7027dd32ae010ac2ae4491b5f653f86c499e6ec20a6a54e799d/obstore-0.9.2-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7606d5f5c682cc8be9f55d3b07d282dfc0e0262ddfd31b8a26b0a6a3787e5b78", size = 4135199, upload-time = "2026-03-11T19:09:47.242Z" },
    { url = "https://files.pythonhosted.org/packages/e7/4c/defabe9c19bddf44f22591bcf0fffbc3b2b3202eb5ab99a0d894562f56de/obstore-0.9.2-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80e870ab402ac0f93799049a6680faacbfc2995c60fa87fd683807ce1366e544", size = 4413291, upload-time = "2026-03-11T19:09:48.934Z" },
    { url = "https://files.pythonhosted.org/packages/10/ce/fcfd0436834657a6617d06f07de7630889036c722d35ed9df7913e6caac7/obstore-0.9.2-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:534049c4b970e1e49c33b47a3e2a051fdc9727f844c3d4737aac4e4c89939fe4", size = 4337512, upload-time = "2026-03-11T19:09:50.13Z" },
    { url = "https://files.pythonhosted.org/packages/70/12/565d0cd60f7ae6bb65bde745e182f745a0520f314b32cb802d5f445ad10a/obstore-0.9.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c903949b9994003bda82b57f938ab88f458e75fd27eed809547533bffad99a77", size = 4221955, upload-time = "2026-03-11T19:09:51.499Z" },
    { url = "https://files.pythonhosted.org/packages/0e/27/3fb7f28277fbc929168ff7e02a36a64a56e1288936ac10fce49420c343f4/obstore-0.9.2-cp314-cp314t-manylinux_2_24_aarch64.whl", hash = "sha256:3f07a060702c8b1af51ca15a92658a34bb3ff2e38625173c5592c5aae7fdbfcd", size = 4103438, upload-time = "2026-03-11T19:09:52.748Z" },
    { url = "https://files.pythonhosted.org/packages/67/8f/53ed223ee069da797b09f45e9dbf4a1ed24743081be1ec1411ab6baf8ce9/obstore-0.9.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:462a864782a8d7a1a60c55ac19ce4ad53668a39e35d16b98b787fe97d3fec193", size = 4290842, upload-time = "2026-03-11T19:09:54.3Z" },
    { url = "https://files.pythonhosted.org/packages/05/cd/fc94afca13776c4eb8b7a2f27ecb9ee964156d20d699100b719c6c8b6246/obstore-0.9.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:afe36e0452e753c2fece5e6849dd13f209400d5feca668514c0cca2242b0eee8", size = 4273457, upload-time = "2026-03-11T19:09:55.715Z" },
    { url = "https://files.pythonhosted.org/packages/7a/8e/fb02a7a8d4f966af5e069315075bc4388eb63d9cff1c2f3283f3c5781919/obstore-0.9.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3bfae2c634bca903141ef09d6d65e343402de0470e595799881a47ac7c08b2bd", size = 4256979, upload-time = "2026-03-11T19:09:56.983Z" },
    { url = "https://files.pythonhosted.org/packages/c0/87/5621ea304d39b4099d36bfa50dce901eb37b3861e2592d76baa26031d407/obstore-0.9.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:71d4059b5e948fe6e8cfc2b77da9c2fc944dfe0ee98090d985e60dd6ebecd7f6", size = 4441545, upload-time = "2026-03-11T19:09:58.59Z" },
    { url = "https://files.pythonhosted.org/packages/30/44/5a7b98d5d92a2267df7a9a905b3cc4f0ca98fbf207b9fae5179a6838a80b/obstore-0.9.2-cp314-cp314t-win_amd64.whl", hash = "sha256:e75295c9c522dde5020d4ff763315af75a165a8a6b8d7f9ed247ce17b7d7f7b0", size = 4175247, upload-time = "2026-03-11T19:10:00.111Z" },
    { url = "https://files.pythonhosted.org/packages/0b/b0/958df4459af032a589a96657eb896949c7077f1c6b97fbd12dcbb2b31163/obstore-0.9.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:034071b5d54fc5e945cd9a15e0a7c3128cd3bc1b97bfffe153e09d940bfbf390", size = 4101577, upload-time = "2026-03-11T19:10:01.51Z" },
    { url = "https://files.pythonhosted.org/packages/d7/78/cb10d54e988506c26bcf0a3d1e9d4c896fa42f40640cffde91f6c28c0e12/obstore-0.9.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc62c77dd29f2642b9ac0338de16b105701445c0baf04f9a665250aa490ecfaf", size = 3881592, upload-time = "2026-03-11T19:10:02.803Z" },
    { url = "https://files.pythonhosted.org/packages/fa/d8/f87684bf91af83da63963170d4ec40afa72615f6af66bb9820a1d00c1ac4/obstore-0.9.2-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9fd12afd030a3867879e62a20aa294e89bf8d744e958cf889b462004edbdeca6", size = 4038571, upload-time = "2026-03-11T19:10:04Z" },
    { url = "https://files.pythonhosted.org/packages/e4/ad/d71cad9086935b7f9e9f7c8f6a6e36e8946f888b83c46f1bd4c45567f00e/obstore-0.9.2-pp311-pypy311_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:490606e6a0d52c846b36c85a4cd772fe14776821e50a4bff1680e8643c4a9d73", size = 4141769, upload-time = "2026-03-11T19:10:05.422Z" },
    { url = "https://files.pythonhosted.org/packages/d3/b0/c789b07c56b4e88fd904a7e684dc78b0b179a7fc84afd5a9039efe37cc19/obstore-0.9.2-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2ca53037bcc6a061cc02652ff0652c4313b75e9f66a0a147701459c65192a72d", size = 4422333, upload-time = "2026-03-11T19:10:06.795Z" },
    { url = "https://files.pythonhosted.org/packages/5d/80/956254c0dfffe2b795849987b14afbc8ebfe79e6bf4993e3dd5969c1f9d8/obstore-0.9.2-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:56138a4546fb5b46cb576b2b5951582fbc613cba97f51aab4a03fc305f66758b", size = 4345863, upload-time = "2026-03-11T19:10:08.156Z" },
    { url = "https://files.pythonhosted.org/packages/3f/6a/d07a8f5b63d6bb259eb349958882638268624af5da5eda4731a510aa3dbe/obstore-0.9.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:08280bb90ccd19350cca352348eef4c44ea34be34893819cb35f29ac578c01f9", size = 4226798, upload-time = "2026-03-11T19:10:09.792Z" },
    { url = "https://files.pythonhosted.org/packages/71/a4/8a6db6db5a2218de47a7cdecdf1d37185280a4ed364e1c27bdce813372da/obstore-0.9.2-pp311-pypy311_pp73-manylinux_2_24_aarch64.whl", hash = "sha256:a98c5da06ee34c285b0bdc32be03ab453af1fbf51c9a02fce36b195f2e404c03", size = 4108402, upload-time = "2026-03-11T19:10:11.125Z" },
    { url = "https://files.pythonhosted.org/packages/55/a9/3af105f9e8769840b9d7fa16f13ee3413e4ae40d5ef4b233a01c20faabb8/obstore-0.9.2-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:845b77a65add7449efc74994f02c62df87390a28d4a07a9296a065c88206d4fd", size = 4296537, upload-time = "2026-03-11T19:10:12.519Z" },
    { url = "https://files.pythonhosted.org/packages/e0/d7/ba263d6bdd7f7fe61143233fead0b0fdf4907092eb2cf8f1d1db1e8e4bb6/obstore-0.9.2-pp311-pypy311_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:50bc1f25ad3881bcd74142f143ab41a26c1b7a7c0b14a72ecbf6fddc34f5a36e", size = 4273106, upload-time = "2026-03-11T19:10:14.184Z" },
    { url = "https://files.pythonhosted.org/packages/05/22/179117970abd49d8fe53e014c9326363b8ce20f4635a4389e7d1215529bb/obstore-0.9.2-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:cada36b244361a25df6a943f374110034cb75c4bd284375df53bc1826c4b3bb6", size = 4262228, upload-time = "2026-03-11T19:10:15.463Z" },
    { url = "https://files.pythonhosted.org/packages/24/80/2de1995c1c195f5ce7d54184a01868741445333c751153f9d979b77de9e5/obstore-0.9.2-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:66ad31b4630c2cd41b1369529b6e9a472c84a7a8df045dc62b0bfea6d922110c", size = 4445675, upload-time = "2026-03-11T19:10:16.805Z" },
]

[[package]]
name = "opentelemetry-api"
version = "1.40.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "importlib-metadata" },
    { name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2c/1d/4049a9e8698361cc1a1aa03a6c59e4fa4c71e0c0f94a30f988a6876a2ae6/opentelemetry_api-1.40.0.tar.gz", hash = "sha256:159be641c0b04d11e9ecd576906462773eb97ae1b657730f0ecf64d32071569f", size = 70851, upload-time = "2026-03-04T14:17:21.555Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/5f/bf/93795954016c522008da367da292adceed71cca6ee1717e1d64c83089099/opentelemetry_api-1.40.0-py3-none-any.whl", hash = "sha256:82dd69331ae74b06f6a874704be0cfaa49a1650e1537d4a813b86ecef7d0ecf9", size = 68676, upload-time = "2026-03-04T14:17:01.24Z" },
]

[[package]]
name = "opentelemetry-resourcedetector-gcp"
version = "1.11.0a0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "opentelemetry-api" },
    { name = "opentelemetry-sdk" },
    { name = "requests", version = "2.32.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "requests", version = "2.33.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c1/5d/2b3240d914b87b6dd9cd5ca2ef1ccaf1d0626b897d4c06877e22c8c10fcf/opentelemetry_resourcedetector_gcp-1.11.0a0.tar.gz", hash = "sha256:915a1d6fd15daca9eedd3fc52b0f705375054f2ef140e2e7a6b4cca95a47cdb1", size = 18796, upload-time = "2025-11-04T19:32:16.59Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/c3/6c/1e13fe142a7ca3dc6489167203a1209d32430cca12775e1df9c9a41c54b2/opentelemetry_resourcedetector_gcp-1.11.0a0-py3-none-any.whl", hash = "sha256:5d65a2a039b1d40c6f41421dbb08d5f441368275ac6de6e76a8fccd1f6acb67e", size = 18798, upload-time = "2025-11-04T19:32:10.915Z" },
]

[[package]]
name = "opentelemetry-sdk"
version = "1.40.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "opentelemetry-api" },
    { name = "opentelemetry-semantic-conventions" },
    { name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/58/fd/3c3125b20ba18ce2155ba9ea74acb0ae5d25f8cd39cfd37455601b7955cc/opentelemetry_sdk-1.40.0.tar.gz", hash = "sha256:18e9f5ec20d859d268c7cb3c5198c8d105d073714db3de50b593b8c1345a48f2", size = 184252, upload-time = "2026-03-04T14:17:31.87Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/2c/c5/6a852903d8bfac758c6dc6e9a68b015d3c33f2f1be5e9591e0f4b69c7e0a/opentelemetry_sdk-1.40.0-py3-none-any.whl", hash = "sha256:787d2154a71f4b3d81f20524a8ce061b7db667d24e46753f32a7bc48f1c1f3f1", size = 141951, upload-time = "2026-03-04T14:17:17.961Z" },
]

[[package]]
name = "opentelemetry-semantic-conventions"
version = "0.61b0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "opentelemetry-api" },
    { name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6d/c0/4ae7973f3c2cfd2b6e321f1675626f0dab0a97027cc7a297474c9c8f3d04/opentelemetry_semantic_conventions-0.61b0.tar.gz", hash = "sha256:072f65473c5d7c6dc0355b27d6c9d1a679d63b6d4b4b16a9773062cb7e31192a", size = 145755, upload-time = "2026-03-04T14:17:32.664Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/b2/37/cc6a55e448deaa9b27377d087da8615a3416d8ad523d5960b78dbeadd02a/opentelemetry_semantic_conventions-0.61b0-py3-none-any.whl", hash = "sha256:fa530a96be229795f8cef353739b618148b0fe2b4b3f005e60e262926c4d38e2", size = 231621, upload-time = "2026-03-04T14:17:19.33Z" },
]

[[package]]
name = "oracledb"
version = "3.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "cryptography" },
    { name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f7/02/70a872d1a4a739b4f7371ab8d3d5ed8c6e57e142e2503531aafcb220893c/oracledb-3.4.2.tar.gz", hash = "sha256:46e0f2278ff1fe83fbc33a3b93c72d429323ec7eed47bc9484e217776cd437e5", size = 855467, upload-time = "2026-01-28T17:25:39.91Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/4c/5d/b8a0ca1c520fa43ae33260f6f8ca9bd468ade43da7986029bc214965df12/oracledb-3.4.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ff3c89cecea62af8ca02aa33cab0f2edc0214c747eac7d3364ed6b2640cb55e4", size = 4243966, upload-time = "2026-01-28T17:25:45.05Z" },
    { url = "https://files.pythonhosted.org/packages/f6/43/26e2bbb2a6ee31392a339089e53cb2e386ca795ff4fbe2f673c167821bd6/oracledb-3.4.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e068ef844a327877bfefbef1bc6fb7284c727bb87af80095f08d95bcaf7b8bb2", size = 2426056, upload-time = "2026-01-28T17:25:47.176Z" },
    { url = "https://files.pythonhosted.org/packages/09/ba/11ee1d044295465a04ff45c6e3023d35400bb3f67bc5fed9408f0f2dc04c/oracledb-3.4.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f434a739405557bd57cb39b62238142bb27855a524a70dc6d397a2a8c576c9d", size = 2603062, upload-time = "2026-01-28T17:25:49.817Z" },
    { url = "https://files.pythonhosted.org/packages/c5/bc/292f2f5f7b65a667787871e300889ab8f4a3b9cfd88c5d78f828a40f6d31/oracledb-3.4.2-cp310-cp310-win32.whl", hash = "sha256:00c79448017f367bb7ab6900efe0706658a53768abea2b4519a4c9b2d5743890", size = 1496639, upload-time = "2026-01-28T17:25:51.298Z" },
    { url = "https://files.pythonhosted.org/packages/21/23/81931c16663e771937c0161bb90460668d2a5f7982b5030ab7bef3b3a4f9/oracledb-3.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:574c8280d49cbbe21dbe03fc28356d9b9a5b9e300ebcde6c6d106e51453a7e65", size = 1837314, upload-time = "2026-01-28T17:25:52.718Z" },
    { url = "https://files.pythonhosted.org/packages/64/80/be263b668ba32b258d07c85f7bfb6967a9677e016c299207b28734f04c4b/oracledb-3.4.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b8e4b8a852251cef09038b75f30fce1227010835f4e19cfbd436027acba2697c", size = 4228552, upload-time = "2026-01-28T17:25:54.844Z" },
    { url = "https://files.pythonhosted.org/packages/91/bc/e832a649529da7c60409a81be41f3213b4c7ffda4fe424222b2145e8d43c/oracledb-3.4.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1617a1db020346883455af005efbefd51be2c4d797e43b1b38455a19f8526b48", size = 2421924, upload-time = "2026-01-28T17:25:56.984Z" },
    { url = "https://files.pythonhosted.org/packages/86/21/d867c37e493a63b5521bd248110ad5b97b18253d64a30703e3e8f3d9631e/oracledb-3.4.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed78d7e7079a778062744ccf42141ce4806818c3f4dd6463e4a7edd561c9f86", size = 2599301, upload-time = "2026-01-28T17:25:58.529Z" },
    { url = "https://files.pythonhosted.org/packages/2a/de/9b1843ea27f7791449652d7f340f042c3053336d2c11caf29e59bab86189/oracledb-3.4.2-cp311-cp311-win32.whl", hash = "sha256:0e16fe3d057e0c41a23ad2ae95bfa002401690773376d476be608f79ac74bf05", size = 1492890, upload-time = "2026-01-28T17:26:00.662Z" },
    { url = "https://files.pythonhosted.org/packages/d6/10/cbc8afa2db0cec80530858d3e4574f9734fae8c0b7f1df261398aa026c5f/oracledb-3.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:f93cae08e8ed20f2d5b777a8602a71f9418389c661d2c937e84d94863e7e7011", size = 1843355, upload-time = "2026-01-28T17:26:02.637Z" },
    { url = "https://files.pythonhosted.org/packages/8f/81/2e6154f34b71cd93b4946c73ea13b69d54b8d45a5f6bbffe271793240d21/oracledb-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a7396664e592881225ba66385ee83ce339d864f39003d6e4ca31a894a7e7c552", size = 4220806, upload-time = "2026-01-28T17:26:04.322Z" },
    { url = "https://files.pythonhosted.org/packages/ab/a9/a1d59aaac77d8f727156ec6a3b03399917c90b7da4f02d057f92e5601f56/oracledb-3.4.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f04a2d62073407672f114d02529921de0677c6883ed7c64d8d1a3c04caa3238", size = 2233795, upload-time = "2026-01-28T17:26:05.877Z" },
    { url = "https://files.pythonhosted.org/packages/94/ec/8c4a38020cd251572bd406ddcbde98ca052ec94b5684f9aa9ef1ddfcc68c/oracledb-3.4.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d8d75e4f879b908be66cce05ba6c05791a5dbb4a15e39abc01aa25c8a2492bd9", size = 2424756, upload-time = "2026-01-28T17:26:07.35Z" },
    { url = "https://files.pythonhosted.org/packages/fa/7d/c251c2a8567151ccfcfbe3467ea9a60fb5480dc4719342e2e6b7a9679e5d/oracledb-3.4.2-cp312-cp312-win32.whl", hash = "sha256:31b7ee83c23d0439778303de8a675717f805f7e8edb5556d48c4d8343bcf14f5", size = 1453486, upload-time = "2026-01-28T17:26:08.869Z" },
    { url = "https://files.pythonhosted.org/packages/4c/78/c939f3c16fb39400c4734d5a3340db5659ba4e9dce23032d7b33ccfd3fe5/oracledb-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:ac25a0448fc830fb7029ad50cd136cdbfcd06975d53967e269772cc5cb8c203a", size = 1794445, upload-time = "2026-01-28T17:26:10.66Z" },
    { url = "https://files.pythonhosted.org/packages/22/68/f7126f5d911c295b57720c6b1a0609a5a2667b4546946433552a4de46333/oracledb-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:643c25d301a289a371e37fcedb59e5fa5e54fb321708e5c12821c4b55bdd8a4d", size = 4205176, upload-time = "2026-01-28T17:26:12.463Z" },
    { url = "https://files.pythonhosted.org/packages/5d/93/2fced60f92dc82e66980a8a3ba5c1ea48110bf1dd81d030edb69d88f992e/oracledb-3.4.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55397e7eb43bb7017c03a981c736c25724182f5210951181dfe3fab0e5d457fb", size = 2231298, upload-time = "2026-01-28T17:26:14.497Z" },
    { url = "https://files.pythonhosted.org/packages/75/a7/4dd286f3a6348d786fef9e6ab2e6c9b74ca9195d9a756f2a67e45743cdf0/oracledb-3.4.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26a10f9c790bd141ffc8af68520803ed4a44a9258bf7d1eea9bfdd36bd6df7f", size = 2439430, upload-time = "2026-01-28T17:26:16.044Z" },
    { url = "https://files.pythonhosted.org/packages/19/28/94bc753e5e969c60ee5d9c914e2b4ef79999eaca8e91bcab2fbf0586b80b/oracledb-3.4.2-cp313-cp313-win32.whl", hash = "sha256:b974caec2c330c22bbe765705a5ac7d98ec3022811dec2042d561a3c65cb991b", size = 1458209, upload-time = "2026-01-28T17:26:17.652Z" },
    { url = "https://files.pythonhosted.org/packages/cb/2b/593a9b2d4c12c9de3289e67d84fe023336d99f36ba51442a5a0f5ce6acf7/oracledb-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:3df8eee1410d25360599968b1625b000f10c5ae0e47274031a7842a9dc418890", size = 1793558, upload-time = "2026-01-28T17:26:19.914Z" },
    { url = "https://files.pythonhosted.org/packages/42/20/1e98f84c1555911c46b4fa870fbef2a80617bf7e0a5f178078ecf466c917/oracledb-3.4.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:59ad6438f56a25e8e1a4a3dd1b42235a5d09ab9ba417ff2ad14eae6596f3d06f", size = 4247459, upload-time = "2026-01-28T17:26:22.356Z" },
    { url = "https://files.pythonhosted.org/packages/7d/74/95963e2d94f84b9937a562a9a2529f72d050afbc2ffd88f6661e3a876f7d/oracledb-3.4.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:404ec1451d0448653ee074213b87d6c5bd65eaa74b50083ddf2c9c3e11c71c71", size = 2271749, upload-time = "2026-01-28T17:26:24.078Z" },
    { url = "https://files.pythonhosted.org/packages/82/89/38ce85148a246087795379ee52c5b20726a00a69c87ba6ec266bcdad30fc/oracledb-3.4.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:19fa80ef84f85ad74077aa626067bbe697e527bd39604b4209f9d86cb2876b89", size = 2452031, upload-time = "2026-01-28T17:26:26.08Z" },
    { url = "https://files.pythonhosted.org/packages/3f/8d/51fe907fdec0267ad7c6e9a62998cbe878efcd168ea6e39f162fab62fdaa/oracledb-3.4.2-cp314-cp314-win32.whl", hash = "sha256:d7ce75c498bff758548ec6e4424ab4271aa257e5887cc436a54bc947fd46199a", size = 1480973, upload-time = "2026-01-28T17:26:27.584Z" },
    { url = "https://files.pythonhosted.org/packages/48/22/a37354f19786774e5e4041338043b516db060aacfdfcd5aca8bb92c2539a/oracledb-3.4.2-cp314-cp314-win_amd64.whl", hash = "sha256:5d7befb014174c5ae11c3a08f5ed6668a25ab2335d8e7104dca70d54d54a5b3a", size = 1837756, upload-time = "2026-01-28T17:26:29.032Z" },
    { url = "https://files.pythonhosted.org/packages/dd/22/153711194b5042aa8576ba4db5416143d1e842e536befd211752032bb114/oracledb-3.4.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:1e4930d7f6584832dcc15b8ca415a7957b0c45f5aa7c4f88702e070e5c53bf93", size = 4251607, upload-time = "2026-01-28T17:26:30.649Z" },
    { url = "https://files.pythonhosted.org/packages/e6/d6/12d9f228b8d081850b5b00e13784f4d847c95babdad00f0663206121d546/oracledb-3.4.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23aa07c1eaca17ae74c6fdc86b218f58484d56452958aead1aa460c0596a76c1", size = 2430130, upload-time = "2026-01-28T17:26:32.25Z" },
    { url = "https://files.pythonhosted.org/packages/ec/c1/cf6c81567fb25d64a4094409d5fb915c257029bf7eb2244433e26a838e77/oracledb-3.4.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f8ea989965a4f636a309444bd696ab877bba373d5d67bf744785f9bd8c560865", size = 2605324, upload-time = "2026-01-28T17:26:33.825Z" },
    { url = "https://files.pythonhosted.org/packages/f8/ee/2543ccfc3d7155baf5cfa9a3909768fa2a0781a92e5f28e3027c397e7bbe/oracledb-3.4.2-cp39-cp39-win32.whl", hash = "sha256:6d85622664cc88d5a82bbd7beccb62cd53bd272c550a5e15e7d5f8ae6b86f1f1", size = 1498691, upload-time = "2026-01-28T17:26:35.373Z" },
    { url = "https://files.pythonhosted.org/packages/f7/9c/07f0fa510358612188eebdf31930ac813246644c8dc8771370fd10226ea5/oracledb-3.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:b1095d95d0c8b37e4d0e17cf1928919cb59222b6344362a1cf6a2f3ca205a28a", size = 1840401, upload-time = "2026-01-28T17:26:36.775Z" },
]

[[package]]
name = "orjson"
version = "3.11.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/04/b8/333fdb27840f3bf04022d21b654a35f58e15407183aeb16f3b41aa053446/orjson-3.11.5.tar.gz", hash = "sha256:82393ab47b4fe44ffd0a7659fa9cfaacc717eb617c93cde83795f14af5c2e9d5", size = 5972347, upload-time = "2025-12-06T15:55:39.458Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/79/19/b22cf9dad4db20c8737041046054cbd4f38bb5a2d0e4bb60487832ce3d76/orjson-3.11.5-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:df9eadb2a6386d5ea2bfd81309c505e125cfc9ba2b1b99a97e60985b0b3665d1", size = 245719, upload-time = "2025-12-06T15:53:43.877Z" },
    { url = "https://files.pythonhosted.org/packages/03/2e/b136dd6bf30ef5143fbe76a4c142828b55ccc618be490201e9073ad954a1/orjson-3.11.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ccc70da619744467d8f1f49a8cadae5ec7bbe054e5232d95f92ed8737f8c5870", size = 132467, upload-time = "2025-12-06T15:53:45.379Z" },
    { url = "https://files.pythonhosted.org/packages/ae/fc/ae99bfc1e1887d20a0268f0e2686eb5b13d0ea7bbe01de2b566febcd2130/orjson-3.11.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:073aab025294c2f6fc0807201c76fdaed86f8fc4be52c440fb78fbb759a1ac09", size = 130702, upload-time = "2025-12-06T15:53:46.659Z" },
    { url = "https://files.pythonhosted.org/packages/6e/43/ef7912144097765997170aca59249725c3ab8ef6079f93f9d708dd058df5/orjson-3.11.5-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:835f26fa24ba0bb8c53ae2a9328d1706135b74ec653ed933869b74b6909e63fd", size = 135907, upload-time = "2025-12-06T15:53:48.487Z" },
    { url = "https://files.pythonhosted.org/packages/3f/da/24d50e2d7f4092ddd4d784e37a3fa41f22ce8ed97abc9edd222901a96e74/orjson-3.11.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:667c132f1f3651c14522a119e4dd631fad98761fa960c55e8e7430bb2a1ba4ac", size = 139935, upload-time = "2025-12-06T15:53:49.88Z" },
    { url = "https://files.pythonhosted.org/packages/02/4a/b4cb6fcbfff5b95a3a019a8648255a0fac9b221fbf6b6e72be8df2361feb/orjson-3.11.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42e8961196af655bb5e63ce6c60d25e8798cd4dfbc04f4203457fa3869322c2e", size = 137541, upload-time = "2025-12-06T15:53:51.226Z" },
    { url = "https://files.pythonhosted.org/packages/a5/99/a11bd129f18c2377c27b2846a9d9be04acec981f770d711ba0aaea563984/orjson-3.11.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75412ca06e20904c19170f8a24486c4e6c7887dea591ba18a1ab572f1300ee9f", size = 139031, upload-time = "2025-12-06T15:53:52.309Z" },
    { url = "https://files.pythonhosted.org/packages/64/29/d7b77d7911574733a036bb3e8ad7053ceb2b7d6ea42208b9dbc55b23b9ed/orjson-3.11.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6af8680328c69e15324b5af3ae38abbfcf9cbec37b5346ebfd52339c3d7e8a18", size = 141622, upload-time = "2025-12-06T15:53:53.606Z" },
    { url = "https://files.pythonhosted.org/packages/93/41/332db96c1de76b2feda4f453e91c27202cd092835936ce2b70828212f726/orjson-3.11.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:a86fe4ff4ea523eac8f4b57fdac319faf037d3c1be12405e6a7e86b3fbc4756a", size = 413800, upload-time = "2025-12-06T15:53:54.866Z" },
    { url = "https://files.pythonhosted.org/packages/76/e1/5a0d148dd1f89ad2f9651df67835b209ab7fcb1118658cf353425d7563e9/orjson-3.11.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e607b49b1a106ee2086633167033afbd63f76f2999e9236f638b06b112b24ea7", size = 151198, upload-time = "2025-12-06T15:53:56.383Z" },
    { url = "https://files.pythonhosted.org/packages/0d/96/8db67430d317a01ae5cf7971914f6775affdcfe99f5bff9ef3da32492ecc/orjson-3.11.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7339f41c244d0eea251637727f016b3d20050636695bc78345cce9029b189401", size = 141984, upload-time = "2025-12-06T15:53:57.746Z" },
    { url = "https://files.pythonhosted.org/packages/71/49/40d21e1aa1ac569e521069228bb29c9b5a350344ccf922a0227d93c2ed44/orjson-3.11.5-cp310-cp310-win32.whl", hash = "sha256:8be318da8413cdbbce77b8c5fac8d13f6eb0f0db41b30bb598631412619572e8", size = 135272, upload-time = "2025-12-06T15:53:59.769Z" },
    { url = "https://files.pythonhosted.org/packages/c4/7e/d0e31e78be0c100e08be64f48d2850b23bcb4d4c70d114f4e43b39f6895a/orjson-3.11.5-cp310-cp310-win_amd64.whl", hash = "sha256:b9f86d69ae822cabc2a0f6c099b43e8733dda788405cba2665595b7e8dd8d167", size = 133360, upload-time = "2025-12-06T15:54:01.25Z" },
    { url = "https://files.pythonhosted.org/packages/fd/68/6b3659daec3a81aed5ab47700adb1a577c76a5452d35b91c88efee89987f/orjson-3.11.5-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9c8494625ad60a923af6b2b0bd74107146efe9b55099e20d7740d995f338fcd8", size = 245318, upload-time = "2025-12-06T15:54:02.355Z" },
    { url = "https://files.pythonhosted.org/packages/e9/00/92db122261425f61803ccf0830699ea5567439d966cbc35856fe711bfe6b/orjson-3.11.5-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:7bb2ce0b82bc9fd1168a513ddae7a857994b780b2945a8c51db4ab1c4b751ebc", size = 129491, upload-time = "2025-12-06T15:54:03.877Z" },
    { url = "https://files.pythonhosted.org/packages/94/4f/ffdcb18356518809d944e1e1f77589845c278a1ebbb5a8297dfefcc4b4cb/orjson-3.11.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67394d3becd50b954c4ecd24ac90b5051ee7c903d167459f93e77fc6f5b4c968", size = 132167, upload-time = "2025-12-06T15:54:04.944Z" },
    { url = "https://files.pythonhosted.org/packages/97/c6/0a8caff96f4503f4f7dd44e40e90f4d14acf80d3b7a97cb88747bb712d3e/orjson-3.11.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:298d2451f375e5f17b897794bcc3e7b821c0f32b4788b9bcae47ada24d7f3cf7", size = 130516, upload-time = "2025-12-06T15:54:06.274Z" },
    { url = "https://files.pythonhosted.org/packages/4d/63/43d4dc9bd9954bff7052f700fdb501067f6fb134a003ddcea2a0bb3854ed/orjson-3.11.5-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa5e4244063db8e1d87e0f54c3f7522f14b2dc937e65d5241ef0076a096409fd", size = 135695, upload-time = "2025-12-06T15:54:07.702Z" },
    { url = "https://files.pythonhosted.org/packages/87/6f/27e2e76d110919cb7fcb72b26166ee676480a701bcf8fc53ac5d0edce32f/orjson-3.11.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1db2088b490761976c1b2e956d5d4e6409f3732e9d79cfa69f876c5248d1baf9", size = 139664, upload-time = "2025-12-06T15:54:08.828Z" },
    { url = "https://files.pythonhosted.org/packages/d4/f8/5966153a5f1be49b5fbb8ca619a529fde7bc71aa0a376f2bb83fed248bcd/orjson-3.11.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c2ed66358f32c24e10ceea518e16eb3549e34f33a9d51f99ce23b0251776a1ef", size = 137289, upload-time = "2025-12-06T15:54:09.898Z" },
    { url = "https://files.pythonhosted.org/packages/a7/34/8acb12ff0299385c8bbcbb19fbe40030f23f15a6de57a9c587ebf71483fb/orjson-3.11.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c2021afda46c1ed64d74b555065dbd4c2558d510d8cec5ea6a53001b3e5e82a9", size = 138784, upload-time = "2025-12-06T15:54:11.022Z" },
    { url = "https://files.pythonhosted.org/packages/ee/27/910421ea6e34a527f73d8f4ee7bdffa48357ff79c7b8d6eb6f7b82dd1176/orjson-3.11.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b42ffbed9128e547a1647a3e50bc88ab28ae9daa61713962e0d3dd35e820c125", size = 141322, upload-time = "2025-12-06T15:54:12.427Z" },
    { url = "https://files.pythonhosted.org/packages/87/a3/4b703edd1a05555d4bb1753d6ce44e1a05b7a6d7c164d5b332c795c63d70/orjson-3.11.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:8d5f16195bb671a5dd3d1dbea758918bada8f6cc27de72bd64adfbd748770814", size = 413612, upload-time = "2025-12-06T15:54:13.858Z" },
    { url = "https://files.pythonhosted.org/packages/1b/36/034177f11d7eeea16d3d2c42a1883b0373978e08bc9dad387f5074c786d8/orjson-3.11.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c0e5d9f7a0227df2927d343a6e3859bebf9208b427c79bd31949abcc2fa32fa5", size = 150993, upload-time = "2025-12-06T15:54:15.189Z" },
    { url = "https://files.pythonhosted.org/packages/44/2f/ea8b24ee046a50a7d141c0227c4496b1180b215e728e3b640684f0ea448d/orjson-3.11.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:23d04c4543e78f724c4dfe656b3791b5f98e4c9253e13b2636f1af5d90e4a880", size = 141774, upload-time = "2025-12-06T15:54:16.451Z" },
    { url = "https://files.pythonhosted.org/packages/8a/12/cc440554bf8200eb23348a5744a575a342497b65261cd65ef3b28332510a/orjson-3.11.5-cp311-cp311-win32.whl", hash = "sha256:c404603df4865f8e0afe981aa3c4b62b406e6d06049564d58934860b62b7f91d", size = 135109, upload-time = "2025-12-06T15:54:17.73Z" },
    { url = "https://files.pythonhosted.org/packages/a3/83/e0c5aa06ba73a6760134b169f11fb970caa1525fa4461f94d76e692299d9/orjson-3.11.5-cp311-cp311-win_amd64.whl", hash = "sha256:9645ef655735a74da4990c24ffbd6894828fbfa117bc97c1edd98c282ecb52e1", size = 133193, upload-time = "2025-12-06T15:54:19.426Z" },
    { url = "https://files.pythonhosted.org/packages/cb/35/5b77eaebc60d735e832c5b1a20b155667645d123f09d471db0a78280fb49/orjson-3.11.5-cp311-cp311-win_arm64.whl", hash = "sha256:1cbf2735722623fcdee8e712cbaaab9e372bbcb0c7924ad711b261c2eccf4a5c", size = 126830, upload-time = "2025-12-06T15:54:20.836Z" },
    { url = "https://files.pythonhosted.org/packages/ef/a4/8052a029029b096a78955eadd68ab594ce2197e24ec50e6b6d2ab3f4e33b/orjson-3.11.5-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:334e5b4bff9ad101237c2d799d9fd45737752929753bf4faf4b207335a416b7d", size = 245347, upload-time = "2025-12-06T15:54:22.061Z" },
    { url = "https://files.pythonhosted.org/packages/64/67/574a7732bd9d9d79ac620c8790b4cfe0717a3d5a6eb2b539e6e8995e24a0/orjson-3.11.5-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:ff770589960a86eae279f5d8aa536196ebda8273a2a07db2a54e82b93bc86626", size = 129435, upload-time = "2025-12-06T15:54:23.615Z" },
    { url = "https://files.pythonhosted.org/packages/52/8d/544e77d7a29d90cf4d9eecd0ae801c688e7f3d1adfa2ebae5e1e94d38ab9/orjson-3.11.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed24250e55efbcb0b35bed7caaec8cedf858ab2f9f2201f17b8938c618c8ca6f", size = 132074, upload-time = "2025-12-06T15:54:24.694Z" },
    { url = "https://files.pythonhosted.org/packages/6e/57/b9f5b5b6fbff9c26f77e785baf56ae8460ef74acdb3eae4931c25b8f5ba9/orjson-3.11.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a66d7769e98a08a12a139049aac2f0ca3adae989817f8c43337455fbc7669b85", size = 130520, upload-time = "2025-12-06T15:54:26.185Z" },
    { url = "https://files.pythonhosted.org/packages/f6/6d/d34970bf9eb33f9ec7c979a262cad86076814859e54eb9a059a52f6dc13d/orjson-3.11.5-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:86cfc555bfd5794d24c6a1903e558b50644e5e68e6471d66502ce5cb5fdef3f9", size = 136209, upload-time = "2025-12-06T15:54:27.264Z" },
    { url = "https://files.pythonhosted.org/packages/e7/39/bc373b63cc0e117a105ea12e57280f83ae52fdee426890d57412432d63b3/orjson-3.11.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a230065027bc2a025e944f9d4714976a81e7ecfa940923283bca7bbc1f10f626", size = 139837, upload-time = "2025-12-06T15:54:28.75Z" },
    { url = "https://files.pythonhosted.org/packages/cb/aa/7c4818c8d7d324da220f4f1af55c343956003aa4d1ce1857bdc1d396ba69/orjson-3.11.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b29d36b60e606df01959c4b982729c8845c69d1963f88686608be9ced96dbfaa", size = 137307, upload-time = "2025-12-06T15:54:29.856Z" },
    { url = "https://files.pythonhosted.org/packages/46/bf/0993b5a056759ba65145effe3a79dd5a939d4a070eaa5da2ee3180fbb13f/orjson-3.11.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c74099c6b230d4261fdc3169d50efc09abf38ace1a42ea2f9994b1d79153d477", size = 139020, upload-time = "2025-12-06T15:54:31.024Z" },
    { url = "https://files.pythonhosted.org/packages/65/e8/83a6c95db3039e504eda60fc388f9faedbb4f6472f5aba7084e06552d9aa/orjson-3.11.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e697d06ad57dd0c7a737771d470eedc18e68dfdefcdd3b7de7f33dfda5b6212e", size = 141099, upload-time = "2025-12-06T15:54:32.196Z" },
    { url = "https://files.pythonhosted.org/packages/b9/b4/24fdc024abfce31c2f6812973b0a693688037ece5dc64b7a60c1ce69e2f2/orjson-3.11.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e08ca8a6c851e95aaecc32bc44a5aa75d0ad26af8cdac7c77e4ed93acf3d5b69", size = 413540, upload-time = "2025-12-06T15:54:33.361Z" },
    { url = "https://files.pythonhosted.org/packages/d9/37/01c0ec95d55ed0c11e4cae3e10427e479bba40c77312b63e1f9665e0737d/orjson-3.11.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e8b5f96c05fce7d0218df3fdfeb962d6b8cfff7e3e20264306b46dd8b217c0f3", size = 151530, upload-time = "2025-12-06T15:54:34.6Z" },
    { url = "https://files.pythonhosted.org/packages/f9/d4/f9ebc57182705bb4bbe63f5bbe14af43722a2533135e1d2fb7affa0c355d/orjson-3.11.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ddbfdb5099b3e6ba6d6ea818f61997bb66de14b411357d24c4612cf1ebad08ca", size = 141863, upload-time = "2025-12-06T15:54:35.801Z" },
    { url = "https://files.pythonhosted.org/packages/0d/04/02102b8d19fdcb009d72d622bb5781e8f3fae1646bf3e18c53d1bc8115b5/orjson-3.11.5-cp312-cp312-win32.whl", hash = "sha256:9172578c4eb09dbfcf1657d43198de59b6cef4054de385365060ed50c458ac98", size = 135255, upload-time = "2025-12-06T15:54:37.209Z" },
    { url = "https://files.pythonhosted.org/packages/d4/fb/f05646c43d5450492cb387de5549f6de90a71001682c17882d9f66476af5/orjson-3.11.5-cp312-cp312-win_amd64.whl", hash = "sha256:2b91126e7b470ff2e75746f6f6ee32b9ab67b7a93c8ba1d15d3a0caaf16ec875", size = 133252, upload-time = "2025-12-06T15:54:38.401Z" },
    { url = "https://files.pythonhosted.org/packages/dc/a6/7b8c0b26ba18c793533ac1cd145e131e46fcf43952aa94c109b5b913c1f0/orjson-3.11.5-cp312-cp312-win_arm64.whl", hash = "sha256:acbc5fac7e06777555b0722b8ad5f574739e99ffe99467ed63da98f97f9ca0fe", size = 126777, upload-time = "2025-12-06T15:54:39.515Z" },
    { url = "https://files.pythonhosted.org/packages/10/43/61a77040ce59f1569edf38f0b9faadc90c8cf7e9bec2e0df51d0132c6bb7/orjson-3.11.5-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:3b01799262081a4c47c035dd77c1301d40f568f77cc7ec1bb7db5d63b0a01629", size = 245271, upload-time = "2025-12-06T15:54:40.878Z" },
    { url = "https://files.pythonhosted.org/packages/55/f9/0f79be617388227866d50edd2fd320cb8fb94dc1501184bb1620981a0aba/orjson-3.11.5-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:61de247948108484779f57a9f406e4c84d636fa5a59e411e6352484985e8a7c3", size = 129422, upload-time = "2025-12-06T15:54:42.403Z" },
    { url = "https://files.pythonhosted.org/packages/77/42/f1bf1549b432d4a78bfa95735b79b5dac75b65b5bb815bba86ad406ead0a/orjson-3.11.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:894aea2e63d4f24a7f04a1908307c738d0dce992e9249e744b8f4e8dd9197f39", size = 132060, upload-time = "2025-12-06T15:54:43.531Z" },
    { url = "https://files.pythonhosted.org/packages/25/49/825aa6b929f1a6ed244c78acd7b22c1481fd7e5fda047dc8bf4c1a807eb6/orjson-3.11.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ddc21521598dbe369d83d4d40338e23d4101dad21dae0e79fa20465dbace019f", size = 130391, upload-time = "2025-12-06T15:54:45.059Z" },
    { url = "https://files.pythonhosted.org/packages/42/ec/de55391858b49e16e1aa8f0bbbb7e5997b7345d8e984a2dec3746d13065b/orjson-3.11.5-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7cce16ae2f5fb2c53c3eafdd1706cb7b6530a67cc1c17abe8ec747f5cd7c0c51", size = 135964, upload-time = "2025-12-06T15:54:46.576Z" },
    { url = "https://files.pythonhosted.org/packages/1c/40/820bc63121d2d28818556a2d0a09384a9f0262407cf9fa305e091a8048df/orjson-3.11.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e46c762d9f0e1cfb4ccc8515de7f349abbc95b59cb5a2bd68df5973fdef913f8", size = 139817, upload-time = "2025-12-06T15:54:48.084Z" },
    { url = "https://files.pythonhosted.org/packages/09/c7/3a445ca9a84a0d59d26365fd8898ff52bdfcdcb825bcc6519830371d2364/orjson-3.11.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d7345c759276b798ccd6d77a87136029e71e66a8bbf2d2755cbdde1d82e78706", size = 137336, upload-time = "2025-12-06T15:54:49.426Z" },
    { url = "https://files.pythonhosted.org/packages/9a/b3/dc0d3771f2e5d1f13368f56b339c6782f955c6a20b50465a91acb79fe961/orjson-3.11.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75bc2e59e6a2ac1dd28901d07115abdebc4563b5b07dd612bf64260a201b1c7f", size = 138993, upload-time = "2025-12-06T15:54:50.939Z" },
    { url = "https://files.pythonhosted.org/packages/d1/a2/65267e959de6abe23444659b6e19c888f242bf7725ff927e2292776f6b89/orjson-3.11.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:54aae9b654554c3b4edd61896b978568c6daa16af96fa4681c9b5babd469f863", size = 141070, upload-time = "2025-12-06T15:54:52.414Z" },
    { url = "https://files.pythonhosted.org/packages/63/c9/da44a321b288727a322c6ab17e1754195708786a04f4f9d2220a5076a649/orjson-3.11.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:4bdd8d164a871c4ec773f9de0f6fe8769c2d6727879c37a9666ba4183b7f8228", size = 413505, upload-time = "2025-12-06T15:54:53.67Z" },
    { url = "https://files.pythonhosted.org/packages/7f/17/68dc14fa7000eefb3d4d6d7326a190c99bb65e319f02747ef3ebf2452f12/orjson-3.11.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a261fef929bcf98a60713bf5e95ad067cea16ae345d9a35034e73c3990e927d2", size = 151342, upload-time = "2025-12-06T15:54:55.113Z" },
    { url = "https://files.pythonhosted.org/packages/c4/c5/ccee774b67225bed630a57478529fc026eda33d94fe4c0eac8fe58d4aa52/orjson-3.11.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c028a394c766693c5c9909dec76b24f37e6a1b91999e8d0c0d5feecbe93c3e05", size = 141823, upload-time = "2025-12-06T15:54:56.331Z" },
    { url = "https://files.pythonhosted.org/packages/67/80/5d00e4155d0cd7390ae2087130637671da713959bb558db9bac5e6f6b042/orjson-3.11.5-cp313-cp313-win32.whl", hash = "sha256:2cc79aaad1dfabe1bd2d50ee09814a1253164b3da4c00a78c458d82d04b3bdef", size = 135236, upload-time = "2025-12-06T15:54:57.507Z" },
    { url = "https://files.pythonhosted.org/packages/95/fe/792cc06a84808dbdc20ac6eab6811c53091b42f8e51ecebf14b540e9cfe4/orjson-3.11.5-cp313-cp313-win_amd64.whl", hash = "sha256:ff7877d376add4e16b274e35a3f58b7f37b362abf4aa31863dadacdd20e3a583", size = 133167, upload-time = "2025-12-06T15:54:58.71Z" },
    { url = "https://files.pythonhosted.org/packages/46/2c/d158bd8b50e3b1cfdcf406a7e463f6ffe3f0d167b99634717acdaf5e299f/orjson-3.11.5-cp313-cp313-win_arm64.whl", hash = "sha256:59ac72ea775c88b163ba8d21b0177628bd015c5dd060647bbab6e22da3aad287", size = 126712, upload-time = "2025-12-06T15:54:59.892Z" },
    { url = "https://files.pythonhosted.org/packages/c2/60/77d7b839e317ead7bb225d55bb50f7ea75f47afc489c81199befc5435b50/orjson-3.11.5-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e446a8ea0a4c366ceafc7d97067bfd55292969143b57e3c846d87fc701e797a0", size = 245252, upload-time = "2025-12-06T15:55:01.127Z" },
    { url = "https://files.pythonhosted.org/packages/f1/aa/d4639163b400f8044cef0fb9aa51b0337be0da3a27187a20d1166e742370/orjson-3.11.5-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:53deb5addae9c22bbe3739298f5f2196afa881ea75944e7720681c7080909a81", size = 129419, upload-time = "2025-12-06T15:55:02.723Z" },
    { url = "https://files.pythonhosted.org/packages/30/94/9eabf94f2e11c671111139edf5ec410d2f21e6feee717804f7e8872d883f/orjson-3.11.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82cd00d49d6063d2b8791da5d4f9d20539c5951f965e45ccf4e96d33505ce68f", size = 132050, upload-time = "2025-12-06T15:55:03.918Z" },
    { url = "https://files.pythonhosted.org/packages/3d/c8/ca10f5c5322f341ea9a9f1097e140be17a88f88d1cfdd29df522970d9744/orjson-3.11.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3fd15f9fc8c203aeceff4fda211157fad114dde66e92e24097b3647a08f4ee9e", size = 130370, upload-time = "2025-12-06T15:55:05.173Z" },
    { url = "https://files.pythonhosted.org/packages/25/d4/e96824476d361ee2edd5c6290ceb8d7edf88d81148a6ce172fc00278ca7f/orjson-3.11.5-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9df95000fbe6777bf9820ae82ab7578e8662051bb5f83d71a28992f539d2cda7", size = 136012, upload-time = "2025-12-06T15:55:06.402Z" },
    { url = "https://files.pythonhosted.org/packages/85/8e/9bc3423308c425c588903f2d103cfcfe2539e07a25d6522900645a6f257f/orjson-3.11.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92a8d676748fca47ade5bc3da7430ed7767afe51b2f8100e3cd65e151c0eaceb", size = 139809, upload-time = "2025-12-06T15:55:07.656Z" },
    { url = "https://files.pythonhosted.org/packages/e9/3c/b404e94e0b02a232b957c54643ce68d0268dacb67ac33ffdee24008c8b27/orjson-3.11.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aa0f513be38b40234c77975e68805506cad5d57b3dfd8fe3baa7f4f4051e15b4", size = 137332, upload-time = "2025-12-06T15:55:08.961Z" },
    { url = "https://files.pythonhosted.org/packages/51/30/cc2d69d5ce0ad9b84811cdf4a0cd5362ac27205a921da524ff42f26d65e0/orjson-3.11.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa1863e75b92891f553b7922ce4ee10ed06db061e104f2b7815de80cdcb135ad", size = 138983, upload-time = "2025-12-06T15:55:10.595Z" },
    { url = "https://files.pythonhosted.org/packages/0e/87/de3223944a3e297d4707d2fe3b1ffb71437550e165eaf0ca8bbe43ccbcb1/orjson-3.11.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4be86b58e9ea262617b8ca6251a2f0d63cc132a6da4b5fcc8e0a4128782c829", size = 141069, upload-time = "2025-12-06T15:55:11.832Z" },
    { url = "https://files.pythonhosted.org/packages/65/30/81d5087ae74be33bcae3ff2d80f5ccaa4a8fedc6d39bf65a427a95b8977f/orjson-3.11.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:b923c1c13fa02084eb38c9c065afd860a5cff58026813319a06949c3af5732ac", size = 413491, upload-time = "2025-12-06T15:55:13.314Z" },
    { url = "https://files.pythonhosted.org/packages/d0/6f/f6058c21e2fc1efaf918986dbc2da5cd38044f1a2d4b7b91ad17c4acf786/orjson-3.11.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:1b6bd351202b2cd987f35a13b5e16471cf4d952b42a73c391cc537974c43ef6d", size = 151375, upload-time = "2025-12-06T15:55:14.715Z" },
    { url = "https://files.pythonhosted.org/packages/54/92/c6921f17d45e110892899a7a563a925b2273d929959ce2ad89e2525b885b/orjson-3.11.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bb150d529637d541e6af06bbe3d02f5498d628b7f98267ff87647584293ab439", size = 141850, upload-time = "2025-12-06T15:55:15.94Z" },
    { url = "https://files.pythonhosted.org/packages/88/86/cdecb0140a05e1a477b81f24739da93b25070ee01ce7f7242f44a6437594/orjson-3.11.5-cp314-cp314-win32.whl", hash = "sha256:9cc1e55c884921434a84a0c3dd2699eb9f92e7b441d7f53f3941079ec6ce7499", size = 135278, upload-time = "2025-12-06T15:55:17.202Z" },
    { url = "https://files.pythonhosted.org/packages/e4/97/b638d69b1e947d24f6109216997e38922d54dcdcdb1b11c18d7efd2d3c59/orjson-3.11.5-cp314-cp314-win_amd64.whl", hash = "sha256:a4f3cb2d874e03bc7767c8f88adaa1a9a05cecea3712649c3b58589ec7317310", size = 133170, upload-time = "2025-12-06T15:55:18.468Z" },
    { url = "https://files.pythonhosted.org/packages/8f/dd/f4fff4a6fe601b4f8f3ba3aa6da8ac33d17d124491a3b804c662a70e1636/orjson-3.11.5-cp314-cp314-win_arm64.whl", hash = "sha256:38b22f476c351f9a1c43e5b07d8b5a02eb24a6ab8e75f700f7d479d4568346a5", size = 126713, upload-time = "2025-12-06T15:55:19.738Z" },
    { url = "https://files.pythonhosted.org/packages/50/c7/7b682849dd4c9fb701a981669b964ea700516ecbd8e88f62aae07c6852bd/orjson-3.11.5-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:1b280e2d2d284a6713b0cfec7b08918ebe57df23e3f76b27586197afca3cb1e9", size = 245298, upload-time = "2025-12-06T15:55:20.984Z" },
    { url = "https://files.pythonhosted.org/packages/1b/3f/194355a9335707a15fdc79ddc670148987b43d04712dd26898a694539ce6/orjson-3.11.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c8d8a112b274fae8c5f0f01954cb0480137072c271f3f4958127b010dfefaec", size = 132150, upload-time = "2025-12-06T15:55:22.364Z" },
    { url = "https://files.pythonhosted.org/packages/e9/08/d74b3a986d37e6c2e04b8821c62927620c9a1924bb49ea51519a87751b86/orjson-3.11.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f0a2ae6f09ac7bd47d2d5a5305c1d9ed08ac057cda55bb0a49fa506f0d2da00", size = 130490, upload-time = "2025-12-06T15:55:23.619Z" },
    { url = "https://files.pythonhosted.org/packages/b2/16/ebd04c38c1db01e493a68eee442efdffc505a43112eccd481e0146c6acc2/orjson-3.11.5-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c0d87bd1896faac0d10b4f849016db81a63e4ec5df38757ffae84d45ab38aa71", size = 135726, upload-time = "2025-12-06T15:55:24.912Z" },
    { url = "https://files.pythonhosted.org/packages/06/64/2ce4b2c09a099403081c37639c224bdcdfe401138bd66fed5c96d4f8dbd3/orjson-3.11.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:801a821e8e6099b8c459ac7540b3c32dba6013437c57fdcaec205b169754f38c", size = 139640, upload-time = "2025-12-06T15:55:26.535Z" },
    { url = "https://files.pythonhosted.org/packages/cd/e2/425796df8ee1d7cea3a7edf868920121dd09162859dbb76fffc9a5c37fd3/orjson-3.11.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:69a0f6ac618c98c74b7fbc8c0172ba86f9e01dbf9f62aa0b1776c2231a7bffe5", size = 137289, upload-time = "2025-12-06T15:55:27.78Z" },
    { url = "https://files.pythonhosted.org/packages/32/a2/88e482eb8e899a037dcc9eff85ef117a568e6ca1ffa1a2b2be3fcb51b7bb/orjson-3.11.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fea7339bdd22e6f1060c55ac31b6a755d86a5b2ad3657f2669ec243f8e3b2bdb", size = 138761, upload-time = "2025-12-06T15:55:29.388Z" },
    { url = "https://files.pythonhosted.org/packages/f1/fd/131dd6d32eeb74c513bfa487f434a2150811d0fbd9cb06689284f2f21b34/orjson-3.11.5-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4dad582bc93cef8f26513e12771e76385a7e6187fd713157e971c784112aad56", size = 141357, upload-time = "2025-12-06T15:55:31.064Z" },
    { url = "https://files.pythonhosted.org/packages/7a/90/e4a0abbcca7b53e9098ac854f27f5ed9949c796f3c760bc04af997da0eb2/orjson-3.11.5-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:0522003e9f7fba91982e83a97fec0708f5a714c96c4209db7104e6b9d132f111", size = 413638, upload-time = "2025-12-06T15:55:32.344Z" },
    { url = "https://files.pythonhosted.org/packages/d1/c2/df91e385514924120001ade9cd52d6295251023d3bfa2c0a01f38cfc485a/orjson-3.11.5-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:7403851e430a478440ecc1258bcbacbfbd8175f9ac1e39031a7121dd0de05ff8", size = 150972, upload-time = "2025-12-06T15:55:33.725Z" },
    { url = "https://files.pythonhosted.org/packages/a6/ff/c76cc5a30a4451191ff1b868a331ad1354433335277fc40931f5fc3cab9d/orjson-3.11.5-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5f691263425d3177977c8d1dd896cde7b98d93cbf390b2544a090675e83a6a0a", size = 141729, upload-time = "2025-12-06T15:55:35.317Z" },
    { url = "https://files.pythonhosted.org/packages/27/c3/7830bf74389ea1eaab2b017d8b15d1cab2bb0737d9412dfa7fb8644f7d78/orjson-3.11.5-cp39-cp39-win32.whl", hash = "sha256:61026196a1c4b968e1b1e540563e277843082e9e97d78afa03eb89315af531f1", size = 135100, upload-time = "2025-12-06T15:55:36.57Z" },
    { url = "https://files.pythonhosted.org/packages/69/e6/babf31154e047e465bc194eb72d1326d7c52ad4d7f50bf92b02b3cacda5c/orjson-3.11.5-cp39-cp39-win_amd64.whl", hash = "sha256:09b94b947ac08586af635ef922d69dc9bc63321527a3a04647f4986a73f4bd30", size = 133189, upload-time = "2025-12-06T15:55:38.143Z" },
]

[[package]]
name = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]

[[package]]
name = "passlib"
version = "1.7.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b6/06/9da9ee59a67fae7761aab3ccc84fa4f3f33f125b370f1ccdb915bf967c11/passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04", size = 689844, upload-time = "2020-10-08T19:00:52.121Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", size = 525554, upload-time = "2020-10-08T19:00:49.856Z" },
]

[package.optional-dependencies]
argon2 = [
    { name = "argon2-cffi", version = "23.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "argon2-cffi", version = "25.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]

[[package]]
name = "pathspec"
version = "1.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" },
]

[[package]]
name = "pgvector"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" },
    { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/25/6c/6d8b4b03b958c02fa8687ec6063c49d952a189f8c91ebbe51e877dfab8f7/pgvector-0.4.2.tar.gz", hash = "sha256:322cac0c1dc5d41c9ecf782bd9991b7966685dee3a00bc873631391ed949513a", size = 31354, upload-time = "2025-12-05T01:07:17.87Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/5a/26/6cee8a1ce8c43625ec561aff19df07f9776b7525d9002c86bceb3e0ac970/pgvector-0.4.2-py3-none-any.whl", hash = "sha256:549d45f7a18593783d5eec609ea1684a724ba8405c4cb182a0b2b08aeff04e08", size = 27441, upload-time = "2025-12-05T01:07:16.536Z" },
]

[[package]]
name = "platformdirs"
version = "4.4.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
sdist = { url = "https://files.pythonhosted.org/packages/23/e8/21db9c9987b0e728855bd57bff6984f67952bea55d6f75e055c46b5383e8/platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf", size = 21634, upload-time = "2025-08-26T14:32:04.268Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654, upload-time = "2025-08-26T14:32:02.735Z" },
]

[[package]]
name = "platformdirs"
version = "4.9.4"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" },
]

[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]

[[package]]
name = "polyfactory"
version = "3.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "faker", version = "37.12.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "faker", version = "40.13.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/85/68/7717bd9e63ed254617a7d3dc9260904fb736d6ea203e58ffddcb186c64e4/polyfactory-3.3.0.tar.gz", hash = "sha256:237258b6ff43edf362ffd1f68086bb796466f786adfa002b0ac256dbf2246e9a", size = 348668, upload-time = "2026-02-22T09:46:28.01Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/dd/34/b6f19941adcdaf415b5e8a8d577499f5b6a76b59cbae37f9b125a9ffe9f2/polyfactory-3.3.0-py3-none-any.whl", hash = "sha256:686abcaa761930d3df87b91e95b26b8d8cb9fdbbbe0b03d5f918acff5c72606e", size = 62707, upload-time = "2026-02-22T09:46:25.985Z" },
]

[[package]]
name = "pre-commit"
version = "4.3.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
dependencies = [
    { name = "cfgv", version = "3.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "identify", version = "2.6.15", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "nodeenv", marker = "python_full_version < '3.10'" },
    { name = "pyyaml", marker = "python_full_version < '3.10'" },
    { name = "virtualenv", marker = "python_full_version < '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ff/29/7cf5bbc236333876e4b41f56e06857a87937ce4bf91e117a6991a2dbb02a/pre_commit-4.3.0.tar.gz", hash = "sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16", size = 193792, upload-time = "2025-08-09T18:56:14.651Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8", size = 220965, upload-time = "2025-08-09T18:56:13.192Z" },
]

[[package]]
name = "pre-commit"
version = "4.5.1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
dependencies = [
    { name = "cfgv", version = "3.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "identify", version = "2.6.18", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "nodeenv", marker = "python_full_version >= '3.10'" },
    { name = "pyyaml", marker = "python_full_version >= '3.10'" },
    { name = "virtualenv", marker = "python_full_version >= '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" },
]

[[package]]
name = "prompt-toolkit"
version = "3.0.52"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "wcwidth" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" },
]

[[package]]
name = "propcache"
version = "0.4.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/3c/0e/934b541323035566a9af292dba85a195f7b78179114f2c6ebb24551118a9/propcache-0.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c2d1fa3201efaf55d730400d945b5b3ab6e672e100ba0f9a409d950ab25d7db", size = 79534, upload-time = "2025-10-08T19:46:02.083Z" },
    { url = "https://files.pythonhosted.org/packages/a1/6b/db0d03d96726d995dc7171286c6ba9d8d14251f37433890f88368951a44e/propcache-0.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1eb2994229cc8ce7fe9b3db88f5465f5fd8651672840b2e426b88cdb1a30aac8", size = 45526, upload-time = "2025-10-08T19:46:03.884Z" },
    { url = "https://files.pythonhosted.org/packages/e4/c3/82728404aea669e1600f304f2609cde9e665c18df5a11cdd57ed73c1dceb/propcache-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:66c1f011f45a3b33d7bcb22daed4b29c0c9e2224758b6be00686731e1b46f925", size = 47263, upload-time = "2025-10-08T19:46:05.405Z" },
    { url = "https://files.pythonhosted.org/packages/df/1b/39313ddad2bf9187a1432654c38249bab4562ef535ef07f5eb6eb04d0b1b/propcache-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9a52009f2adffe195d0b605c25ec929d26b36ef986ba85244891dee3b294df21", size = 201012, upload-time = "2025-10-08T19:46:07.165Z" },
    { url = "https://files.pythonhosted.org/packages/5b/01/f1d0b57d136f294a142acf97f4ed58c8e5b974c21e543000968357115011/propcache-0.4.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5d4e2366a9c7b837555cf02fb9be2e3167d333aff716332ef1b7c3a142ec40c5", size = 209491, upload-time = "2025-10-08T19:46:08.909Z" },
    { url = "https://files.pythonhosted.org/packages/a1/c8/038d909c61c5bb039070b3fb02ad5cccdb1dde0d714792e251cdb17c9c05/propcache-0.4.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9d2b6caef873b4f09e26ea7e33d65f42b944837563a47a94719cc3544319a0db", size = 215319, upload-time = "2025-10-08T19:46:10.7Z" },
    { url = "https://files.pythonhosted.org/packages/08/57/8c87e93142b2c1fa2408e45695205a7ba05fb5db458c0bf5c06ba0e09ea6/propcache-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2b16ec437a8c8a965ecf95739448dd938b5c7f56e67ea009f4300d8df05f32b7", size = 196856, upload-time = "2025-10-08T19:46:12.003Z" },
    { url = "https://files.pythonhosted.org/packages/42/df/5615fec76aa561987a534759b3686008a288e73107faa49a8ae5795a9f7a/propcache-0.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:296f4c8ed03ca7476813fe666c9ea97869a8d7aec972618671b33a38a5182ef4", size = 193241, upload-time = "2025-10-08T19:46:13.495Z" },
    { url = "https://files.pythonhosted.org/packages/d5/21/62949eb3a7a54afe8327011c90aca7e03547787a88fb8bd9726806482fea/propcache-0.4.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:1f0978529a418ebd1f49dad413a2b68af33f85d5c5ca5c6ca2a3bed375a7ac60", size = 190552, upload-time = "2025-10-08T19:46:14.938Z" },
    { url = "https://files.pythonhosted.org/packages/30/ee/ab4d727dd70806e5b4de96a798ae7ac6e4d42516f030ee60522474b6b332/propcache-0.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fd138803047fb4c062b1c1dd95462f5209456bfab55c734458f15d11da288f8f", size = 200113, upload-time = "2025-10-08T19:46:16.695Z" },
    { url = "https://files.pythonhosted.org/packages/8a/0b/38b46208e6711b016aa8966a3ac793eee0d05c7159d8342aa27fc0bc365e/propcache-0.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8c9b3cbe4584636d72ff556d9036e0c9317fa27b3ac1f0f558e7e84d1c9c5900", size = 200778, upload-time = "2025-10-08T19:46:18.023Z" },
    { url = "https://files.pythonhosted.org/packages/cf/81/5abec54355ed344476bee711e9f04815d4b00a311ab0535599204eecc257/propcache-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f93243fdc5657247533273ac4f86ae106cc6445a0efacb9a1bfe982fcfefd90c", size = 193047, upload-time = "2025-10-08T19:46:19.449Z" },
    { url = "https://files.pythonhosted.org/packages/ec/b6/1f237c04e32063cb034acd5f6ef34ef3a394f75502e72703545631ab1ef6/propcache-0.4.1-cp310-cp310-win32.whl", hash = "sha256:a0ee98db9c5f80785b266eb805016e36058ac72c51a064040f2bc43b61101cdb", size = 38093, upload-time = "2025-10-08T19:46:20.643Z" },
    { url = "https://files.pythonhosted.org/packages/a6/67/354aac4e0603a15f76439caf0427781bcd6797f370377f75a642133bc954/propcache-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:1cdb7988c4e5ac7f6d175a28a9aa0c94cb6f2ebe52756a3c0cda98d2809a9e37", size = 41638, upload-time = "2025-10-08T19:46:21.935Z" },
    { url = "https://files.pythonhosted.org/packages/e0/e1/74e55b9fd1a4c209ff1a9a824bf6c8b3d1fc5a1ac3eabe23462637466785/propcache-0.4.1-cp310-cp310-win_arm64.whl", hash = "sha256:d82ad62b19645419fe79dd63b3f9253e15b30e955c0170e5cebc350c1844e581", size = 38229, upload-time = "2025-10-08T19:46:23.368Z" },
    { url = "https://files.pythonhosted.org/packages/8c/d4/4e2c9aaf7ac2242b9358f98dccd8f90f2605402f5afeff6c578682c2c491/propcache-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf", size = 80208, upload-time = "2025-10-08T19:46:24.597Z" },
    { url = "https://files.pythonhosted.org/packages/c2/21/d7b68e911f9c8e18e4ae43bdbc1e1e9bbd971f8866eb81608947b6f585ff/propcache-0.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5", size = 45777, upload-time = "2025-10-08T19:46:25.733Z" },
    { url = "https://files.pythonhosted.org/packages/d3/1d/11605e99ac8ea9435651ee71ab4cb4bf03f0949586246476a25aadfec54a/propcache-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e", size = 47647, upload-time = "2025-10-08T19:46:27.304Z" },
    { url = "https://files.pythonhosted.org/packages/58/1a/3c62c127a8466c9c843bccb503d40a273e5cc69838805f322e2826509e0d/propcache-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566", size = 214929, upload-time = "2025-10-08T19:46:28.62Z" },
    { url = "https://files.pythonhosted.org/packages/56/b9/8fa98f850960b367c4b8fe0592e7fc341daa7a9462e925228f10a60cf74f/propcache-0.4.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165", size = 221778, upload-time = "2025-10-08T19:46:30.358Z" },
    { url = "https://files.pythonhosted.org/packages/46/a6/0ab4f660eb59649d14b3d3d65c439421cf2f87fe5dd68591cbe3c1e78a89/propcache-0.4.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc", size = 228144, upload-time = "2025-10-08T19:46:32.607Z" },
    { url = "https://files.pythonhosted.org/packages/52/6a/57f43e054fb3d3a56ac9fc532bc684fc6169a26c75c353e65425b3e56eef/propcache-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48", size = 210030, upload-time = "2025-10-08T19:46:33.969Z" },
    { url = "https://files.pythonhosted.org/packages/40/e2/27e6feebb5f6b8408fa29f5efbb765cd54c153ac77314d27e457a3e993b7/propcache-0.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570", size = 208252, upload-time = "2025-10-08T19:46:35.309Z" },
    { url = "https://files.pythonhosted.org/packages/9e/f8/91c27b22ccda1dbc7967f921c42825564fa5336a01ecd72eb78a9f4f53c2/propcache-0.4.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85", size = 202064, upload-time = "2025-10-08T19:46:36.993Z" },
    { url = "https://files.pythonhosted.org/packages/f2/26/7f00bd6bd1adba5aafe5f4a66390f243acab58eab24ff1a08bebb2ef9d40/propcache-0.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e", size = 212429, upload-time = "2025-10-08T19:46:38.398Z" },
    { url = "https://files.pythonhosted.org/packages/84/89/fd108ba7815c1117ddca79c228f3f8a15fc82a73bca8b142eb5de13b2785/propcache-0.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757", size = 216727, upload-time = "2025-10-08T19:46:39.732Z" },
    { url = "https://files.pythonhosted.org/packages/79/37/3ec3f7e3173e73f1d600495d8b545b53802cbf35506e5732dd8578db3724/propcache-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f", size = 205097, upload-time = "2025-10-08T19:46:41.025Z" },
    { url = "https://files.pythonhosted.org/packages/61/b0/b2631c19793f869d35f47d5a3a56fb19e9160d3c119f15ac7344fc3ccae7/propcache-0.4.1-cp311-cp311-win32.whl", hash = "sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1", size = 38084, upload-time = "2025-10-08T19:46:42.693Z" },
    { url = "https://files.pythonhosted.org/packages/f4/78/6cce448e2098e9f3bfc91bb877f06aa24b6ccace872e39c53b2f707c4648/propcache-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6", size = 41637, upload-time = "2025-10-08T19:46:43.778Z" },
    { url = "https://files.pythonhosted.org/packages/9c/e9/754f180cccd7f51a39913782c74717c581b9cc8177ad0e949f4d51812383/propcache-0.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239", size = 38064, upload-time = "2025-10-08T19:46:44.872Z" },
    { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061, upload-time = "2025-10-08T19:46:46.075Z" },
    { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037, upload-time = "2025-10-08T19:46:47.23Z" },
    { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324, upload-time = "2025-10-08T19:46:48.384Z" },
    { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505, upload-time = "2025-10-08T19:46:50.055Z" },
    { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242, upload-time = "2025-10-08T19:46:51.815Z" },
    { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474, upload-time = "2025-10-08T19:46:53.208Z" },
    { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575, upload-time = "2025-10-08T19:46:54.511Z" },
    { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736, upload-time = "2025-10-08T19:46:56.212Z" },
    { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019, upload-time = "2025-10-08T19:46:57.595Z" },
    { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376, upload-time = "2025-10-08T19:46:59.067Z" },
    { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988, upload-time = "2025-10-08T19:47:00.544Z" },
    { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615, upload-time = "2025-10-08T19:47:01.968Z" },
    { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066, upload-time = "2025-10-08T19:47:03.503Z" },
    { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655, upload-time = "2025-10-08T19:47:04.973Z" },
    { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789, upload-time = "2025-10-08T19:47:06.077Z" },
    { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" },
    { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" },
    { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" },
    { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" },
    { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" },
    { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" },
    { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" },
    { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" },
    { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" },
    { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" },
    { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" },
    { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" },
    { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" },
    { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" },
    { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" },
    { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" },
    { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" },
    { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" },
    { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" },
    { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" },
    { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" },
    { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" },
    { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" },
    { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" },
    { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" },
    { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" },
    { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" },
    { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" },
    { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" },
    { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" },
    { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" },
    { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" },
    { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" },
    { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" },
    { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" },
    { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" },
    { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" },
    { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" },
    { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" },
    { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" },
    { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" },
    { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" },
    { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" },
    { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" },
    { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" },
    { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" },
    { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" },
    { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" },
    { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" },
    { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" },
    { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" },
    { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" },
    { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" },
    { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" },
    { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" },
    { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" },
    { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" },
    { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" },
    { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" },
    { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" },
    { url = "https://files.pythonhosted.org/packages/9b/01/0ebaec9003f5d619a7475165961f8e3083cf8644d704b60395df3601632d/propcache-0.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3d233076ccf9e450c8b3bc6720af226b898ef5d051a2d145f7d765e6e9f9bcff", size = 80277, upload-time = "2025-10-08T19:48:36.647Z" },
    { url = "https://files.pythonhosted.org/packages/34/58/04af97ac586b4ef6b9026c3fd36ee7798b737a832f5d3440a4280dcebd3a/propcache-0.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:357f5bb5c377a82e105e44bd3d52ba22b616f7b9773714bff93573988ef0a5fb", size = 45865, upload-time = "2025-10-08T19:48:37.859Z" },
    { url = "https://files.pythonhosted.org/packages/7c/19/b65d98ae21384518b291d9939e24a8aeac4fdb5101b732576f8f7540e834/propcache-0.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cbc3b6dfc728105b2a57c06791eb07a94229202ea75c59db644d7d496b698cac", size = 47636, upload-time = "2025-10-08T19:48:39.038Z" },
    { url = "https://files.pythonhosted.org/packages/b3/0f/317048c6d91c356c7154dca5af019e6effeb7ee15fa6a6db327cc19e12b4/propcache-0.4.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:182b51b421f0501952d938dc0b0eb45246a5b5153c50d42b495ad5fb7517c888", size = 201126, upload-time = "2025-10-08T19:48:40.774Z" },
    { url = "https://files.pythonhosted.org/packages/71/69/0b2a7a5a6ee83292b4b997dbd80549d8ce7d40b6397c1646c0d9495f5a85/propcache-0.4.1-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4b536b39c5199b96fc6245eb5fb796c497381d3942f169e44e8e392b29c9ebcc", size = 209837, upload-time = "2025-10-08T19:48:42.167Z" },
    { url = "https://files.pythonhosted.org/packages/a5/92/c699ac495a6698df6e497fc2de27af4b6ace10d8e76528357ce153722e45/propcache-0.4.1-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:db65d2af507bbfbdcedb254a11149f894169d90488dd3e7190f7cdcb2d6cd57a", size = 215578, upload-time = "2025-10-08T19:48:43.56Z" },
    { url = "https://files.pythonhosted.org/packages/b3/ee/14de81c5eb02c0ee4f500b4e39c4e1bd0677c06e72379e6ab18923c773fc/propcache-0.4.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd2dbc472da1f772a4dae4fa24be938a6c544671a912e30529984dd80400cd88", size = 197187, upload-time = "2025-10-08T19:48:45.309Z" },
    { url = "https://files.pythonhosted.org/packages/1d/94/48dce9aaa6d8dd5a0859bad75158ec522546d4ac23f8e2f05fac469477dd/propcache-0.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:daede9cd44e0f8bdd9e6cc9a607fc81feb80fae7a5fc6cecaff0e0bb32e42d00", size = 193478, upload-time = "2025-10-08T19:48:47.743Z" },
    { url = "https://files.pythonhosted.org/packages/60/b5/0516b563e801e1ace212afde869a0596a0d7115eec0b12d296d75633fb29/propcache-0.4.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:71b749281b816793678ae7f3d0d84bd36e694953822eaad408d682efc5ca18e0", size = 190650, upload-time = "2025-10-08T19:48:49.373Z" },
    { url = "https://files.pythonhosted.org/packages/24/89/e0f7d4a5978cd56f8cd67735f74052f257dc471ec901694e430f0d1572fe/propcache-0.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:0002004213ee1f36cfb3f9a42b5066100c44276b9b72b4e1504cddd3d692e86e", size = 200251, upload-time = "2025-10-08T19:48:51.4Z" },
    { url = "https://files.pythonhosted.org/packages/06/7d/a1fac863d473876ed4406c914f2e14aa82d2f10dd207c9e16fc383cc5a24/propcache-0.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:fe49d0a85038f36ba9e3ffafa1103e61170b28e95b16622e11be0a0ea07c6781", size = 200919, upload-time = "2025-10-08T19:48:53.227Z" },
    { url = "https://files.pythonhosted.org/packages/c3/4e/f86a256ff24944cf5743e4e6c6994e3526f6acfcfb55e21694c2424f758c/propcache-0.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:99d43339c83aaf4d32bda60928231848eee470c6bda8d02599cc4cebe872d183", size = 193211, upload-time = "2025-10-08T19:48:55.027Z" },
    { url = "https://files.pythonhosted.org/packages/6e/3f/3fbad5f4356b068f1b047d300a6ff2c66614d7030f078cd50be3fec04228/propcache-0.4.1-cp39-cp39-win32.whl", hash = "sha256:a129e76735bc792794d5177069691c3217898b9f5cee2b2661471e52ffe13f19", size = 38314, upload-time = "2025-10-08T19:48:56.792Z" },
    { url = "https://files.pythonhosted.org/packages/a4/45/d78d136c3a3d215677abb886785aae744da2c3005bcb99e58640c56529b1/propcache-0.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:948dab269721ae9a87fd16c514a0a2c2a1bdb23a9a61b969b0f9d9ee2968546f", size = 41912, upload-time = "2025-10-08T19:48:57.995Z" },
    { url = "https://files.pythonhosted.org/packages/fc/2a/b0632941f25139f4e58450b307242951f7c2717a5704977c6d5323a800af/propcache-0.4.1-cp39-cp39-win_arm64.whl", hash = "sha256:5fd37c406dd6dc85aa743e214cef35dc54bbdd1419baac4f6ae5e5b1a2976938", size = 38450, upload-time = "2025-10-08T19:48:59.349Z" },
    { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" },
]

[[package]]
name = "proto-plus"
version = "1.27.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/81/0d/94dfe80193e79d55258345901acd2917523d56e8381bc4dee7fd38e3868a/proto_plus-1.27.2.tar.gz", hash = "sha256:b2adde53adadf75737c44d3dcb0104fde65250dfc83ad59168b4aa3e574b6a24", size = 57204, upload-time = "2026-03-26T22:18:57.174Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/84/f3/1fba73eeffafc998a25d59703b63f8be4fe8a5cb12eaff7386a0ba0f7125/proto_plus-1.27.2-py3-none-any.whl", hash = "sha256:6432f75893d3b9e70b9c412f1d2f03f65b11fb164b793d14ae2ca01821d22718", size = 50450, upload-time = "2026-03-26T22:13:42.927Z" },
]

[[package]]
name = "protobuf"
version = "6.33.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" },
    { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" },
    { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" },
    { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" },
    { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" },
    { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" },
    { url = "https://files.pythonhosted.org/packages/0c/bd/88a687e9147329fc7e6c26a058fc52214c47190688a496bb283000a4d2a3/protobuf-6.33.6-cp39-cp39-win32.whl", hash = "sha256:bd56799fb262994b2c2faa1799693c95cc2e22c62f56fb43af311cae45d26f0e", size = 425861, upload-time = "2026-03-18T19:04:57.064Z" },
    { url = "https://files.pythonhosted.org/packages/84/d6/fab384eea064bfc3b273183e4e09bb3a3cf4ec83876b3828c09fcacbb651/protobuf-6.33.6-cp39-cp39-win_amd64.whl", hash = "sha256:f443a394af5ed23672bc6c486be138628fbe5c651ccbc536873d7da23d1868cf", size = 437109, upload-time = "2026-03-18T19:04:58.713Z" },
    { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" },
]

[[package]]
name = "psycopg"
version = "3.2.13"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
dependencies = [
    { name = "typing-extensions", marker = "python_full_version < '3.10'" },
    { name = "tzdata", marker = "python_full_version < '3.10' and sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/44/05/d4a05988f15fcf90e0088c735b1f2fc04a30b7fc65461d6ec278f5f2f17a/psycopg-3.2.13.tar.gz", hash = "sha256:309adaeda61d44556046ec9a83a93f42bbe5310120b1995f3af49ab6d9f13c1d", size = 160626, upload-time = "2025-11-21T22:34:32.328Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/a9/14/f2724bd1986158a348316e86fdd0837a838b14a711df3f00e47fba597447/psycopg-3.2.13-py3-none-any.whl", hash = "sha256:a481374514f2da627157f767a9336705ebefe93ea7a0522a6cbacba165da179a", size = 206797, upload-time = "2025-11-21T22:29:39.733Z" },
]

[package.optional-dependencies]
binary = [
    { name = "psycopg-binary", version = "3.2.13", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' and implementation_name != 'pypy'" },
]
pool = [
    { name = "psycopg-pool", version = "3.2.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
]

[[package]]
name = "psycopg"
version = "3.3.3"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
dependencies = [
    { name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.13'" },
    { name = "tzdata", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d3/b6/379d0a960f8f435ec78720462fd94c4863e7a31237cf81bf76d0af5883bf/psycopg-3.3.3.tar.gz", hash = "sha256:5e9a47458b3c1583326513b2556a2a9473a1001a56c9efe9e587245b43148dd9", size = 165624, upload-time = "2026-02-18T16:52:16.546Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/c8/5b/181e2e3becb7672b502f0ed7f16ed7352aca7c109cfb94cf3878a9186db9/psycopg-3.3.3-py3-none-any.whl", hash = "sha256:f96525a72bcfade6584ab17e89de415ff360748c766f0106959144dcbb38c698", size = 212768, upload-time = "2026-02-18T16:46:27.365Z" },
]

[package.optional-dependencies]
binary = [
    { name = "psycopg-binary", version = "3.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and implementation_name != 'pypy'" },
]
pool = [
    { name = "psycopg-pool", version = "3.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]

[[package]]
name = "psycopg-binary"
version = "3.2.13"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
wheels = [
    { url = "https://files.pythonhosted.org/packages/8f/16/325f72b7ebdb906bd6cca6c0caea5b8fd7092c4686237c5669fe3f3cc7f2/psycopg_binary-3.2.13-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9e25eb65494955c0dabdcd7097b004cbd70b982cf3cbc7186c2e854f788677a9", size = 4013642, upload-time = "2025-11-21T22:29:43.39Z" },
    { url = "https://files.pythonhosted.org/packages/4a/a6/f7616dfcab942d5ad6fb5ce8364148e22a4cd817340ac368b6a6bd17559d/psycopg_binary-3.2.13-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:732b25c2d932ca0655ea2588563eae831dc0842c93c69be4754a5b0e9760b38d", size = 4076666, upload-time = "2025-11-21T22:29:51.33Z" },
    { url = "https://files.pythonhosted.org/packages/4d/f7/cddf75c43c967c9262afe6863275fdd2e5f877d98c379f5c3a21b6fa419d/psycopg_binary-3.2.13-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7350d9cc4e35529c4548ddda34a1c17f28d3f3a8f792c25cd67e8a04952ed415", size = 4639390, upload-time = "2025-11-21T22:29:57.614Z" },
    { url = "https://files.pythonhosted.org/packages/9f/b9/f86f2e6413ac024b3a759fd446cc90c325a0d7403dce533bd419e1c41164/psycopg_binary-3.2.13-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:090c22795969ee1ace17322b1718769694607d942cef084c6fb4493adfa57da0", size = 4737745, upload-time = "2025-11-21T22:30:01.814Z" },
    { url = "https://files.pythonhosted.org/packages/19/aa/1a17c7176875d7e0a848710d87f13fdd3cc08724fa6bfcc43c72846f22b9/psycopg_binary-3.2.13-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9ac329532f36342ff99fc1aefdbb531563bec03c7bc3ae934c8347a7a61339df", size = 4419762, upload-time = "2025-11-21T22:30:05.401Z" },
    { url = "https://files.pythonhosted.org/packages/a3/9b/5c7f8c90a3504c45ceadffa1f1f4b2fc8ce9e04494cf67d27dfa265e5681/psycopg_binary-3.2.13-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1db11a7e618d58cfb937c409c7d279a84cbb31d32a7efc63f1e5f426f3613793", size = 3878529, upload-time = "2025-11-21T22:30:09.493Z" },
    { url = "https://files.pythonhosted.org/packages/ea/37/37e7152e6b0813e68361768d1baf0e40d8ed0ac8091471641c2c88e0cec6/psycopg_binary-3.2.13-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:5f5081b2cbb0358bb3625109d41b57411bf9d9c29762a867e38c06d974b245ee", size = 3560767, upload-time = "2025-11-21T22:30:13.88Z" },
    { url = "https://files.pythonhosted.org/packages/f7/b2/929d8e15b8797486d160b797ce84a4d0251a9361f7f31e9b01b439608e3b/psycopg_binary-3.2.13-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5d466ac3a3738647ff2405397946870dc363e33282ced151e7ea74f622947c06", size = 3604456, upload-time = "2025-11-21T22:30:18.392Z" },
    { url = "https://files.pythonhosted.org/packages/c7/74/4d4e7481bc717bbe3de689c4d40439d4e1be07df989da2c38140298cbae5/psycopg_binary-3.2.13-cp310-cp310-win_amd64.whl", hash = "sha256:087acf2b24787ae206718136c1f51bc90cda68b02c3819b0556f418e3565f2c3", size = 2910871, upload-time = "2025-11-21T22:30:22.24Z" },
    { url = "https://files.pythonhosted.org/packages/06/f5/fc70804a999167daf5b876107b99e8fe91c3f785a31753c0e3e7b93446ba/psycopg_binary-3.2.13-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9cfe87749d010dfd34534ba8c71aa0674db9a3fce65232c98989f77c742c9ce7", size = 4013844, upload-time = "2025-11-21T22:30:25.985Z" },
    { url = "https://files.pythonhosted.org/packages/07/87/857639681f5dfcd567aaf199fe4e5b026a105b0462a604f4fb7eda0735d8/psycopg_binary-3.2.13-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8db77fac1dfe3f69c982db92a51fd78e1354fa8f523a6781a636123e5c7ffcde", size = 4077002, upload-time = "2025-11-21T22:30:29.539Z" },
    { url = "https://files.pythonhosted.org/packages/7c/1d/2cb7af6a31429b9022455c966d8408a2b5a19acd3de7610402381518e8f7/psycopg_binary-3.2.13-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cbbac4cd5b0e14b91ad8244268ca3fc2f527d1a337b489af57d7669c9d2e1a24", size = 4637181, upload-time = "2025-11-21T22:30:34.126Z" },
    { url = "https://files.pythonhosted.org/packages/28/bd/ffde1ac7e6ab75646c253fbe0378772fb6f0229af8a05cd9862ee8aad0f0/psycopg_binary-3.2.13-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:a146f0a59a7e3ca92996f8133b1d5e5922e668f7c656b4a9201e702f4cf25896", size = 4737775, upload-time = "2025-11-21T22:30:38.408Z" },
    { url = "https://files.pythonhosted.org/packages/c2/74/3702732d01639c97943d56ec26860357dfacda0b5a708e82e794d07f499c/psycopg_binary-3.2.13-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:27150515de5f709e4142429db6fd36a1d01f0b8b17d915b5f7bb095364465398", size = 4421537, upload-time = "2025-11-21T22:30:42.696Z" },
    { url = "https://files.pythonhosted.org/packages/f2/8c/915a899857c2211196aa7f1749ba85bed421afaf72f185a0eb91e64ba550/psycopg_binary-3.2.13-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9942255705255367d94368941e3a913b0daf74b47d191471dbe4dc0de9fbc769", size = 3877500, upload-time = "2025-11-21T22:30:47.064Z" },
    { url = "https://files.pythonhosted.org/packages/36/d9/46060c183413bf62d47df98d7e3b30ab561639bcb583c3796cca30dafa43/psycopg_binary-3.2.13-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:75ebc8335f48c339ec24f4c371595f6b7043147fe6d18e619c8564428ab8adaf", size = 3560186, upload-time = "2025-11-21T22:30:54.522Z" },
    { url = "https://files.pythonhosted.org/packages/56/cf/2987689614632898e4861e4122cd41937ea9b5afcbe3c3061c7265bfa6de/psycopg_binary-3.2.13-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6fe2982a73b2ea473c9e2b91a35a21af3b03313bed188eccbcde4972483ac60a", size = 3601117, upload-time = "2025-11-21T22:31:01.218Z" },
    { url = "https://files.pythonhosted.org/packages/e2/ef/df7fa8a47ef47d08af8a792343811a98bc7ab48f763560fc1d5acc1f28af/psycopg_binary-3.2.13-cp311-cp311-win_amd64.whl", hash = "sha256:6a50db4661fae78779d3cc38a0a68cabc997ca9d485ec27443b109ef8ac1672a", size = 2912873, upload-time = "2025-11-21T22:31:05.473Z" },
    { url = "https://files.pythonhosted.org/packages/49/9e/f90243b3d0d007a89989b013b0eb3e78ac929fed4eb40a2b317452abafe1/psycopg_binary-3.2.13-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:223fc610a80bbc4355ad3c9952d468a18bb5cd7065846a8c275f100d80cd4004", size = 3996285, upload-time = "2025-11-21T22:31:08.95Z" },
    { url = "https://files.pythonhosted.org/packages/12/42/7d55f515ee3e2ced5ff9bc493fb2308f5187686b6d9583cd6a9c880d2053/psycopg_binary-3.2.13-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b67f06a68d68b4621b6a411f9e583df876977afa06b1ba270b1b347d40aa93fc", size = 4070567, upload-time = "2025-11-21T22:31:12.31Z" },
    { url = "https://files.pythonhosted.org/packages/a8/a8/ead4de04d8cf5f35119a75a8dd92fa4a2ec8a309b1aa58855f64616c03d7/psycopg_binary-3.2.13-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:082579f2ae41bdabe20c82810810f3e290ac2206cccf0cb41cf36b3218f53b3c", size = 4616833, upload-time = "2025-11-21T22:31:16.614Z" },
    { url = "https://files.pythonhosted.org/packages/26/2e/4af6ab69ade7d67d31296f88c79c322a3522564e30b3f1458f19e74d67c3/psycopg_binary-3.2.13-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:ff7df7bd8ec2c805f3a4896b8ade971139af0f9f8cf45d05014ac71fe54887be", size = 4711710, upload-time = "2025-11-21T22:31:22.007Z" },
    { url = "https://files.pythonhosted.org/packages/9a/31/bdbd6b2264bb7ae5fe8b775c5524da73329d8888c6137fd8b050ff9cabbc/psycopg_binary-3.2.13-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8f1189dc78553ef4b2e55d9e116fc74870191bc6a9a5f4442412a703c4cc6c3b", size = 4401656, upload-time = "2025-11-21T22:31:26.842Z" },
    { url = "https://files.pythonhosted.org/packages/33/c5/8fd8f96450e4ef242022c9a588305e3dc7309c34bc392a9b4c2da60854b1/psycopg_binary-3.2.13-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0ef8ed4a4e0f7bf5e941782478a43c14b2b585b031e2266dd3afb87be2775d95", size = 3851747, upload-time = "2025-11-21T22:31:30.5Z" },
    { url = "https://files.pythonhosted.org/packages/4a/47/406d102ae49d253f124644530f1e5b3fd2f92aea59d4f9b8dd1c71cf8e0f/psycopg_binary-3.2.13-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:de06fc9707a49f7c081b5c950974dd6de3dc33d681f7524f0b396471f5a4a480", size = 3524796, upload-time = "2025-11-21T22:31:34.377Z" },
    { url = "https://files.pythonhosted.org/packages/45/6f/a89be8aee27a5522e97dbcb225fe429c489acdf0bb25fc0fadb329dfb39f/psycopg_binary-3.2.13-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:917ad1cd6e6ef8a9df2f28d7b29c7148f089be46ac56fe838f986c0227652d14", size = 3576536, upload-time = "2025-11-21T22:31:38.06Z" },
    { url = "https://files.pythonhosted.org/packages/ef/f8/c924c7dc792c81bf6181d7d4eeb613c8b2151b3a208f95cedec3c1a25ba3/psycopg_binary-3.2.13-cp312-cp312-win_amd64.whl", hash = "sha256:b53b0d9499805b307017070492189e349256e0946f62c815e442baa01f2ea6c5", size = 2902172, upload-time = "2025-11-21T22:31:41.256Z" },
    { url = "https://files.pythonhosted.org/packages/28/ec/ef37bb44dc02fcc6c0a3eeb93f4baaac13bcb228633fe38ad3fb5a3f6449/psycopg_binary-3.2.13-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dbae6ab1966e2b61d97e47220556c330c4608bb4cfb3a124aa0595c39995c068", size = 3995628, upload-time = "2025-11-21T22:31:45.921Z" },
    { url = "https://files.pythonhosted.org/packages/6d/ad/4748f5f1a40248af16dba087dbec50bd335ee025cc1fb9bf64773378ceff/psycopg_binary-3.2.13-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fae933e4564386199fc54845d85413eedb49760e0bcd2b621fde2dd1825b99b3", size = 4069024, upload-time = "2025-11-21T22:31:50.202Z" },
    { url = "https://files.pythonhosted.org/packages/cf/c2/f02ec6bbc30c7fcd3b39823d2d624b42fae480edeb6e50eb3276281d5635/psycopg_binary-3.2.13-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:13e2f8894d410678529ff9f1211f96c5a93ff142f992b302682b42d924428b61", size = 4615127, upload-time = "2025-11-21T22:31:56.517Z" },
    { url = "https://files.pythonhosted.org/packages/f0/0d/a54fc2cdd672c84175d6869cc823d6ec2a8909318d491f3c24e6077983f2/psycopg_binary-3.2.13-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f26f7009375cf1e92180e5c517c52da1054f7e690dde90e0ed00fa8b5736bcd4", size = 4710267, upload-time = "2025-11-21T22:32:04.585Z" },
    { url = "https://files.pythonhosted.org/packages/9d/b7/067de1acaf3d312253351f3af4121f972584bd36cada6378d4b0cdcebd38/psycopg_binary-3.2.13-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ea2fdbcc9142933a47c66970e0df8b363e3bd1ea4c5ce376f2f3d94a9aeec847", size = 4400795, upload-time = "2025-11-21T22:32:08.883Z" },
    { url = "https://files.pythonhosted.org/packages/64/b5/030e6b1ebfc4d3a8fca03adc5fc827982643bad0b01a1268538d17c08ed3/psycopg_binary-3.2.13-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac92d6bc1d4a41c7459953a9aa727b9966e937e94c9e072527317fd2a67d488b", size = 3851239, upload-time = "2025-11-21T22:32:12.333Z" },
    { url = "https://files.pythonhosted.org/packages/79/6f/0541845364a7de9eae6807060da6a04b22a8eb2e803606d285d9250fbe93/psycopg_binary-3.2.13-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:8b843c00478739e95c46d6d3472b13123b634685f107831a9bfc41503a06ecbd", size = 3525084, upload-time = "2025-11-21T22:32:15.946Z" },
    { url = "https://files.pythonhosted.org/packages/83/ae/6507890dc30a4bbd9d938d4ff3a4079d009a5ad8170af51c7f762438fdbf/psycopg_binary-3.2.13-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2f63868cc96bc18486cebec24445affbdd7f7debf28fac466ea935a8b5a4753b", size = 3576787, upload-time = "2025-11-21T22:32:19.922Z" },
    { url = "https://files.pythonhosted.org/packages/9d/64/3d1c2f1fd09b60cdfbe68b9a810b357ba505eff6e4bdb1a2d9f6729da64c/psycopg_binary-3.2.13-cp313-cp313-win_amd64.whl", hash = "sha256:594dfbca3326e997ae738d3d339004e8416b1f7390f52ce8dc2d692393e8fa96", size = 2905584, upload-time = "2025-11-21T22:32:23.399Z" },
    { url = "https://files.pythonhosted.org/packages/d3/b4/7656b3d67bedff2b900c8c4671cb6eb5fb99c2fc36da33579cac89779c25/psycopg_binary-3.2.13-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:502a778c3e07c6b3aabfa56ee230e8c264d2debfab42d11535513a01bdfff0d6", size = 3997201, upload-time = "2025-11-21T22:32:28.185Z" },
    { url = "https://files.pythonhosted.org/packages/e0/2e/3b4afbd94d48df19c3931cedba464b109f89d81ac43178e6a3d654b4e8d5/psycopg_binary-3.2.13-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7561a71d764d6f74d66e8b7d844b0f27fa33de508f65c17b1d56a94c73644776", size = 4071631, upload-time = "2025-11-21T22:32:32.594Z" },
    { url = "https://files.pythonhosted.org/packages/5e/8b/107d06d55992e2f13157eb705ba5a47d06c4cf1bed077dff0c567b10c187/psycopg_binary-3.2.13-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9caf14745a1930b4e03fe4072cd7154eaf6e1241d20c42130ed784408a26b24b", size = 4620918, upload-time = "2025-11-21T22:32:37.357Z" },
    { url = "https://files.pythonhosted.org/packages/e1/47/a925620f261b115f31e813a5bfe640f316413b1864094a60162f4a6e4d67/psycopg_binary-3.2.13-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a6cafabdc0bfa37e11c6f365020fd5916b62d6296df581f4dceaa43a2ce680c", size = 4714494, upload-time = "2025-11-21T22:32:42.138Z" },
    { url = "https://files.pythonhosted.org/packages/46/33/bed384665356bb9ba17dd8e104884d87cc2343d16dffdfd9aaa9a159bd4d/psycopg_binary-3.2.13-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96cb5a27e68acac6d74b64fca38592a692de9c4b7827339190698d58027aa45", size = 4403046, upload-time = "2025-11-21T22:32:47.241Z" },
    { url = "https://files.pythonhosted.org/packages/41/88/749d8e8102fb5df502e2ecb053b79e78e3358af01af652b5dbeb96ab7905/psycopg_binary-3.2.13-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:596176ae3dfbf56fc61108870bfe17c7205d33ac28d524909feb5335201daa0a", size = 3859046, upload-time = "2025-11-21T22:32:51.481Z" },
    { url = "https://files.pythonhosted.org/packages/38/7c/f492e63b517d6dcd564e8c43bc15e11a4c712a848adf8938ce33bfd4c867/psycopg_binary-3.2.13-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:cc3a0408435dfbb77eeca5e8050df4b19a6e9b7e5e5583edf524c4a83d6293b2", size = 3531351, upload-time = "2025-11-21T22:32:55.571Z" },
    { url = "https://files.pythonhosted.org/packages/07/5a/d8743eb23944e5cf2a0bbfa92935c140b5beaacdb872be641065ed70ab2c/psycopg_binary-3.2.13-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65df0d459ffba14082d8ca4bb2f6ffbb2f8d02968f7d34a747e1031934b76b23", size = 3581034, upload-time = "2025-11-21T22:33:01.648Z" },
    { url = "https://files.pythonhosted.org/packages/46/b2/411d4180252144f7eff024894d2d2ebb98c012c944a282fc20250870e461/psycopg_binary-3.2.13-cp314-cp314-win_amd64.whl", hash = "sha256:5c77f156c7316529ed371b5f95a51139e531328ee39c37493a2afcbc1f79d5de", size = 3000162, upload-time = "2025-11-21T22:33:07.378Z" },
    { url = "https://files.pythonhosted.org/packages/80/dc/3ea3fe5df19af323b4b78e0e98e073f8117b1336e5b6dc6978c067485019/psycopg_binary-3.2.13-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6d8d1b709509d0f8cb857acf740b5eccd5bd2fb208a5b20e895f250519a32459", size = 4015148, upload-time = "2025-11-21T22:33:47.539Z" },
    { url = "https://files.pythonhosted.org/packages/e1/28/a832b014974e7bda61b3c684afe5e47f70d5dc4471cbab90a41a7c2bdf6a/psycopg_binary-3.2.13-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2d45bc5f4335498d32a26c8f8c0bf9ce8c973c19e78a9ee77c031300fb361300", size = 4078197, upload-time = "2025-11-21T22:33:52.494Z" },
    { url = "https://files.pythonhosted.org/packages/5c/8c/5962c876a8bba4a6f8ff941998577e8359c928c700d092893e10f97aa94e/psycopg_binary-3.2.13-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f062d725898bf6fc5cfc6349a0d08ee09f129deb14d7fcd5c30f9f1b349f39dc", size = 4638520, upload-time = "2025-11-21T22:33:57.568Z" },
    { url = "https://files.pythonhosted.org/packages/cd/b2/b557ac96752da8fd4b0ff7a128d148e6809ce576a2add6156c91d55abe0a/psycopg_binary-3.2.13-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:915647b5bbbcde2bd464dc293eec4f74710fa71edc4f85aa6f6c8494a179dc9e", size = 4737730, upload-time = "2025-11-21T22:34:02.969Z" },
    { url = "https://files.pythonhosted.org/packages/b5/9a/af2d96c0e711e90cf340a5f607911cd6df593fe1aec9c46644162161af18/psycopg_binary-3.2.13-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d3aec6e2f1cf4deb1b9a3ac287c0591479f3bd851d0a911d628f8c2c71c14f4a", size = 4421382, upload-time = "2025-11-21T22:34:11.501Z" },
    { url = "https://files.pythonhosted.org/packages/0e/f6/f8135198a2c70ca663b55d44c6fc3beb4e36025679b541a9d489814f2ddc/psycopg_binary-3.2.13-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a56a8b1794cbf27ca04012ac2890d58cfc82b3b310c1dac4fa78fbf6f57e7440", size = 3879259, upload-time = "2025-11-21T22:34:17.706Z" },
    { url = "https://files.pythonhosted.org/packages/7c/8c/3f778fc954f0b691941073a1d8b78c07219594135831cad32a739e4eee97/psycopg_binary-3.2.13-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:4150a5e72f863be442d153829724109d83a76871d9bc801d6bb5b9c84b5b19b9", size = 3560475, upload-time = "2025-11-21T22:34:21.329Z" },
    { url = "https://files.pythonhosted.org/packages/21/d2/731d56c636155f210fbb00cdbb7498c0e04a21052415520da54ac96eca63/psycopg_binary-3.2.13-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:028b49eb465f5d263d250cfd4f168fdabb306d0bbd97fd66a8a1fd7b696a953c", size = 3605616, upload-time = "2025-11-21T22:34:25.229Z" },
    { url = "https://files.pythonhosted.org/packages/8f/22/2619870c9ed44b5eaeae4f7706126754ccadde6319483cd4c490f5d13fbb/psycopg_binary-3.2.13-cp39-cp39-win_amd64.whl", hash = "sha256:532ea34f673148d637be65a96251832252e278540b39fbd683ef37e58ec361c1", size = 2912739, upload-time = "2025-11-21T22:34:29.069Z" },
]

[[package]]
name = "psycopg-binary"
version = "3.3.3"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
wheels = [
    { url = "https://files.pythonhosted.org/packages/b4/d8/a763308a41e2ecfb6256ba0877d340c2f2b124c8b2746401863d96fa2c7a/psycopg_binary-3.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b3385b58b2fe408a13d084c14b8dcf468cd36cbbe774408250facc128f9fa75c", size = 4609758, upload-time = "2026-02-18T16:46:33.132Z" },
    { url = "https://files.pythonhosted.org/packages/6c/a9/f8a683e85400c1208685e7c895abc049dc13aa0b6ea989e6adf0a3681fe0/psycopg_binary-3.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1bef235a50a80f6aba05147002bc354559657cb6386dbd04d8e1c97d1d7cbe84", size = 4676740, upload-time = "2026-02-18T16:46:42.904Z" },
    { url = "https://files.pythonhosted.org/packages/e3/7d/03512c4aaac8a58fc3b1221f38293aa517a1950d10ef8646c72c49addc7d/psycopg_binary-3.3.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:97c839717bf8c8df3f6d983a20949c4fb22e2a34ee172e3e427ede363feda27b", size = 5496335, upload-time = "2026-02-18T16:46:51.517Z" },
    { url = "https://files.pythonhosted.org/packages/8a/bc/23319b4b1c2c0b810d225e1b6f16efbb16150074fc0ea96bfcabdf59ee09/psycopg_binary-3.3.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:48e500cf1c0984dacf1f28ea482c3cdbb4c2288d51c336c04bc64198ab21fc51", size = 5172032, upload-time = "2026-02-18T16:47:00.878Z" },
    { url = "https://files.pythonhosted.org/packages/aa/c8/6d61dc0a56654c558a37b2d9b2094e470aa12621305cc7935fd769122e32/psycopg_binary-3.3.3-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb36a08859b9432d94ea6b26ec41a2f98f83f14868c91321d0c1e11f672eeae7", size = 6763107, upload-time = "2026-02-18T16:47:11.784Z" },
    { url = "https://files.pythonhosted.org/packages/9e/b5/e2a3c90aa1059f5b5f593379caad7be3cc3c2ce1ddfc7730e39854e174fe/psycopg_binary-3.3.3-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dde92cfde09293fb63b3f547919ba7d73bd2654573c03502b3263dd0218e44e", size = 5006494, upload-time = "2026-02-18T16:47:17.062Z" },
    { url = "https://files.pythonhosted.org/packages/5d/3e/bf126e0a1f864e191b7f3eeea667ee2ce13d582b036255fb8b12946d1f7a/psycopg_binary-3.3.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:78c9ce98caaf82ac8484d269791c1b403d7598633e0e4e2fa1097baae244e2f1", size = 4533850, upload-time = "2026-02-18T16:47:21.673Z" },
    { url = "https://files.pythonhosted.org/packages/f4/d8/bb5e8d395deb945629aa0c65d12ab90ec3bfcbdf56be89e2a84d001864c9/psycopg_binary-3.3.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d593612758d0041cb13cb0003f7f8d3fabb7ad9319e651e78afae49b1cf5860e", size = 4223316, upload-time = "2026-02-18T16:47:25.82Z" },
    { url = "https://files.pythonhosted.org/packages/c2/70/33eef61b0f0fd41ebf93b9699f44067313a45016827f67b3c8cc41f0a7ab/psycopg_binary-3.3.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f24e8e17035200a465c178e9ea945527ad0738118694184c450f1192a452ff25", size = 3954515, upload-time = "2026-02-18T16:47:30.434Z" },
    { url = "https://files.pythonhosted.org/packages/ea/db/27c2b3b9698e713e83e11e8540daa27516f9e90390ec21a41091cb15fcaf/psycopg_binary-3.3.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e7b607f0e14f2a4cf7e78a05ebd13df6144acfba87cb90842e70d3f125d9f53f", size = 4260274, upload-time = "2026-02-18T16:47:36.128Z" },
    { url = "https://files.pythonhosted.org/packages/a1/3b/71e5d603059bf5474215f573a3e2d357a4e95672b26e04d41674400d4862/psycopg_binary-3.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:b27d3a23c79fa59557d2cc63a7e8bb4c7e022c018558eda36f9d7c4e6b99a6e0", size = 3557375, upload-time = "2026-02-18T16:47:42.799Z" },
    { url = "https://files.pythonhosted.org/packages/be/c0/b389119dd754483d316805260f3e73cdcad97925839107cc7a296f6132b1/psycopg_binary-3.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a89bb9ee11177b2995d87186b1d9fa892d8ea725e85eab28c6525e4cc14ee048", size = 4609740, upload-time = "2026-02-18T16:47:51.093Z" },
    { url = "https://files.pythonhosted.org/packages/cf/e3/9976eef20f61840285174d360da4c820a311ab39d6b82fa09fbb545be825/psycopg_binary-3.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9f7d0cf072c6fbac3795b08c98ef9ea013f11db609659dcfc6b1f6cc31f9e181", size = 4676837, upload-time = "2026-02-18T16:47:55.523Z" },
    { url = "https://files.pythonhosted.org/packages/9f/f2/d28ba2f7404fd7f68d41e8a11df86313bd646258244cb12a8dd83b868a97/psycopg_binary-3.3.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:90eecd93073922f085967f3ed3a98ba8c325cbbc8c1a204e300282abd2369e13", size = 5497070, upload-time = "2026-02-18T16:47:59.929Z" },
    { url = "https://files.pythonhosted.org/packages/de/2f/6c5c54b815edeb30a281cfcea96dc93b3bb6be939aea022f00cab7aa1420/psycopg_binary-3.3.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dac7ee2f88b4d7bb12837989ca354c38d400eeb21bce3b73dac02622f0a3c8d6", size = 5172410, upload-time = "2026-02-18T16:48:05.665Z" },
    { url = "https://files.pythonhosted.org/packages/51/75/8206c7008b57de03c1ada46bd3110cc3743f3fd9ed52031c4601401d766d/psycopg_binary-3.3.3-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b62cf8784eb6d35beaee1056d54caf94ec6ecf2b7552395e305518ab61eb8fd2", size = 6763408, upload-time = "2026-02-18T16:48:13.541Z" },
    { url = "https://files.pythonhosted.org/packages/d4/5a/ea1641a1e6c8c8b3454b0fcb43c3045133a8b703e6e824fae134088e63bd/psycopg_binary-3.3.3-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a39f34c9b18e8f6794cca17bfbcd64572ca2482318db644268049f8c738f35a6", size = 5006255, upload-time = "2026-02-18T16:48:22.176Z" },
    { url = "https://files.pythonhosted.org/packages/aa/fb/538df099bf55ae1637d52d7ccb6b9620b535a40f4c733897ac2b7bb9e14c/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:883d68d48ca9ff3cb3d10c5fdebea02c79b48eecacdddbf7cce6e7cdbdc216b8", size = 4532694, upload-time = "2026-02-18T16:48:27.338Z" },
    { url = "https://files.pythonhosted.org/packages/a1/d1/00780c0e187ea3c13dfc53bd7060654b2232cd30df562aac91a5f1c545ac/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:cab7bc3d288d37a80aa8c0820033250c95e40b1c2b5c57cf59827b19c2a8b69d", size = 4222833, upload-time = "2026-02-18T16:48:31.221Z" },
    { url = "https://files.pythonhosted.org/packages/7a/34/a07f1ff713c51d64dc9f19f2c32be80299a2055d5d109d5853662b922cb4/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:56c767007ca959ca32f796b42379fc7e1ae2ed085d29f20b05b3fc394f3715cc", size = 3952818, upload-time = "2026-02-18T16:48:35.869Z" },
    { url = "https://files.pythonhosted.org/packages/d3/67/d33f268a7759b4445f3c9b5a181039b01af8c8263c865c1be7a6444d4749/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:da2f331a01af232259a21573a01338530c6016dcfad74626c01330535bcd8628", size = 4258061, upload-time = "2026-02-18T16:48:41.365Z" },
    { url = "https://files.pythonhosted.org/packages/b4/3b/0d8d2c5e8e29ccc07d28c8af38445d9d9abcd238d590186cac82ee71fc84/psycopg_binary-3.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:19f93235ece6dbfc4036b5e4f6d8b13f0b8f2b3eeb8b0bd2936d406991bcdd40", size = 3558915, upload-time = "2026-02-18T16:48:46.679Z" },
    { url = "https://files.pythonhosted.org/packages/90/15/021be5c0cbc5b7c1ab46e91cc3434eb42569f79a0592e67b8d25e66d844d/psycopg_binary-3.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6698dbab5bcef8fdb570fc9d35fd9ac52041771bfcfe6fd0fc5f5c4e36f1e99d", size = 4591170, upload-time = "2026-02-18T16:48:55.594Z" },
    { url = "https://files.pythonhosted.org/packages/f1/54/a60211c346c9a2f8c6b272b5f2bbe21f6e11800ce7f61e99ba75cf8b63e1/psycopg_binary-3.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:329ff393441e75f10b673ae99ab45276887993d49e65f141da20d915c05aafd8", size = 4670009, upload-time = "2026-02-18T16:49:03.608Z" },
    { url = "https://files.pythonhosted.org/packages/c1/53/ac7c18671347c553362aadbf65f92786eef9540676ca24114cc02f5be405/psycopg_binary-3.3.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:eb072949b8ebf4082ae24289a2b0fd724da9adc8f22743409d6fd718ddb379df", size = 5469735, upload-time = "2026-02-18T16:49:10.128Z" },
    { url = "https://files.pythonhosted.org/packages/7f/c3/4f4e040902b82a344eff1c736cde2f2720f127fe939c7e7565706f96dd44/psycopg_binary-3.3.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:263a24f39f26e19ed7fc982d7859a36f17841b05bebad3eb47bb9cd2dd785351", size = 5152919, upload-time = "2026-02-18T16:49:16.335Z" },
    { url = "https://files.pythonhosted.org/packages/0c/e7/d929679c6a5c212bcf738806c7c89f5b3d0919f2e1685a0e08d6ff877945/psycopg_binary-3.3.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5152d50798c2fa5bd9b68ec68eb68a1b71b95126c1d70adaa1a08cd5eefdc23d", size = 6738785, upload-time = "2026-02-18T16:49:22.687Z" },
    { url = "https://files.pythonhosted.org/packages/69/b0/09703aeb69a9443d232d7b5318d58742e8ca51ff79f90ffe6b88f1db45e7/psycopg_binary-3.3.3-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d6a1e56dd267848edb824dbeb08cf5bac649e02ee0b03ba883ba3f4f0bd54f2", size = 4979008, upload-time = "2026-02-18T16:49:27.313Z" },
    { url = "https://files.pythonhosted.org/packages/cc/a6/e662558b793c6e13a7473b970fee327d635270e41eded3090ef14045a6a5/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73eaaf4bb04709f545606c1db2f65f4000e8a04cdbf3e00d165a23004692093e", size = 4508255, upload-time = "2026-02-18T16:49:31.575Z" },
    { url = "https://files.pythonhosted.org/packages/5f/7f/0f8b2e1d5e0093921b6f324a948a5c740c1447fbb45e97acaf50241d0f39/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:162e5675efb4704192411eaf8e00d07f7960b679cd3306e7efb120bb8d9456cc", size = 4189166, upload-time = "2026-02-18T16:49:35.801Z" },
    { url = "https://files.pythonhosted.org/packages/92/ec/ce2e91c33bc8d10b00c87e2f6b0fb570641a6a60042d6a9ae35658a3a797/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:fab6b5e37715885c69f5d091f6ff229be71e235f272ebaa35158d5a46fd548a0", size = 3924544, upload-time = "2026-02-18T16:49:41.129Z" },
    { url = "https://files.pythonhosted.org/packages/c5/2f/7718141485f73a924205af60041c392938852aa447a94c8cbd222ff389a1/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a4aab31bd6d1057f287c96c0effca3a25584eb9cc702f282ecb96ded7814e830", size = 4235297, upload-time = "2026-02-18T16:49:46.726Z" },
    { url = "https://files.pythonhosted.org/packages/57/f9/1add717e2643a003bbde31b1b220172e64fbc0cb09f06429820c9173f7fc/psycopg_binary-3.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:59aa31fe11a0e1d1bcc2ce37ed35fe2ac84cd65bb9036d049b1a1c39064d0f14", size = 3547659, upload-time = "2026-02-18T16:49:52.999Z" },
    { url = "https://files.pythonhosted.org/packages/03/0a/cac9fdf1df16a269ba0e5f0f06cac61f826c94cadb39df028cdfe19d3a33/psycopg_binary-3.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05f32239aec25c5fb15f7948cffdc2dc0dac098e48b80a140e4ba32b572a2e7d", size = 4590414, upload-time = "2026-02-18T16:50:01.441Z" },
    { url = "https://files.pythonhosted.org/packages/9c/c0/d8f8508fbf440edbc0099b1abff33003cd80c9e66eb3a1e78834e3fb4fb9/psycopg_binary-3.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c84f9d214f2d1de2fafebc17fa68ac3f6561a59e291553dfc45ad299f4898c1", size = 4669021, upload-time = "2026-02-18T16:50:08.803Z" },
    { url = "https://files.pythonhosted.org/packages/04/05/097016b77e343b4568feddf12c72171fc513acef9a4214d21b9478569068/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e77957d2ba17cada11be09a5066d93026cdb61ada7c8893101d7fe1c6e1f3925", size = 5467453, upload-time = "2026-02-18T16:50:14.985Z" },
    { url = "https://files.pythonhosted.org/packages/91/23/73244e5feb55b5ca109cede6e97f32ef45189f0fdac4c80d75c99862729d/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:42961609ac07c232a427da7c87a468d3c82fee6762c220f38e37cfdacb2b178d", size = 5151135, upload-time = "2026-02-18T16:50:24.82Z" },
    { url = "https://files.pythonhosted.org/packages/11/49/5309473b9803b207682095201d8708bbc7842ddf3f192488a69204e36455/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae07a3114313dd91fce686cab2f4c44af094398519af0e0f854bc707e1aeedf1", size = 6737315, upload-time = "2026-02-18T16:50:35.106Z" },
    { url = "https://files.pythonhosted.org/packages/d4/5d/03abe74ef34d460b33c4d9662bf6ec1dd38888324323c1a1752133c10377/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d257c58d7b36a621dcce1d01476ad8b60f12d80eb1406aee4cf796f88b2ae482", size = 4979783, upload-time = "2026-02-18T16:50:42.067Z" },
    { url = "https://files.pythonhosted.org/packages/f0/6c/3fbf8e604e15f2f3752900434046c00c90bb8764305a1b81112bff30ba24/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:07c7211f9327d522c9c47560cae00a4ecf6687f4e02d779d035dd3177b41cb12", size = 4509023, upload-time = "2026-02-18T16:50:50.116Z" },
    { url = "https://files.pythonhosted.org/packages/9c/6b/1a06b43b7c7af756c80b67eac8bfaa51d77e68635a8a8d246e4f0bb7604a/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:8e7e9eca9b363dbedeceeadd8be97149d2499081f3c52d141d7cd1f395a91f83", size = 4185874, upload-time = "2026-02-18T16:50:55.97Z" },
    { url = "https://files.pythonhosted.org/packages/2b/d3/bf49e3dcaadba510170c8d111e5e69e5ae3f981c1554c5bb71c75ce354bb/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:cb85b1d5702877c16f28d7b92ba030c1f49ebcc9b87d03d8c10bf45a2f1c7508", size = 3925668, upload-time = "2026-02-18T16:51:03.299Z" },
    { url = "https://files.pythonhosted.org/packages/f8/92/0aac830ed6a944fe334404e1687a074e4215630725753f0e3e9a9a595b62/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d4606c84d04b80f9138d72f1e28c6c02dc5ae0c7b8f3f8aaf89c681ce1cd1b1", size = 4234973, upload-time = "2026-02-18T16:51:09.097Z" },
    { url = "https://files.pythonhosted.org/packages/2e/96/102244653ee5a143ece5afe33f00f52fe64e389dfce8dbc87580c6d70d3d/psycopg_binary-3.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:74eae563166ebf74e8d950ff359be037b85723d99ca83f57d9b244a871d6c13b", size = 3551342, upload-time = "2026-02-18T16:51:13.892Z" },
    { url = "https://files.pythonhosted.org/packages/a2/71/7a57e5b12275fe7e7d84d54113f0226080423a869118419c9106c083a21c/psycopg_binary-3.3.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:497852c5eaf1f0c2d88ab74a64a8097c099deac0c71de1cbcf18659a8a04a4b2", size = 4607368, upload-time = "2026-02-18T16:51:19.295Z" },
    { url = "https://files.pythonhosted.org/packages/c7/04/cb834f120f2b2c10d4003515ef9ca9d688115b9431735e3936ae48549af8/psycopg_binary-3.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:258d1ea53464d29768bf25930f43291949f4c7becc706f6e220c515a63a24edd", size = 4687047, upload-time = "2026-02-18T16:51:23.84Z" },
    { url = "https://files.pythonhosted.org/packages/40/e9/47a69692d3da9704468041aa5ed3ad6fc7f6bb1a5ae788d261a26bbca6c7/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:111c59897a452196116db12e7f608da472fbff000693a21040e35fc978b23430", size = 5487096, upload-time = "2026-02-18T16:51:29.645Z" },
    { url = "https://files.pythonhosted.org/packages/0b/b6/0e0dd6a2f802864a4ae3dbadf4ec620f05e3904c7842b326aafc43e5f464/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:17bb6600e2455993946385249a3c3d0af52cd70c1c1cdbf712e9d696d0b0bf1b", size = 5168720, upload-time = "2026-02-18T16:51:36.499Z" },
    { url = "https://files.pythonhosted.org/packages/6f/0d/977af38ac19a6b55d22dff508bd743fd7c1901e1b73657e7937c7cccb0a3/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:642050398583d61c9856210568eb09a8e4f2fe8224bf3be21b67a370e677eead", size = 6762076, upload-time = "2026-02-18T16:51:43.167Z" },
    { url = "https://files.pythonhosted.org/packages/34/40/912a39d48322cf86895c0eaf2d5b95cb899402443faefd4b09abbba6b6e1/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:533efe6dc3a7cba5e2a84e38970786bb966306863e45f3db152007e9f48638a6", size = 4997623, upload-time = "2026-02-18T16:51:47.707Z" },
    { url = "https://files.pythonhosted.org/packages/98/0c/c14d0e259c65dc7be854d926993f151077887391d5a081118907a9d89603/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5958dbf28b77ce2033482f6cb9ef04d43f5d8f4b7636e6963d5626f000efb23e", size = 4532096, upload-time = "2026-02-18T16:51:51.421Z" },
    { url = "https://files.pythonhosted.org/packages/39/21/8b7c50a194cfca6ea0fd4d1f276158307785775426e90700ab2eba5cd623/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a6af77b6626ce92b5817bf294b4d45ec1a6161dba80fc2d82cdffdd6814fd023", size = 4208884, upload-time = "2026-02-18T16:51:57.336Z" },
    { url = "https://files.pythonhosted.org/packages/c7/2c/a4981bf42cf30ebba0424971d7ce70a222ae9b82594c42fc3f2105d7b525/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:47f06fcbe8542b4d96d7392c476a74ada521c5aebdb41c3c0155f6595fc14c8d", size = 3944542, upload-time = "2026-02-18T16:52:04.266Z" },
    { url = "https://files.pythonhosted.org/packages/60/e9/b7c29b56aa0b85a4e0c4d89db691c1ceef08f46a356369144430c155a2f5/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e7800e6c6b5dc4b0ca7cc7370f770f53ac83886b76afda0848065a674231e856", size = 4254339, upload-time = "2026-02-18T16:52:10.444Z" },
    { url = "https://files.pythonhosted.org/packages/98/5a/291d89f44d3820fffb7a04ebc8f3ef5dda4f542f44a5daea0c55a84abf45/psycopg_binary-3.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:165f22ab5a9513a3d7425ffb7fcc7955ed8ccaeef6d37e369d6cc1dff1582383", size = 3652796, upload-time = "2026-02-18T16:52:14.02Z" },
]

[[package]]
name = "psycopg-pool"
version = "3.2.8"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
dependencies = [
    { name = "typing-extensions", marker = "python_full_version < '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b7/20/10064379ed363b7a2a6da3aca986a668c792a8145d7344854ab14c7d7292/psycopg_pool-3.2.8.tar.gz", hash = "sha256:854e17c2a637c3b9f8d8b24faad57d4cf850baf3fc03ca56ef7e5b4998e391b9", size = 29956, upload-time = "2025-11-21T22:34:35.453Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/e3/5f/947b4b4e51d67c4c9e97626c815caa9b241a62fd66ddd0d00a4a572013f5/psycopg_pool-3.2.8-py3-none-any.whl", hash = "sha256:5474137f3a58e697e0141d0311e70ec067fc4466031496d7f9ef3e2c28a1dc09", size = 38507, upload-time = "2025-11-21T22:34:31Z" },
]

[[package]]
name = "psycopg-pool"
version = "3.3.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
dependencies = [
    { name = "typing-extensions", marker = "python_full_version >= '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/56/9a/9470d013d0d50af0da9c4251614aeb3c1823635cab3edc211e3839db0bcf/psycopg_pool-3.3.0.tar.gz", hash = "sha256:fa115eb2860bd88fce1717d75611f41490dec6135efb619611142b24da3f6db5", size = 31606, upload-time = "2025-12-01T11:34:33.11Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/e7/c3/26b8a0908a9db249de3b4169692e1c7c19048a9bc41a4d3209cee7dbb758/psycopg_pool-3.3.0-py3-none-any.whl", hash = "sha256:2e44329155c410b5e8666372db44276a8b1ebd8c90f1c3026ebba40d4bc81063", size = 39995, upload-time = "2025-12-01T11:34:29.761Z" },
]

[[package]]
name = "psycopg2-binary"
version = "2.9.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ac/6c/8767aaa597ba424643dc87348c6f1754dd9f48e80fdc1b9f7ca5c3a7c213/psycopg2-binary-2.9.11.tar.gz", hash = "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c", size = 379620, upload-time = "2025-10-10T11:14:48.041Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/6a/f2/8e377d29c2ecf99f6062d35ea606b036e8800720eccfec5fe3dd672c2b24/psycopg2_binary-2.9.11-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d6fe6b47d0b42ce1c9f1fa3e35bb365011ca22e39db37074458f27921dca40f2", size = 3756506, upload-time = "2025-10-10T11:10:30.144Z" },
    { url = "https://files.pythonhosted.org/packages/24/cc/dc143ea88e4ec9d386106cac05023b69668bd0be20794c613446eaefafe5/psycopg2_binary-2.9.11-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a6c0e4262e089516603a09474ee13eabf09cb65c332277e39af68f6233911087", size = 3863943, upload-time = "2025-10-10T11:10:34.586Z" },
    { url = "https://files.pythonhosted.org/packages/8c/df/16848771155e7c419c60afeb24950b8aaa3ab09c0a091ec3ccca26a574d0/psycopg2_binary-2.9.11-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c47676e5b485393f069b4d7a811267d3168ce46f988fa602658b8bb901e9e64d", size = 4410873, upload-time = "2025-10-10T11:10:38.951Z" },
    { url = "https://files.pythonhosted.org/packages/43/79/5ef5f32621abd5a541b89b04231fe959a9b327c874a1d41156041c75494b/psycopg2_binary-2.9.11-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:a28d8c01a7b27a1e3265b11250ba7557e5f72b5ee9e5f3a2fa8d2949c29bf5d2", size = 4468016, upload-time = "2025-10-10T11:10:43.319Z" },
    { url = "https://files.pythonhosted.org/packages/f0/9b/d7542d0f7ad78f57385971f426704776d7b310f5219ed58da5d605b1892e/psycopg2_binary-2.9.11-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f3f2732cf504a1aa9e9609d02f79bea1067d99edf844ab92c247bbca143303b", size = 4164996, upload-time = "2025-10-10T11:10:46.705Z" },
    { url = "https://files.pythonhosted.org/packages/14/ed/e409388b537fa7414330687936917c522f6a77a13474e4238219fcfd9a84/psycopg2_binary-2.9.11-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:865f9945ed1b3950d968ec4690ce68c55019d79e4497366d36e090327ce7db14", size = 3981881, upload-time = "2025-10-30T02:54:57.182Z" },
    { url = "https://files.pythonhosted.org/packages/bf/30/50e330e63bb05efc6fa7c1447df3e08954894025ca3dcb396ecc6739bc26/psycopg2_binary-2.9.11-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:91537a8df2bde69b1c1db01d6d944c831ca793952e4f57892600e96cee95f2cd", size = 3650857, upload-time = "2025-10-10T11:10:50.112Z" },
    { url = "https://files.pythonhosted.org/packages/f0/e0/4026e4c12bb49dd028756c5b0bc4c572319f2d8f1c9008e0dad8cc9addd7/psycopg2_binary-2.9.11-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4dca1f356a67ecb68c81a7bc7809f1569ad9e152ce7fd02c2f2036862ca9f66b", size = 3296063, upload-time = "2025-10-10T11:10:54.089Z" },
    { url = "https://files.pythonhosted.org/packages/2c/34/eb172be293c886fef5299fe5c3fcf180a05478be89856067881007934a7c/psycopg2_binary-2.9.11-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0da4de5c1ac69d94ed4364b6cbe7190c1a70d325f112ba783d83f8440285f152", size = 3043464, upload-time = "2025-10-30T02:55:02.483Z" },
    { url = "https://files.pythonhosted.org/packages/18/1c/532c5d2cb11986372f14b798a95f2eaafe5779334f6a80589a68b5fcf769/psycopg2_binary-2.9.11-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:37d8412565a7267f7d79e29ab66876e55cb5e8e7b3bbf94f8206f6795f8f7e7e", size = 3345378, upload-time = "2025-10-10T11:11:01.039Z" },
    { url = "https://files.pythonhosted.org/packages/70/e7/de420e1cf16f838e1fa17b1120e83afff374c7c0130d088dba6286fcf8ea/psycopg2_binary-2.9.11-cp310-cp310-win_amd64.whl", hash = "sha256:c665f01ec8ab273a61c62beeb8cce3014c214429ced8a308ca1fc410ecac3a39", size = 2713904, upload-time = "2025-10-10T11:11:04.81Z" },
    { url = "https://files.pythonhosted.org/packages/c7/ae/8d8266f6dd183ab4d48b95b9674034e1b482a3f8619b33a0d86438694577/psycopg2_binary-2.9.11-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0e8480afd62362d0a6a27dd09e4ca2def6fa50ed3a4e7c09165266106b2ffa10", size = 3756452, upload-time = "2025-10-10T11:11:11.583Z" },
    { url = "https://files.pythonhosted.org/packages/4b/34/aa03d327739c1be70e09d01182619aca8ebab5970cd0cfa50dd8b9cec2ac/psycopg2_binary-2.9.11-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:763c93ef1df3da6d1a90f86ea7f3f806dc06b21c198fa87c3c25504abec9404a", size = 3863957, upload-time = "2025-10-10T11:11:16.932Z" },
    { url = "https://files.pythonhosted.org/packages/48/89/3fdb5902bdab8868bbedc1c6e6023a4e08112ceac5db97fc2012060e0c9a/psycopg2_binary-2.9.11-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e164359396576a3cc701ba8af4751ae68a07235d7a380c631184a611220d9a4", size = 4410955, upload-time = "2025-10-10T11:11:21.21Z" },
    { url = "https://files.pythonhosted.org/packages/ce/24/e18339c407a13c72b336e0d9013fbbbde77b6fd13e853979019a1269519c/psycopg2_binary-2.9.11-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:d57c9c387660b8893093459738b6abddbb30a7eab058b77b0d0d1c7d521ddfd7", size = 4468007, upload-time = "2025-10-10T11:11:24.831Z" },
    { url = "https://files.pythonhosted.org/packages/91/7e/b8441e831a0f16c159b5381698f9f7f7ed54b77d57bc9c5f99144cc78232/psycopg2_binary-2.9.11-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2c226ef95eb2250974bf6fa7a842082b31f68385c4f3268370e3f3870e7859ee", size = 4165012, upload-time = "2025-10-10T11:11:29.51Z" },
    { url = "https://files.pythonhosted.org/packages/0d/61/4aa89eeb6d751f05178a13da95516c036e27468c5d4d2509bb1e15341c81/psycopg2_binary-2.9.11-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a311f1edc9967723d3511ea7d2708e2c3592e3405677bf53d5c7246753591fbb", size = 3981881, upload-time = "2025-10-30T02:55:07.332Z" },
    { url = "https://files.pythonhosted.org/packages/76/a1/2f5841cae4c635a9459fe7aca8ed771336e9383b6429e05c01267b0774cf/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ebb415404821b6d1c47353ebe9c8645967a5235e6d88f914147e7fd411419e6f", size = 3650985, upload-time = "2025-10-10T11:11:34.975Z" },
    { url = "https://files.pythonhosted.org/packages/84/74/4defcac9d002bca5709951b975173c8c2fa968e1a95dc713f61b3a8d3b6a/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f07c9c4a5093258a03b28fab9b4f151aa376989e7f35f855088234e656ee6a94", size = 3296039, upload-time = "2025-10-10T11:11:40.432Z" },
    { url = "https://files.pythonhosted.org/packages/6d/c2/782a3c64403d8ce35b5c50e1b684412cf94f171dc18111be8c976abd2de1/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:00ce1830d971f43b667abe4a56e42c1e2d594b32da4802e44a73bacacb25535f", size = 3043477, upload-time = "2025-10-30T02:55:11.182Z" },
    { url = "https://files.pythonhosted.org/packages/c8/31/36a1d8e702aa35c38fc117c2b8be3f182613faa25d794b8aeaab948d4c03/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cffe9d7697ae7456649617e8bb8d7a45afb71cd13f7ab22af3e5c61f04840908", size = 3345842, upload-time = "2025-10-10T11:11:45.366Z" },
    { url = "https://files.pythonhosted.org/packages/6e/b4/a5375cda5b54cb95ee9b836930fea30ae5a8f14aa97da7821722323d979b/psycopg2_binary-2.9.11-cp311-cp311-win_amd64.whl", hash = "sha256:304fd7b7f97eef30e91b8f7e720b3db75fee010b520e434ea35ed1ff22501d03", size = 2713894, upload-time = "2025-10-10T11:11:48.775Z" },
    { url = "https://files.pythonhosted.org/packages/d8/91/f870a02f51be4a65987b45a7de4c2e1897dd0d01051e2b559a38fa634e3e/psycopg2_binary-2.9.11-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:be9b840ac0525a283a96b556616f5b4820e0526addb8dcf6525a0fa162730be4", size = 3756603, upload-time = "2025-10-10T11:11:52.213Z" },
    { url = "https://files.pythonhosted.org/packages/27/fa/cae40e06849b6c9a95eb5c04d419942f00d9eaac8d81626107461e268821/psycopg2_binary-2.9.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f090b7ddd13ca842ebfe301cd587a76a4cf0913b1e429eb92c1be5dbeb1a19bc", size = 3864509, upload-time = "2025-10-10T11:11:56.452Z" },
    { url = "https://files.pythonhosted.org/packages/2d/75/364847b879eb630b3ac8293798e380e441a957c53657995053c5ec39a316/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ab8905b5dcb05bf3fb22e0cf90e10f469563486ffb6a96569e51f897c750a76a", size = 4411159, upload-time = "2025-10-10T11:12:00.49Z" },
    { url = "https://files.pythonhosted.org/packages/6f/a0/567f7ea38b6e1c62aafd58375665a547c00c608a471620c0edc364733e13/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf940cd7e7fec19181fdbc29d76911741153d51cab52e5c21165f3262125685e", size = 4468234, upload-time = "2025-10-10T11:12:04.892Z" },
    { url = "https://files.pythonhosted.org/packages/30/da/4e42788fb811bbbfd7b7f045570c062f49e350e1d1f3df056c3fb5763353/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa0f693d3c68ae925966f0b14b8edda71696608039f4ed61b1fe9ffa468d16db", size = 4166236, upload-time = "2025-10-10T11:12:11.674Z" },
    { url = "https://files.pythonhosted.org/packages/3c/94/c1777c355bc560992af848d98216148be5f1be001af06e06fc49cbded578/psycopg2_binary-2.9.11-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a1cf393f1cdaf6a9b57c0a719a1068ba1069f022a59b8b1fe44b006745b59757", size = 3983083, upload-time = "2025-10-30T02:55:15.73Z" },
    { url = "https://files.pythonhosted.org/packages/bd/42/c9a21edf0e3daa7825ed04a4a8588686c6c14904344344a039556d78aa58/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7a6beb4beaa62f88592ccc65df20328029d721db309cb3250b0aae0fa146c3", size = 3652281, upload-time = "2025-10-10T11:12:17.713Z" },
    { url = "https://files.pythonhosted.org/packages/12/22/dedfbcfa97917982301496b6b5e5e6c5531d1f35dd2b488b08d1ebc52482/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:31b32c457a6025e74d233957cc9736742ac5a6cb196c6b68499f6bb51390bd6a", size = 3298010, upload-time = "2025-10-10T11:12:22.671Z" },
    { url = "https://files.pythonhosted.org/packages/66/ea/d3390e6696276078bd01b2ece417deac954dfdd552d2edc3d03204416c0c/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:edcb3aeb11cb4bf13a2af3c53a15b3d612edeb6409047ea0b5d6a21a9d744b34", size = 3044641, upload-time = "2025-10-30T02:55:19.929Z" },
    { url = "https://files.pythonhosted.org/packages/12/9a/0402ded6cbd321da0c0ba7d34dc12b29b14f5764c2fc10750daa38e825fc/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b6d93d7c0b61a1dd6197d208ab613eb7dcfdcca0a49c42ceb082257991de9d", size = 3347940, upload-time = "2025-10-10T11:12:26.529Z" },
    { url = "https://files.pythonhosted.org/packages/b1/d2/99b55e85832ccde77b211738ff3925a5d73ad183c0b37bcbbe5a8ff04978/psycopg2_binary-2.9.11-cp312-cp312-win_amd64.whl", hash = "sha256:b33fabeb1fde21180479b2d4667e994de7bbf0eec22832ba5d9b5e4cf65b6c6d", size = 2714147, upload-time = "2025-10-10T11:12:29.535Z" },
    { url = "https://files.pythonhosted.org/packages/ff/a8/a2709681b3ac11b0b1786def10006b8995125ba268c9a54bea6f5ae8bd3e/psycopg2_binary-2.9.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b8fb3db325435d34235b044b199e56cdf9ff41223a4b9752e8576465170bb38c", size = 3756572, upload-time = "2025-10-10T11:12:32.873Z" },
    { url = "https://files.pythonhosted.org/packages/62/e1/c2b38d256d0dafd32713e9f31982a5b028f4a3651f446be70785f484f472/psycopg2_binary-2.9.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:366df99e710a2acd90efed3764bb1e28df6c675d33a7fb40df9b7281694432ee", size = 3864529, upload-time = "2025-10-10T11:12:36.791Z" },
    { url = "https://files.pythonhosted.org/packages/11/32/b2ffe8f3853c181e88f0a157c5fb4e383102238d73c52ac6d93a5c8bffe6/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c55b385daa2f92cb64b12ec4536c66954ac53654c7f15a203578da4e78105c0", size = 4411242, upload-time = "2025-10-10T11:12:42.388Z" },
    { url = "https://files.pythonhosted.org/packages/10/04/6ca7477e6160ae258dc96f67c371157776564679aefd247b66f4661501a2/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c0377174bf1dd416993d16edc15357f6eb17ac998244cca19bc67cdc0e2e5766", size = 4468258, upload-time = "2025-10-10T11:12:48.654Z" },
    { url = "https://files.pythonhosted.org/packages/3c/7e/6a1a38f86412df101435809f225d57c1a021307dd0689f7a5e7fe83588b1/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6ff3335ce08c75afaed19e08699e8aacf95d4a260b495a4a8545244fe2ceb3", size = 4166295, upload-time = "2025-10-10T11:12:52.525Z" },
    { url = "https://files.pythonhosted.org/packages/f2/7d/c07374c501b45f3579a9eb761cbf2604ddef3d96ad48679112c2c5aa9c25/psycopg2_binary-2.9.11-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84011ba3109e06ac412f95399b704d3d6950e386b7994475b231cf61eec2fc1f", size = 3983133, upload-time = "2025-10-30T02:55:24.329Z" },
    { url = "https://files.pythonhosted.org/packages/82/56/993b7104cb8345ad7d4516538ccf8f0d0ac640b1ebd8c754a7b024e76878/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ba34475ceb08cccbdd98f6b46916917ae6eeb92b5ae111df10b544c3a4621dc4", size = 3652383, upload-time = "2025-10-10T11:12:56.387Z" },
    { url = "https://files.pythonhosted.org/packages/2d/ac/eaeb6029362fd8d454a27374d84c6866c82c33bfc24587b4face5a8e43ef/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b31e90fdd0f968c2de3b26ab014314fe814225b6c324f770952f7d38abf17e3c", size = 3298168, upload-time = "2025-10-10T11:13:00.403Z" },
    { url = "https://files.pythonhosted.org/packages/2b/39/50c3facc66bded9ada5cbc0de867499a703dc6bca6be03070b4e3b65da6c/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d526864e0f67f74937a8fce859bd56c979f5e2ec57ca7c627f5f1071ef7fee60", size = 3044712, upload-time = "2025-10-30T02:55:27.975Z" },
    { url = "https://files.pythonhosted.org/packages/9c/8e/b7de019a1f562f72ada81081a12823d3c1590bedc48d7d2559410a2763fe/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04195548662fa544626c8ea0f06561eb6203f1984ba5b4562764fbeb4c3d14b1", size = 3347549, upload-time = "2025-10-10T11:13:03.971Z" },
    { url = "https://files.pythonhosted.org/packages/80/2d/1bb683f64737bbb1f86c82b7359db1eb2be4e2c0c13b947f80efefa7d3e5/psycopg2_binary-2.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:efff12b432179443f54e230fdf60de1f6cc726b6c832db8701227d089310e8aa", size = 2714215, upload-time = "2025-10-10T11:13:07.14Z" },
    { url = "https://files.pythonhosted.org/packages/64/12/93ef0098590cf51d9732b4f139533732565704f45bdc1ffa741b7c95fb54/psycopg2_binary-2.9.11-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:92e3b669236327083a2e33ccfa0d320dd01b9803b3e14dd986a4fc54aa00f4e1", size = 3756567, upload-time = "2025-10-10T11:13:11.885Z" },
    { url = "https://files.pythonhosted.org/packages/7c/a9/9d55c614a891288f15ca4b5209b09f0f01e3124056924e17b81b9fa054cc/psycopg2_binary-2.9.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e0deeb03da539fa3577fcb0b3f2554a97f7e5477c246098dbb18091a4a01c16f", size = 3864755, upload-time = "2025-10-10T11:13:17.727Z" },
    { url = "https://files.pythonhosted.org/packages/13/1e/98874ce72fd29cbde93209977b196a2edae03f8490d1bd8158e7f1daf3a0/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b52a3f9bb540a3e4ec0f6ba6d31339727b2950c9772850d6545b7eae0b9d7c5", size = 4411646, upload-time = "2025-10-10T11:13:24.432Z" },
    { url = "https://files.pythonhosted.org/packages/5a/bd/a335ce6645334fb8d758cc358810defca14a1d19ffbc8a10bd38a2328565/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:db4fd476874ccfdbb630a54426964959e58da4c61c9feba73e6094d51303d7d8", size = 4468701, upload-time = "2025-10-10T11:13:29.266Z" },
    { url = "https://files.pythonhosted.org/packages/44/d6/c8b4f53f34e295e45709b7568bf9b9407a612ea30387d35eb9fa84f269b4/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47f212c1d3be608a12937cc131bd85502954398aaa1320cb4c14421a0ffccf4c", size = 4166293, upload-time = "2025-10-10T11:13:33.336Z" },
    { url = "https://files.pythonhosted.org/packages/4b/e0/f8cc36eadd1b716ab36bb290618a3292e009867e5c97ce4aba908cb99644/psycopg2_binary-2.9.11-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e35b7abae2b0adab776add56111df1735ccc71406e56203515e228a8dc07089f", size = 3983184, upload-time = "2025-10-30T02:55:32.483Z" },
    { url = "https://files.pythonhosted.org/packages/53/3e/2a8fe18a4e61cfb3417da67b6318e12691772c0696d79434184a511906dc/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fcf21be3ce5f5659daefd2b3b3b6e4727b028221ddc94e6c1523425579664747", size = 3652650, upload-time = "2025-10-10T11:13:38.181Z" },
    { url = "https://files.pythonhosted.org/packages/76/36/03801461b31b29fe58d228c24388f999fe814dfc302856e0d17f97d7c54d/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9bd81e64e8de111237737b29d68039b9c813bdf520156af36d26819c9a979e5f", size = 3298663, upload-time = "2025-10-10T11:13:44.878Z" },
    { url = "https://files.pythonhosted.org/packages/97/77/21b0ea2e1a73aa5fa9222b2a6b8ba325c43c3a8d54272839c991f2345656/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:32770a4d666fbdafab017086655bcddab791d7cb260a16679cc5a7338b64343b", size = 3044737, upload-time = "2025-10-30T02:55:35.69Z" },
    { url = "https://files.pythonhosted.org/packages/67/69/f36abe5f118c1dca6d3726ceae164b9356985805480731ac6712a63f24f0/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3cb3a676873d7506825221045bd70e0427c905b9c8ee8d6acd70cfcbd6e576d", size = 3347643, upload-time = "2025-10-10T11:13:53.499Z" },
    { url = "https://files.pythonhosted.org/packages/e1/36/9c0c326fe3a4227953dfb29f5d0c8ae3b8eb8c1cd2967aa569f50cb3c61f/psycopg2_binary-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316", size = 2803913, upload-time = "2025-10-10T11:13:57.058Z" },
    { url = "https://files.pythonhosted.org/packages/b2/41/cb36a61146b3afed03e980477f6dd29c0263f15e4b4844660501a774dc0b/psycopg2_binary-2.9.11-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:20e7fb94e20b03dcc783f76c0865f9da39559dcc0c28dd1a3fce0d01902a6b9c", size = 3756418, upload-time = "2025-10-10T11:14:00.728Z" },
    { url = "https://files.pythonhosted.org/packages/f4/e3/8623be505c8921727277f22753092835b559543b078e83378bb74712dbc8/psycopg2_binary-2.9.11-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4bdab48575b6f870f465b397c38f1b415520e9879fdf10a53ee4f49dcbdf8a21", size = 3863981, upload-time = "2025-10-10T11:14:05.215Z" },
    { url = "https://files.pythonhosted.org/packages/bc/86/ec3682dc3550c65eff80384f603a6a55b798e1b86ccef262d454d19f96eb/psycopg2_binary-2.9.11-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9d3a9edcfbe77a3ed4bc72836d466dfce4174beb79eda79ea155cc77237ed9e8", size = 4410882, upload-time = "2025-10-10T11:14:09.552Z" },
    { url = "https://files.pythonhosted.org/packages/41/af/540ee7d56fb33408c57240d55904c95e4a30952c096f5e1542769cadc787/psycopg2_binary-2.9.11-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:44fc5c2b8fa871ce7f0023f619f1349a0aa03a0857f2c96fbc01c657dcbbdb49", size = 4468062, upload-time = "2025-10-10T11:14:15.225Z" },
    { url = "https://files.pythonhosted.org/packages/b4/d5/b95d47b2e67b2adfaba517c803a99a1ac41e84c8201d0f3b29d77b56e357/psycopg2_binary-2.9.11-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9c55460033867b4622cda1b6872edf445809535144152e5d14941ef591980edf", size = 4165036, upload-time = "2025-10-10T11:14:21.209Z" },
    { url = "https://files.pythonhosted.org/packages/af/f9/99e39882b70d9b0cfdcbad33bea2e5823843c3a7839c1aaf89fc1337c05c/psycopg2_binary-2.9.11-cp39-cp39-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2d11098a83cca92deaeaed3d58cfd150d49b3b06ee0d0852be466bf87596899e", size = 3981901, upload-time = "2025-10-30T02:55:39.325Z" },
    { url = "https://files.pythonhosted.org/packages/de/c3/8d2c97f1dfddedf5a06c6ad2eda83fba48555a7bc525c3150aedc6f2bedc/psycopg2_binary-2.9.11-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:691c807d94aecfbc76a14e1408847d59ff5b5906a04a23e12a89007672b9e819", size = 3650995, upload-time = "2025-10-10T11:14:27.733Z" },
    { url = "https://files.pythonhosted.org/packages/9c/89/afdf59b44b84ebb28111652485fab608429389f4051d22bc5a7bb43d5208/psycopg2_binary-2.9.11-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:8b81627b691f29c4c30a8f322546ad039c40c328373b11dff7490a3e1b517855", size = 3296106, upload-time = "2025-10-10T11:14:33.312Z" },
    { url = "https://files.pythonhosted.org/packages/94/21/851c9ecf0e9a699907d1c455dbbde7ef9b11dba28e7b7b132c7bb28391f2/psycopg2_binary-2.9.11-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:b637d6d941209e8d96a072d7977238eea128046effbf37d1d8b2c0764750017d", size = 3043491, upload-time = "2025-10-30T02:55:42.228Z" },
    { url = "https://files.pythonhosted.org/packages/9c/de/50f6eced439e7a131b268276c4b68cf8800fd55d8cef7b37109c44bf957a/psycopg2_binary-2.9.11-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:41360b01c140c2a03d346cec3280cf8a71aa07d94f3b1509fa0161c366af66b4", size = 3345816, upload-time = "2025-10-10T11:14:38.648Z" },
    { url = "https://files.pythonhosted.org/packages/45/3b/e0506f199dc8a90ff3b462f261f45d15c0703bb8c59f0da1add5f0c11a30/psycopg2_binary-2.9.11-cp39-cp39-win_amd64.whl", hash = "sha256:875039274f8a2361e5207857899706da840768e2a775bf8c65e82f60b197df02", size = 2714968, upload-time = "2025-10-10T11:14:43.24Z" },
]

[[package]]
name = "pwdlib"
version = "0.2.1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
sdist = { url = "https://files.pythonhosted.org/packages/82/a0/9daed437a6226f632a25d98d65d60ba02bdafa920c90dcb6454c611ead6c/pwdlib-0.2.1.tar.gz", hash = "sha256:9a1d8a8fa09a2f7ebf208265e55d7d008103cbdc82b9e4902ffdd1ade91add5e", size = 11699, upload-time = "2024-08-19T06:48:59.58Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/01/f3/0dae5078a486f0fdf4d4a1121e103bc42694a9da9bea7b0f2c63f29cfbd3/pwdlib-0.2.1-py3-none-any.whl", hash = "sha256:1823dc6f22eae472b540e889ecf57fd424051d6a4023ec0bcf7f0de2d9d7ef8c", size = 8082, upload-time = "2024-08-19T06:49:00.997Z" },
]

[package.optional-dependencies]
argon2 = [
    { name = "argon2-cffi", version = "23.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
]

[[package]]
name = "pwdlib"
version = "0.3.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
sdist = { url = "https://files.pythonhosted.org/packages/5f/41/a7c0d8a003c36ce3828ae3ed0391fe6a15aad65f082dbd6bec817ea95c0b/pwdlib-0.3.0.tar.gz", hash = "sha256:6ca30f9642a1467d4f5d0a4d18619de1c77f17dfccb42dd200b144127d3c83fc", size = 215810, upload-time = "2025-10-25T12:44:24.395Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/62/0c/9086a357d02a050fbb3270bf5043ac284dbfb845670e16c9389a41defc9e/pwdlib-0.3.0-py3-none-any.whl", hash = "sha256:f86c15c138858c09f3bba0a10984d4f9178158c55deaa72eac0210849b1a140d", size = 8633, upload-time = "2025-10-25T12:44:23.406Z" },
]

[package.optional-dependencies]
argon2 = [
    { name = "argon2-cffi", version = "25.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]

[[package]]
name = "pyasn1"
version = "0.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" },
]

[[package]]
name = "pyasn1-modules"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "pyasn1" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" },
]

[[package]]
name = "pycparser"
version = "2.23"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" },
]

[[package]]
name = "pycparser"
version = "3.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
]

[[package]]
name = "pycryptodome"
version = "3.23.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" },
    { url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" },
    { url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" },
    { url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" },
    { url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" },
    { url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" },
    { url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" },
    { url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" },
    { url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" },
    { url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" },
    { url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" },
    { url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" },
    { url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" },
    { url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" },
    { url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" },
    { url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" },
    { url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" },
    { url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" },
    { url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" },
    { url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" },
    { url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" },
    { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" },
    { url = "https://files.pythonhosted.org/packages/d9/12/e33935a0709c07de084d7d58d330ec3f4daf7910a18e77937affdb728452/pycryptodome-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ddb95b49df036ddd264a0ad246d1be5b672000f12d6961ea2c267083a5e19379", size = 1623886, upload-time = "2025-05-17T17:21:20.614Z" },
    { url = "https://files.pythonhosted.org/packages/22/0b/aa8f9419f25870889bebf0b26b223c6986652bdf071f000623df11212c90/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e95564beb8782abfd9e431c974e14563a794a4944c29d6d3b7b5ea042110b4", size = 1672151, upload-time = "2025-05-17T17:21:22.666Z" },
    { url = "https://files.pythonhosted.org/packages/d4/5e/63f5cbde2342b7f70a39e591dbe75d9809d6338ce0b07c10406f1a140cdc/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14e15c081e912c4b0d75632acd8382dfce45b258667aa3c67caf7a4d4c13f630", size = 1664461, upload-time = "2025-05-17T17:21:25.225Z" },
    { url = "https://files.pythonhosted.org/packages/d6/92/608fbdad566ebe499297a86aae5f2a5263818ceeecd16733006f1600403c/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7fc76bf273353dc7e5207d172b83f569540fc9a28d63171061c42e361d22353", size = 1702440, upload-time = "2025-05-17T17:21:27.991Z" },
    { url = "https://files.pythonhosted.org/packages/d1/92/2eadd1341abd2989cce2e2740b4423608ee2014acb8110438244ee97d7ff/pycryptodome-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:45c69ad715ca1a94f778215a11e66b7ff989d792a4d63b68dc586a1da1392ff5", size = 1803005, upload-time = "2025-05-17T17:21:31.37Z" },
    { url = "https://files.pythonhosted.org/packages/dc/c4/6925ad41576d3e84f03aaf9a0411667af861f9fa2c87553c7dd5bde01518/pycryptodome-3.23.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:865d83c906b0fc6a59b510deceee656b6bc1c4fa0d82176e2b77e97a420a996a", size = 1623768, upload-time = "2025-05-17T17:21:33.418Z" },
    { url = "https://files.pythonhosted.org/packages/a8/14/d6c6a3098ddf2624068f041c5639be5092ad4ae1a411842369fd56765994/pycryptodome-3.23.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d4d56153efc4d81defe8b65fd0821ef8b2d5ddf8ed19df31ba2f00872b8002", size = 1672070, upload-time = "2025-05-17T17:21:35.565Z" },
    { url = "https://files.pythonhosted.org/packages/20/89/5d29c8f178fea7c92fd20d22f9ddd532a5e3ac71c574d555d2362aaa832a/pycryptodome-3.23.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e3f2d0aaf8080bda0587d58fc9fe4766e012441e2eed4269a77de6aea981c8be", size = 1664359, upload-time = "2025-05-17T17:21:37.551Z" },
    { url = "https://files.pythonhosted.org/packages/38/bc/a287d41b4421ad50eafb02313137d0276d6aeffab90a91e2b08f64140852/pycryptodome-3.23.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:64093fc334c1eccfd3933c134c4457c34eaca235eeae49d69449dc4728079339", size = 1702359, upload-time = "2025-05-17T17:21:39.827Z" },
    { url = "https://files.pythonhosted.org/packages/2b/62/2392b7879f4d2c1bfa20815720b89d464687877851716936b9609959c201/pycryptodome-3.23.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:ce64e84a962b63a47a592690bdc16a7eaf709d2c2697ababf24a0def566899a6", size = 1802461, upload-time = "2025-05-17T17:21:41.722Z" },
]

[[package]]
name = "pydantic"
version = "2.12.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "annotated-types" },
    { name = "pydantic-core" },
    { name = "typing-extensions" },
    { name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
]

[package.optional-dependencies]
email = [
    { name = "email-validator" },
]

[[package]]
name = "pydantic-core"
version = "2.41.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" },
    { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" },
    { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" },
    { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" },
    { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" },
    { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" },
    { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" },
    { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" },
    { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" },
    { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" },
    { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" },
    { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" },
    { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" },
    { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" },
    { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" },
    { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" },
    { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" },
    { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" },
    { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" },
    { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" },
    { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" },
    { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" },
    { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" },
    { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" },
    { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" },
    { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" },
    { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" },
    { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" },
    { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" },
    { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" },
    { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" },
    { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" },
    { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" },
    { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" },
    { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" },
    { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" },
    { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" },
    { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" },
    { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" },
    { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" },
    { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" },
    { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
    { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
    { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
    { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
    { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
    { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
    { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
    { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
    { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
    { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
    { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
    { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
    { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
    { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
    { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
    { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
    { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
    { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
    { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
    { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
    { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
    { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
    { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
    { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
    { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
    { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
    { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
    { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
    { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
    { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
    { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
    { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
    { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
    { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
    { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
    { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
    { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
    { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
    { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
    { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
    { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
    { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
    { url = "https://files.pythonhosted.org/packages/54/db/160dffb57ed9a3705c4cbcbff0ac03bdae45f1ca7d58ab74645550df3fbd/pydantic_core-2.41.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf", size = 2107999, upload-time = "2025-11-04T13:42:03.885Z" },
    { url = "https://files.pythonhosted.org/packages/a3/7d/88e7de946f60d9263cc84819f32513520b85c0f8322f9b8f6e4afc938383/pydantic_core-2.41.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5", size = 1929745, upload-time = "2025-11-04T13:42:06.075Z" },
    { url = "https://files.pythonhosted.org/packages/d5/c2/aef51e5b283780e85e99ff19db0f05842d2d4a8a8cd15e63b0280029b08f/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d", size = 1920220, upload-time = "2025-11-04T13:42:08.457Z" },
    { url = "https://files.pythonhosted.org/packages/c7/97/492ab10f9ac8695cd76b2fdb24e9e61f394051df71594e9bcc891c9f586e/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60", size = 2067296, upload-time = "2025-11-04T13:42:10.817Z" },
    { url = "https://files.pythonhosted.org/packages/ec/23/984149650e5269c59a2a4c41d234a9570adc68ab29981825cfaf4cfad8f4/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82", size = 2231548, upload-time = "2025-11-04T13:42:13.843Z" },
    { url = "https://files.pythonhosted.org/packages/71/0c/85bcbb885b9732c28bec67a222dbed5ed2d77baee1f8bba2002e8cd00c5c/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5", size = 2362571, upload-time = "2025-11-04T13:42:16.208Z" },
    { url = "https://files.pythonhosted.org/packages/c0/4a/412d2048be12c334003e9b823a3fa3d038e46cc2d64dd8aab50b31b65499/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3", size = 2068175, upload-time = "2025-11-04T13:42:18.911Z" },
    { url = "https://files.pythonhosted.org/packages/73/f4/c58b6a776b502d0a5540ad02e232514285513572060f0d78f7832ca3c98b/pydantic_core-2.41.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425", size = 2177203, upload-time = "2025-11-04T13:42:22.578Z" },
    { url = "https://files.pythonhosted.org/packages/ed/ae/f06ea4c7e7a9eead3d165e7623cd2ea0cb788e277e4f935af63fc98fa4e6/pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504", size = 2148191, upload-time = "2025-11-04T13:42:24.89Z" },
    { url = "https://files.pythonhosted.org/packages/c1/57/25a11dcdc656bf5f8b05902c3c2934ac3ea296257cc4a3f79a6319e61856/pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5", size = 2343907, upload-time = "2025-11-04T13:42:27.683Z" },
    { url = "https://files.pythonhosted.org/packages/96/82/e33d5f4933d7a03327c0c43c65d575e5919d4974ffc026bc917a5f7b9f61/pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3", size = 2322174, upload-time = "2025-11-04T13:42:30.776Z" },
    { url = "https://files.pythonhosted.org/packages/81/45/4091be67ce9f469e81656f880f3506f6a5624121ec5eb3eab37d7581897d/pydantic_core-2.41.5-cp39-cp39-win32.whl", hash = "sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460", size = 1990353, upload-time = "2025-11-04T13:42:33.111Z" },
    { url = "https://files.pythonhosted.org/packages/44/8a/a98aede18db6e9cd5d66bcacd8a409fcf8134204cdede2e7de35c5a2c5ef/pydantic_core-2.41.5-cp39-cp39-win_amd64.whl", hash = "sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b", size = 2015698, upload-time = "2025-11-04T13:42:35.484Z" },
    { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" },
    { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" },
    { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" },
    { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" },
    { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
    { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
    { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
    { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
    { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" },
    { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" },
    { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" },
    { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" },
    { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" },
    { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" },
    { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" },
    { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" },
    { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" },
    { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" },
    { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" },
    { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" },
    { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" },
    { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" },
    { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" },
    { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" },
]

[[package]]
name = "pydantic-extra-types"
version = "2.11.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "pydantic" },
    { name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/41/d3/3be31542180c0300b6860129ff1e3a428f3ef580727616ce22462626129b/pydantic_extra_types-2.11.2.tar.gz", hash = "sha256:3a2b83b61fe920925688e7838b59caa90a45637d1dbba2b1364b8d1f7ff72a0a", size = 203929, upload-time = "2026-04-05T20:50:51.556Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/92/a4/7b6ab05c18d6c6e682382a0f0235301684452c4131a869f45961d1d032c9/pydantic_extra_types-2.11.2-py3-none-any.whl", hash = "sha256:683b8943252543e49760f89733b1519bc62f31d1a287ebbdc5a7b7959fb4acfd", size = 82851, upload-time = "2026-04-05T20:50:50.036Z" },
]

[[package]]
name = "pydantic-settings"
version = "2.11.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
dependencies = [
    { name = "pydantic", marker = "python_full_version < '3.10'" },
    { name = "python-dotenv", version = "1.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "typing-inspection", marker = "python_full_version < '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/20/c5/dbbc27b814c71676593d1c3f718e6cd7d4f00652cefa24b75f7aa3efb25e/pydantic_settings-2.11.0.tar.gz", hash = "sha256:d0e87a1c7d33593beb7194adb8470fc426e95ba02af83a0f23474a04c9a08180", size = 188394, upload-time = "2025-09-24T14:19:11.764Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/83/d6/887a1ff844e64aa823fb4905978d882a633cfe295c32eacad582b78a7d8b/pydantic_settings-2.11.0-py3-none-any.whl", hash = "sha256:fe2cea3413b9530d10f3a5875adffb17ada5c1e1bab0b2885546d7310415207c", size = 48608, upload-time = "2025-09-24T14:19:10.015Z" },
]

[[package]]
name = "pydantic-settings"
version = "2.13.1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
dependencies = [
    { name = "pydantic", marker = "python_full_version >= '3.10'" },
    { name = "python-dotenv", version = "1.2.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "typing-inspection", marker = "python_full_version >= '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" },
]

[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]

[[package]]
name = "pygments-styles"
version = "0.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1c/2c/3886ed4783dd78bb62ccab7d43380f526a7e2ff0db8c77d9c87559b2f5de/pygments_styles-0.3.0.tar.gz", hash = "sha256:67746b8fc6ff72c1179ca4d9a8bc89c7f54c196c2ff9d087f07392cd6fde3ecf", size = 15258, upload-time = "2025-11-04T13:15:23.512Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/8f/64/7e0266f0c541e26df86c31d2add9be3dd9914ae83785ce0aba7cbb693667/pygments_styles-0.3.0-py3-none-any.whl", hash = "sha256:c6c45e9939eb7590345bc9084113bac46c45f12b009d13422be02e80e84a034c", size = 36617, upload-time = "2025-11-04T13:15:21.989Z" },
]

[[package]]
name = "pymssql"
version = "2.3.13"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7a/cc/843c044b7f71ee329436b7327c578383e2f2499313899f88ad267cdf1f33/pymssql-2.3.13.tar.gz", hash = "sha256:2137e904b1a65546be4ccb96730a391fcd5a85aab8a0632721feb5d7e39cfbce", size = 203153, upload-time = "2026-02-14T05:00:36.865Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/0f/b5/77d2af3cab1a129891f75a4223a145664ec0443a340ee737518b499b4edb/pymssql-2.3.13-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:476f6f06b2ae5dfbfa0b169a6ecdd0d9ddfedb07f2d6dc97d2dd630ff2d6789a", size = 3173361, upload-time = "2026-02-14T04:59:16.582Z" },
    { url = "https://files.pythonhosted.org/packages/93/e4/d7833727a19cd1aca2e6de4d59b9ecd3c7c15c1b9c9f0fef8c6a38176f76/pymssql-2.3.13-cp310-cp310-macosx_15_0_x86_64.whl", hash = "sha256:17942dc9474693ab2229a8a6013e5b9cb1312a5251207552141bb85fcce8c131", size = 2976106, upload-time = "2026-02-14T04:59:19.833Z" },
    { url = "https://files.pythonhosted.org/packages/e9/16/121a3d77530ccfdec3cfdf30342d4ca67283441a0adc4f4daaf963f57454/pymssql-2.3.13-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d87237500def5f743a52e415cd369d632907212154fcc7b4e13f264b4e30021", size = 3043228, upload-time = "2026-02-14T04:59:21.603Z" },
    { url = "https://files.pythonhosted.org/packages/85/34/178a4293329c9cf61d438262a52553a92b4421a40419a9ef58f7783ce444/pymssql-2.3.13-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:612ac062027d2118879f11a5986e9d9d82d07ca3545bb98c93200b68826ea687", size = 3175449, upload-time = "2026-02-14T04:59:24.047Z" },
    { url = "https://files.pythonhosted.org/packages/00/aa/bcfc4b7488a19026bac62a819dd6ee389bc2e706f72d7c021d7c6fa8d08e/pymssql-2.3.13-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f1897c1b767cc143e77d285123ae5fd4fa7379a1bfec5c515d38826caf084eb6", size = 3689919, upload-time = "2026-02-14T04:59:26.345Z" },
    { url = "https://files.pythonhosted.org/packages/8d/58/036bdbb7f036d97a3d502bf285f3530a9e800b07922ab6227ef69ce15da7/pymssql-2.3.13-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:48631c7b9fd14a1bd5675c521b6082590bf700b7961c65638d237817b3fde735", size = 3436776, upload-time = "2026-02-14T04:59:28.942Z" },
    { url = "https://files.pythonhosted.org/packages/83/45/92ef7f032c9ce46c5adcf1fa1513e99a9b533ca673bbf01748574ca0f2cc/pymssql-2.3.13-cp310-cp310-win_amd64.whl", hash = "sha256:79c759db6e991eeae473b000c2e0a7fb8da799b2da469fe5a10d30916315e0b5", size = 2009253, upload-time = "2026-02-14T04:59:31.034Z" },
    { url = "https://files.pythonhosted.org/packages/23/20/3d270f2bcfe7cf73b6d17719998316856550ca719791ac00be07e5be7a47/pymssql-2.3.13-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:152be40c0d7f5e4b1323f7728b0a01f3ee0082190cfbadf84b2c2e930d57e00e", size = 3171876, upload-time = "2026-02-14T04:59:33.247Z" },
    { url = "https://files.pythonhosted.org/packages/9c/b0/b4f1c7879a4fdac688f227cc55f31a5cfc8c36dc63117a10af472a399fc1/pymssql-2.3.13-cp311-cp311-macosx_15_0_x86_64.whl", hash = "sha256:d94da3a55545c5b6926cb4d1c6469396f0ae32ad5d6932c513f7a0bf569b4799", size = 2973968, upload-time = "2026-02-14T04:59:35.462Z" },
    { url = "https://files.pythonhosted.org/packages/3c/68/45157f1bb9b8499e4abe7b64195f44aaa2d6bf6aae305d4e8cf5df522424/pymssql-2.3.13-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51e42c5defc3667f0803c7ade85db0e6f24b9a1c5a18fcdfa2d09c36bff9b065", size = 3035519, upload-time = "2026-02-14T04:59:37.138Z" },
    { url = "https://files.pythonhosted.org/packages/57/f3/3aeffc2b3683c135d75e0536657090ddb5f07114eb5e528303e1f4880393/pymssql-2.3.13-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4aa18944a121f996178e26cadc598abdbf73759f03dc3cd74263fdab1b28cd96", size = 3162703, upload-time = "2026-02-14T04:59:39.571Z" },
    { url = "https://files.pythonhosted.org/packages/e5/c0/bd35090a223961d9190dfb884be14529358d561cad1b4211dc351b20dcfd/pymssql-2.3.13-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:910404e0ec85c4cc7c633ec3df9b04a35f23bb74a844dd377a387026ae635e3a", size = 3681162, upload-time = "2026-02-14T04:59:42.181Z" },
    { url = "https://files.pythonhosted.org/packages/c0/d4/d6a5d74c9942d1554538087cfd6ff489d3645bce484c53339f25c4cf6077/pymssql-2.3.13-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4b834c34e7600369eee7bc877948b53eb0fe6f3689f0888d005ae47dd53c0a66", size = 3424100, upload-time = "2026-02-14T04:59:43.795Z" },
    { url = "https://files.pythonhosted.org/packages/27/38/7f1eff7dbbd286a16e341b813fd55c88258e07268a3ccebbfb0e9c46ca74/pymssql-2.3.13-cp311-cp311-win_amd64.whl", hash = "sha256:5c2e55b6513f9c5a2f58543233ed40baaa7f91c79e64a5f961ea3fc57a700b80", size = 2010201, upload-time = "2026-02-14T04:59:45.404Z" },
    { url = "https://files.pythonhosted.org/packages/ba/60/a2e8a8a38f7be21d54402e2b3365cd56f1761ce9f2706c97f864e8aa8300/pymssql-2.3.13-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cf4f32b4a05b66f02cb7d55a0f3bcb0574a6f8cf0bee4bea6f7b104038364733", size = 3158689, upload-time = "2026-02-14T04:59:46.982Z" },
    { url = "https://files.pythonhosted.org/packages/43/9e/0cf0ffb9e2f73238baf766d8e31d7237b5bee3cc1bb29a376b404610994a/pymssql-2.3.13-cp312-cp312-macosx_15_0_x86_64.whl", hash = "sha256:2b056eb175955f7fb715b60dc1c0c624969f4d24dbdcf804b41ab1e640a2b131", size = 2960018, upload-time = "2026-02-14T04:59:48.668Z" },
    { url = "https://files.pythonhosted.org/packages/93/ea/bc27354feaca717faa4626911f6b19bb62985c87dda28957c63de4de5895/pymssql-2.3.13-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:319810b89aa64b99d9c5c01518752c813938df230496fa2c4c6dda0603f04c4c", size = 3065719, upload-time = "2026-02-14T04:59:50.369Z" },
    { url = "https://files.pythonhosted.org/packages/1e/7a/8028681c96241fb5fc850b87c8959402c353e4b83c6e049a99ffa67ded54/pymssql-2.3.13-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0ea72641cb0f8bce7ad8565dbdbda4a7437aa58bce045f2a3a788d71af2e4be", size = 3190567, upload-time = "2026-02-14T04:59:52.202Z" },
    { url = "https://files.pythonhosted.org/packages/aa/f1/ab5b76adbbd6db9ce746d448db34b044683522e7e7b95053f9dd0165297b/pymssql-2.3.13-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1493f63d213607f708a5722aa230776ada726ccdb94097fab090a1717a2534e0", size = 3710481, upload-time = "2026-02-14T04:59:54.01Z" },
    { url = "https://files.pythonhosted.org/packages/59/aa/2fa0951475cd0a1829e0b8bfbe334d04ece4bce11546a556b005c4100689/pymssql-2.3.13-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:eb3275985c23479e952d6462ae6c8b2b6993ab6b99a92805a9c17942cf3d5b3d", size = 3453789, upload-time = "2026-02-14T04:59:56.841Z" },
    { url = "https://files.pythonhosted.org/packages/78/08/8cd2af9003f9fc03912b658a64f5a4919dcd68f0dd3bbc822b49a3d14fd9/pymssql-2.3.13-cp312-cp312-win_amd64.whl", hash = "sha256:a930adda87bdd8351a5637cf73d6491936f34e525a5e513068a6eac742f69cdb", size = 1994709, upload-time = "2026-02-14T04:59:58.972Z" },
    { url = "https://files.pythonhosted.org/packages/d4/4f/ee15b1f6b11e7c3accdc7da7840a019b63f12ba09eaa008acc601182f516/pymssql-2.3.13-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:30918bb044242865c01838909777ef5e0f1b9ecd7f5882346aefa57f4414b29c", size = 3156333, upload-time = "2026-02-14T05:00:01.21Z" },
    { url = "https://files.pythonhosted.org/packages/79/03/aea5c77bad4a52649a1d9f786a1d9ce1c83d50f1a75df288e292737b6d80/pymssql-2.3.13-cp313-cp313-macosx_15_0_x86_64.whl", hash = "sha256:1c6d0b2d7961f159a07e4f0d8cc81f70ceab83f5e7fd1e832a2d069e1d67ee4e", size = 2957990, upload-time = "2026-02-14T05:00:03.11Z" },
    { url = "https://files.pythonhosted.org/packages/5f/f8/30ac16fba32ff066b05f12c392d7b812fe11f06cb62d1d86ca5177c50a8b/pymssql-2.3.13-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16c5957a3c9e51a03276bfd76a22431e2bc4c565e2e95f2cbb3559312edda230", size = 3065264, upload-time = "2026-02-14T05:00:05.377Z" },
    { url = "https://files.pythonhosted.org/packages/a9/98/7568447bf85921d21453fd56e19b6c9591d595fde0546c5a569f3ae937a8/pymssql-2.3.13-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0fddd24efe9d18bbf174fab7c6745b0927773718387f5517cf8082241f721a68", size = 3190039, upload-time = "2026-02-14T05:00:06.925Z" },
    { url = "https://files.pythonhosted.org/packages/35/f1/4d9d275ebaac42cdd49d40d504ccb648f27710660c8b60cc427752438c09/pymssql-2.3.13-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:123c55ee41bc7a82c76db12e2eb189b50d0d7a11222b4f8789206d1cda3b33b9", size = 3710151, upload-time = "2026-02-14T05:00:08.424Z" },
    { url = "https://files.pythonhosted.org/packages/6f/bd/a5cc6244fd27d3ea0cc82f12a7d38a24d7fd90b0022afd250014e8bfba15/pymssql-2.3.13-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e053b443e842f9e1698fcb2b23a4bff1ff3d410894d880064e754ad823d541e5", size = 3453156, upload-time = "2026-02-14T05:00:09.978Z" },
    { url = "https://files.pythonhosted.org/packages/26/d0/c20ff0bbffd18db528bcc7b0c68b25c12ad563ed67c56ceca87c58f7399e/pymssql-2.3.13-cp313-cp313-win_amd64.whl", hash = "sha256:5c045c0f1977a679cc30d5acd9da3f8aeb2dc6e744895b26444b4a2f20dad9a0", size = 1995236, upload-time = "2026-02-14T05:00:11.495Z" },
    { url = "https://files.pythonhosted.org/packages/ec/5f/6b64f78181d680f655ab40ba7b34cb68c045a2f4e04a10a70d768cd383b7/pymssql-2.3.13-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:fc5482969c813b0a45ce51c41844ae5bfa8044ad5ef8b4820ef6de7d4545b7f2", size = 3158377, upload-time = "2026-02-14T05:00:13.581Z" },
    { url = "https://files.pythonhosted.org/packages/ff/24/155dbb0992c431496d440f47fb9d587cd0059ee20baf65e3d891794d862a/pymssql-2.3.13-cp314-cp314-macosx_15_0_x86_64.whl", hash = "sha256:ff5be7ab1d643dbce2ee3424d2ef9ae8e4146cf75bd20946bc7a6108e3ad1e47", size = 2959039, upload-time = "2026-02-14T05:00:15.883Z" },
    { url = "https://files.pythonhosted.org/packages/c9/89/b453dd1b1188779621fb974ac715ab2e738f4a0b69f7291ab014298bd80d/pymssql-2.3.13-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d66ce0a249d2e3b57369048d71e1f00d08dfb90a758d134da0250ae7bc739c1", size = 3063862, upload-time = "2026-02-14T05:00:17.537Z" },
    { url = "https://files.pythonhosted.org/packages/02/e5/96f57c78162013678ecc3f3f7e5fb52c83ee07beef26906d0870770c3ef6/pymssql-2.3.13-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d663c908414a6a032f04d17628138b1782af916afc0df9fefac4751fa394c3ac", size = 3188155, upload-time = "2026-02-14T05:00:19.011Z" },
    { url = "https://files.pythonhosted.org/packages/cd/a2/4bee9484734ae0c55d10a2f6ff82dd4e416f52420755161b8760c817ad64/pymssql-2.3.13-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aa5e07eff7e6e8bd4ba22c30e4cb8dd073e138cd272090603609a15cc5dbc75b", size = 3709344, upload-time = "2026-02-14T05:00:21.139Z" },
    { url = "https://files.pythonhosted.org/packages/37/cf/3520d96afa213c88db4f4a1988199db476d869a62afdd5d9c4635c184631/pymssql-2.3.13-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:db77da1a3fc9b5b5c5400639d79d7658ba7ad620957100c5b025be608b562193", size = 3451799, upload-time = "2026-02-14T05:00:22.504Z" },
    { url = "https://files.pythonhosted.org/packages/25/50/4be9bd9cf4b43208a7175117a533ece200cfe4131a39f9909bdc7560ddeb/pymssql-2.3.13-cp314-cp314-win_amd64.whl", hash = "sha256:7d7037d2b5b907acc7906d0479924db2935a70c720450c41339146a4ada2b93d", size = 2049139, upload-time = "2026-02-14T05:00:23.951Z" },
    { url = "https://files.pythonhosted.org/packages/f0/a1/54a769293ed8032f0971cb0259a67c4ae298637044101b90e9c94366c88c/pymssql-2.3.13-cp39-cp39-macosx_14_0_arm64.whl", hash = "sha256:b0af51904764811da0bfe4b057b1d72dee11a399ce9ed5770875162772740c8a", size = 3174203, upload-time = "2026-02-14T05:00:25.397Z" },
    { url = "https://files.pythonhosted.org/packages/b7/c2/e36bdd9610125e6e227e63e3728e7e4e1c24477c929fc08efb7ffe057ee7/pymssql-2.3.13-cp39-cp39-macosx_15_0_x86_64.whl", hash = "sha256:0a7e6431925572bc75fb47929ae8ca5b0aac26abfe8b98d4c08daf117b5657f1", size = 2977372, upload-time = "2026-02-14T05:00:27.226Z" },
    { url = "https://files.pythonhosted.org/packages/1e/c5/70818fb251432baccd4c0682b1dfcc44766b60c40f554da2b209ff0b2426/pymssql-2.3.13-cp39-cp39-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9b1d5aef2b5f47a7f9d9733caee4d66772681e8f798a0f5e4739a8bdab408c", size = 3044386, upload-time = "2026-02-14T05:00:28.661Z" },
    { url = "https://files.pythonhosted.org/packages/2d/5b/00e0cdde0ac003b1bfeb891a8cf97108ec0d0fac51374196e572c33d5477/pymssql-2.3.13-cp39-cp39-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c690f1869dadbf4201b7f51317fceff6e5d8f5175cec6a4a813e06b0dca2d6ed", size = 3176776, upload-time = "2026-02-14T05:00:30.349Z" },
    { url = "https://files.pythonhosted.org/packages/32/51/5a8dabd2aedaa553c7444fbd38b9c65f1fc5bc7dbc790989d86acc27af08/pymssql-2.3.13-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:e7c31f192da9d30f0e03ad99e548120a8740a675302e2f04fa8c929f7cbee771", size = 3691096, upload-time = "2026-02-14T05:00:31.881Z" },
    { url = "https://files.pythonhosted.org/packages/1b/6b/888c91b1d5d551e4d96aa72c70c3d223296cee7be2a5a735ccc14034b0a6/pymssql-2.3.13-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:f5d995a80996235ed32102a93067ce6a7143cce3bfd4e5042bf600020fc08456", size = 3438375, upload-time = "2026-02-14T05:00:33.427Z" },
    { url = "https://files.pythonhosted.org/packages/32/4f/45d1caaed24103c892795be67b484d9a0e2984aaa607c542a3f1f3e57713/pymssql-2.3.13-cp39-cp39-win_amd64.whl", hash = "sha256:6a6c0783d97f57133573a03aad3017917dbdf7831a65e0d84ccf2a85e183ca66", size = 2009950, upload-time = "2026-02-14T05:00:34.924Z" },
]

[[package]]
name = "pyodbc"
version = "5.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8f/85/44b10070a769a56bd910009bb185c0c0a82daff8d567cd1a116d7d730c7d/pyodbc-5.3.0.tar.gz", hash = "sha256:2fe0e063d8fb66efd0ac6dc39236c4de1a45f17c33eaded0d553d21c199f4d05", size = 121770, upload-time = "2025-10-17T18:04:09.43Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/be/cd/d0ac9e8963cf43f3c0e8ebd284cd9c5d0e17457be76c35abe4998b7b6df2/pyodbc-5.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6682cdec78f1302d0c559422c8e00991668e039ed63dece8bf99ef62173376a5", size = 71888, upload-time = "2025-10-17T18:02:58.285Z" },
    { url = "https://files.pythonhosted.org/packages/cb/7b/95ea2795ea8a0db60414e14f117869a5ba44bd52387886c1a210da637315/pyodbc-5.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9cd3f0a9796b3e1170a9fa168c7e7ca81879142f30e20f46663b882db139b7d2", size = 71813, upload-time = "2025-10-17T18:02:59.722Z" },
    { url = "https://files.pythonhosted.org/packages/95/c9/6f4644b60af513ea1c9cab1ff4af633e8f300e8468f4ae3507f04524e641/pyodbc-5.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46185a1a7f409761716c71de7b95e7bbb004390c650d00b0b170193e3d6224bb", size = 318556, upload-time = "2025-10-17T18:03:01.129Z" },
    { url = "https://files.pythonhosted.org/packages/19/3f/24876d9cb9c6ce1bd2b6f43f69ebc00b8eb47bf1ed99ee95e340bf90ed79/pyodbc-5.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:349a9abae62a968b98f6bbd23d2825151f8d9de50b3a8f5f3271b48958fdb672", size = 322048, upload-time = "2025-10-17T18:03:02.522Z" },
    { url = "https://files.pythonhosted.org/packages/1f/27/faf17353605ac60f80136bc3172ed2d69d7defcb9733166293fc14ac2c52/pyodbc-5.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ac23feb7ddaa729f6b840639e92f83ff0ccaa7072801d944f1332cd5f5b05f47", size = 1286123, upload-time = "2025-10-17T18:03:04.157Z" },
    { url = "https://files.pythonhosted.org/packages/d4/61/c9d407d2aa3e89f9bb68acf6917b0045a788ae8c3f4045c34759cb77af63/pyodbc-5.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8aa396c6d6af52ccd51b8c8a5bffbb46fd44e52ce07ea4272c1d28e5e5b12722", size = 1343502, upload-time = "2025-10-17T18:03:05.485Z" },
    { url = "https://files.pythonhosted.org/packages/d9/9f/f1b0f3238d873d4930aa2a2b8d5ba97132f6416764bf0c87368f8d6f2139/pyodbc-5.3.0-cp310-cp310-win32.whl", hash = "sha256:46869b9a6555ff003ed1d8ebad6708423adf2a5c88e1a578b9f029fb1435186e", size = 62968, upload-time = "2025-10-17T18:03:06.933Z" },
    { url = "https://files.pythonhosted.org/packages/d8/26/5f8ebdca4735aad0119aaaa6d5d73b379901b7a1dbb643aaa636040b27cf/pyodbc-5.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:705903acf6f43c44fc64e764578d9a88649eb21bf7418d78677a9d2e337f56f2", size = 69397, upload-time = "2025-10-17T18:03:08.49Z" },
    { url = "https://files.pythonhosted.org/packages/d1/c8/480a942fd2e87dd7df6d3c1f429df075695ed8ae34d187fe95c64219fd49/pyodbc-5.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:c68d9c225a97aedafb7fff1c0e1bfe293093f77da19eaf200d0e988fa2718d16", size = 64446, upload-time = "2025-10-17T18:03:09.333Z" },
    { url = "https://files.pythonhosted.org/packages/e0/c7/534986d97a26cb8f40ef456dfcf00d8483161eade6d53fa45fcf2d5c2b87/pyodbc-5.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ebc3be93f61ea0553db88589e683ace12bf975baa954af4834ab89f5ee7bf8ae", size = 71958, upload-time = "2025-10-17T18:03:10.163Z" },
    { url = "https://files.pythonhosted.org/packages/69/3c/6fe3e9eae6db1c34d6616a452f9b954b0d5516c430f3dd959c9d8d725f2a/pyodbc-5.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9b987a25a384f31e373903005554230f5a6d59af78bce62954386736a902a4b3", size = 71843, upload-time = "2025-10-17T18:03:11.058Z" },
    { url = "https://files.pythonhosted.org/packages/44/0e/81a0315d0bf7e57be24338dbed616f806131ab706d87c70f363506dc13d5/pyodbc-5.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:676031723aac7dcbbd2813bddda0e8abf171b20ec218ab8dfb21d64a193430ea", size = 327191, upload-time = "2025-10-17T18:03:11.93Z" },
    { url = "https://files.pythonhosted.org/packages/43/ae/b95bb2068f911950322a97172c68675c85a3e87dc04a98448c339fcbef21/pyodbc-5.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5c30c5cd40b751f77bbc73edd32c4498630939bcd4e72ee7e6c9a4b982cc5ca", size = 332228, upload-time = "2025-10-17T18:03:13.096Z" },
    { url = "https://files.pythonhosted.org/packages/dc/21/2433625f7d5922ee9a34e3805805fa0f1355d01d55206c337bb23ec869bf/pyodbc-5.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2035c7dfb71677cd5be64d3a3eb0779560279f0a8dc6e33673499498caa88937", size = 1296469, upload-time = "2025-10-17T18:03:14.61Z" },
    { url = "https://files.pythonhosted.org/packages/3a/f4/c760caf7bb9b3ab988975d84bd3e7ebda739fe0075c82f476d04ee97324c/pyodbc-5.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5cbe4d753723c8a8f65020b7a259183ef5f14307587165ce37e8c7e251951852", size = 1353163, upload-time = "2025-10-17T18:03:16.272Z" },
    { url = "https://files.pythonhosted.org/packages/14/ad/f9ca1e9e44fd91058f6e35b233b1bb6213d590185bfcc2a2c4f1033266e7/pyodbc-5.3.0-cp311-cp311-win32.whl", hash = "sha256:d255f6b117d05cfc046a5201fdf39535264045352ea536c35777cf66d321fbb8", size = 62925, upload-time = "2025-10-17T18:03:17.649Z" },
    { url = "https://files.pythonhosted.org/packages/e6/cf/52b9b94efd8cfd11890ae04f31f50561710128d735e4e38a8fbb964cd2c2/pyodbc-5.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:f1ad0e93612a6201621853fc661209d82ff2a35892b7d590106fe8f97d9f1f2a", size = 69329, upload-time = "2025-10-17T18:03:18.474Z" },
    { url = "https://files.pythonhosted.org/packages/8b/6f/bf5433bb345007f93003fa062e045890afb42e4e9fc6bd66acc2c3bd12ca/pyodbc-5.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:0df7ff47fab91ea05548095b00e5eb87ed88ddf4648c58c67b4db95ea4913e23", size = 64447, upload-time = "2025-10-17T18:03:19.691Z" },
    { url = "https://files.pythonhosted.org/packages/f5/0c/7ecf8077f4b932a5d25896699ff5c394ffc2a880a9c2c284d6a3e6ea5949/pyodbc-5.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5ebf6b5d989395efe722b02b010cb9815698a4d681921bf5db1c0e1195ac1bde", size = 72994, upload-time = "2025-10-17T18:03:20.551Z" },
    { url = "https://files.pythonhosted.org/packages/03/78/9fbde156055d88c1ef3487534281a5b1479ee7a2f958a7e90714968749ac/pyodbc-5.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:197bb6ddafe356a916b8ee1b8752009057fce58e216e887e2174b24c7ab99269", size = 72535, upload-time = "2025-10-17T18:03:21.423Z" },
    { url = "https://files.pythonhosted.org/packages/9f/f9/8c106dcd6946e95fee0da0f1ba58cd90eb872eebe8968996a2ea1f7ac3c1/pyodbc-5.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c6ccb5315ec9e081f5cbd66f36acbc820ad172b8fa3736cf7f993cdf69bd8a96", size = 333565, upload-time = "2025-10-17T18:03:22.695Z" },
    { url = "https://files.pythonhosted.org/packages/4b/30/2c70f47a76a4fafa308d148f786aeb35a4d67a01d41002f1065b465d9994/pyodbc-5.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5dd3d5e469f89a3112cf8b0658c43108a4712fad65e576071e4dd44d2bd763c7", size = 340283, upload-time = "2025-10-17T18:03:23.691Z" },
    { url = "https://files.pythonhosted.org/packages/7d/b2/0631d84731606bfe40d3b03a436b80cbd16b63b022c7b13444fb30761ca8/pyodbc-5.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b180bc5e49b74fd40a24ef5b0fe143d0c234ac1506febe810d7434bf47cb925b", size = 1302767, upload-time = "2025-10-17T18:03:25.311Z" },
    { url = "https://files.pythonhosted.org/packages/74/b9/707c5314cca9401081b3757301241c167a94ba91b4bd55c8fa591bf35a4a/pyodbc-5.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e3c39de3005fff3ae79246f952720d44affc6756b4b85398da4c5ea76bf8f506", size = 1361251, upload-time = "2025-10-17T18:03:26.538Z" },
    { url = "https://files.pythonhosted.org/packages/97/7c/893036c8b0c8d359082a56efdaa64358a38dda993124162c3faa35d1924d/pyodbc-5.3.0-cp312-cp312-win32.whl", hash = "sha256:d32c3259762bef440707098010035bbc83d1c73d81a434018ab8c688158bd3bb", size = 63413, upload-time = "2025-10-17T18:03:27.903Z" },
    { url = "https://files.pythonhosted.org/packages/c0/70/5e61b216cc13c7f833ef87f4cdeab253a7873f8709253f5076e9bb16c1b3/pyodbc-5.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:fe77eb9dcca5fc1300c9121f81040cc9011d28cff383e2c35416e9ec06d4bc95", size = 70133, upload-time = "2025-10-17T18:03:28.746Z" },
    { url = "https://files.pythonhosted.org/packages/aa/85/e7d0629c9714a85eb4f85d21602ce6d8a1ec0f313fde8017990cf913e3b4/pyodbc-5.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:afe7c4ac555a8d10a36234788fc6cfc22a86ce37fc5ba88a1f75b3e6696665dc", size = 64700, upload-time = "2025-10-17T18:03:29.638Z" },
    { url = "https://files.pythonhosted.org/packages/0c/1d/9e74cbcc1d4878553eadfd59138364b38656369eb58f7e5b42fb344c0ce7/pyodbc-5.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e9ab0b91de28a5ab838ac4db0253d7cc8ce2452efe4ad92ee6a57b922bf0c24", size = 72975, upload-time = "2025-10-17T18:03:30.466Z" },
    { url = "https://files.pythonhosted.org/packages/37/c7/27d83f91b3144d3e275b5b387f0564b161ddbc4ce1b72bb3b3653e7f4f7a/pyodbc-5.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6132554ffbd7910524d643f13ce17f4a72f3a6824b0adef4e9a7f66efac96350", size = 72541, upload-time = "2025-10-17T18:03:31.348Z" },
    { url = "https://files.pythonhosted.org/packages/1b/33/2bb24e7fc95e98a7b11ea5ad1f256412de35d2e9cc339be198258c1d9a76/pyodbc-5.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1629af4706e9228d79dabb4863c11cceb22a6dab90700db0ef449074f0150c0d", size = 343287, upload-time = "2025-10-17T18:03:32.287Z" },
    { url = "https://files.pythonhosted.org/packages/fa/24/88cde8b6dc07a93a92b6c15520a947db24f55db7bd8b09e85956642b7cf3/pyodbc-5.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ceaed87ba2ea848c11223f66f629ef121f6ebe621f605cde9cfdee4fd9f4b68", size = 350094, upload-time = "2025-10-17T18:03:33.336Z" },
    { url = "https://files.pythonhosted.org/packages/c2/99/53c08562bc171a618fa1699297164f8885e66cde38c3b30f454730d0c488/pyodbc-5.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3cc472c8ae2feea5b4512e23b56e2b093d64f7cbc4b970af51da488429ff7818", size = 1301029, upload-time = "2025-10-17T18:03:34.561Z" },
    { url = "https://files.pythonhosted.org/packages/d8/10/68a0b5549876d4b53ba4c46eed2a7aca32d589624ed60beef5bd7382619e/pyodbc-5.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c79df54bbc25bce9f2d87094e7b39089c28428df5443d1902b0cc5f43fd2da6f", size = 1361420, upload-time = "2025-10-17T18:03:35.958Z" },
    { url = "https://files.pythonhosted.org/packages/41/0f/9dfe4987283ffcb981c49a002f0339d669215eb4a3fe4ee4e14537c52852/pyodbc-5.3.0-cp313-cp313-win32.whl", hash = "sha256:c2eb0b08e24fe5c40c7ebe9240c5d3bd2f18cd5617229acee4b0a0484dc226f2", size = 63399, upload-time = "2025-10-17T18:03:36.931Z" },
    { url = "https://files.pythonhosted.org/packages/56/03/15dcefe549d3888b649652af7cca36eda97c12b6196d92937ca6d11306e9/pyodbc-5.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:01166162149adf2b8a6dc21a212718f205cabbbdff4047dc0c415af3fd85867e", size = 70133, upload-time = "2025-10-17T18:03:38.47Z" },
    { url = "https://files.pythonhosted.org/packages/c4/c1/c8b128ae59a14ecc8510e9b499208e342795aecc3af4c3874805c720b8db/pyodbc-5.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:363311bd40320b4a61454bebf7c38b243cd67c762ed0f8a5219de3ec90c96353", size = 64683, upload-time = "2025-10-17T18:03:39.68Z" },
    { url = "https://files.pythonhosted.org/packages/ab/f2/c26d82a7ce1e90b8bbb8731d3d53de73814e2f6606b9db9d978303aa8d5f/pyodbc-5.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3f1bdb3ce6480a17afaaef4b5242b356d4997a872f39e96f015cabef00613797", size = 73513, upload-time = "2025-10-17T18:03:40.536Z" },
    { url = "https://files.pythonhosted.org/packages/82/d5/1ab1b7c4708cbd701990a8f7183c5bb5e0712d5e8479b919934e46dadab4/pyodbc-5.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7713c740a10f33df3cb08f49a023b7e1e25de0c7c99650876bbe717bc95ee780", size = 72631, upload-time = "2025-10-17T18:03:41.713Z" },
    { url = "https://files.pythonhosted.org/packages/b1/f1/7e3831eeac2b09b31a77e6b3495491ce162035ff2903d7261b49d35aa3c2/pyodbc-5.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf18797a12e70474e1b7f5027deeeccea816372497e3ff2d46b15bec2d18a0cc", size = 344580, upload-time = "2025-10-17T18:03:42.67Z" },
    { url = "https://files.pythonhosted.org/packages/a2/a6/71d26d626a3c45951620b7ff356ec920e420f0e09b0a924123682aa5e4ab/pyodbc-5.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:08b2439500e212625471d32f8fde418075a5ddec556e095e5a4ba56d61df2dc6", size = 350224, upload-time = "2025-10-17T18:03:43.731Z" },
    { url = "https://files.pythonhosted.org/packages/93/14/f702c5e8c2d595776266934498505f11b7f1545baf21ffec1d32c258e9d3/pyodbc-5.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:729c535341bb09c476f219d6f7ab194bcb683c4a0a368010f1cb821a35136f05", size = 1301503, upload-time = "2025-10-17T18:03:45.013Z" },
    { url = "https://files.pythonhosted.org/packages/d9/b2/ad92ebdd1b5c7fec36b065e586d1d34b57881e17ba5beec5c705f1031058/pyodbc-5.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c67e7f2ce649155ea89beb54d3b42d83770488f025cf3b6f39ca82e9c598a02e", size = 1361050, upload-time = "2025-10-17T18:03:46.298Z" },
    { url = "https://files.pythonhosted.org/packages/19/40/dc84e232da07056cb5aaaf5f759ba4c874bc12f37569f7f1670fc71e7ae1/pyodbc-5.3.0-cp314-cp314-win32.whl", hash = "sha256:a48d731432abaee5256ed6a19a3e1528b8881f9cb25cb9cf72d8318146ea991b", size = 65670, upload-time = "2025-10-17T18:03:56.414Z" },
    { url = "https://files.pythonhosted.org/packages/b8/79/c48be07e8634f764662d7a279ac204f93d64172162dbf90f215e2398b0bd/pyodbc-5.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:58635a1cc859d5af3f878c85910e5d7228fe5c406d4571bffcdd281375a54b39", size = 72177, upload-time = "2025-10-17T18:03:57.296Z" },
    { url = "https://files.pythonhosted.org/packages/fc/79/e304574446b2263f428ce14df590ba52c2e0e0205e8d34b235b582b7d57e/pyodbc-5.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:754d052030d00c3ac38da09ceb9f3e240e8dd1c11da8906f482d5419c65b9ef5", size = 66668, upload-time = "2025-10-17T18:03:58.174Z" },
    { url = "https://files.pythonhosted.org/packages/43/17/f4eabf443b838a2728773554017d08eee3aca353102934a7e3ba96fb0e31/pyodbc-5.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f927b440c38ade1668f0da64047ffd20ec34e32d817f9a60d07553301324b364", size = 75780, upload-time = "2025-10-17T18:03:47.273Z" },
    { url = "https://files.pythonhosted.org/packages/59/ea/e79e168c3d38c27d59d5d96273fd9e3c3ba55937cc944c4e60618f51de90/pyodbc-5.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:25c4cfb2c08e77bc6e82f666d7acd52f0e52a0401b1876e60f03c73c3b8aedc0", size = 75503, upload-time = "2025-10-17T18:03:48.171Z" },
    { url = "https://files.pythonhosted.org/packages/90/81/d1d7c125ec4a20e83fdc28e119b8321192b2bd694f432cf63e1199b2b929/pyodbc-5.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc834567c2990584b9726cba365834d039380c9dbbcef3030ddeb00c6541b943", size = 398356, upload-time = "2025-10-17T18:03:49.131Z" },
    { url = "https://files.pythonhosted.org/packages/5e/fc/f6be4b3cc3910f8c2aba37aa41671121fd6f37b402ae0fefe53a70ac7cd5/pyodbc-5.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8339d3094858893c1a68ee1af93efc4dff18b8b65de54d99104b99af6306320d", size = 397291, upload-time = "2025-10-17T18:03:50.18Z" },
    { url = "https://files.pythonhosted.org/packages/03/2e/0610b1ed05a5625528d52f6cece9610e84617d35f475c89c2a52f66d13f7/pyodbc-5.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74528fe148980d0c735c0ebb4a4dc74643ac4574337c43c1006ac4d09593f92d", size = 1353900, upload-time = "2025-10-17T18:03:51.339Z" },
    { url = "https://files.pythonhosted.org/packages/1d/f1/43497e1d37f9f71b43b2b3172e7b1bdf50851e278390c3fb6b46a3630c53/pyodbc-5.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d89a7f2e24227150c13be8164774b7e1f9678321a4248f1356a465b9cc17d31e", size = 1406062, upload-time = "2025-10-17T18:03:52.546Z" },
    { url = "https://files.pythonhosted.org/packages/9e/8b/88a1277c2f7d9ab1cec0a71e074ba24fd4a1710a43974682546da90a1343/pyodbc-5.3.0-cp314-cp314t-win32.whl", hash = "sha256:af4d8c9842fc4a6360c31c35508d6594d5a3b39922f61b282c2b4c9d9da99514", size = 70132, upload-time = "2025-10-17T18:03:53.715Z" },
    { url = "https://files.pythonhosted.org/packages/ba/c7/ee98c62050de4aa8bafb6eb1e11b95e0b0c898bd5930137c6dc776e06a9b/pyodbc-5.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bfeb3e34795d53b7d37e66dd54891d4f9c13a3889a8f5fe9640e56a82d770955", size = 79452, upload-time = "2025-10-17T18:03:54.664Z" },
    { url = "https://files.pythonhosted.org/packages/4b/8f/d8889efd96bbe8e5d43ff9701f6b1565a8e09c3e1f58c388d550724f777b/pyodbc-5.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:13656184faa3f2d5c6f19b701b8f247342ed581484f58bf39af7315c054e69db", size = 70142, upload-time = "2025-10-17T18:03:55.551Z" },
    { url = "https://files.pythonhosted.org/packages/98/21/879440a55360075137bf125103e01b2722e2fac8ff65ba5fe4fd4c5ec63a/pyodbc-5.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0263323fc47082c2bf02562f44149446bbbfe91450d271e44bffec0c3143bfb1", size = 71965, upload-time = "2025-10-17T18:03:59.128Z" },
    { url = "https://files.pythonhosted.org/packages/1a/c9/cd3cf6b8070a0c604cf83d46319390d9fc56405a91c9bcf96706e0d4d507/pyodbc-5.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:452e7911a35ee12a56b111ac5b596d6ed865b83fcde8427127913df53132759e", size = 71919, upload-time = "2025-10-17T18:03:59.999Z" },
    { url = "https://files.pythonhosted.org/packages/10/6c/8df5a61060f49b82977668a850015e182353a1e953d03dc4ddd5854270d2/pyodbc-5.3.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b35b9983ad300e5aea82b8d1661fc9d3afe5868de527ee6bd252dd550e61ecd6", size = 314623, upload-time = "2025-10-17T18:04:00.963Z" },
    { url = "https://files.pythonhosted.org/packages/91/5e/793834aa203766008bbd503abdd86d610b01e35cff3d3f7680d91dbc353f/pyodbc-5.3.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e981db84fee4cebec67f41bd266e1e7926665f1b99c3f8f4ea73cd7f7666e381", size = 318809, upload-time = "2025-10-17T18:04:02.055Z" },
    { url = "https://files.pythonhosted.org/packages/9b/64/5b14a07efb7a3bbe7672572335d85af8805c2031853db416ffb6f01dfc7f/pyodbc-5.3.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:25b6766e56748eb1fc1d567d863e06cbb7b7c749a41dfed85db0031e696fa39a", size = 1282409, upload-time = "2025-10-17T18:04:03.604Z" },
    { url = "https://files.pythonhosted.org/packages/f2/6a/efd1ab7351681610659986f379ba0778c2035eadc62f1260cf537be4b9d9/pyodbc-5.3.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2eb7151ed0a1959cae65b6ac0454f5c8bbcd2d8bafeae66483c09d58b0c7a7fc", size = 1340280, upload-time = "2025-10-17T18:04:05.228Z" },
    { url = "https://files.pythonhosted.org/packages/36/88/4b8fc797de1a792bc3de60bcf2845c3b802347d280bc6608425f06d703dc/pyodbc-5.3.0-cp39-cp39-win32.whl", hash = "sha256:fc5ac4f2165f7088e74ecec5413b5c304247949f9702c8853b0e43023b4187e8", size = 63065, upload-time = "2025-10-17T18:04:06.351Z" },
    { url = "https://files.pythonhosted.org/packages/41/43/87bfbeaa36f60ef4be56a5ce3869b35251bebfe443a0a357f1a32ce6794e/pyodbc-5.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:c25dc9c41f61573bdcf61a3408c34b65e4c0f821b8f861ca7531b1353b389804", size = 69586, upload-time = "2025-10-17T18:04:07.291Z" },
    { url = "https://files.pythonhosted.org/packages/99/2f/9c1ead06516e492b8cfab35605972bc8d306ad728adea53b280d6a7e4f87/pyodbc-5.3.0-cp39-cp39-win_arm64.whl", hash = "sha256:101313a21d2654df856a60e4a13763e4d9f6c5d3fd974bcf3fc6b4e86d1bbe8e", size = 64467, upload-time = "2025-10-17T18:04:08.169Z" },
]

[[package]]
name = "pyright"
version = "1.1.408"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "nodeenv" },
    { name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/74/b2/5db700e52554b8f025faa9c3c624c59f1f6c8841ba81ab97641b54322f16/pyright-1.1.408.tar.gz", hash = "sha256:f28f2321f96852fa50b5829ea492f6adb0e6954568d1caa3f3af3a5f555eb684", size = 4400578, upload-time = "2026-01-08T08:07:38.795Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/0c/82/a2c93e32800940d9573fb28c346772a14778b84ba7524e691b324620ab89/pyright-1.1.408-py3-none-any.whl", hash = "sha256:090b32865f4fdb1e0e6cd82bf5618480d48eecd2eb2e70f960982a3d9a4c17c1", size = 6399144, upload-time = "2026-01-08T08:07:37.082Z" },
]

[[package]]
name = "pytest"
version = "8.4.2"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
dependencies = [
    { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" },
    { name = "exceptiongroup", marker = "python_full_version < '3.10'" },
    { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "packaging", marker = "python_full_version < '3.10'" },
    { name = "pluggy", marker = "python_full_version < '3.10'" },
    { name = "pygments", marker = "python_full_version < '3.10'" },
    { name = "tomli", marker = "python_full_version < '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
]

[[package]]
name = "pytest"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
dependencies = [
    { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" },
    { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" },
    { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "packaging", marker = "python_full_version >= '3.10'" },
    { name = "pluggy", marker = "python_full_version >= '3.10'" },
    { name = "pygments", marker = "python_full_version >= '3.10'" },
    { name = "tomli", marker = "python_full_version == '3.10.*'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]

[[package]]
name = "pytest-asyncio"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
dependencies = [
    { name = "backports-asyncio-runner", marker = "python_full_version < '3.10'" },
    { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "typing-extensions", marker = "python_full_version < '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" },
]

[[package]]
name = "pytest-asyncio"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
dependencies = [
    { name = "backports-asyncio-runner", marker = "python_full_version == '3.10.*'" },
    { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
]

[[package]]
name = "pytest-click"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "click", version = "8.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ec/ec/bca3cd29ba2b025ae41666b851f6ff05fb77cb4c13719baaeda6a757772a/pytest_click-1.1.0.tar.gz", hash = "sha256:fdd9f6721f877dda021e7c5dc73e70aecd37e5ed23ec6820f8a7b3fd7b4f8d30", size = 5054, upload-time = "2022-02-11T09:09:35.169Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/72/1a/eb53371999b94b3c995c00117f3a232dbf6f56c7152a52cf3e3777e7d49d/pytest_click-1.1.0-py3-none-any.whl", hash = "sha256:eade4742c2f02c345e78a32534a43e8db04acf98d415090539dacc880b7cd0e9", size = 4110, upload-time = "2022-02-11T09:09:33.922Z" },
]

[[package]]
name = "pytest-cov"
version = "7.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "coverage", version = "7.10.7", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version < '3.10'" },
    { name = "coverage", version = "7.13.5", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version >= '3.10'" },
    { name = "pluggy" },
    { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
]

[[package]]
name = "pytest-databases"
version = "0.17.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "docker" },
    { name = "filelock", version = "3.19.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "filelock", version = "3.25.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/16/7f/58e6de47d1c4c59c1c31a8c8fb181a50bfe2737bcce5fe85a896cfb09b0b/pytest_databases-0.17.0.tar.gz", hash = "sha256:3918c932877d0b4e1252084fcae98fd4ee0315a6adc7409510e4fea8af5adf47", size = 330299, upload-time = "2026-03-10T19:33:46.227Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/ec/34/acedc8d44ac86a62f1e3576c1adeba92479bf115962b0925c8d87e664bfb/pytest_databases-0.17.0-py3-none-any.whl", hash = "sha256:d7831c0a5a4fd06b87902e95447b2e4b52ec8910d34ad94c6222e75db6950327", size = 33318, upload-time = "2026-03-10T19:33:43.824Z" },
]

[package.optional-dependencies]
bigquery = [
    { name = "google-cloud-bigquery" },
]
cockroachdb = [
    { name = "psycopg", version = "3.2.13", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "psycopg", version = "3.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]
minio = [
    { name = "minio" },
]
mssql = [
    { name = "pymssql" },
]
mysql = [
    { name = "mysql-connector-python", version = "9.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "mysql-connector-python", version = "9.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]
oracle = [
    { name = "oracledb" },
]
postgres = [
    { name = "psycopg", version = "3.2.13", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "psycopg", version = "3.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]
spanner = [
    { name = "google-cloud-spanner" },
]

[[package]]
name = "pytest-lazy-fixtures"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/75/05/030c4efe596bc31bcb4fefb31f5fcefc8917df99bd745a920763c5e81863/pytest_lazy_fixtures-1.4.0.tar.gz", hash = "sha256:f544b60c96b909b307558a62cc1f28f026f11e9f03d7f583a1dc636de3dbcb10", size = 36188, upload-time = "2025-09-16T18:42:31.797Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/60/a0/a07399bd4842282fe3c2da264746069d5216640bc0940b7a359e2c950aa6/pytest_lazy_fixtures-1.4.0-py3-none-any.whl", hash = "sha256:c5db4506fa0ade5887189d1a18857fec4c329b4f49043fef6732c67c9553389a", size = 9680, upload-time = "2025-09-16T18:42:30.534Z" },
]

[[package]]
name = "pytest-mock"
version = "3.15.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" },
]

[[package]]
name = "pytest-rerunfailures"
version = "16.0.1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
dependencies = [
    { name = "packaging", marker = "python_full_version < '3.10'" },
    { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/26/53/a543a76f922a5337d10df22441af8bf68f1b421cadf9aedf8a77943b81f6/pytest_rerunfailures-16.0.1.tar.gz", hash = "sha256:ed4b3a6e7badb0a720ddd93f9de1e124ba99a0cb13bc88561b3c168c16062559", size = 27612, upload-time = "2025-09-02T06:48:25.193Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/38/73/67dc14cda1942914e70fbb117fceaf11e259362c517bdadd76b0dd752524/pytest_rerunfailures-16.0.1-py3-none-any.whl", hash = "sha256:0bccc0e3b0e3388275c25a100f7077081318196569a121217688ed05e58984b9", size = 13610, upload-time = "2025-09-02T06:48:23.615Z" },
]

[[package]]
name = "pytest-rerunfailures"
version = "16.1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
dependencies = [
    { name = "packaging", marker = "python_full_version >= '3.10'" },
    { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/de/04/71e9520551fc8fe2cf5c1a1842e4e600265b0815f2016b7c27ec85688682/pytest_rerunfailures-16.1.tar.gz", hash = "sha256:c38b266db8a808953ebd71ac25c381cb1981a78ff9340a14bcb9f1b9bff1899e", size = 30889, upload-time = "2025-10-10T07:06:01.238Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/77/54/60eabb34445e3db3d3d874dc1dfa72751bfec3265bd611cb13c8b290adea/pytest_rerunfailures-16.1-py3-none-any.whl", hash = "sha256:5d11b12c0ca9a1665b5054052fcc1084f8deadd9328962745ef6b04e26382e86", size = 14093, upload-time = "2025-10-10T07:06:00.019Z" },
]

[[package]]
name = "pytest-sugar"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "termcolor", version = "3.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "termcolor", version = "3.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0b/4e/60fed105549297ba1a700e1ea7b828044842ea27d72c898990510b79b0e2/pytest-sugar-1.1.1.tar.gz", hash = "sha256:73b8b65163ebf10f9f671efab9eed3d56f20d2ca68bda83fa64740a92c08f65d", size = 16533, upload-time = "2025-08-23T12:19:35.737Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/87/d5/81d38a91c1fdafb6711f053f5a9b92ff788013b19821257c2c38c1e132df/pytest_sugar-1.1.1-py3-none-any.whl", hash = "sha256:2f8319b907548d5b9d03a171515c1d43d2e38e32bd8182a1781eb20b43344cc8", size = 11440, upload-time = "2025-08-23T12:19:34.894Z" },
]

[[package]]
name = "pytest-xdist"
version = "3.8.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "execnet" },
    { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" },
]

[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]

[[package]]
name = "python-discovery"
version = "1.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "filelock", version = "3.19.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "filelock", version = "3.25.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "platformdirs", version = "4.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "platformdirs", version = "4.9.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b9/88/815e53084c5079a59df912825a279f41dd2e0df82281770eadc732f5352c/python_discovery-1.2.1.tar.gz", hash = "sha256:180c4d114bff1c32462537eac5d6a332b768242b76b69c0259c7d14b1b680c9e", size = 58457, upload-time = "2026-03-26T22:30:44.496Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/67/0f/019d3949a40280f6193b62bc010177d4ce702d0fce424322286488569cd3/python_discovery-1.2.1-py3-none-any.whl", hash = "sha256:b6a957b24c1cd79252484d3566d1b49527581d46e789aaf43181005e56201502", size = 31674, upload-time = "2026-03-26T22:30:43.396Z" },
]

[[package]]
name = "python-dotenv"
version = "1.2.1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
]

[[package]]
name = "python-dotenv"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]

[[package]]
name = "python-multipart"
version = "0.0.20"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" },
]

[[package]]
name = "python-multipart"
version = "0.0.24"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
sdist = { url = "https://files.pythonhosted.org/packages/8a/45/e23b5dc14ddb9918ae4a625379506b17b6f8fc56ca1d82db62462f59aea6/python_multipart-0.0.24.tar.gz", hash = "sha256:9574c97e1c026e00bc30340ef7c7d76739512ab4dfd428fec8c330fa6a5cc3c8", size = 37695, upload-time = "2026-04-05T20:49:13.829Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/a3/73/89930efabd4da63cea44a3f438aeb753d600123570e6d6264e763617a9ce/python_multipart-0.0.24-py3-none-any.whl", hash = "sha256:9b110a98db707df01a53c194f0af075e736a770dc5058089650d70b4a182f950", size = 24420, upload-time = "2026-04-05T20:49:12.555Z" },
]

[[package]]
name = "pytz"
version = "2026.1.post1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/56/db/b8721d71d945e6a8ac63c0fc900b2067181dbb50805958d4d4661cf7d277/pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1", size = 321088, upload-time = "2026-03-03T07:47:50.683Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a", size = 510489, upload-time = "2026-03-03T07:47:49.167Z" },
]

[[package]]
name = "pywin32"
version = "311"
source = { registry = "https://pypi.org/simple" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/7b/40/44efbb0dfbd33aca6a6483191dae0716070ed99e2ecb0c53683f400a0b4f/pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3", size = 8760432, upload-time = "2025-07-14T20:13:05.9Z" },
    { url = "https://files.pythonhosted.org/packages/5e/bf/360243b1e953bd254a82f12653974be395ba880e7ec23e3731d9f73921cc/pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b", size = 9590103, upload-time = "2025-07-14T20:13:07.698Z" },
    { url = "https://files.pythonhosted.org/packages/57/38/d290720e6f138086fb3d5ffe0b6caa019a791dd57866940c82e4eeaf2012/pywin32-311-cp310-cp310-win_arm64.whl", hash = "sha256:0502d1facf1fed4839a9a51ccbcc63d952cf318f78ffc00a7e78528ac27d7a2b", size = 8778557, upload-time = "2025-07-14T20:13:11.11Z" },
    { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" },
    { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" },
    { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" },
    { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" },
    { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" },
    { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" },
    { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" },
    { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" },
    { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" },
    { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" },
    { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" },
    { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" },
    { url = "https://files.pythonhosted.org/packages/59/42/b86689aac0cdaee7ae1c58d464b0ff04ca909c19bb6502d4973cdd9f9544/pywin32-311-cp39-cp39-win32.whl", hash = "sha256:aba8f82d551a942cb20d4a83413ccbac30790b50efb89a75e4f586ac0bb8056b", size = 8760837, upload-time = "2025-07-14T20:12:59.59Z" },
    { url = "https://files.pythonhosted.org/packages/9f/8a/1403d0353f8c5a2f0829d2b1c4becbf9da2f0a4d040886404fc4a5431e4d/pywin32-311-cp39-cp39-win_amd64.whl", hash = "sha256:e0c4cfb0621281fe40387df582097fd796e80430597cb9944f0ae70447bacd91", size = 9590187, upload-time = "2025-07-14T20:13:01.419Z" },
    { url = "https://files.pythonhosted.org/packages/60/22/e0e8d802f124772cec9c75430b01a212f86f9de7546bda715e54140d5aeb/pywin32-311-cp39-cp39-win_arm64.whl", hash = "sha256:62ea666235135fee79bb154e695f3ff67370afefd71bd7fea7512fc70ef31e3d", size = 8778162, upload-time = "2025-07-14T20:13:03.544Z" },
]

[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" },
    { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" },
    { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" },
    { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" },
    { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" },
    { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" },
    { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" },
    { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" },
    { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" },
    { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
    { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
    { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
    { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
    { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
    { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
    { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
    { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
    { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
    { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
    { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
    { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
    { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
    { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
    { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
    { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
    { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
    { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
    { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
    { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
    { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
    { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
    { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
    { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
    { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
    { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
    { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
    { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
    { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
    { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
    { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
    { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
    { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
    { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
    { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
    { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
    { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
    { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
    { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
    { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
    { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
    { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
    { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
    { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
    { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
    { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
    { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
    { url = "https://files.pythonhosted.org/packages/9f/62/67fc8e68a75f738c9200422bf65693fb79a4cd0dc5b23310e5202e978090/pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da", size = 184450, upload-time = "2025-09-25T21:33:00.618Z" },
    { url = "https://files.pythonhosted.org/packages/ae/92/861f152ce87c452b11b9d0977952259aa7df792d71c1053365cc7b09cc08/pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917", size = 174319, upload-time = "2025-09-25T21:33:02.086Z" },
    { url = "https://files.pythonhosted.org/packages/d0/cd/f0cfc8c74f8a030017a2b9c771b7f47e5dd702c3e28e5b2071374bda2948/pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9", size = 737631, upload-time = "2025-09-25T21:33:03.25Z" },
    { url = "https://files.pythonhosted.org/packages/ef/b2/18f2bd28cd2055a79a46c9b0895c0b3d987ce40ee471cecf58a1a0199805/pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5", size = 836795, upload-time = "2025-09-25T21:33:05.014Z" },
    { url = "https://files.pythonhosted.org/packages/73/b9/793686b2d54b531203c160ef12bec60228a0109c79bae6c1277961026770/pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a", size = 750767, upload-time = "2025-09-25T21:33:06.398Z" },
    { url = "https://files.pythonhosted.org/packages/a9/86/a137b39a611def2ed78b0e66ce2fe13ee701a07c07aebe55c340ed2a050e/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926", size = 727982, upload-time = "2025-09-25T21:33:08.708Z" },
    { url = "https://files.pythonhosted.org/packages/dd/62/71c27c94f457cf4418ef8ccc71735324c549f7e3ea9d34aba50874563561/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7", size = 755677, upload-time = "2025-09-25T21:33:09.876Z" },
    { url = "https://files.pythonhosted.org/packages/29/3d/6f5e0d58bd924fb0d06c3a6bad00effbdae2de5adb5cda5648006ffbd8d3/pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0", size = 142592, upload-time = "2025-09-25T21:33:10.983Z" },
    { url = "https://files.pythonhosted.org/packages/f0/0c/25113e0b5e103d7f1490c0e947e303fe4a696c10b501dea7a9f49d4e876c/pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007", size = 158777, upload-time = "2025-09-25T21:33:15.55Z" },
]

[[package]]
name = "questionary"
version = "2.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "prompt-toolkit" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" },
]

[[package]]
name = "requests"
version = "2.32.5"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
dependencies = [
    { name = "certifi", marker = "python_full_version < '3.10'" },
    { name = "charset-normalizer", marker = "python_full_version < '3.10'" },
    { name = "idna", marker = "python_full_version < '3.10'" },
    { name = "urllib3", version = "1.26.20", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
]

[[package]]
name = "requests"
version = "2.33.1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
dependencies = [
    { name = "certifi", marker = "python_full_version >= '3.10'" },
    { name = "charset-normalizer", marker = "python_full_version >= '3.10'" },
    { name = "idna", marker = "python_full_version >= '3.10'" },
    { name = "urllib3", version = "2.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" },
]

[[package]]
name = "rich"
version = "14.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
    { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
    { name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" },
]

[[package]]
name = "rich-click"
version = "1.9.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "click", version = "8.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "colorama", marker = "sys_platform == 'win32'" },
    { name = "rich" },
    { name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/04/27/091e140ea834272188e63f8dd6faac1f5c687582b687197b3e0ec3c78ebf/rich_click-1.9.7.tar.gz", hash = "sha256:022997c1e30731995bdbc8ec2f82819340d42543237f033a003c7b1f843fc5dc", size = 74838, upload-time = "2026-01-31T04:29:27.707Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/ca/e5/d708d262b600a352abe01c2ae360d8ff75b0af819b78e9af293191d928e6/rich_click-1.9.7-py3-none-any.whl", hash = "sha256:2f99120fca78f536e07b114d3b60333bc4bb2a0969053b1250869bcdc1b5351b", size = 71491, upload-time = "2026-01-31T04:29:26.777Z" },
]

[[package]]
name = "rich-toolkit"
version = "0.19.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "click", version = "8.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "rich" },
    { name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/42/ba/dae9e3096651042754da419a4042bc1c75e07d615f9b15066d738838e4df/rich_toolkit-0.19.7.tar.gz", hash = "sha256:133c0915872da91d4c25d85342d5ec1dfacc69b63448af1a08a0d4b4f23ef46e", size = 195877, upload-time = "2026-02-24T16:06:20.555Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/fb/3c/c923619f6d2f5fafcc96fec0aaf9550a46cd5b6481f06e0c6b66a2a4fed0/rich_toolkit-0.19.7-py3-none-any.whl", hash = "sha256:0288e9203728c47c5a4eb60fd2f0692d9df7455a65901ab6f898437a2ba5989d", size = 32963, upload-time = "2026-02-24T16:06:22.066Z" },
]

[[package]]
name = "rignore"
version = "0.7.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e5/f5/8bed2310abe4ae04b67a38374a4d311dd85220f5d8da56f47ae9361be0b0/rignore-0.7.6.tar.gz", hash = "sha256:00d3546cd793c30cb17921ce674d2c8f3a4b00501cb0e3dd0e82217dbeba2671", size = 57140, upload-time = "2025-11-05T21:41:21.968Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/86/7a/b970cd0138b0ece72eb28f086e933f9ed75b795716ad3de5ab22994b3b54/rignore-0.7.6-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:f3c74a7e5ee77aea669c95fdb3933f2a6c7549893700082e759128a29cf67e45", size = 884999, upload-time = "2025-11-05T20:42:38.373Z" },
    { url = "https://files.pythonhosted.org/packages/ca/05/23faca29616d8966ada63fb0e13c214107811fa9a0aba2275e4c7ca63bd5/rignore-0.7.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b7202404958f5fe3474bac91f65350f0b1dde1a5e05089f2946549b7e91e79ec", size = 824824, upload-time = "2025-11-05T20:42:22.1Z" },
    { url = "https://files.pythonhosted.org/packages/fa/2e/05a1e61f04cf2548524224f0b5f21ca19ea58f7273a863bac10846b8ff69/rignore-0.7.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6bde7c5835fa3905bfb7e329a4f1d7eccb676de63da7a3f934ddd5c06df20597", size = 899121, upload-time = "2025-11-05T20:40:48.94Z" },
    { url = "https://files.pythonhosted.org/packages/ff/35/71518847e10bdbf359badad8800e4681757a01f4777b3c5e03dbde8a42d8/rignore-0.7.6-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:626c3d4ba03af266694d25101bc1d8d16eda49c5feb86cedfec31c614fceca7d", size = 873813, upload-time = "2025-11-05T20:41:04.71Z" },
    { url = "https://files.pythonhosted.org/packages/f6/c8/32ae405d3e7fd4d9f9b7838f2fcca0a5005bb87fa514b83f83fd81c0df22/rignore-0.7.6-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0a43841e651e7a05a4274b9026cc408d1912e64016ede8cd4c145dae5d0635be", size = 1168019, upload-time = "2025-11-05T20:41:20.723Z" },
    { url = "https://files.pythonhosted.org/packages/25/98/013c955982bc5b4719bf9a5bea58be317eea28aa12bfd004025e3cd7c000/rignore-0.7.6-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7978c498dbf7f74d30cdb8859fe612167d8247f0acd377ae85180e34490725da", size = 942822, upload-time = "2025-11-05T20:41:36.99Z" },
    { url = "https://files.pythonhosted.org/packages/90/fb/9a3f3156c6ed30bcd597e63690353edac1fcffe9d382ad517722b56ac195/rignore-0.7.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2d22f72ab695c07d2d96d2a645208daff17084441b5d58c07378c9dd6f9c4c87", size = 959820, upload-time = "2025-11-05T20:42:06.364Z" },
    { url = "https://files.pythonhosted.org/packages/5e/b2/93bf609633021e9658acaff24cfb055d8cdaf7f5855d10ebb35307900dda/rignore-0.7.6-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d5bd8e1a91ed1a789b2cbe39eeea9204a6719d4f2cf443a9544b521a285a295f", size = 985050, upload-time = "2025-11-05T20:41:51.124Z" },
    { url = "https://files.pythonhosted.org/packages/69/bc/ec2d040469bdfd7b743df10f2201c5d285009a4263d506edbf7a06a090bb/rignore-0.7.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:bc1fc03efad5789365018e94ac4079f851a999bc154d1551c45179f7fcf45322", size = 1079164, upload-time = "2025-11-05T21:40:10.368Z" },
    { url = "https://files.pythonhosted.org/packages/df/26/4b635f4ea5baf4baa8ba8eee06163f6af6e76dfbe72deb57da34bb24b19d/rignore-0.7.6-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:ce2617fe28c51367fd8abfd4eeea9e61664af63c17d4ea00353d8ef56dfb95fa", size = 1139028, upload-time = "2025-11-05T21:40:27.977Z" },
    { url = "https://files.pythonhosted.org/packages/6a/54/a3147ebd1e477b06eb24e2c2c56d951ae5faa9045b7b36d7892fec5080d9/rignore-0.7.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:7c4ad2cee85068408e7819a38243043214e2c3047e9bd4c506f8de01c302709e", size = 1119024, upload-time = "2025-11-05T21:40:45.148Z" },
    { url = "https://files.pythonhosted.org/packages/fb/f4/27475db769a57cff18fe7e7267b36e6cdb5b1281caa185ba544171106cba/rignore-0.7.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:02cd240bfd59ecc3907766f4839cbba20530a2e470abca09eaa82225e4d946fb", size = 1128531, upload-time = "2025-11-05T21:41:02.734Z" },
    { url = "https://files.pythonhosted.org/packages/97/32/6e782d3b352e4349fa0e90bf75b13cb7f11d8908b36d9e2b262224b65d9a/rignore-0.7.6-cp310-cp310-win32.whl", hash = "sha256:fe2bd8fa1ff555259df54c376abc73855cb02628a474a40d51b358c3a1ddc55b", size = 646817, upload-time = "2025-11-05T21:41:47.51Z" },
    { url = "https://files.pythonhosted.org/packages/c0/8a/53185c69abb3bb362e8a46b8089999f820bf15655629ff8395107633c8ab/rignore-0.7.6-cp310-cp310-win_amd64.whl", hash = "sha256:d80afd6071c78baf3765ec698841071b19e41c326f994cfa69b5a1df676f5d39", size = 727001, upload-time = "2025-11-05T21:41:32.778Z" },
    { url = "https://files.pythonhosted.org/packages/25/41/b6e2be3069ef3b7f24e35d2911bd6deb83d20ed5642ad81d5a6d1c015473/rignore-0.7.6-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:40be8226e12d6653abbebaffaea2885f80374c1c8f76fe5ca9e0cadd120a272c", size = 885285, upload-time = "2025-11-05T20:42:39.763Z" },
    { url = "https://files.pythonhosted.org/packages/52/66/ba7f561b6062402022887706a7f2b2c2e2e2a28f1e3839202b0a2f77e36d/rignore-0.7.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:182f4e5e4064d947c756819446a7d4cdede8e756b8c81cf9e509683fe38778d7", size = 823882, upload-time = "2025-11-05T20:42:23.488Z" },
    { url = "https://files.pythonhosted.org/packages/f5/81/4087453df35a90b07370647b19017029324950c1b9137d54bf1f33843f17/rignore-0.7.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:16b63047648a916a87be1e51bb5c009063f1b8b6f5afe4f04f875525507e63dc", size = 899362, upload-time = "2025-11-05T20:40:51.111Z" },
    { url = "https://files.pythonhosted.org/packages/fb/c9/390a8fdfabb76d71416be773bd9f162977bd483084f68daf19da1dec88a6/rignore-0.7.6-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ba5524f5178deca4d7695e936604ebc742acb8958f9395776e1fcb8133f8257a", size = 873633, upload-time = "2025-11-05T20:41:06.193Z" },
    { url = "https://files.pythonhosted.org/packages/df/c9/79404fcb0faa76edfbc9df0901f8ef18568d1104919ebbbad6d608c888d1/rignore-0.7.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:62020dbb89a1dd4b84ab3d60547b3b2eb2723641d5fb198463643f71eaaed57d", size = 1167633, upload-time = "2025-11-05T20:41:22.491Z" },
    { url = "https://files.pythonhosted.org/packages/6e/8d/b3466d32d445d158a0aceb80919085baaae495b1f540fb942f91d93b5e5b/rignore-0.7.6-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b34acd532769d5a6f153a52a98dcb81615c949ab11697ce26b2eb776af2e174d", size = 941434, upload-time = "2025-11-05T20:41:38.151Z" },
    { url = "https://files.pythonhosted.org/packages/e8/40/9cd949761a7af5bc27022a939c91ff622d29c7a0b66d0c13a863097dde2d/rignore-0.7.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c5e53b752f9de44dff7b3be3c98455ce3bf88e69d6dc0cf4f213346c5e3416c", size = 959461, upload-time = "2025-11-05T20:42:08.476Z" },
    { url = "https://files.pythonhosted.org/packages/b5/87/1e1a145731f73bdb7835e11f80da06f79a00d68b370d9a847de979575e6d/rignore-0.7.6-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:25b3536d13a5d6409ce85f23936f044576eeebf7b6db1d078051b288410fc049", size = 985323, upload-time = "2025-11-05T20:41:52.735Z" },
    { url = "https://files.pythonhosted.org/packages/6c/31/1ecff992fc3f59c4fcdcb6c07d5f6c1e6dfb55ccda19c083aca9d86fa1c6/rignore-0.7.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6e01cad2b0b92f6b1993f29fc01f23f2d78caf4bf93b11096d28e9d578eb08ce", size = 1079173, upload-time = "2025-11-05T21:40:12.007Z" },
    { url = "https://files.pythonhosted.org/packages/17/18/162eedadb4c2282fa4c521700dbf93c9b14b8842e8354f7d72b445b8d593/rignore-0.7.6-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:5991e46ab9b4868334c9e372ab0892b0150f3f586ff2b1e314272caeb38aaedb", size = 1139012, upload-time = "2025-11-05T21:40:29.399Z" },
    { url = "https://files.pythonhosted.org/packages/78/96/a9ca398a8af74bb143ad66c2a31303c894111977e28b0d0eab03867f1b43/rignore-0.7.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6c8ae562e5d1246cba5eaeb92a47b2a279e7637102828dde41dcbe291f529a3e", size = 1118827, upload-time = "2025-11-05T21:40:46.6Z" },
    { url = "https://files.pythonhosted.org/packages/9f/22/1c1a65047df864def9a047dbb40bc0b580b8289a4280e62779cd61ae21f2/rignore-0.7.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:aaf938530dcc0b47c4cfa52807aa2e5bfd5ca6d57a621125fe293098692f6345", size = 1128182, upload-time = "2025-11-05T21:41:04.239Z" },
    { url = "https://files.pythonhosted.org/packages/bd/f4/1526eb01fdc2235aca1fd9d0189bee4021d009a8dcb0161540238c24166e/rignore-0.7.6-cp311-cp311-win32.whl", hash = "sha256:166ebce373105dd485ec213a6a2695986346e60c94ff3d84eb532a237b24a4d5", size = 646547, upload-time = "2025-11-05T21:41:49.439Z" },
    { url = "https://files.pythonhosted.org/packages/7c/c8/dda0983e1845706beb5826459781549a840fe5a7eb934abc523e8cd17814/rignore-0.7.6-cp311-cp311-win_amd64.whl", hash = "sha256:44f35ee844b1a8cea50d056e6a595190ce9d42d3cccf9f19d280ae5f3058973a", size = 727139, upload-time = "2025-11-05T21:41:34.367Z" },
    { url = "https://files.pythonhosted.org/packages/e3/47/eb1206b7bf65970d41190b879e1723fc6bbdb2d45e53565f28991a8d9d96/rignore-0.7.6-cp311-cp311-win_arm64.whl", hash = "sha256:14b58f3da4fa3d5c3fa865cab49821675371f5e979281c683e131ae29159a581", size = 657598, upload-time = "2025-11-05T21:41:23.758Z" },
    { url = "https://files.pythonhosted.org/packages/0b/0e/012556ef3047a2628842b44e753bb15f4dc46806780ff090f1e8fe4bf1eb/rignore-0.7.6-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:03e82348cb7234f8d9b2834f854400ddbbd04c0f8f35495119e66adbd37827a8", size = 883488, upload-time = "2025-11-05T20:42:41.359Z" },
    { url = "https://files.pythonhosted.org/packages/93/b0/d4f1f3fe9eb3f8e382d45ce5b0547ea01c4b7e0b4b4eb87bcd66a1d2b888/rignore-0.7.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9e624f6be6116ea682e76c5feb71ea91255c67c86cb75befe774365b2931961", size = 820411, upload-time = "2025-11-05T20:42:24.782Z" },
    { url = "https://files.pythonhosted.org/packages/4a/c8/dea564b36dedac8de21c18e1851789545bc52a0c22ece9843444d5608a6a/rignore-0.7.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bda49950d405aa8d0ebe26af807c4e662dd281d926530f03f29690a2e07d649a", size = 897821, upload-time = "2025-11-05T20:40:52.613Z" },
    { url = "https://files.pythonhosted.org/packages/b3/2b/ee96db17ac1835e024c5d0742eefb7e46de60020385ac883dd3d1cde2c1f/rignore-0.7.6-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5fd5ab3840b8c16851d327ed06e9b8be6459702a53e5ab1fc4073b684b3789e", size = 873963, upload-time = "2025-11-05T20:41:07.49Z" },
    { url = "https://files.pythonhosted.org/packages/a5/8c/ad5a57bbb9d14d5c7e5960f712a8a0b902472ea3f4a2138cbf70d1777b75/rignore-0.7.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ced2a248352636a5c77504cb755dc02c2eef9a820a44d3f33061ce1bb8a7f2d2", size = 1169216, upload-time = "2025-11-05T20:41:23.73Z" },
    { url = "https://files.pythonhosted.org/packages/80/e6/5b00bc2a6bc1701e6878fca798cf5d9125eb3113193e33078b6fc0d99123/rignore-0.7.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a04a3b73b75ddc12c9c9b21efcdaab33ca3832941d6f1d67bffd860941cd448a", size = 942942, upload-time = "2025-11-05T20:41:39.393Z" },
    { url = "https://files.pythonhosted.org/packages/85/e5/7f99bd0cc9818a91d0e8b9acc65b792e35750e3bdccd15a7ee75e64efca4/rignore-0.7.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d24321efac92140b7ec910ac7c53ab0f0c86a41133d2bb4b0e6a7c94967f44dd", size = 959787, upload-time = "2025-11-05T20:42:09.765Z" },
    { url = "https://files.pythonhosted.org/packages/55/54/2ffea79a7c1eabcede1926347ebc2a81bc6b81f447d05b52af9af14948b9/rignore-0.7.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:73c7aa109d41e593785c55fdaa89ad80b10330affa9f9d3e3a51fa695f739b20", size = 984245, upload-time = "2025-11-05T20:41:54.062Z" },
    { url = "https://files.pythonhosted.org/packages/41/f7/e80f55dfe0f35787fa482aa18689b9c8251e045076c35477deb0007b3277/rignore-0.7.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1734dc49d1e9501b07852ef44421f84d9f378da9fbeda729e77db71f49cac28b", size = 1078647, upload-time = "2025-11-05T21:40:13.463Z" },
    { url = "https://files.pythonhosted.org/packages/d4/cf/2c64f0b6725149f7c6e7e5a909d14354889b4beaadddaa5fff023ec71084/rignore-0.7.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5719ea14ea2b652c0c0894be5dfde954e1853a80dea27dd2fbaa749618d837f5", size = 1139186, upload-time = "2025-11-05T21:40:31.27Z" },
    { url = "https://files.pythonhosted.org/packages/75/95/a86c84909ccc24af0d094b50d54697951e576c252a4d9f21b47b52af9598/rignore-0.7.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8e23424fc7ce35726854f639cb7968151a792c0c3d9d082f7f67e0c362cfecca", size = 1117604, upload-time = "2025-11-05T21:40:48.07Z" },
    { url = "https://files.pythonhosted.org/packages/7f/5e/13b249613fd5d18d58662490ab910a9f0be758981d1797789913adb4e918/rignore-0.7.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3efdcf1dd84d45f3e2bd2f93303d9be103888f56dfa7c3349b5bf4f0657ec696", size = 1127725, upload-time = "2025-11-05T21:41:05.804Z" },
    { url = "https://files.pythonhosted.org/packages/c7/28/fa5dcd1e2e16982c359128664e3785f202d3eca9b22dd0b2f91c4b3d242f/rignore-0.7.6-cp312-cp312-win32.whl", hash = "sha256:ccca9d1a8b5234c76b71546fc3c134533b013f40495f394a65614a81f7387046", size = 646145, upload-time = "2025-11-05T21:41:51.096Z" },
    { url = "https://files.pythonhosted.org/packages/26/87/69387fb5dd81a0f771936381431780b8cf66fcd2cfe9495e1aaf41548931/rignore-0.7.6-cp312-cp312-win_amd64.whl", hash = "sha256:c96a285e4a8bfec0652e0bfcf42b1aabcdda1e7625f5006d188e3b1c87fdb543", size = 726090, upload-time = "2025-11-05T21:41:36.485Z" },
    { url = "https://files.pythonhosted.org/packages/24/5f/e8418108dcda8087fb198a6f81caadbcda9fd115d61154bf0df4d6d3619b/rignore-0.7.6-cp312-cp312-win_arm64.whl", hash = "sha256:a64a750e7a8277a323f01ca50b7784a764845f6cce2fe38831cb93f0508d0051", size = 656317, upload-time = "2025-11-05T21:41:25.305Z" },
    { url = "https://files.pythonhosted.org/packages/b7/8a/a4078f6e14932ac7edb171149c481de29969d96ddee3ece5dc4c26f9e0c3/rignore-0.7.6-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:2bdab1d31ec9b4fb1331980ee49ea051c0d7f7bb6baa28b3125ef03cdc48fdaf", size = 883057, upload-time = "2025-11-05T20:42:42.741Z" },
    { url = "https://files.pythonhosted.org/packages/f9/8f/f8daacd177db4bf7c2223bab41e630c52711f8af9ed279be2058d2fe4982/rignore-0.7.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:90f0a00ce0c866c275bf888271f1dc0d2140f29b82fcf33cdbda1e1a6af01010", size = 820150, upload-time = "2025-11-05T20:42:26.545Z" },
    { url = "https://files.pythonhosted.org/packages/36/31/b65b837e39c3f7064c426754714ac633b66b8c2290978af9d7f513e14aa9/rignore-0.7.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1ad295537041dc2ed4b540fb1a3906bd9ede6ccdad3fe79770cd89e04e3c73c", size = 897406, upload-time = "2025-11-05T20:40:53.854Z" },
    { url = "https://files.pythonhosted.org/packages/ca/58/1970ce006c427e202ac7c081435719a076c478f07b3a23f469227788dc23/rignore-0.7.6-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f782dbd3a65a5ac85adfff69e5c6b101285ef3f845c3a3cae56a54bebf9fe116", size = 874050, upload-time = "2025-11-05T20:41:08.922Z" },
    { url = "https://files.pythonhosted.org/packages/d4/00/eb45db9f90137329072a732273be0d383cb7d7f50ddc8e0bceea34c1dfdf/rignore-0.7.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65cece3b36e5b0826d946494734c0e6aaf5a0337e18ff55b071438efe13d559e", size = 1167835, upload-time = "2025-11-05T20:41:24.997Z" },
    { url = "https://files.pythonhosted.org/packages/f3/f1/6f1d72ddca41a64eed569680587a1236633587cc9f78136477ae69e2c88a/rignore-0.7.6-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d7e4bb66c13cd7602dc8931822c02dfbbd5252015c750ac5d6152b186f0a8be0", size = 941945, upload-time = "2025-11-05T20:41:40.628Z" },
    { url = "https://files.pythonhosted.org/packages/48/6f/2f178af1c1a276a065f563ec1e11e7a9e23d4996fd0465516afce4b5c636/rignore-0.7.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:297e500c15766e196f68aaaa70e8b6db85fa23fdc075b880d8231fdfba738cd7", size = 959067, upload-time = "2025-11-05T20:42:11.09Z" },
    { url = "https://files.pythonhosted.org/packages/5b/db/423a81c4c1e173877c7f9b5767dcaf1ab50484a94f60a0b2ed78be3fa765/rignore-0.7.6-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a07084211a8d35e1a5b1d32b9661a5ed20669970b369df0cf77da3adea3405de", size = 984438, upload-time = "2025-11-05T20:41:55.443Z" },
    { url = "https://files.pythonhosted.org/packages/31/eb/c4f92cc3f2825d501d3c46a244a671eb737fc1bcf7b05a3ecd34abb3e0d7/rignore-0.7.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:181eb2a975a22256a1441a9d2f15eb1292839ea3f05606620bd9e1938302cf79", size = 1078365, upload-time = "2025-11-05T21:40:15.148Z" },
    { url = "https://files.pythonhosted.org/packages/26/09/99442f02794bd7441bfc8ed1c7319e890449b816a7493b2db0e30af39095/rignore-0.7.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:7bbcdc52b5bf9f054b34ce4af5269df5d863d9c2456243338bc193c28022bd7b", size = 1139066, upload-time = "2025-11-05T21:40:32.771Z" },
    { url = "https://files.pythonhosted.org/packages/2c/88/bcfc21e520bba975410e9419450f4b90a2ac8236b9a80fd8130e87d098af/rignore-0.7.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f2e027a6da21a7c8c0d87553c24ca5cc4364def18d146057862c23a96546238e", size = 1118036, upload-time = "2025-11-05T21:40:49.646Z" },
    { url = "https://files.pythonhosted.org/packages/e2/25/d37215e4562cda5c13312636393aea0bafe38d54d4e0517520a4cc0753ec/rignore-0.7.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee4a18b82cbbc648e4aac1510066682fe62beb5dc88e2c67c53a83954e541360", size = 1127550, upload-time = "2025-11-05T21:41:07.648Z" },
    { url = "https://files.pythonhosted.org/packages/dc/76/a264ab38bfa1620ec12a8ff1c07778da89e16d8c0f3450b0333020d3d6dc/rignore-0.7.6-cp313-cp313-win32.whl", hash = "sha256:a7d7148b6e5e95035d4390396895adc384d37ff4e06781a36fe573bba7c283e5", size = 646097, upload-time = "2025-11-05T21:41:53.201Z" },
    { url = "https://files.pythonhosted.org/packages/62/44/3c31b8983c29ea8832b6082ddb1d07b90379c2d993bd20fce4487b71b4f4/rignore-0.7.6-cp313-cp313-win_amd64.whl", hash = "sha256:b037c4b15a64dced08fc12310ee844ec2284c4c5c1ca77bc37d0a04f7bff386e", size = 726170, upload-time = "2025-11-05T21:41:38.131Z" },
    { url = "https://files.pythonhosted.org/packages/aa/41/e26a075cab83debe41a42661262f606166157df84e0e02e2d904d134c0d8/rignore-0.7.6-cp313-cp313-win_arm64.whl", hash = "sha256:e47443de9b12fe569889bdbe020abe0e0b667516ee2ab435443f6d0869bd2804", size = 656184, upload-time = "2025-11-05T21:41:27.396Z" },
    { url = "https://files.pythonhosted.org/packages/9a/b9/1f5bd82b87e5550cd843ceb3768b4a8ef274eb63f29333cf2f29644b3d75/rignore-0.7.6-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:8e41be9fa8f2f47239ded8920cc283699a052ac4c371f77f5ac017ebeed75732", size = 882632, upload-time = "2025-11-05T20:42:44.063Z" },
    { url = "https://files.pythonhosted.org/packages/e9/6b/07714a3efe4a8048864e8a5b7db311ba51b921e15268b17defaebf56d3db/rignore-0.7.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6dc1e171e52cefa6c20e60c05394a71165663b48bca6c7666dee4f778f2a7d90", size = 820760, upload-time = "2025-11-05T20:42:27.885Z" },
    { url = "https://files.pythonhosted.org/packages/ac/0f/348c829ea2d8d596e856371b14b9092f8a5dfbb62674ec9b3f67e4939a9d/rignore-0.7.6-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ce2268837c3600f82ab8db58f5834009dc638ee17103582960da668963bebc5", size = 899044, upload-time = "2025-11-05T20:40:55.336Z" },
    { url = "https://files.pythonhosted.org/packages/f0/30/2e1841a19b4dd23878d73edd5d82e998a83d5ed9570a89675f140ca8b2ad/rignore-0.7.6-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:690a3e1b54bfe77e89c4bacb13f046e642f8baadafc61d68f5a726f324a76ab6", size = 874144, upload-time = "2025-11-05T20:41:10.195Z" },
    { url = "https://files.pythonhosted.org/packages/c2/bf/0ce9beb2e5f64c30e3580bef09f5829236889f01511a125f98b83169b993/rignore-0.7.6-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09d12ac7a0b6210c07bcd145007117ebd8abe99c8eeb383e9e4673910c2754b2", size = 1168062, upload-time = "2025-11-05T20:41:26.511Z" },
    { url = "https://files.pythonhosted.org/packages/b9/8b/571c178414eb4014969865317da8a02ce4cf5241a41676ef91a59aab24de/rignore-0.7.6-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2a2b2b74a8c60203b08452479b90e5ce3dbe96a916214bc9eb2e5af0b6a9beb0", size = 942542, upload-time = "2025-11-05T20:41:41.838Z" },
    { url = "https://files.pythonhosted.org/packages/19/62/7a3cf601d5a45137a7e2b89d10c05b5b86499190c4b7ca5c3c47d79ee519/rignore-0.7.6-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8fc5a531ef02131e44359419a366bfac57f773ea58f5278c2cdd915f7d10ea94", size = 958739, upload-time = "2025-11-05T20:42:12.463Z" },
    { url = "https://files.pythonhosted.org/packages/5f/1f/4261f6a0d7caf2058a5cde2f5045f565ab91aa7badc972b57d19ce58b14e/rignore-0.7.6-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b7a1f77d9c4cd7e76229e252614d963442686bfe12c787a49f4fe481df49e7a9", size = 984138, upload-time = "2025-11-05T20:41:56.775Z" },
    { url = "https://files.pythonhosted.org/packages/2b/bf/628dfe19c75e8ce1f45f7c248f5148b17dfa89a817f8e3552ab74c3ae812/rignore-0.7.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ead81f728682ba72b5b1c3d5846b011d3e0174da978de87c61645f2ed36659a7", size = 1079299, upload-time = "2025-11-05T21:40:16.639Z" },
    { url = "https://files.pythonhosted.org/packages/af/a5/be29c50f5c0c25c637ed32db8758fdf5b901a99e08b608971cda8afb293b/rignore-0.7.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:12ffd50f520c22ffdabed8cd8bfb567d9ac165b2b854d3e679f4bcaef11a9441", size = 1139618, upload-time = "2025-11-05T21:40:34.507Z" },
    { url = "https://files.pythonhosted.org/packages/2a/40/3c46cd7ce4fa05c20b525fd60f599165e820af66e66f2c371cd50644558f/rignore-0.7.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e5a16890fbe3c894f8ca34b0fcacc2c200398d4d46ae654e03bc9b3dbf2a0a72", size = 1117626, upload-time = "2025-11-05T21:40:51.494Z" },
    { url = "https://files.pythonhosted.org/packages/8c/b9/aea926f263b8a29a23c75c2e0d8447965eb1879d3feb53cfcf84db67ed58/rignore-0.7.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3abab3bf99e8a77488ef6c7c9a799fac22224c28fe9f25cc21aa7cc2b72bfc0b", size = 1128144, upload-time = "2025-11-05T21:41:09.169Z" },
    { url = "https://files.pythonhosted.org/packages/a4/f6/0d6242f8d0df7f2ecbe91679fefc1f75e7cd2072cb4f497abaab3f0f8523/rignore-0.7.6-cp314-cp314-win32.whl", hash = "sha256:eeef421c1782953c4375aa32f06ecae470c1285c6381eee2a30d2e02a5633001", size = 646385, upload-time = "2025-11-05T21:41:55.105Z" },
    { url = "https://files.pythonhosted.org/packages/d5/38/c0dcd7b10064f084343d6af26fe9414e46e9619c5f3224b5272e8e5d9956/rignore-0.7.6-cp314-cp314-win_amd64.whl", hash = "sha256:6aeed503b3b3d5af939b21d72a82521701a4bd3b89cd761da1e7dc78621af304", size = 725738, upload-time = "2025-11-05T21:41:39.736Z" },
    { url = "https://files.pythonhosted.org/packages/d9/7a/290f868296c1ece914d565757ab363b04730a728b544beb567ceb3b2d96f/rignore-0.7.6-cp314-cp314-win_arm64.whl", hash = "sha256:104f215b60b3c984c386c3e747d6ab4376d5656478694e22c7bd2f788ddd8304", size = 656008, upload-time = "2025-11-05T21:41:29.028Z" },
    { url = "https://files.pythonhosted.org/packages/ca/d2/3c74e3cd81fe8ea08a8dcd2d755c09ac2e8ad8fe409508904557b58383d3/rignore-0.7.6-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:bb24a5b947656dd94cb9e41c4bc8b23cec0c435b58be0d74a874f63c259549e8", size = 882835, upload-time = "2025-11-05T20:42:45.443Z" },
    { url = "https://files.pythonhosted.org/packages/77/61/a772a34b6b63154877433ac2d048364815b24c2dd308f76b212c408101a2/rignore-0.7.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5b1e33c9501cefe24b70a1eafd9821acfd0ebf0b35c3a379430a14df089993e3", size = 820301, upload-time = "2025-11-05T20:42:29.226Z" },
    { url = "https://files.pythonhosted.org/packages/71/30/054880b09c0b1b61d17eeb15279d8bf729c0ba52b36c3ada52fb827cbb3c/rignore-0.7.6-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bec3994665a44454df86deb762061e05cd4b61e3772f5b07d1882a8a0d2748d5", size = 897611, upload-time = "2025-11-05T20:40:56.475Z" },
    { url = "https://files.pythonhosted.org/packages/1e/40/b2d1c169f833d69931bf232600eaa3c7998ba4f9a402e43a822dad2ea9f2/rignore-0.7.6-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:26cba2edfe3cff1dfa72bddf65d316ddebf182f011f2f61538705d6dbaf54986", size = 873875, upload-time = "2025-11-05T20:41:11.561Z" },
    { url = "https://files.pythonhosted.org/packages/55/59/ca5ae93d83a1a60e44b21d87deb48b177a8db1b85e82fc8a9abb24a8986d/rignore-0.7.6-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ffa86694fec604c613696cb91e43892aa22e1fec5f9870e48f111c603e5ec4e9", size = 1167245, upload-time = "2025-11-05T20:41:28.29Z" },
    { url = "https://files.pythonhosted.org/packages/a5/52/cf3dce392ba2af806cba265aad6bcd9c48bb2a6cb5eee448d3319f6e505b/rignore-0.7.6-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48efe2ed95aa8104145004afb15cdfa02bea5cdde8b0344afeb0434f0d989aa2", size = 941750, upload-time = "2025-11-05T20:41:43.111Z" },
    { url = "https://files.pythonhosted.org/packages/ec/be/3f344c6218d779395e785091d05396dfd8b625f6aafbe502746fcd880af2/rignore-0.7.6-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dcae43eb44b7f2457fef7cc87f103f9a0013017a6f4e62182c565e924948f21", size = 958896, upload-time = "2025-11-05T20:42:13.784Z" },
    { url = "https://files.pythonhosted.org/packages/c9/34/d3fa71938aed7d00dcad87f0f9bcb02ad66c85d6ffc83ba31078ce53646a/rignore-0.7.6-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2cd649a7091c0dad2f11ef65630d30c698d505cbe8660dd395268e7c099cc99f", size = 983992, upload-time = "2025-11-05T20:41:58.022Z" },
    { url = "https://files.pythonhosted.org/packages/24/a4/52a697158e9920705bdbd0748d59fa63e0f3233fb92e9df9a71afbead6ca/rignore-0.7.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42de84b0289d478d30ceb7ae59023f7b0527786a9a5b490830e080f0e4ea5aeb", size = 1078181, upload-time = "2025-11-05T21:40:18.151Z" },
    { url = "https://files.pythonhosted.org/packages/ac/65/aa76dbcdabf3787a6f0fd61b5cc8ed1e88580590556d6c0207960d2384bb/rignore-0.7.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:875a617e57b53b4acbc5a91de418233849711c02e29cc1f4f9febb2f928af013", size = 1139232, upload-time = "2025-11-05T21:40:35.966Z" },
    { url = "https://files.pythonhosted.org/packages/08/44/31b31a49b3233c6842acc1c0731aa1e7fb322a7170612acf30327f700b44/rignore-0.7.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8703998902771e96e49968105207719f22926e4431b108450f3f430b4e268b7c", size = 1117349, upload-time = "2025-11-05T21:40:53.013Z" },
    { url = "https://files.pythonhosted.org/packages/e9/ae/1b199a2302c19c658cf74e5ee1427605234e8c91787cfba0015f2ace145b/rignore-0.7.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:602ef33f3e1b04c1e9a10a3c03f8bc3cef2d2383dcc250d309be42b49923cabc", size = 1127702, upload-time = "2025-11-05T21:41:10.881Z" },
    { url = "https://files.pythonhosted.org/packages/fc/d3/18210222b37e87e36357f7b300b7d98c6dd62b133771e71ae27acba83a4f/rignore-0.7.6-cp314-cp314t-win32.whl", hash = "sha256:c1d8f117f7da0a4a96a8daef3da75bc090e3792d30b8b12cfadc240c631353f9", size = 647033, upload-time = "2025-11-05T21:42:00.095Z" },
    { url = "https://files.pythonhosted.org/packages/3e/87/033eebfbee3ec7d92b3bb1717d8f68c88e6fc7de54537040f3b3a405726f/rignore-0.7.6-cp314-cp314t-win_amd64.whl", hash = "sha256:ca36e59408bec81de75d307c568c2d0d410fb880b1769be43611472c61e85c96", size = 725647, upload-time = "2025-11-05T21:41:44.449Z" },
    { url = "https://files.pythonhosted.org/packages/79/62/b88e5879512c55b8ee979c666ee6902adc4ed05007226de266410ae27965/rignore-0.7.6-cp314-cp314t-win_arm64.whl", hash = "sha256:b83adabeb3e8cf662cabe1931b83e165b88c526fa6af6b3aa90429686e474896", size = 656035, upload-time = "2025-11-05T21:41:31.13Z" },
    { url = "https://files.pythonhosted.org/packages/b9/b4/e7577504d926ced2d6a3fa5ec5f27756639a1ed58a6a3fbefcf3a5659721/rignore-0.7.6-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:b3746bda73f2fe6a9c3ab2f20b792e7d810b30acbdba044313fbd2d0174802e7", size = 886535, upload-time = "2025-11-05T20:42:49.317Z" },
    { url = "https://files.pythonhosted.org/packages/2b/74/098bc71a33e2997bc3291d500760123d23e3a6d354380d26c8a7ddc036de/rignore-0.7.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:67a99cf19a5137cc12f14b78dc1bb3f48500f1d5580702c623297d5297bf2752", size = 826621, upload-time = "2025-11-05T20:42:32.421Z" },
    { url = "https://files.pythonhosted.org/packages/7b/73/5f8c276d71009a7e73fb3af6ec3bb930269efeae5830de5c796fa1fb020f/rignore-0.7.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9e851cfa87033c0c3fd9d35dd8b102aff2981db8bc6e0cab27b460bfe38bf3f", size = 900335, upload-time = "2025-11-05T20:40:59.178Z" },
    { url = "https://files.pythonhosted.org/packages/0d/5f/dde3758084a087e6a5cd981c5277c6171d12127deed64fc4fbf12fb8ceaa/rignore-0.7.6-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e9b0def154665036516114437a5d603274e5451c0dc9694f622cc3b7e94603e7", size = 874274, upload-time = "2025-11-05T20:41:14.512Z" },
    { url = "https://files.pythonhosted.org/packages/58/b9/da85646824ab728036378ce62c330316108a52f30f36e6c69cac6ceda376/rignore-0.7.6-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b81274a47e8121224f7f637392b5dfcd9558e32a53e67ba7d04007d8b5281da9", size = 1171639, upload-time = "2025-11-05T20:41:31.206Z" },
    { url = "https://files.pythonhosted.org/packages/35/d1/8c12b779b7f0302c03c1d41511f2ab47012afecdfcd684fbec80af06b331/rignore-0.7.6-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d75d0b0696fb476664bea1169c8e67b13197750b91eceb4f10b3c7f379c7a204", size = 943985, upload-time = "2025-11-05T20:41:45.598Z" },
    { url = "https://files.pythonhosted.org/packages/79/bf/c233a85d31e4f94b911e92ee7e2dd2b962a5c2528f5ebd79a702596f0626/rignore-0.7.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ad3aa4dca77cef9168d0c142f72376f5bd27d1d4b8a81561bd01276d3ad9fe1", size = 961707, upload-time = "2025-11-05T20:42:16.461Z" },
    { url = "https://files.pythonhosted.org/packages/9d/eb/cadee9316a5f2a52b4aa7051967ecb94ec17938d6b425bd842d9317559eb/rignore-0.7.6-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:00f8a59e19d219f44a93af7173de197e0d0e61c386364da20ebe98a303cbe38c", size = 986638, upload-time = "2025-11-05T20:42:00.65Z" },
    { url = "https://files.pythonhosted.org/packages/d0/f0/2c3042c8c9639056593def5e99c3bfe850fbb9a38d061ba67b6314315bad/rignore-0.7.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dd6c682f3cdd741e7a30af2581f6a382ac910080977cd1f97c651467b6268352", size = 1080136, upload-time = "2025-11-05T21:40:21.551Z" },
    { url = "https://files.pythonhosted.org/packages/fc/28/7237b9eb1257b593ee51cd7ef8eed7cc32f50ccff18cb4d7cfe1e6dc54d7/rignore-0.7.6-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ae4e93193f75ebf6b820241594a78f347785cfd5a5fbbac94634052589418352", size = 1139413, upload-time = "2025-11-05T21:40:39.025Z" },
    { url = "https://files.pythonhosted.org/packages/a5/df/c3f382a31ad7ed68510b411c28fec42354d2c43fecb7c053d998ee9410ed/rignore-0.7.6-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1163d8b5d3a320d4d7cc8635213328850dc41f60e438c7869d540061adf66c98", size = 1120204, upload-time = "2025-11-05T21:40:56.062Z" },
    { url = "https://files.pythonhosted.org/packages/9c/3d/e8585c4e9c0077255ba599684aee78326176ab13ff13805ea62aa7e3235f/rignore-0.7.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:3e685f47b4c58b2df7dee81ebc1ec9dbb7f798b9455c3f22be6d75ac6bddee30", size = 1129757, upload-time = "2025-11-05T21:41:14.148Z" },
    { url = "https://files.pythonhosted.org/packages/fd/56/852226c13f89ddbbf12d639900941dc55dcbcf79f5d15294796fd3279d73/rignore-0.7.6-cp39-cp39-win32.whl", hash = "sha256:2af6a0a76575220863cd838693c808a94e750640e0c8a3e9f707e93c2f131fdf", size = 648265, upload-time = "2025-11-05T21:41:58.589Z" },
    { url = "https://files.pythonhosted.org/packages/cc/c6/14e7585dc453a870fe99b1270ee95e2adff02ea0d297cd6e2c4aa46cd43a/rignore-0.7.6-cp39-cp39-win_amd64.whl", hash = "sha256:a326eab6db9ab85b4afb5e6eb28736a9f2b885a9246d9e8c1989bc693dd059a0", size = 728715, upload-time = "2025-11-05T21:41:42.823Z" },
    { url = "https://files.pythonhosted.org/packages/85/12/62d690b4644c330d7ac0f739b7f078190ab4308faa909a60842d0e4af5b2/rignore-0.7.6-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c3d3a523af1cd4ed2c0cba8d277a32d329b0c96ef9901fb7ca45c8cfaccf31a5", size = 887462, upload-time = "2025-11-05T20:42:50.804Z" },
    { url = "https://files.pythonhosted.org/packages/05/bc/6528a0e97ed2bd7a7c329183367d1ffbc5b9762ae8348d88dae72cc9d1f5/rignore-0.7.6-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:990853566e65184a506e1e2af2d15045afad3ebaebb8859cb85b882081915110", size = 826918, upload-time = "2025-11-05T20:42:33.689Z" },
    { url = "https://files.pythonhosted.org/packages/3e/2c/7d7bad116e09a04e9e1688c6f891fa2d4fd33f11b69ac0bd92419ddebeae/rignore-0.7.6-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1cab9ff2e436ce7240d7ee301c8ef806ed77c1fd6b8a8239ff65f9bbbcb5b8a3", size = 900922, upload-time = "2025-11-05T20:41:00.361Z" },
    { url = "https://files.pythonhosted.org/packages/09/ba/e5ea89fbde8e37a90ce456e31c5e9d85512cef5ae38e0f4d2426eb776a19/rignore-0.7.6-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d1a6671b2082c13bfd9a5cf4ce64670f832a6d41470556112c4ab0b6519b2fc4", size = 876987, upload-time = "2025-11-05T20:41:16.219Z" },
    { url = "https://files.pythonhosted.org/packages/d0/fb/93d14193f0ec0c3d35b763f0a000e9780f63b2031f3d3756442c2152622d/rignore-0.7.6-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2468729b4c5295c199d084ab88a40afcb7c8b974276805105239c07855bbacee", size = 1171110, upload-time = "2025-11-05T20:41:32.631Z" },
    { url = "https://files.pythonhosted.org/packages/9e/46/08436312ff96ffa29cfa4e1a987efc37e094531db46ba5e9fda9bb792afd/rignore-0.7.6-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:775710777fd71e5fdf54df69cdc249996a1d6f447a2b5bfb86dbf033fddd9cf9", size = 943339, upload-time = "2025-11-05T20:41:47.128Z" },
    { url = "https://files.pythonhosted.org/packages/34/28/3b3c51328f505cfaf7e53f408f78a1e955d561135d02f9cb0341ea99f69a/rignore-0.7.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4565407f4a77f72cf9d91469e75d15d375f755f0a01236bb8aaa176278cc7085", size = 961680, upload-time = "2025-11-05T20:42:18.061Z" },
    { url = "https://files.pythonhosted.org/packages/5c/9e/cbff75c8676d4f4a90bd58a1581249d255c7305141b0868f0abc0324836b/rignore-0.7.6-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:dc44c33f8fb2d5c9da748de7a6e6653a78aa740655e7409895e94a247ffa97c8", size = 987045, upload-time = "2025-11-05T20:42:02.315Z" },
    { url = "https://files.pythonhosted.org/packages/8c/25/d802d1d369502a7ddb8816059e7c79d2d913e17df975b863418e0aca4d8a/rignore-0.7.6-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:8f32478f05540513c11923e8838afab9efef0131d66dca7f67f0e1bbd118af6a", size = 1080310, upload-time = "2025-11-05T21:40:23.184Z" },
    { url = "https://files.pythonhosted.org/packages/43/f0/250b785c2e473b1ab763eaf2be820934c2a5409a722e94b279dddac21c7d/rignore-0.7.6-pp310-pypy310_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:1b63a3dd76225ea35b01dd6596aa90b275b5d0f71d6dc28fce6dd295d98614aa", size = 1140998, upload-time = "2025-11-05T21:40:40.603Z" },
    { url = "https://files.pythonhosted.org/packages/f5/d6/bb42fd2a8bba6aea327962656e20621fd495523259db40cfb4c5f760f05c/rignore-0.7.6-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:fe6c41175c36554a4ef0994cd1b4dbd6d73156fca779066456b781707402048e", size = 1121178, upload-time = "2025-11-05T21:40:57.585Z" },
    { url = "https://files.pythonhosted.org/packages/97/f4/aeb548374129dce3dc191a4bb598c944d9ed663f467b9af830315d86059c/rignore-0.7.6-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:9a0c6792406ae36f4e7664dc772da909451d46432ff8485774526232d4885063", size = 1130190, upload-time = "2025-11-05T21:41:16.403Z" },
    { url = "https://files.pythonhosted.org/packages/82/78/a6250ff0c49a3cdb943910ada4116e708118e9b901c878cfae616c80a904/rignore-0.7.6-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a20b6fb61bcced9a83dfcca6599ad45182b06ba720cff7c8d891e5b78db5b65f", size = 886470, upload-time = "2025-11-05T20:42:52.314Z" },
    { url = "https://files.pythonhosted.org/packages/35/af/c69c0c51b8f9f7914d95c4ea91c29a2ac067572048cae95dd6d2efdbe05d/rignore-0.7.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:392dcabfecbe176c9ebbcb40d85a5e86a5989559c4f988c2741da7daf1b5be25", size = 825976, upload-time = "2025-11-05T20:42:35.118Z" },
    { url = "https://files.pythonhosted.org/packages/f1/d2/1b264f56132264ea609d3213ab603d6a27016b19559a1a1ede1a66a03dcd/rignore-0.7.6-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22baa462abdc36fdd5a5e2dae423107723351b85ff093762f9261148b9d0a04a", size = 899739, upload-time = "2025-11-05T20:41:01.518Z" },
    { url = "https://files.pythonhosted.org/packages/55/e4/b3c5dfdd8d8a10741dfe7199ef45d19a0e42d0c13aa377c83bd6caf65d90/rignore-0.7.6-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53fb28882d2538cb2d231972146c4927a9d9455e62b209f85d634408c4103538", size = 874843, upload-time = "2025-11-05T20:41:17.687Z" },
    { url = "https://files.pythonhosted.org/packages/cc/10/d6f3750233881a2a154cefc9a6a0a9b19da526b19f7f08221b552c6f827d/rignore-0.7.6-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:87409f7eeb1103d6b77f3472a3a0d9a5953e3ae804a55080bdcb0120ee43995b", size = 1170348, upload-time = "2025-11-05T20:41:34.21Z" },
    { url = "https://files.pythonhosted.org/packages/6e/10/ad98ca05c9771c15af734cee18114a3c280914b6e34fde9ffea2e61e88aa/rignore-0.7.6-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:684014e42e4341ab3ea23a203551857fcc03a7f8ae96ca3aefb824663f55db32", size = 942315, upload-time = "2025-11-05T20:41:48.508Z" },
    { url = "https://files.pythonhosted.org/packages/de/00/ab5c0f872acb60d534e687e629c17e0896c62da9b389c66d3aa16b817aa8/rignore-0.7.6-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77356ebb01ba13f8a425c3d30fcad40e57719c0e37670d022d560884a30e4767", size = 961047, upload-time = "2025-11-05T20:42:19.403Z" },
    { url = "https://files.pythonhosted.org/packages/b8/86/3030fdc363a8f0d1cd155b4c453d6db9bab47a24fcc64d03f61d9d78fe6a/rignore-0.7.6-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6cbd8a48abbd3747a6c830393cd578782fab5d43f4deea48c5f5e344b8fed2b0", size = 986090, upload-time = "2025-11-05T20:42:03.581Z" },
    { url = "https://files.pythonhosted.org/packages/33/b8/133aa4002cee0ebbb39362f94e4898eec7fbd09cec9fcbce1cd65b355b7f/rignore-0.7.6-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2673225dcec7f90497e79438c35e34638d0d0391ccea3cbb79bfb9adc0dc5bd7", size = 1079656, upload-time = "2025-11-05T21:40:24.89Z" },
    { url = "https://files.pythonhosted.org/packages/67/56/36d5d34210e5e7dfcd134eed8335b19e80ae940ee758f493e4f2b344dd70/rignore-0.7.6-pp311-pypy311_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:c081f17290d8a2b96052b79207622aa635686ea39d502b976836384ede3d303c", size = 1139789, upload-time = "2025-11-05T21:40:42.119Z" },
    { url = "https://files.pythonhosted.org/packages/6b/5b/bb4f9420802bf73678033a4a55ab1bede36ce2e9b41fec5f966d83d932b3/rignore-0.7.6-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:57e8327aacc27f921968cb2a174f9e47b084ce9a7dd0122c8132d22358f6bd79", size = 1120308, upload-time = "2025-11-05T21:40:59.402Z" },
    { url = "https://files.pythonhosted.org/packages/ce/8b/a1299085b28a2f6135e30370b126e3c5055b61908622f2488ade67641479/rignore-0.7.6-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:d8955b57e42f2a5434670d5aa7b75eaf6e74602ccd8955dddf7045379cd762fb", size = 1129444, upload-time = "2025-11-05T21:41:17.906Z" },
    { url = "https://files.pythonhosted.org/packages/47/98/80ef6fda78161e88ef9336fcbe851afccf78c48e69e8266a23fb7922b5aa/rignore-0.7.6-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e6ba1511c0ab8cd1ed8d6055bb0a6e629f48bfe04854293e0cd2dd88bd7153f8", size = 887180, upload-time = "2025-11-05T21:40:07.665Z" },
    { url = "https://files.pythonhosted.org/packages/21/d7/8666e7081f8476b003d8d2c8f39ecc17c93b7efd261740d15b6830acde82/rignore-0.7.6-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:50586d90be15f9aa8a2e2ee5a042ee6c51e28848812a35f0c95d4bfc0533d469", size = 827029, upload-time = "2025-11-05T20:42:36.628Z" },
    { url = "https://files.pythonhosted.org/packages/01/aa/3aba657d17b1737f4180b143866fedd269de15f361a8cb26ba363c0c3c13/rignore-0.7.6-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b129873dd0ade248e67f25a09b5b72288cbef76ba1a9aae6bac193ee1d8be72", size = 901338, upload-time = "2025-11-05T20:41:03.059Z" },
    { url = "https://files.pythonhosted.org/packages/90/cc/d8c2c9770f5f61b28999c582804f282f2227c155ba13dfb0e9ea03daeaaf/rignore-0.7.6-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d9d6dd947556ddebfd62753005104986ee14a4e0663818aed19cdf2c33a6b5d5", size = 877563, upload-time = "2025-11-05T20:41:19.209Z" },
    { url = "https://files.pythonhosted.org/packages/55/63/42dd625bf96989be4a928b5444ddec9101ee63a98a15646e611b3ce58b82/rignore-0.7.6-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91b95faa532efba888b196331e9af69e693635d469185ac52c796e435e2484e5", size = 1171087, upload-time = "2025-11-05T20:41:35.558Z" },
    { url = "https://files.pythonhosted.org/packages/bf/1e/4130fb622c2081c5322caf7a8888d1d265b99cd5d62cb714b512b8911233/rignore-0.7.6-pp39-pypy39_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a1016f430fb56f7e400838bbc56fdf43adddb6fcb7bf2a14731dfd725c2fae6c", size = 944335, upload-time = "2025-11-05T20:41:49.859Z" },
    { url = "https://files.pythonhosted.org/packages/0f/b9/3d3ef7773da85e002fab53b1fdd9e9bb111cc86792b761cb38bd00c1532e/rignore-0.7.6-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f00c519861926dc703ecbb7bbeb884be67099f96f98b175671fa0a54718f55d1", size = 961500, upload-time = "2025-11-05T20:42:20.798Z" },
    { url = "https://files.pythonhosted.org/packages/1f/bc/346c874a31a721064935c60666a19016b6b01cd716cf73d52dc64e467b30/rignore-0.7.6-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e34d172bf50e881b7c02e530ae8b1ea96093f0b16634c344f637227b39707b41", size = 987741, upload-time = "2025-11-05T20:42:05.071Z" },
    { url = "https://files.pythonhosted.org/packages/6d/b8/d12dc548da8fdb63292a38727b035153495220cd93730019ee8ed3bdcffb/rignore-0.7.6-pp39-pypy39_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:101d3143619898db1e7bede2e3e647daf19bb867c4fb25978016d67978d14868", size = 1081057, upload-time = "2025-11-05T21:40:26.53Z" },
    { url = "https://files.pythonhosted.org/packages/8e/51/7eea5d949212709740ad07e01c524336e44608ef0614a2a1cb31c9a0ea30/rignore-0.7.6-pp39-pypy39_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:c9f3b420f54199a2b2b3b532d8c7e0860be3fa51f67501113cca6c7bfc392840", size = 1141653, upload-time = "2025-11-05T21:40:43.676Z" },
    { url = "https://files.pythonhosted.org/packages/c4/2b/76ec843cc392fcb4e37d6a8340e823a0bf644872e191d2f5652a4c2c18ee/rignore-0.7.6-pp39-pypy39_pp73-musllinux_1_2_i686.whl", hash = "sha256:1c6795e3694d750ae5ef172eab7d68a52aefbd9168d2e06647df691db2b03a50", size = 1121465, upload-time = "2025-11-05T21:41:00.904Z" },
    { url = "https://files.pythonhosted.org/packages/7c/9d/e69ad5cf03211a1076f9fe04ca2698c9cb8208b63419c928c26646bdf1d9/rignore-0.7.6-pp39-pypy39_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:750a83a254b020e1193bfa7219dc7edca26bd8888a94cdc59720cbe386ab0c72", size = 1130110, upload-time = "2025-11-05T21:41:20.263Z" },
]

[[package]]
name = "roman-numerals"
version = "4.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/41dc953bbeb056c17d5f7a519f50fdf010bd0553be2d630bc69d1e022703/roman_numerals-4.1.0.tar.gz", hash = "sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2", size = 9077, upload-time = "2025-12-17T18:25:34.381Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" },
]

[[package]]
name = "ruff"
version = "0.15.9"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e6/97/e9f1ca355108ef7194e38c812ef40ba98c7208f47b13ad78d023caa583da/ruff-0.15.9.tar.gz", hash = "sha256:29cbb1255a9797903f6dde5ba0188c707907ff44a9006eb273b5a17bfa0739a2", size = 4617361, upload-time = "2026-04-02T18:17:20.829Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/0b/1f/9cdfd0ac4b9d1e5a6cf09bedabdf0b56306ab5e333c85c87281273e7b041/ruff-0.15.9-py3-none-linux_armv6l.whl", hash = "sha256:6efbe303983441c51975c243e26dff328aca11f94b70992f35b093c2e71801e1", size = 10511206, upload-time = "2026-04-02T18:16:41.574Z" },
    { url = "https://files.pythonhosted.org/packages/3d/f6/32bfe3e9c136b35f02e489778d94384118bb80fd92c6d92e7ccd97db12ce/ruff-0.15.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4965bac6ac9ea86772f4e23587746f0b7a395eccabb823eb8bfacc3fa06069f7", size = 10923307, upload-time = "2026-04-02T18:17:08.645Z" },
    { url = "https://files.pythonhosted.org/packages/ca/25/de55f52ab5535d12e7aaba1de37a84be6179fb20bddcbe71ec091b4a3243/ruff-0.15.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eaf05aad70ca5b5a0a4b0e080df3a6b699803916d88f006efd1f5b46302daab8", size = 10316722, upload-time = "2026-04-02T18:16:44.206Z" },
    { url = "https://files.pythonhosted.org/packages/48/11/690d75f3fd6278fe55fff7c9eb429c92d207e14b25d1cae4064a32677029/ruff-0.15.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9439a342adb8725f32f92732e2bafb6d5246bd7a5021101166b223d312e8fc59", size = 10623674, upload-time = "2026-04-02T18:16:50.951Z" },
    { url = "https://files.pythonhosted.org/packages/bd/ec/176f6987be248fc5404199255522f57af1b4a5a1b57727e942479fec98ad/ruff-0.15.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c5e6faf9d97c8edc43877c3f406f47446fc48c40e1442d58cfcdaba2acea745", size = 10351516, upload-time = "2026-04-02T18:16:57.206Z" },
    { url = "https://files.pythonhosted.org/packages/b2/fc/51cffbd2b3f240accc380171d51446a32aa2ea43a40d4a45ada67368fbd2/ruff-0.15.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b34a9766aeec27a222373d0b055722900fbc0582b24f39661aa96f3fe6ad901", size = 11150202, upload-time = "2026-04-02T18:17:06.452Z" },
    { url = "https://files.pythonhosted.org/packages/d6/d4/25292a6dfc125f6b6528fe6af31f5e996e19bf73ca8e3ce6eb7fa5b95885/ruff-0.15.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89dd695bc72ae76ff484ae54b7e8b0f6b50f49046e198355e44ea656e521fef9", size = 11988891, upload-time = "2026-04-02T18:17:18.575Z" },
    { url = "https://files.pythonhosted.org/packages/13/e1/1eebcb885c10e19f969dcb93d8413dfee8172578709d7ee933640f5e7147/ruff-0.15.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce187224ef1de1bd225bc9a152ac7102a6171107f026e81f317e4257052916d5", size = 11480576, upload-time = "2026-04-02T18:16:52.986Z" },
    { url = "https://files.pythonhosted.org/packages/ff/6b/a1548ac378a78332a4c3dcf4a134c2475a36d2a22ddfa272acd574140b50/ruff-0.15.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b0c7c341f68adb01c488c3b7d4b49aa8ea97409eae6462d860a79cf55f431b6", size = 11254525, upload-time = "2026-04-02T18:17:02.041Z" },
    { url = "https://files.pythonhosted.org/packages/42/aa/4bb3af8e61acd9b1281db2ab77e8b2c3c5e5599bf2a29d4a942f1c62b8d6/ruff-0.15.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:55cc15eee27dc0eebdfcb0d185a6153420efbedc15eb1d38fe5e685657b0f840", size = 11204072, upload-time = "2026-04-02T18:17:13.581Z" },
    { url = "https://files.pythonhosted.org/packages/69/48/d550dc2aa6e423ea0bcc1d0ff0699325ffe8a811e2dba156bd80750b86dc/ruff-0.15.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6537f6eed5cda688c81073d46ffdfb962a5f29ecb6f7e770b2dc920598997ed", size = 10594998, upload-time = "2026-04-02T18:16:46.369Z" },
    { url = "https://files.pythonhosted.org/packages/63/47/321167e17f5344ed5ec6b0aa2cff64efef5f9e985af8f5622cfa6536043f/ruff-0.15.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6d3fcbca7388b066139c523bda744c822258ebdcfbba7d24410c3f454cc9af71", size = 10359769, upload-time = "2026-04-02T18:17:10.994Z" },
    { url = "https://files.pythonhosted.org/packages/67/5e/074f00b9785d1d2c6f8c22a21e023d0c2c1817838cfca4c8243200a1fa87/ruff-0.15.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:058d8e99e1bfe79d8a0def0b481c56059ee6716214f7e425d8e737e412d69677", size = 10850236, upload-time = "2026-04-02T18:16:48.749Z" },
    { url = "https://files.pythonhosted.org/packages/76/37/804c4135a2a2caf042925d30d5f68181bdbd4461fd0d7739da28305df593/ruff-0.15.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8e1ddb11dbd61d5983fa2d7d6370ef3eb210951e443cace19594c01c72abab4c", size = 11358343, upload-time = "2026-04-02T18:16:55.068Z" },
    { url = "https://files.pythonhosted.org/packages/88/3d/1364fcde8656962782aa9ea93c92d98682b1ecec2f184e625a965ad3b4a6/ruff-0.15.9-py3-none-win32.whl", hash = "sha256:bde6ff36eaf72b700f32b7196088970bf8fdb2b917b7accd8c371bfc0fd573ec", size = 10583382, upload-time = "2026-04-02T18:17:04.261Z" },
    { url = "https://files.pythonhosted.org/packages/4c/56/5c7084299bd2cacaa07ae63a91c6f4ba66edc08bf28f356b24f6b717c799/ruff-0.15.9-py3-none-win_amd64.whl", hash = "sha256:45a70921b80e1c10cf0b734ef09421f71b5aa11d27404edc89d7e8a69505e43d", size = 11744969, upload-time = "2026-04-02T18:16:59.611Z" },
    { url = "https://files.pythonhosted.org/packages/03/36/76704c4f312257d6dbaae3c959add2a622f63fcca9d864659ce6d8d97d3d/ruff-0.15.9-py3-none-win_arm64.whl", hash = "sha256:0694e601c028fd97dc5c6ee244675bc241aeefced7ef80cd9c6935a871078f53", size = 11005870, upload-time = "2026-04-02T18:17:15.773Z" },
]

[[package]]
name = "s3fs"
version = "2025.10.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
dependencies = [
    { name = "aiobotocore", version = "2.26.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "aiohttp", marker = "python_full_version < '3.10'" },
    { name = "fsspec", version = "2025.10.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bb/ee/7cf7de3b17ef6db10b027cc9f8a1108ceb6333e267943e666a35882b1474/s3fs-2025.10.0.tar.gz", hash = "sha256:e8be6cddc77aceea1681ece0f472c3a7f8ef71a0d2acddb1cc92bb6afa3e9e4f", size = 80383, upload-time = "2025-10-30T15:06:04.647Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/2d/fc/56cba14af8ad8fd020c85b6e44328520ac55939bb1f9d01444ad470504cb/s3fs-2025.10.0-py3-none-any.whl", hash = "sha256:da7ef25efc1541f5fca8e1116361e49ea1081f83f4e8001fbd77347c625da28a", size = 30357, upload-time = "2025-10-30T15:06:03.48Z" },
]

[[package]]
name = "s3fs"
version = "2026.3.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
dependencies = [
    { name = "aiobotocore", version = "3.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "aiohttp", marker = "python_full_version >= '3.10'" },
    { name = "fsspec", version = "2026.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0b/93/093972862fb9c2fdc24ecf8d6d2212853df1945eddf26ba2625e8eaeee66/s3fs-2026.3.0.tar.gz", hash = "sha256:ce8b30a9dc5e01c5127c96cb7377290243a689a251ef9257336ac29d72d7b0d8", size = 85986, upload-time = "2026-03-27T19:28:20.963Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/6a/52/5ccdc01f7a8a61357d15a66b5d8a6580aa8529cb33f32e6cbb71c52622c5/s3fs-2026.3.0-py3-none-any.whl", hash = "sha256:2fa40a64c03003cfa5ae0e352788d97aa78ae8f9e25ea98b28ce9d21ba10c1b8", size = 32399, upload-time = "2026-03-27T19:28:19.702Z" },
]

[[package]]
name = "sanic"
version = "25.3.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
dependencies = [
    { name = "aiofiles", marker = "python_full_version < '3.10'" },
    { name = "html5tagger", marker = "python_full_version < '3.10'" },
    { name = "httptools", marker = "python_full_version < '3.10'" },
    { name = "multidict", marker = "python_full_version < '3.10'" },
    { name = "sanic-routing", marker = "python_full_version < '3.10'" },
    { name = "setuptools", marker = "python_full_version < '3.10'" },
    { name = "tracerite", marker = "python_full_version < '3.10'" },
    { name = "typing-extensions", marker = "python_full_version < '3.10'" },
    { name = "ujson", version = "5.11.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' and implementation_name == 'cpython' and sys_platform != 'win32'" },
    { name = "uvloop", marker = "python_full_version < '3.10' and implementation_name == 'cpython' and sys_platform != 'win32'" },
    { name = "websockets", version = "15.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/8b/08dc376390fe854ef32984973883b646ee68c6727da72ffcc65340d8f192/sanic-25.3.0.tar.gz", hash = "sha256:775d522001ec81f034ec8e4d7599e2175bfc097b8d57884f5e4c9322f5e369bb", size = 353027, upload-time = "2025-03-31T21:22:29.718Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/a6/e1/b36ddc16862d63d22986ae21b04a79c8fb7ec48d5d664acdfd1c2acf78ac/sanic-25.3.0-py3-none-any.whl", hash = "sha256:fb519b38b4c220569b0e2e868583ffeaffaab96a78b2e42ae78bc56a644a4cd7", size = 246416, upload-time = "2025-03-31T21:22:27.946Z" },
]

[package.optional-dependencies]
ext = [
    { name = "sanic-ext", version = "24.12.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
]

[[package]]
name = "sanic"
version = "25.12.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
dependencies = [
    { name = "aiofiles", marker = "python_full_version >= '3.10'" },
    { name = "html5tagger", marker = "python_full_version >= '3.10'" },
    { name = "httptools", marker = "python_full_version >= '3.10'" },
    { name = "multidict", marker = "python_full_version >= '3.10'" },
    { name = "sanic-routing", marker = "python_full_version >= '3.10'" },
    { name = "setuptools", marker = "python_full_version >= '3.10'" },
    { name = "tracerite", marker = "python_full_version >= '3.10'" },
    { name = "typing-extensions", marker = "python_full_version >= '3.10'" },
    { name = "ujson", version = "5.12.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and implementation_name == 'cpython' and sys_platform != 'win32'" },
    { name = "uvloop", marker = "python_full_version >= '3.10' and implementation_name == 'cpython' and sys_platform != 'win32'" },
    { name = "websockets", version = "16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/69/f6/5e9ba853d2872119a252bff0cad712c015c1ed5318cceab5da68c7d2f1c4/sanic-25.12.0.tar.gz", hash = "sha256:ec124338f83a781da8095ed2676e60eb40c7fe21e7aa649a879f8860b4c7bd7a", size = 373452, upload-time = "2025-12-31T19:36:49.087Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/9e/8a/16adaf66d358abfd0d24f2b76857196cf7effbf75c01306306bf39904e30/sanic-25.12.0-py3-none-any.whl", hash = "sha256:42ccf717f564aadab529a1522c489a709c4971c8123793ae07852aa110f8a913", size = 257787, upload-time = "2025-12-31T19:36:47.406Z" },
]

[package.optional-dependencies]
ext = [
    { name = "sanic-ext", version = "25.12.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]

[[package]]
name = "sanic-ext"
version = "24.12.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
dependencies = [
    { name = "pyyaml", marker = "python_full_version < '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/c6/f5f87268e72825e3cd39c5b833996a2ac47f98b888f4253c5830afebd057/sanic_ext-24.12.0.tar.gz", hash = "sha256:8f912f4c29f242bc638346d09b79f0c8896ff64e79bd0e7fa09eac4b6c0e23c8", size = 66209, upload-time = "2025-03-05T07:24:39.795Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/f4/3f/4c23be085bce45defd3863cbc707227fc82f49e7d9a5e1bb2656e2e1a2ed/sanic_ext-24.12.0-py3-none-any.whl", hash = "sha256:861f809f071770cf28acd5f13e97ed59985e07361b13b4b4540da1333730c83e", size = 96445, upload-time = "2025-03-05T07:24:38.059Z" },
]

[[package]]
name = "sanic-ext"
version = "25.12.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
dependencies = [
    { name = "pyyaml", marker = "python_full_version >= '3.10'" },
    { name = "sanic", version = "25.12.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e5/5c/85d5d82eccd8df3fb2fc4b8a3209b3c3a8149875130b4106eb0167f6a0cf/sanic_ext-25.12.0.tar.gz", hash = "sha256:db6f5c8b880bb6b4cb73717b655b63aea4e77cf57825678ec74310fd69581ce1", size = 48553, upload-time = "2026-01-20T12:34:09.78Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/e1/74/c885d8744ecda12aff8211cf491f7214933c8667a815d0fe779d09744e1d/sanic_ext-25.12.0-py3-none-any.whl", hash = "sha256:4bf9afcb28c7abbc4bcac489cf4b6d53f12fa49ea4777200ea7d7d6d23ffe8ca", size = 63316, upload-time = "2026-01-20T12:34:08.844Z" },
]

[[package]]
name = "sanic-routing"
version = "23.12.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d1/5c/2a7edd14fbccca3719a8d680951d4b25f986752c781c61ccf156a6d1ebff/sanic-routing-23.12.0.tar.gz", hash = "sha256:1dcadc62c443e48c852392dba03603f9862b6197fc4cba5bbefeb1ace0848b04", size = 29473, upload-time = "2023-12-31T09:28:36.992Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/cf/e3/3425c9a8773807ac2c01d6a56c8521733f09b627e5827e733c5cd36b9ac5/sanic_routing-23.12.0-py3-none-any.whl", hash = "sha256:1558a72afcb9046ed3134a5edae02fc1552cff08f0fff2e8d5de0877ea43ed73", size = 25522, upload-time = "2023-12-31T09:28:35.233Z" },
]

[[package]]
name = "sanic-testing"
version = "24.6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "httpx" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b2/56/8d31d8a7e0b61633d6358694edfae976e69739b5bd640ceac7989b62e749/sanic_testing-24.6.0.tar.gz", hash = "sha256:7591ce537e2a651efb6dc01b458e7e4ea5347f6d91438676774c6f505a124731", size = 10871, upload-time = "2024-06-30T12:13:31.346Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/0c/93/1d588f1cb9b710b9f22fa78b53d699a8062edc94204d50dd0d78c5f5b495/sanic_testing-24.6.0-py3-none-any.whl", hash = "sha256:b1027184735e88230891aa0461fff84093abfa3bff0f4d29c0f78f42e59efada", size = 10326, upload-time = "2024-06-30T12:13:30.014Z" },
]

[[package]]
name = "sentry-sdk"
version = "2.57.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "certifi" },
    { name = "urllib3", version = "1.26.20", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "urllib3", version = "2.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4f/87/46c0406d8b5ddd026f73adaf5ab75ce144219c41a4830b52df4b9ab55f7f/sentry_sdk-2.57.0.tar.gz", hash = "sha256:4be8d1e71c32fb27f79c577a337ac8912137bba4bcbc64a4ec1da4d6d8dc5199", size = 435288, upload-time = "2026-03-31T09:39:29.264Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/c9/64/982e07b93219cb52e1cca5d272cb579e2f3eb001956c9e7a9a6d106c9473/sentry_sdk-2.57.0-py2.py3-none-any.whl", hash = "sha256:812c8bf5ff3d2f0e89c82f5ce80ab3a6423e102729c4706af7413fd1eb480585", size = 456489, upload-time = "2026-03-31T09:39:27.524Z" },
]

[[package]]
name = "setuptools"
version = "82.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" },
]

[[package]]
name = "shellingham"
version = "1.5.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
]

[[package]]
name = "shibuya"
version = "2025.10.21"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
dependencies = [
    { name = "pygments-styles", marker = "python_full_version < '3.10'" },
    { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/17/e8/462f93f31465220542ee8f1e415819d9e96da78694dc6d04a96dbbf0011f/shibuya-2025.10.21.tar.gz", hash = "sha256:a668f2a33c8b57d33d78bd6edf723eece5734d01f129f973333e7096ad6b3690", size = 82108, upload-time = "2025-10-21T06:03:40.444Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/15/f4/cfad1fb1395e3756ea8eee7db9510c8dbe6796bb6c34eac2eac50650df9c/shibuya-2025.10.21-py3-none-any.whl", hash = "sha256:916e48c97ba5cc4b1949742beac19cf48923e12965a0d1689fda460d363f4d82", size = 98077, upload-time = "2025-10-21T06:03:38.571Z" },
]

[[package]]
name = "shibuya"
version = "2026.1.9"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
dependencies = [
    { name = "pygments-styles", marker = "python_full_version >= '3.10'" },
    { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" },
    { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
    { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/b94cb04adbb984973fe83fd670dd066514610241d829723f678366e691d2/shibuya-2026.1.9.tar.gz", hash = "sha256:b389f10fd9c07b048e940f32d1e1ac096a2d49736389173ac771b37a10b51fdf", size = 86002, upload-time = "2026-01-09T02:19:14.365Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/37/ae/06d7dfc5633c7250fefc61fd624990aa2c37e3495c08a2f23968b1acb23e/shibuya-2026.1.9-py3-none-any.whl", hash = "sha256:b58a3cc6e5619c71d00fcf0be4a3060c87040c2a62a1b3f1a93a6a41ca8eaf45", size = 103389, upload-time = "2026-01-09T02:19:12.798Z" },
]

[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]

[[package]]
name = "slotscheck"
version = "0.19.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "click", version = "8.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4b/57/6fcb8df11e7c76eb87b23bfa931408e47f051c6161749c531b4060a45516/slotscheck-0.19.1.tar.gz", hash = "sha256:6146b7747f8db335a00a66b782f86011b74b995f61746dc5b36a9e77d5326013", size = 16050, upload-time = "2024-10-19T13:30:53.369Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/da/32/bd569256267f80b76b87d21a09795741a175778b954bee1d7b1a89852b6f/slotscheck-0.19.1-py3-none-any.whl", hash = "sha256:bff9926f8d6408ea21b6c6bbaa4389cea1682962e73ee4f30084b6d2b89260ee", size = 16995, upload-time = "2024-10-19T13:30:51.23Z" },
]

[[package]]
name = "sniffio"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]

[[package]]
name = "snowballstemmer"
version = "3.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/75/a7/9810d872919697c9d01295633f5d574fb416d47e535f258272ca1f01f447/snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895", size = 105575, upload-time = "2025-05-09T16:34:51.843Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" },
]

[[package]]
name = "sphinx"
version = "7.4.7"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
dependencies = [
    { name = "alabaster", version = "0.7.16", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "babel", marker = "python_full_version < '3.10'" },
    { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" },
    { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "imagesize", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "importlib-metadata", marker = "python_full_version < '3.10'" },
    { name = "jinja2", marker = "python_full_version < '3.10'" },
    { name = "packaging", marker = "python_full_version < '3.10'" },
    { name = "pygments", marker = "python_full_version < '3.10'" },
    { name = "requests", version = "2.32.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "snowballstemmer", marker = "python_full_version < '3.10'" },
    { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.10'" },
    { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.10'" },
    { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.10'" },
    { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.10'" },
    { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.10'" },
    { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.10'" },
    { name = "tomli", marker = "python_full_version < '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5b/be/50e50cb4f2eff47df05673d361095cafd95521d2a22521b920c67a372dcb/sphinx-7.4.7.tar.gz", hash = "sha256:242f92a7ea7e6c5b406fdc2615413890ba9f699114a9c09192d7dfead2ee9cfe", size = 8067911, upload-time = "2024-07-20T14:46:56.059Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/0d/ef/153f6803c5d5f8917dbb7f7fcf6d34a871ede3296fa89c2c703f5f8a6c8e/sphinx-7.4.7-py3-none-any.whl", hash = "sha256:c2419e2135d11f1951cd994d6eb18a1835bd8fdd8429f9ca375dc1f3281bd239", size = 3401624, upload-time = "2024-07-20T14:46:52.142Z" },
]

[[package]]
name = "sphinx"
version = "8.1.3"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version == '3.10.*'",
]
dependencies = [
    { name = "alabaster", version = "1.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" },
    { name = "babel", marker = "python_full_version == '3.10.*'" },
    { name = "colorama", marker = "python_full_version == '3.10.*' and sys_platform == 'win32'" },
    { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" },
    { name = "imagesize", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" },
    { name = "jinja2", marker = "python_full_version == '3.10.*'" },
    { name = "packaging", marker = "python_full_version == '3.10.*'" },
    { name = "pygments", marker = "python_full_version == '3.10.*'" },
    { name = "requests", version = "2.33.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" },
    { name = "snowballstemmer", marker = "python_full_version == '3.10.*'" },
    { name = "sphinxcontrib-applehelp", marker = "python_full_version == '3.10.*'" },
    { name = "sphinxcontrib-devhelp", marker = "python_full_version == '3.10.*'" },
    { name = "sphinxcontrib-htmlhelp", marker = "python_full_version == '3.10.*'" },
    { name = "sphinxcontrib-jsmath", marker = "python_full_version == '3.10.*'" },
    { name = "sphinxcontrib-qthelp", marker = "python_full_version == '3.10.*'" },
    { name = "sphinxcontrib-serializinghtml", marker = "python_full_version == '3.10.*'" },
    { name = "tomli", marker = "python_full_version == '3.10.*'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/26/60/1ddff83a56d33aaf6f10ec8ce84b4c007d9368b21008876fceda7e7381ef/sphinx-8.1.3-py3-none-any.whl", hash = "sha256:09719015511837b76bf6e03e42eb7595ac8c2e41eeb9c29c5b755c6b677992a2", size = 3487125, upload-time = "2024-10-13T20:27:10.448Z" },
]

[[package]]
name = "sphinx"
version = "9.0.4"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version == '3.11.*'",
]
dependencies = [
    { name = "alabaster", version = "1.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
    { name = "babel", marker = "python_full_version == '3.11.*'" },
    { name = "colorama", marker = "python_full_version == '3.11.*' and sys_platform == 'win32'" },
    { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
    { name = "imagesize", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
    { name = "jinja2", marker = "python_full_version == '3.11.*'" },
    { name = "packaging", marker = "python_full_version == '3.11.*'" },
    { name = "pygments", marker = "python_full_version == '3.11.*'" },
    { name = "requests", version = "2.33.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
    { name = "roman-numerals", marker = "python_full_version == '3.11.*'" },
    { name = "snowballstemmer", marker = "python_full_version == '3.11.*'" },
    { name = "sphinxcontrib-applehelp", marker = "python_full_version == '3.11.*'" },
    { name = "sphinxcontrib-devhelp", marker = "python_full_version == '3.11.*'" },
    { name = "sphinxcontrib-htmlhelp", marker = "python_full_version == '3.11.*'" },
    { name = "sphinxcontrib-jsmath", marker = "python_full_version == '3.11.*'" },
    { name = "sphinxcontrib-qthelp", marker = "python_full_version == '3.11.*'" },
    { name = "sphinxcontrib-serializinghtml", marker = "python_full_version == '3.11.*'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/42/50/a8c6ccc36d5eacdfd7913ddccd15a9cee03ecafc5ee2bc40e1f168d85022/sphinx-9.0.4.tar.gz", hash = "sha256:594ef59d042972abbc581d8baa577404abe4e6c3b04ef61bd7fc2acbd51f3fa3", size = 8710502, upload-time = "2025-12-04T07:45:27.343Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/c6/3f/4bbd76424c393caead2e1eb89777f575dee5c8653e2d4b6afd7a564f5974/sphinx-9.0.4-py3-none-any.whl", hash = "sha256:5bebc595a5e943ea248b99c13814c1c5e10b3ece718976824ffa7959ff95fffb", size = 3917713, upload-time = "2025-12-04T07:45:24.944Z" },
]

[[package]]
name = "sphinx"
version = "9.1.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
]
dependencies = [
    { name = "alabaster", version = "1.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
    { name = "babel", marker = "python_full_version >= '3.12'" },
    { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" },
    { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
    { name = "imagesize", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
    { name = "jinja2", marker = "python_full_version >= '3.12'" },
    { name = "packaging", marker = "python_full_version >= '3.12'" },
    { name = "pygments", marker = "python_full_version >= '3.12'" },
    { name = "requests", version = "2.33.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
    { name = "roman-numerals", marker = "python_full_version >= '3.12'" },
    { name = "snowballstemmer", marker = "python_full_version >= '3.12'" },
    { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.12'" },
    { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.12'" },
    { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.12'" },
    { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.12'" },
    { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.12'" },
    { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.12'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/73/f7/b1884cb3188ab181fc81fa00c266699dab600f927a964df02ec3d5d1916a/sphinx-9.1.0-py3-none-any.whl", hash = "sha256:c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978", size = 3921742, upload-time = "2025-12-31T15:09:25.561Z" },
]

[[package]]
name = "sphinx-autobuild"
version = "2024.10.3"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version == '3.10.*'",
    "python_full_version < '3.10'",
]
dependencies = [
    { name = "colorama", marker = "python_full_version < '3.11'" },
    { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" },
    { name = "starlette", version = "0.49.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "starlette", version = "1.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" },
    { name = "uvicorn", version = "0.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "uvicorn", version = "0.44.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" },
    { name = "watchfiles", marker = "python_full_version < '3.11'" },
    { name = "websockets", version = "15.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "websockets", version = "16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a5/2c/155e1de2c1ba96a72e5dba152c509a8b41e047ee5c2def9e9f0d812f8be7/sphinx_autobuild-2024.10.3.tar.gz", hash = "sha256:248150f8f333e825107b6d4b86113ab28fa51750e5f9ae63b59dc339be951fb1", size = 14023, upload-time = "2024-10-02T23:15:30.172Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/18/c0/eba125db38c84d3c74717008fd3cb5000b68cd7e2cbafd1349c6a38c3d3b/sphinx_autobuild-2024.10.3-py3-none-any.whl", hash = "sha256:158e16c36f9d633e613c9aaf81c19b0fc458ca78b112533b20dafcda430d60fa", size = 11908, upload-time = "2024-10-02T23:15:28.739Z" },
]

[[package]]
name = "sphinx-autobuild"
version = "2025.8.25"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
]
dependencies = [
    { name = "colorama", marker = "python_full_version >= '3.11'" },
    { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
    { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
    { name = "starlette", version = "1.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
    { name = "uvicorn", version = "0.44.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
    { name = "watchfiles", marker = "python_full_version >= '3.11'" },
    { name = "websockets", version = "16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e0/3c/a59a3a453d4133777f7ed2e83c80b7dc817d43c74b74298ca0af869662ad/sphinx_autobuild-2025.8.25.tar.gz", hash = "sha256:9cf5aab32853c8c31af572e4fecdc09c997e2b8be5a07daf2a389e270e85b213", size = 15200, upload-time = "2025-08-25T18:44:55.436Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/d7/20/56411b52f917696995f5ad27d2ea7e9492c84a043c5b49a3a3173573cd93/sphinx_autobuild-2025.8.25-py3-none-any.whl", hash = "sha256:b750ac7d5a18603e4665294323fd20f6dcc0a984117026d1986704fa68f0379a", size = 12535, upload-time = "2025-08-25T18:44:54.164Z" },
]

[[package]]
name = "sphinx-autodoc-typehints"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
dependencies = [
    { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/74/cd/03e7b917230dc057922130a79ba0240df1693bfd76727ea33fae84b39138/sphinx_autodoc_typehints-2.3.0.tar.gz", hash = "sha256:535c78ed2d6a1bad393ba9f3dfa2602cf424e2631ee207263e07874c38fde084", size = 40709, upload-time = "2024-08-29T16:25:48.343Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/a0/f3/e0a4ce49da4b6f4e4ce84b3c39a0677831884cb9d8a87ccbf1e9e56e53ac/sphinx_autodoc_typehints-2.3.0-py3-none-any.whl", hash = "sha256:3098e2c6d0ba99eacd013eb06861acc9b51c6e595be86ab05c08ee5506ac0c67", size = 19836, upload-time = "2024-08-29T16:25:46.707Z" },
]

[[package]]
name = "sphinx-autodoc-typehints"
version = "3.0.1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version == '3.10.*'",
]
dependencies = [
    { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/26/f0/43c6a5ff3e7b08a8c3b32f81b859f1b518ccc31e45f22e2b41ced38be7b9/sphinx_autodoc_typehints-3.0.1.tar.gz", hash = "sha256:b9b40dd15dee54f6f810c924f863f9cf1c54f9f3265c495140ea01be7f44fa55", size = 36282, upload-time = "2025-01-16T18:25:30.958Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/3c/dc/dc46c5c7c566b7ec5e8f860f9c89533bf03c0e6aadc96fb9b337867e4460/sphinx_autodoc_typehints-3.0.1-py3-none-any.whl", hash = "sha256:4b64b676a14b5b79cefb6628a6dc8070e320d4963e8ff640a2f3e9390ae9045a", size = 20245, upload-time = "2025-01-16T18:25:27.394Z" },
]

[[package]]
name = "sphinx-autodoc-typehints"
version = "3.6.1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version == '3.11.*'",
]
dependencies = [
    { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1d/f6/bdd93582b2aaad2cfe9eb5695a44883c8bc44572dd3c351a947acbb13789/sphinx_autodoc_typehints-3.6.1.tar.gz", hash = "sha256:fa0b686ae1b85965116c88260e5e4b82faec3687c2e94d6a10f9b36c3743e2fe", size = 37563, upload-time = "2026-01-02T15:23:46.543Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/dc/6a/c0360b115c81d449b3b73bf74b64ca773464d5c7b1b77bda87c5e874853b/sphinx_autodoc_typehints-3.6.1-py3-none-any.whl", hash = "sha256:dd818ba31d4c97f219a8c0fcacef280424f84a3589cedcb73003ad99c7da41ca", size = 20869, upload-time = "2026-01-02T15:23:45.194Z" },
]

[[package]]
name = "sphinx-autodoc-typehints"
version = "3.9.11"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
]
dependencies = [
    { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/12/e9/d29ae58dd12971d2cbb872884676a70d1a5e4719b4d82e197264cdf0431a/sphinx_autodoc_typehints-3.9.11.tar.gz", hash = "sha256:28516c916b41fa83271ee2ab9191b73807e4113d3bfb94222ac87d8d9795b6e7", size = 70261, upload-time = "2026-03-24T16:57:28.462Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/dd/e3/ff212b51c16717681792eaf18691e6b5affbbb3d4290147c457fa9127372/sphinx_autodoc_typehints-3.9.11-py3-none-any.whl", hash = "sha256:b5cbc7a56a9338021ab7a4e6aa132aa7829fa2f8b64eca927faab64cd3971b80", size = 37279, upload-time = "2026-03-24T16:57:27.147Z" },
]

[[package]]
name = "sphinx-click"
version = "6.0.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
dependencies = [
    { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/db/0a/5b1e8d0579dbb4ca8114e456ca4a68020bfe8e15c7001f3856be4929ab83/sphinx_click-6.0.0.tar.gz", hash = "sha256:f5d664321dc0c6622ff019f1e1c84e58ce0cecfddeb510e004cf60c2a3ab465b", size = 29574, upload-time = "2024-05-15T14:49:17.044Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/d0/d7/8621c4726ad3f788a1db4c0c409044b16edc563f5c9542807b3724037555/sphinx_click-6.0.0-py3-none-any.whl", hash = "sha256:1e0a3c83bcb7c55497751b19d07ebe56b5d7b85eb76dd399cf9061b497adc317", size = 9922, upload-time = "2024-05-15T14:49:15.768Z" },
]

[[package]]
name = "sphinx-click"
version = "6.2.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
dependencies = [
    { name = "click", version = "8.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" },
    { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
    { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" },
    { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
    { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9a/ed/a9767cd1b8b7fbdf260a89d5c8c86e20e3536b9878579e5ab7965a291e55/sphinx_click-6.2.0.tar.gz", hash = "sha256:fc78b4154a4e5159462e36de55b8643747da6cda86b3b52a8bb62289e603776c", size = 27035, upload-time = "2025-12-04T19:33:05.437Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/44/bd/cb244695f67f77b0a36200ce1670fc42a6fe2770847e870daab99cc2b177/sphinx_click-6.2.0-py3-none-any.whl", hash = "sha256:1fb1851cb4f2c286d43cbcd57f55db6ef5a8d208bfc3370f19adde232e5803d7", size = 8939, upload-time = "2025-12-04T19:33:04.037Z" },
]

[[package]]
name = "sphinx-copybutton"
version = "0.5.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" },
    { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
    { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fc/2b/a964715e7f5295f77509e59309959f4125122d648f86b4fe7d70ca1d882c/sphinx-copybutton-0.5.2.tar.gz", hash = "sha256:4cf17c82fb9646d1bc9ca92ac280813a3b605d8c421225fd9913154103ee1fbd", size = 23039, upload-time = "2023-04-14T08:10:22.998Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/9e/48/1ea60e74949eecb12cdd6ac43987f9fd331156388dcc2319b45e2ebb81bf/sphinx_copybutton-0.5.2-py3-none-any.whl", hash = "sha256:fb543fd386d917746c9a2c50360c7905b605726b9355cd26e9974857afeae06e", size = 13343, upload-time = "2023-04-14T08:10:20.844Z" },
]

[[package]]
name = "sphinx-design"
version = "0.6.1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version == '3.10.*'",
    "python_full_version < '3.10'",
]
dependencies = [
    { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2b/69/b34e0cb5336f09c6866d53b4a19d76c227cdec1bbc7ac4de63ca7d58c9c7/sphinx_design-0.6.1.tar.gz", hash = "sha256:b44eea3719386d04d765c1a8257caca2b3e6f8421d7b3a5e742c0fd45f84e632", size = 2193689, upload-time = "2024-08-02T13:48:44.277Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/c6/43/65c0acbd8cc6f50195a3a1fc195c404988b15c67090e73c7a41a9f57d6bd/sphinx_design-0.6.1-py3-none-any.whl", hash = "sha256:b11f37db1a802a183d61b159d9a202314d4d2fe29c163437001324fe2f19549c", size = 2215338, upload-time = "2024-08-02T13:48:42.106Z" },
]

[[package]]
name = "sphinx-design"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
]
dependencies = [
    { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
    { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/13/7b/804f311da4663a4aecc6cf7abd83443f3d4ded970826d0c958edc77d4527/sphinx_design-0.7.0.tar.gz", hash = "sha256:d2a3f5b19c24b916adb52f97c5f00efab4009ca337812001109084a740ec9b7a", size = 2203582, upload-time = "2026-01-19T13:12:53.297Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/30/cf/45dd359f6ca0c3762ce0490f681da242f0530c49c81050c035c016bfdd3a/sphinx_design-0.7.0-py3-none-any.whl", hash = "sha256:f82bf179951d58f55dca78ab3706aeafa496b741a91b1911d371441127d64282", size = 2220350, upload-time = "2026-01-19T13:12:51.077Z" },
]

[[package]]
name = "sphinx-paramlinks"
version = "0.6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
    { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
    { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" },
    { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
    { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ae/21/62d3a58ff7bd02bbb9245a63d1f0d2e0455522a11a78951d16088569fca8/sphinx-paramlinks-0.6.0.tar.gz", hash = "sha256:746a0816860aa3fff5d8d746efcbec4deead421f152687411db1d613d29f915e", size = 12363, upload-time = "2023-08-11T16:09:28.604Z" }

[[package]]
name = "sphinx-togglebutton"
version = "0.4.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
    { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
    { name = "setuptools" },
    { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" },
    { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
    { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
    { name = "wheel" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cc/be/169a0b0a8ad9588e8697c85e1d489aaaca7416073c2fc0267c360af5aae9/sphinx_togglebutton-0.4.5.tar.gz", hash = "sha256:c870dfbd3bc6e119b50ff9a37a64f8991902269e856728931c7d89877e8d4b3d", size = 18101, upload-time = "2026-03-27T13:50:41.984Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/d8/2e/3dd55564928c5d61f92827d4b91307dde7911a40fbe0000645d73202eea9/sphinx_togglebutton-0.4.5-py3-none-any.whl", hash = "sha256:74eac6d2426110c3e1e6f989a98e07d7823141a335df1ad8a9d637bdf6a7af62", size = 44907, upload-time = "2026-03-27T13:50:40.94Z" },
]

[[package]]
name = "sphinxcontrib-applehelp"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" },
]

[[package]]
name = "sphinxcontrib-devhelp"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" },
]

[[package]]
name = "sphinxcontrib-htmlhelp"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" },
]

[[package]]
name = "sphinxcontrib-jsmath"
version = "1.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" },
]

[[package]]
name = "sphinxcontrib-mermaid"
version = "1.2.3"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
dependencies = [
    { name = "pyyaml", marker = "python_full_version < '3.10'" },
    { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f5/49/c6ddfe709a4ab76ac6e5a00e696f73626b2c189dc1e1965a361ec102e6cc/sphinxcontrib_mermaid-1.2.3.tar.gz", hash = "sha256:358699d0ec924ef679b41873d9edd97d0773446daf9760c75e18dc0adfd91371", size = 18885, upload-time = "2025-11-26T04:18:32.43Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/d1/39/8b54299ffa00e597d3b0b4d042241a0a0b22cb429ad007ccfb9c1745b4d1/sphinxcontrib_mermaid-1.2.3-py3-none-any.whl", hash = "sha256:5be782b27026bef97bfb15ccb2f7868b674a1afc0982b54cb149702cfc25aa02", size = 13413, upload-time = "2025-11-26T04:18:31.269Z" },
]

[[package]]
name = "sphinxcontrib-mermaid"
version = "2.0.1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
dependencies = [
    { name = "jinja2", marker = "python_full_version >= '3.10'" },
    { name = "pyyaml", marker = "python_full_version >= '3.10'" },
    { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" },
    { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
    { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2b/ae/999891de292919b66ea34f2c22fc22c9be90ab3536fbc0fca95716277351/sphinxcontrib_mermaid-2.0.1.tar.gz", hash = "sha256:a21a385a059a6cafd192aa3a586b14bf5c42721e229db67b459dc825d7f0a497", size = 19839, upload-time = "2026-03-05T14:10:41.901Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/03/46/25d64bcd7821c8d6f1080e1c43d5fcdfc442a18f759a230b5ccdc891093e/sphinxcontrib_mermaid-2.0.1-py3-none-any.whl", hash = "sha256:9dca7fbe827bad5e7e2b97c4047682cfd26e3e07398cfdc96c7a8842ae7f06e7", size = 14064, upload-time = "2026-03-05T14:10:40.533Z" },
]

[[package]]
name = "sphinxcontrib-qthelp"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" },
]

[[package]]
name = "sphinxcontrib-serializinghtml"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" },
]

[[package]]
name = "sqlalchemy"
version = "2.0.49"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "greenlet", version = "3.2.5", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.10' and platform_machine == 'AMD64') or (python_full_version < '3.10' and platform_machine == 'WIN32') or (python_full_version < '3.10' and platform_machine == 'aarch64') or (python_full_version < '3.10' and platform_machine == 'amd64') or (python_full_version < '3.10' and platform_machine == 'ppc64le') or (python_full_version < '3.10' and platform_machine == 'win32') or (python_full_version < '3.10' and platform_machine == 'x86_64')" },
    { name = "greenlet", version = "3.3.2", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.10' and platform_machine == 'AMD64') or (python_full_version >= '3.10' and platform_machine == 'WIN32') or (python_full_version >= '3.10' and platform_machine == 'aarch64') or (python_full_version >= '3.10' and platform_machine == 'amd64') or (python_full_version >= '3.10' and platform_machine == 'ppc64le') or (python_full_version >= '3.10' and platform_machine == 'win32') or (python_full_version >= '3.10' and platform_machine == 'x86_64')" },
    { name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/09/45/461788f35e0364a8da7bda51a1fe1b09762d0c32f12f63727998d85a873b/sqlalchemy-2.0.49.tar.gz", hash = "sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f", size = 9898221, upload-time = "2026-04-03T16:38:11.704Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/96/76/f908955139842c362aa877848f42f9249642d5b69e06cee9eae5111da1bd/sqlalchemy-2.0.49-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:42e8804962f9e6f4be2cbaedc0c3718f08f60a16910fa3d86da5a1e3b1bfe60f", size = 2159321, upload-time = "2026-04-03T16:50:11.8Z" },
    { url = "https://files.pythonhosted.org/packages/24/e2/17ba0b7bfbd8de67196889b6d951de269e8a46057d92baca162889beb16d/sqlalchemy-2.0.49-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc992c6ed024c8c3c592c5fc9846a03dd68a425674900c70122c77ea16c5fb0b", size = 3238937, upload-time = "2026-04-03T16:54:45.731Z" },
    { url = "https://files.pythonhosted.org/packages/90/1e/410dd499c039deacff395eec01a9da057125fcd0c97e3badc252c6a2d6a7/sqlalchemy-2.0.49-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6eb188b84269f357669b62cb576b5b918de10fb7c728a005fa0ebb0b758adce1", size = 3237188, upload-time = "2026-04-03T16:56:53.217Z" },
    { url = "https://files.pythonhosted.org/packages/ab/06/e797a8b98a3993ac4bc785309b9b6d005457fc70238ee6cefa7c8867a92e/sqlalchemy-2.0.49-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:62557958002b69699bdb7f5137c6714ca1133f045f97b3903964f47db97ea339", size = 3190061, upload-time = "2026-04-03T16:54:47.489Z" },
    { url = "https://files.pythonhosted.org/packages/44/d3/5a9f7ef580af1031184b38235da6ac58c3b571df01c9ec061c44b2b0c5a6/sqlalchemy-2.0.49-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:da9b91bca419dc9b9267ffadde24eae9b1a6bffcd09d0a207e5e3af99a03ce0d", size = 3211477, upload-time = "2026-04-03T16:56:55.056Z" },
    { url = "https://files.pythonhosted.org/packages/69/ec/7be8c8cb35f038e963a203e4fe5a028989167cc7299927b7cf297c271e37/sqlalchemy-2.0.49-cp310-cp310-win32.whl", hash = "sha256:5e61abbec255be7b122aa461021daa7c3f310f3e743411a67079f9b3cc91ece3", size = 2119965, upload-time = "2026-04-03T17:00:50.009Z" },
    { url = "https://files.pythonhosted.org/packages/b5/31/0defb93e3a10b0cf7d1271aedd87251a08c3a597ee4f353281769b547b5a/sqlalchemy-2.0.49-cp310-cp310-win_amd64.whl", hash = "sha256:0c98c59075b890df8abfcc6ad632879540f5791c68baebacb4f833713b510e75", size = 2142935, upload-time = "2026-04-03T17:00:51.675Z" },
    { url = "https://files.pythonhosted.org/packages/60/b5/e3617cc67420f8f403efebd7b043128f94775e57e5b84e7255203390ceae/sqlalchemy-2.0.49-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c5070135e1b7409c4161133aa525419b0062088ed77c92b1da95366ec5cbebbe", size = 2159126, upload-time = "2026-04-03T16:50:13.242Z" },
    { url = "https://files.pythonhosted.org/packages/20/9b/91ca80403b17cd389622a642699e5f6564096b698e7cdcbcbb6409898bc4/sqlalchemy-2.0.49-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ac7a3e245fd0310fd31495eb61af772e637bdf7d88ee81e7f10a3f271bff014", size = 3315509, upload-time = "2026-04-03T16:54:49.332Z" },
    { url = "https://files.pythonhosted.org/packages/b1/61/0722511d98c54de95acb327824cb759e8653789af2b1944ab1cc69d32565/sqlalchemy-2.0.49-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d4e5a0ceba319942fa6b585cf82539288a61e314ef006c1209f734551ab9536", size = 3315014, upload-time = "2026-04-03T16:56:56.376Z" },
    { url = "https://files.pythonhosted.org/packages/46/55/d514a653ffeb4cebf4b54c47bec32ee28ad89d39fafba16eeed1d81dccd5/sqlalchemy-2.0.49-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3ddcb27fb39171de36e207600116ac9dfd4ae46f86c82a9bf3934043e80ebb88", size = 3267388, upload-time = "2026-04-03T16:54:51.272Z" },
    { url = "https://files.pythonhosted.org/packages/2f/16/0dcc56cb6d3335c1671a2258f5d2cb8267c9a2260e27fde53cbfb1b3540a/sqlalchemy-2.0.49-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:32fe6a41ad97302db2931f05bb91abbcc65b5ce4c675cd44b972428dd2947700", size = 3289602, upload-time = "2026-04-03T16:56:57.63Z" },
    { url = "https://files.pythonhosted.org/packages/51/6c/f8ab6fb04470a133cd80608db40aa292e6bae5f162c3a3d4ab19544a67af/sqlalchemy-2.0.49-cp311-cp311-win32.whl", hash = "sha256:46d51518d53edfbe0563662c96954dc8fcace9832332b914375f45a99b77cc9a", size = 2119044, upload-time = "2026-04-03T17:00:53.455Z" },
    { url = "https://files.pythonhosted.org/packages/c4/59/55a6d627d04b6ebb290693681d7683c7da001eddf90b60cfcc41ee907978/sqlalchemy-2.0.49-cp311-cp311-win_amd64.whl", hash = "sha256:951d4a210744813be63019f3df343bf233b7432aadf0db54c75802247330d3af", size = 2143642, upload-time = "2026-04-03T17:00:54.769Z" },
    { url = "https://files.pythonhosted.org/packages/49/b3/2de412451330756aaaa72d27131db6dde23995efe62c941184e15242a5fa/sqlalchemy-2.0.49-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4bbccb45260e4ff1b7db0be80a9025bb1e6698bdb808b83fff0000f7a90b2c0b", size = 2157681, upload-time = "2026-04-03T16:53:07.132Z" },
    { url = "https://files.pythonhosted.org/packages/50/84/b2a56e2105bd11ebf9f0b93abddd748e1a78d592819099359aa98134a8bf/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb37f15714ec2652d574f021d479e78cd4eb9d04396dca36568fdfffb3487982", size = 3338976, upload-time = "2026-04-03T17:07:40Z" },
    { url = "https://files.pythonhosted.org/packages/2c/fa/65fcae2ed62f84ab72cf89536c7c3217a156e71a2c111b1305ab6f0690e2/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb9ec6436a820a4c006aad1ac351f12de2f2dbdaad171692ee457a02429b672", size = 3351937, upload-time = "2026-04-03T17:12:23.374Z" },
    { url = "https://files.pythonhosted.org/packages/f8/2f/6fd118563572a7fe475925742eb6b3443b2250e346a0cc27d8d408e73773/sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8d6efc136f44a7e8bc8088507eaabbb8c2b55b3dbb63fe102c690da0ddebe55e", size = 3281646, upload-time = "2026-04-03T17:07:41.949Z" },
    { url = "https://files.pythonhosted.org/packages/c5/d7/410f4a007c65275b9cf82354adb4bb8ba587b176d0a6ee99caa16fe638f8/sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e06e617e3d4fd9e51d385dfe45b077a41e9d1b033a7702551e3278ac597dc750", size = 3316695, upload-time = "2026-04-03T17:12:25.642Z" },
    { url = "https://files.pythonhosted.org/packages/d9/95/81f594aa60ded13273a844539041ccf1e66c5a7bed0a8e27810a3b52d522/sqlalchemy-2.0.49-cp312-cp312-win32.whl", hash = "sha256:83101a6930332b87653886c01d1ee7e294b1fe46a07dd9a2d2b4f91bcc88eec0", size = 2117483, upload-time = "2026-04-03T17:05:40.896Z" },
    { url = "https://files.pythonhosted.org/packages/47/9e/fd90114059175cac64e4fafa9bf3ac20584384d66de40793ae2e2f26f3bb/sqlalchemy-2.0.49-cp312-cp312-win_amd64.whl", hash = "sha256:618a308215b6cececb6240b9abde545e3acdabac7ae3e1d4e666896bf5ba44b4", size = 2144494, upload-time = "2026-04-03T17:05:42.282Z" },
    { url = "https://files.pythonhosted.org/packages/ae/81/81755f50eb2478eaf2049728491d4ea4f416c1eb013338682173259efa09/sqlalchemy-2.0.49-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df2d441bacf97022e81ad047e1597552eb3f83ca8a8f1a1fdd43cd7fe3898120", size = 2154547, upload-time = "2026-04-03T16:53:08.64Z" },
    { url = "https://files.pythonhosted.org/packages/a2/bc/3494270da80811d08bcfa247404292428c4fe16294932bce5593f215cad9/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8e20e511dc15265fb433571391ba313e10dd8ea7e509d51686a51313b4ac01a2", size = 3280782, upload-time = "2026-04-03T17:07:43.508Z" },
    { url = "https://files.pythonhosted.org/packages/cd/f5/038741f5e747a5f6ea3e72487211579d8cbea5eb9827a9cbd61d0108c4bd/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47604cb2159f8bbd5a1ab48a714557156320f20871ee64d550d8bf2683d980d3", size = 3297156, upload-time = "2026-04-03T17:12:27.697Z" },
    { url = "https://files.pythonhosted.org/packages/88/50/a6af0ff9dc954b43a65ca9b5367334e45d99684c90a3d3413fc19a02d43c/sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:22d8798819f86720bc646ab015baff5ea4c971d68121cb36e2ebc2ee43ead2b7", size = 3228832, upload-time = "2026-04-03T17:07:45.38Z" },
    { url = "https://files.pythonhosted.org/packages/bc/d1/5f6bdad8de0bf546fc74370939621396515e0cdb9067402d6ba1b8afbe9a/sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9b1c058c171b739e7c330760044803099c7fff11511e3ab3573e5327116a9c33", size = 3267000, upload-time = "2026-04-03T17:12:29.657Z" },
    { url = "https://files.pythonhosted.org/packages/f7/30/ad62227b4a9819a5e1c6abff77c0f614fa7c9326e5a3bdbee90f7139382b/sqlalchemy-2.0.49-cp313-cp313-win32.whl", hash = "sha256:a143af2ea6672f2af3f44ed8f9cd020e9cc34c56f0e8db12019d5d9ecf41cb3b", size = 2115641, upload-time = "2026-04-03T17:05:43.989Z" },
    { url = "https://files.pythonhosted.org/packages/17/3a/7215b1b7d6d49dc9a87211be44562077f5f04f9bb5a59552c1c8e2d98173/sqlalchemy-2.0.49-cp313-cp313-win_amd64.whl", hash = "sha256:12b04d1db2663b421fe072d638a138460a51d5a862403295671c4f3987fb9148", size = 2141498, upload-time = "2026-04-03T17:05:45.7Z" },
    { url = "https://files.pythonhosted.org/packages/28/4b/52a0cb2687a9cd1648252bb257be5a1ba2c2ded20ba695c65756a55a15a4/sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24bd94bb301ec672d8f0623eba9226cc90d775d25a0c92b5f8e4965d7f3a1518", size = 3560807, upload-time = "2026-04-03T16:58:31.666Z" },
    { url = "https://files.pythonhosted.org/packages/8c/d8/fda95459204877eed0458550d6c7c64c98cc50c2d8d618026737de9ed41a/sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a51d3db74ba489266ef55c7a4534eb0b8db9a326553df481c11e5d7660c8364d", size = 3527481, upload-time = "2026-04-03T17:06:00.155Z" },
    { url = "https://files.pythonhosted.org/packages/ff/0a/2aac8b78ac6487240cf7afef8f203ca783e8796002dc0cf65c4ee99ff8bb/sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:55250fe61d6ebfd6934a272ee16ef1244e0f16b7af6cd18ab5b1fc9f08631db0", size = 3468565, upload-time = "2026-04-03T16:58:33.414Z" },
    { url = "https://files.pythonhosted.org/packages/a5/3d/ce71cfa82c50a373fd2148b3c870be05027155ce791dc9a5dcf439790b8b/sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:46796877b47034b559a593d7e4b549aba151dae73f9e78212a3478161c12ab08", size = 3477769, upload-time = "2026-04-03T17:06:02.787Z" },
    { url = "https://files.pythonhosted.org/packages/d5/e8/0a9f5c1f7c6f9ca480319bf57c2d7423f08d31445974167a27d14483c948/sqlalchemy-2.0.49-cp313-cp313t-win32.whl", hash = "sha256:9c4969a86e41454f2858256c39bdfb966a20961e9b58bf8749b65abf447e9a8d", size = 2143319, upload-time = "2026-04-03T17:02:04.328Z" },
    { url = "https://files.pythonhosted.org/packages/0e/51/fb5240729fbec73006e137c4f7a7918ffd583ab08921e6ff81a999d6517a/sqlalchemy-2.0.49-cp313-cp313t-win_amd64.whl", hash = "sha256:b9870d15ef00e4d0559ae10ee5bc71b654d1f20076dbe8bc7ed19b4c0625ceba", size = 2175104, upload-time = "2026-04-03T17:02:05.989Z" },
    { url = "https://files.pythonhosted.org/packages/55/33/bf28f618c0a9597d14e0b9ee7d1e0622faff738d44fe986ee287cdf1b8d0/sqlalchemy-2.0.49-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:233088b4b99ebcbc5258c755a097aa52fbf90727a03a5a80781c4b9c54347a2e", size = 2156356, upload-time = "2026-04-03T16:53:09.914Z" },
    { url = "https://files.pythonhosted.org/packages/d1/a7/5f476227576cb8644650eff68cc35fa837d3802b997465c96b8340ced1e2/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57ca426a48eb2c682dae8204cd89ea8ab7031e2675120a47924fabc7caacbc2a", size = 3276486, upload-time = "2026-04-03T17:07:46.9Z" },
    { url = "https://files.pythonhosted.org/packages/2e/84/efc7c0bf3a1c5eef81d397f6fddac855becdbb11cb38ff957888603014a7/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:685e93e9c8f399b0c96a624799820176312f5ceef958c0f88215af4013d29066", size = 3281479, upload-time = "2026-04-03T17:12:32.226Z" },
    { url = "https://files.pythonhosted.org/packages/91/68/bb406fa4257099c67bd75f3f2261b129c63204b9155de0d450b37f004698/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e0400fa22f79acc334d9a6b185dc00a44a8e6578aa7e12d0ddcd8434152b187", size = 3226269, upload-time = "2026-04-03T17:07:48.678Z" },
    { url = "https://files.pythonhosted.org/packages/67/84/acb56c00cca9f251f437cb49e718e14f7687505749ea9255d7bd8158a6df/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a05977bffe9bffd2229f477fa75eabe3192b1b05f408961d1bebff8d1cd4d401", size = 3248260, upload-time = "2026-04-03T17:12:34.381Z" },
    { url = "https://files.pythonhosted.org/packages/56/19/6a20ea25606d1efd7bd1862149bb2a22d1451c3f851d23d887969201633f/sqlalchemy-2.0.49-cp314-cp314-win32.whl", hash = "sha256:0f2fa354ba106eafff2c14b0cc51f22801d1e8b2e4149342023bd6f0955de5f5", size = 2118463, upload-time = "2026-04-03T17:05:47.093Z" },
    { url = "https://files.pythonhosted.org/packages/cf/4f/8297e4ed88e80baa1f5aa3c484a0ee29ef3c69c7582f206c916973b75057/sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl", hash = "sha256:77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5", size = 2144204, upload-time = "2026-04-03T17:05:48.694Z" },
    { url = "https://files.pythonhosted.org/packages/1f/33/95e7216df810c706e0cd3655a778604bbd319ed4f43333127d465a46862d/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1dc3368794d522f43914e03312202523cc89692f5389c32bea0233924f8d977", size = 3565474, upload-time = "2026-04-03T16:58:35.128Z" },
    { url = "https://files.pythonhosted.org/packages/0c/a4/ed7b18d8ccf7f954a83af6bb73866f5bc6f5636f44c7731fbb741f72cc4f/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c821c47ecfe05cc32140dcf8dc6fd5d21971c86dbd56eabfe5ba07a64910c01", size = 3530567, upload-time = "2026-04-03T17:06:04.587Z" },
    { url = "https://files.pythonhosted.org/packages/73/a3/20faa869c7e21a827c4a2a42b41353a54b0f9f5e96df5087629c306df71e/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9c04bff9a5335eb95c6ecf1c117576a0aa560def274876fd156cfe5510fccc61", size = 3474282, upload-time = "2026-04-03T16:58:37.131Z" },
    { url = "https://files.pythonhosted.org/packages/b7/50/276b9a007aa0764304ad467eceb70b04822dc32092492ee5f322d559a4dc/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7f605a456948c35260e7b2a39f8952a26f077fd25653c37740ed186b90aaa68a", size = 3480406, upload-time = "2026-04-03T17:06:07.176Z" },
    { url = "https://files.pythonhosted.org/packages/e5/c3/c80fcdb41905a2df650c2a3e0337198b6848876e63d66fe9188ef9003d24/sqlalchemy-2.0.49-cp314-cp314t-win32.whl", hash = "sha256:6270d717b11c5476b0cbb21eedc8d4dbb7d1a956fd6c15a23e96f197a6193158", size = 2149151, upload-time = "2026-04-03T17:02:07.281Z" },
    { url = "https://files.pythonhosted.org/packages/05/52/9f1a62feab6ed368aff068524ff414f26a6daebc7361861035ae00b05530/sqlalchemy-2.0.49-cp314-cp314t-win_amd64.whl", hash = "sha256:275424295f4256fd301744b8f335cff367825d270f155d522b30c7bf49903ee7", size = 2184178, upload-time = "2026-04-03T17:02:08.623Z" },
    { url = "https://files.pythonhosted.org/packages/1d/64/6eb36149b96796ecbc1e2438959d08475e1f8765acbe007f4785a603c39c/sqlalchemy-2.0.49-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:43d044780732d9e0381ac8d5316f95d7f02ef04d6e4ef6dc82379f09795d993f", size = 2162373, upload-time = "2026-04-03T16:49:49.55Z" },
    { url = "https://files.pythonhosted.org/packages/b0/96/87e57cfa06af0032a7470660d33e93ad0a2480781bb7705f4312471b993e/sqlalchemy-2.0.49-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d6be30b2a75362325176c036d7fb8d19e8846c77e87683ffaa8177b35135613", size = 3237991, upload-time = "2026-04-03T17:04:07.027Z" },
    { url = "https://files.pythonhosted.org/packages/b7/aa/0099d0d554313c3587155b60288a9900660afc9989bf382176a5f4d7531b/sqlalchemy-2.0.49-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d898cc2c76c135ef65517f4ddd7a3512fb41f23087b0650efb3418b8389a3cd1", size = 3237313, upload-time = "2026-04-03T17:09:53.187Z" },
    { url = "https://files.pythonhosted.org/packages/d5/9b/a61fcb2e8439a2282e4ac0086bb613e88cd18168cddb358fa2c5790d4705/sqlalchemy-2.0.49-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:059d7151fff513c53a4638da8778be7fce81a0c4854c7348ebd0c4078ddf28fe", size = 3187435, upload-time = "2026-04-03T17:04:08.956Z" },
    { url = "https://files.pythonhosted.org/packages/92/b5/2165d3f8fa593f20039505af15474f63e85ffd7998afb6218b0fc0cd98e0/sqlalchemy-2.0.49-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:334edbcff10514ad1d66e3a70b339c0a29886394892490119dbb669627b17717", size = 3209446, upload-time = "2026-04-03T17:09:55.81Z" },
    { url = "https://files.pythonhosted.org/packages/23/8d/9630ddc9a4db638a7f29954b9e667a4ece41ff65e117460473ca41f06945/sqlalchemy-2.0.49-cp39-cp39-win32.whl", hash = "sha256:74ab4ee7794d7ed1b0c37e7333640e0f0a626fc7b398c07a7aef52f484fddde3", size = 2121680, upload-time = "2026-04-03T16:55:23.258Z" },
    { url = "https://files.pythonhosted.org/packages/e1/5c/480f5d8c737cfb4a494f87de6e0e58a6b6346a0f4db1fa8122c89828e32d/sqlalchemy-2.0.49-cp39-cp39-win_amd64.whl", hash = "sha256:88690f4e1f0fbf5339bedbb127e240fec1fd3070e9934c0b7bef83432f779d2f", size = 2144917, upload-time = "2026-04-03T16:55:24.701Z" },
    { url = "https://files.pythonhosted.org/packages/e5/30/8519fdde58a7bdf155b714359791ad1dc018b47d60269d5d160d311fdc36/sqlalchemy-2.0.49-py3-none-any.whl", hash = "sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0", size = 1942158, upload-time = "2026-04-03T16:53:44.135Z" },
]

[[package]]
name = "sqlalchemy-cockroachdb"
version = "2.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "sqlalchemy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/32/02/2e2e6a0b47259f3c61f3a062653070302eea81f0ae9a57cd3ab7f1dc110e/sqlalchemy_cockroachdb-2.0.3.tar.gz", hash = "sha256:48b763ffd8b2a4dc9d56459934a56d7d5ffad415c6e68d114baee5d12cf79c1d", size = 28539, upload-time = "2025-06-11T00:34:53.619Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/f1/61/ae638b8adaea4bcb5092b7fedad14d53ef2bef2b75877503019ddf06cd89/sqlalchemy_cockroachdb-2.0.3-py3-none-any.whl", hash = "sha256:cd1dca9a8c65ec8564c928ea26d154b7fc02f82948af200d965343894ca0c0f5", size = 22134, upload-time = "2025-06-11T00:34:52.736Z" },
]

[[package]]
name = "sqlalchemy-spanner"
version = "1.17.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "alembic", version = "1.16.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "alembic", version = "1.18.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "google-cloud-spanner" },
    { name = "sqlalchemy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6b/1c/c7d28d88e8dd9a67be006a40135f05cbdf5a0f5f79bc51bb692f54432cf1/sqlalchemy_spanner-1.17.3.tar.gz", hash = "sha256:ea829d8223c404f19f854c4c2dbf6bf2ee48fb1347caa258f03e88071f3afa22", size = 82842, upload-time = "2026-03-23T22:44:01.25Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/f3/43/cf21f3e70a8aa9e721fb557bd1459528906f0d9726b2ce642cd757fe592b/sqlalchemy_spanner-1.17.3-py3-none-any.whl", hash = "sha256:b0a13d2cae3bb0ee5aac898c44d22f56ec3edfc7780dd7d165d51f676590daf3", size = 31925, upload-time = "2026-03-23T22:43:33.214Z" },
]

[[package]]
name = "sqlmodel"
version = "0.0.34"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
dependencies = [
    { name = "pydantic", marker = "python_full_version < '3.10'" },
    { name = "sqlalchemy", marker = "python_full_version < '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3b/6a/b1b26d589063e53a08c10a2d7bc624cba63dec045a312758d68f550a4ea1/sqlmodel-0.0.34.tar.gz", hash = "sha256:577e4aae1ba96ee5038e03d8b1404c642dad1a92e628988cdf4ce68d27abe982", size = 96236, upload-time = "2026-02-16T19:06:34.275Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/eb/ee/1910f4eee41af4268b0d8cd688a05fb8ea23e9e6c64b8710592df24a8c66/sqlmodel-0.0.34-py3-none-any.whl", hash = "sha256:aeabc8f0de32076a0ed9216e88568459d737fca1e7133bfc6d1c657920789a2d", size = 27445, upload-time = "2026-02-16T19:06:35.709Z" },
]

[[package]]
name = "sqlmodel"
version = "0.0.38"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
dependencies = [
    { name = "pydantic", marker = "python_full_version >= '3.10'" },
    { name = "sqlalchemy", marker = "python_full_version >= '3.10'" },
    { name = "typing-extensions", marker = "python_full_version >= '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/64/0d/26ec1329960ea9430131fe63f63a95ea4cb8971d49c891ff7e1f3255421c/sqlmodel-0.0.38.tar.gz", hash = "sha256:d583ec237b14103809f74e8630032bc40ab68cd6b754a610f0813c56911a547b", size = 86710, upload-time = "2026-04-02T21:03:55.571Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/72/c7/10c60af0607ab6fa136264f7f39d205932218516226d38585324ffda705d/sqlmodel-0.0.38-py3-none-any.whl", hash = "sha256:84e3fa990a77395461ded72a6c73173438ce8449d5c1c4d97fbff1b1df692649", size = 27294, upload-time = "2026-04-02T21:03:56.406Z" },
]

[[package]]
name = "sqlparse"
version = "0.5.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" },
]

[[package]]
name = "starlette"
version = "0.49.3"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
dependencies = [
    { name = "anyio", version = "4.12.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "typing-extensions", marker = "python_full_version < '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/de/1a/608df0b10b53b0beb96a37854ee05864d182ddd4b1156a22f1ad3860425a/starlette-0.49.3.tar.gz", hash = "sha256:1c14546f299b5901a1ea0e34410575bc33bbd741377a10484a54445588d00284", size = 2655031, upload-time = "2025-11-01T15:12:26.13Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/a3/e0/021c772d6a662f43b63044ab481dc6ac7592447605b5b35a957785363122/starlette-0.49.3-py3-none-any.whl", hash = "sha256:b579b99715fdc2980cf88c8ec96d3bf1ce16f5a8051a7c2b84ef9b1cdecaea2f", size = 74340, upload-time = "2025-11-01T15:12:24.387Z" },
]

[[package]]
name = "starlette"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
dependencies = [
    { name = "anyio", version = "4.13.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" },
]

[[package]]
name = "stevedore"
version = "5.5.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
sdist = { url = "https://files.pythonhosted.org/packages/2a/5f/8418daad5c353300b7661dd8ce2574b0410a6316a8be650a189d5c68d938/stevedore-5.5.0.tar.gz", hash = "sha256:d31496a4f4df9825e1a1e4f1f74d19abb0154aff311c3b376fcc89dae8fccd73", size = 513878, upload-time = "2025-08-25T12:54:26.806Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/80/c5/0c06759b95747882bb50abda18f5fb48c3e9b0fbfc6ebc0e23550b52415d/stevedore-5.5.0-py3-none-any.whl", hash = "sha256:18363d4d268181e8e8452e71a38cd77630f345b2ef6b4a8d5614dac5ee0d18cf", size = 49518, upload-time = "2025-08-25T12:54:25.445Z" },
]

[[package]]
name = "stevedore"
version = "5.7.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
sdist = { url = "https://files.pythonhosted.org/packages/a2/6d/90764092216fa560f6587f83bb70113a8ba510ba436c6476a2b47359057c/stevedore-5.7.0.tar.gz", hash = "sha256:31dd6fe6b3cbe921e21dcefabc9a5f1cf848cf538a1f27543721b8ca09948aa3", size = 516200, upload-time = "2026-02-20T13:27:06.765Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/69/06/36d260a695f383345ab5bbc3fd447249594ae2fa8dfd19c533d5ae23f46b/stevedore-5.7.0-py3-none-any.whl", hash = "sha256:fd25efbb32f1abb4c9e502f385f0018632baac11f9ee5d1b70f88cc5e22ad4ed", size = 54483, upload-time = "2026-02-20T13:27:05.561Z" },
]

[[package]]
name = "sybil"
version = "9.3.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version == '3.10.*'",
    "python_full_version < '3.10'",
]
sdist = { url = "https://files.pythonhosted.org/packages/c6/46/bae21847b8d761ddd6ede1811d32818342dbd482c32a2a5805c28d9b2f18/sybil-9.3.0.tar.gz", hash = "sha256:847d1d17b8a857c4bb3f8471b4a57b8affa939a60fbf507e70aa72ad79097c05", size = 89078, upload-time = "2025-12-02T09:29:09.302Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/2c/08/cd3cf2a82570748cfb3142e795044197deff81ad3b70a0b9a9c22331e70a/sybil-9.3.0-py3-none-any.whl", hash = "sha256:0b108b980ab9fac774953042b07fcb5858aa19a38404d0cb42c30c93423ac0c1", size = 39286, upload-time = "2025-12-02T09:29:08.417Z" },
]

[[package]]
name = "sybil"
version = "10.0.1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
]
sdist = { url = "https://files.pythonhosted.org/packages/de/2b/5ee5ef413b87f215c03cd08462dc341903b86707e517e20715823c984893/sybil-10.0.1.tar.gz", hash = "sha256:319eed013ebe848f8c57ce79c1ed526e506d952c778693979bc509513ae72a68", size = 80120, upload-time = "2026-03-26T08:56:30.942Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/41/f3/4f2a8069d212a33bccc88be13b5bdad61f810054f6c21c6da3aa25fb38d0/sybil-10.0.1-py3-none-any.whl", hash = "sha256:0030d8583d5ce97969397d303db1f17054f61b23f18469c0ac61f9f42735571c", size = 40316, upload-time = "2026-03-26T08:56:29.502Z" },
]

[[package]]
name = "termcolor"
version = "3.1.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
sdist = { url = "https://files.pythonhosted.org/packages/ca/6c/3d75c196ac07ac8749600b60b03f4f6094d54e132c4d94ebac6ee0e0add0/termcolor-3.1.0.tar.gz", hash = "sha256:6a6dd7fbee581909eeec6a756cff1d7f7c376063b14e4a298dc4980309e55970", size = 14324, upload-time = "2025-04-30T11:37:53.791Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/4f/bd/de8d508070629b6d84a30d01d57e4a65c69aa7f5abe7560b8fad3b50ea59/termcolor-3.1.0-py3-none-any.whl", hash = "sha256:591dd26b5c2ce03b9e43f391264626557873ce1d379019786f99b0c2bee140aa", size = 7684, upload-time = "2025-04-30T11:37:52.382Z" },
]

[[package]]
name = "termcolor"
version = "3.3.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
sdist = { url = "https://files.pythonhosted.org/packages/46/79/cf31d7a93a8fdc6aa0fbb665be84426a8c5a557d9240b6239e9e11e35fc5/termcolor-3.3.0.tar.gz", hash = "sha256:348871ca648ec6a9a983a13ab626c0acce02f515b9e1983332b17af7979521c5", size = 14434, upload-time = "2025-12-29T12:55:21.882Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/33/d1/8bb87d21e9aeb323cc03034f5eaf2c8f69841e40e4853c2627edf8111ed3/termcolor-3.3.0-py3-none-any.whl", hash = "sha256:cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5", size = 7734, upload-time = "2025-12-29T12:55:20.718Z" },
]

[[package]]
name = "time-machine"
version = "2.19.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
dependencies = [
    { name = "python-dateutil", marker = "python_full_version < '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f8/a4/1b5fdd165f61b67f445fac2a7feb0c655118edef429cd09ff5a8067f7f1d/time_machine-2.19.0.tar.gz", hash = "sha256:7c5065a8b3f2bbb449422c66ef71d114d3f909c276a6469642ecfffb6a0fcd29", size = 14576, upload-time = "2025-08-19T17:22:08.402Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/9d/8f/19125611ebbcb3a14da14cd982b9eb4573e2733db60c9f1fbf6a39534f40/time_machine-2.19.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b5169018ef47206997b46086ce01881cd3a4666fd2998c9d76a87858ca3e49e9", size = 19659, upload-time = "2025-08-19T17:20:30.062Z" },
    { url = "https://files.pythonhosted.org/packages/74/da/9b0a928321e7822a3ff96dbd1eae089883848e30e9e1b149b85fb96ba56b/time_machine-2.19.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:85bb7ed440fccf6f6d0c8f7d68d849e7c3d1f771d5e0b2cdf871fa6561da569f", size = 15157, upload-time = "2025-08-19T17:20:31.931Z" },
    { url = "https://files.pythonhosted.org/packages/36/ff/d7e943422038f5f2161fe2c2d791e64a45be691ef946020b20f3a6efc4d4/time_machine-2.19.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a3b12028af1cdc09ccd595be2168b7b26f206c1e190090b048598fbe278beb8e", size = 32860, upload-time = "2025-08-19T17:20:33.241Z" },
    { url = "https://files.pythonhosted.org/packages/fc/80/2b0f1070ed9808ee7da7a6da62a4a0b776957cb4d861578348f86446e778/time_machine-2.19.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c261f073086cf081d1443cbf7684148c662659d3d139d06b772bfe3fe7cc71a6", size = 34510, upload-time = "2025-08-19T17:20:34.221Z" },
    { url = "https://files.pythonhosted.org/packages/ef/b4/48038691c8d89924b36c83335a73adeeb68c884f5a1da08a5b17b8a956f3/time_machine-2.19.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:011954d951230a9f1079f22b39ed1a3a9abb50ee297dfb8c557c46351659d94d", size = 36204, upload-time = "2025-08-19T17:20:35.163Z" },
    { url = "https://files.pythonhosted.org/packages/37/2e/60e8adb541df195e83cb74b720b2cfb1f22ed99c5a7f8abf2a9ab3442cb5/time_machine-2.19.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b0f83308b29c7872006803f2e77318874eb84d0654f2afe0e48e3822e7a2e39b", size = 34936, upload-time = "2025-08-19T17:20:36.61Z" },
    { url = "https://files.pythonhosted.org/packages/5e/72/e8cee59c6cd99dd3b25b8001a0253e779a286aa8f44d5b40777cbd66210b/time_machine-2.19.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:39733ef844e2984620ec9382a42d00cccc4757d75a5dd572be8c2572e86e50b9", size = 32932, upload-time = "2025-08-19T17:20:37.901Z" },
    { url = "https://files.pythonhosted.org/packages/2c/eb/83f300d93c1504965d944e03679f1c943a923bce2d0fdfadef0e2e22cc13/time_machine-2.19.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f8db99f6334432e9ffbf00c215caf2ae9773f17cec08304d77e9e90febc3507b", size = 34010, upload-time = "2025-08-19T17:20:39.202Z" },
    { url = "https://files.pythonhosted.org/packages/e1/77/f35f2500e04daac5033a22fbfd17e68467822b8406ee77966bf222ccaa26/time_machine-2.19.0-cp310-cp310-win32.whl", hash = "sha256:72bf66cd19e27ffd26516b9cbe676d50c2e0b026153289765dfe0cf406708128", size = 17121, upload-time = "2025-08-19T17:20:40.108Z" },
    { url = "https://files.pythonhosted.org/packages/db/df/32d3e0404be1760a64a44caab2af34b07e952bfe00a23134fea9ddba3e8a/time_machine-2.19.0-cp310-cp310-win_amd64.whl", hash = "sha256:46f1c945934ce3d6b4f388b8e581fce7f87ec891ea90d7128e19520e434f96f0", size = 17957, upload-time = "2025-08-19T17:20:41.079Z" },
    { url = "https://files.pythonhosted.org/packages/66/df/598a71a1afb4b509a4587273b76590b16d9110a3e9106f01eedc68d02bb2/time_machine-2.19.0-cp310-cp310-win_arm64.whl", hash = "sha256:fb4897c7a5120a4fd03f0670f332d83b7e55645886cd8864a71944c4c2e5b35b", size = 16821, upload-time = "2025-08-19T17:20:41.967Z" },
    { url = "https://files.pythonhosted.org/packages/1d/ed/4815ebcc9b6c14273f692b9be38a9b09eae52a7e532407cc61a51912b121/time_machine-2.19.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5ee91664880434d98e41585c3446dac7180ec408c786347451ddfca110d19296", size = 19342, upload-time = "2025-08-19T17:20:43.207Z" },
    { url = "https://files.pythonhosted.org/packages/ee/08/154cce8b11b60d8238b0b751b8901d369999f4e8f7c3a5f917caa5d95b0b/time_machine-2.19.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ed3732b83a893d1c7b8cabde762968b4dc5680ee0d305b3ecca9bb516f4e3862", size = 14978, upload-time = "2025-08-19T17:20:44.134Z" },
    { url = "https://files.pythonhosted.org/packages/c7/b7/b689d8c8eeca7af375cfcd64973e49e83aa817cc00f80f98548d42c0eb50/time_machine-2.19.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6ba0303e9cc9f7f947e344f501e26bedfb68fab521e3c2729d370f4f332d2d55", size = 30964, upload-time = "2025-08-19T17:20:45.366Z" },
    { url = "https://files.pythonhosted.org/packages/80/91/38bf9c79674e95ce32e23c267055f281dff651eec77ed32a677db3dc011a/time_machine-2.19.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2851825b524a988ee459c37c1c26bdfaa7eff78194efb2b562ea497a6f375b0a", size = 32606, upload-time = "2025-08-19T17:20:46.693Z" },
    { url = "https://files.pythonhosted.org/packages/19/4a/e9222d85d4de68975a5e799f539a9d32f3a134a9101fca0a61fa6aa33d68/time_machine-2.19.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68d32b09ecfd7fef59255c091e8e7c24dd117f882c4880b5c7ab8c5c32a98f89", size = 34405, upload-time = "2025-08-19T17:20:48.032Z" },
    { url = "https://files.pythonhosted.org/packages/14/e2/09480d608d42d6876f9ff74593cfc9197a7eb2c31381a74fb2b145575b65/time_machine-2.19.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:60c46ab527bf2fa144b530f639cc9e12803524c9e1f111dc8c8f493bb6586eeb", size = 33181, upload-time = "2025-08-19T17:20:48.937Z" },
    { url = "https://files.pythonhosted.org/packages/84/64/f9359e000fad32d9066305c48abc527241d608bcdf77c19d67d66e268455/time_machine-2.19.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:56f26ab9f0201c453d18fe76bb7d1cf05fe58c1b9d9cb0c7d243d05132e01292", size = 31036, upload-time = "2025-08-19T17:20:50.276Z" },
    { url = "https://files.pythonhosted.org/packages/71/0d/fab2aacec71e3e482bd7fce0589381f9414a4a97f8766bddad04ad047b7b/time_machine-2.19.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6c806cf3c1185baa1d807b7f51bed0db7a6506832c961d5d1b4c94c775749bc0", size = 32145, upload-time = "2025-08-19T17:20:51.449Z" },
    { url = "https://files.pythonhosted.org/packages/44/fb/faeba2405fb27553f7b28db441a500e2064ffdb2dcba001ee315fdd2c121/time_machine-2.19.0-cp311-cp311-win32.whl", hash = "sha256:b30039dfd89855c12138095bee39c540b4633cbc3684580d684ef67a99a91587", size = 17004, upload-time = "2025-08-19T17:20:52.38Z" },
    { url = "https://files.pythonhosted.org/packages/2f/84/87e483d660ca669426192969280366635c845c3154a9fe750be546ed3afc/time_machine-2.19.0-cp311-cp311-win_amd64.whl", hash = "sha256:13ed8b34430f1de79905877f5600adffa626793ab4546a70a99fb72c6a3350d8", size = 17822, upload-time = "2025-08-19T17:20:53.348Z" },
    { url = "https://files.pythonhosted.org/packages/41/f4/ebf7bbf5047854a528adaf54a5e8780bc5f7f0104c298ab44566a3053bf8/time_machine-2.19.0-cp311-cp311-win_arm64.whl", hash = "sha256:cc29a50a0257d8750b08056b66d7225daab47606832dea1a69e8b017323bf511", size = 16680, upload-time = "2025-08-19T17:20:54.26Z" },
    { url = "https://files.pythonhosted.org/packages/9b/aa/7e00614d339e4d687f6e96e312a1566022528427d237ec639df66c4547bc/time_machine-2.19.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:c85cf437dc3c07429456d8d6670ac90ecbd8241dcd0fbf03e8db2800576f91ff", size = 19308, upload-time = "2025-08-19T17:20:55.25Z" },
    { url = "https://files.pythonhosted.org/packages/ab/3c/bde3c757394f5bca2fbc1528d4117960a26c38f9b160bf471b38d2378d8f/time_machine-2.19.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d9238897e8ef54acdf59f5dff16f59ca0720e7c02d820c56b4397c11db5d3eb9", size = 15019, upload-time = "2025-08-19T17:20:56.204Z" },
    { url = "https://files.pythonhosted.org/packages/c8/e0/8ca916dd918018352d377f1f5226ee071cfbeb7dbbde2b03d14a411ac2b1/time_machine-2.19.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e312c7d5d6bfffb96c6a7b39ff29e3046de100d7efaa3c01552654cfbd08f14c", size = 33079, upload-time = "2025-08-19T17:20:57.166Z" },
    { url = "https://files.pythonhosted.org/packages/48/69/184a0209f02dd0cb5e01e8d13cd4c97a5f389c4e3d09b95160dd676ad1e7/time_machine-2.19.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:714c40b2c90d1c57cc403382d5a9cf16e504cb525bfe9650095317da3c3d62b5", size = 34925, upload-time = "2025-08-19T17:20:58.117Z" },
    { url = "https://files.pythonhosted.org/packages/43/42/4bbf4309e8e57cea1086eb99052d97ff6ddecc1ab6a3b07aa4512f8bf963/time_machine-2.19.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2eaa1c675d500dc3ccae19e9fb1feff84458a68c132bbea47a80cc3dd2df7072", size = 36384, upload-time = "2025-08-19T17:20:59.108Z" },
    { url = "https://files.pythonhosted.org/packages/b1/af/9f510dc1719157348c1a2e87423aed406589070b54b503cb237d9bf3a4fe/time_machine-2.19.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e77a414e9597988af53b2b2e67242c9d2f409769df0d264b6d06fda8ca3360d4", size = 34881, upload-time = "2025-08-19T17:21:00.116Z" },
    { url = "https://files.pythonhosted.org/packages/ca/28/61764a635c70cc76c76ba582dfdc1a84834cddaeb96789023af5214426b2/time_machine-2.19.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cd93996970e11c382b04d4937c3cd0b0167adeef14725ece35aae88d8a01733c", size = 32931, upload-time = "2025-08-19T17:21:01.095Z" },
    { url = "https://files.pythonhosted.org/packages/b6/e0/f028d93b266e6ade8aca5851f76ebbc605b2905cdc29981a2943b43e1a6c/time_machine-2.19.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8e20a6d8d6e23174bd7e931e134d9610b136db460b249d07e84ecdad029ec352", size = 34241, upload-time = "2025-08-19T17:21:02.052Z" },
    { url = "https://files.pythonhosted.org/packages/7d/a6/36d1950ed1d3f613158024cf1dcc73db1d9ef0b9117cf51ef2e37dc06499/time_machine-2.19.0-cp312-cp312-win32.whl", hash = "sha256:95afc9bc65228b27be80c2756799c20b8eb97c4ef382a9b762b6d7888bc84099", size = 17021, upload-time = "2025-08-19T17:21:03.374Z" },
    { url = "https://files.pythonhosted.org/packages/b1/0d/e2dce93355abda3cac69e77fe96566757e98b8fe7fdcbddce89c9ced3f5f/time_machine-2.19.0-cp312-cp312-win_amd64.whl", hash = "sha256:e84909af950e2448f4e2562ea5759c946248c99ab380d2b47d79b62bd76fa236", size = 17857, upload-time = "2025-08-19T17:21:04.331Z" },
    { url = "https://files.pythonhosted.org/packages/eb/28/50ae6fb83b7feeeca7a461c0dc156cf7ef5e6ef594a600d06634fde6a2cb/time_machine-2.19.0-cp312-cp312-win_arm64.whl", hash = "sha256:0390a1ea9fa7e9d772a39b7c61b34fdcca80eb9ffac339cc0441c6c714c81470", size = 16677, upload-time = "2025-08-19T17:21:05.39Z" },
    { url = "https://files.pythonhosted.org/packages/a9/b8/24ebce67aa531bae2cbe164bb3f4abc6467dc31f3aead35e77f5a075ea3e/time_machine-2.19.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5e172866753e6041d3b29f3037dc47c20525176a494a71bbd0998dfdc4f11f2f", size = 19373, upload-time = "2025-08-19T17:21:06.701Z" },
    { url = "https://files.pythonhosted.org/packages/53/a5/c9a5240fd2f845d3ff9fa26f8c8eaa29f7239af9d65007e61d212250f15b/time_machine-2.19.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f70f68379bd6f542ae6775cce9a4fa3dcc20bf7959c42eaef871c14469e18097", size = 15056, upload-time = "2025-08-19T17:21:07.667Z" },
    { url = "https://files.pythonhosted.org/packages/b9/92/66cce5d2fb2a5e68459aca85fd18a7e2d216f725988940cd83f96630f2f1/time_machine-2.19.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e69e0b0f694728a00e72891ef8dd00c7542952cb1c87237db594b6b27d504a96", size = 33172, upload-time = "2025-08-19T17:21:08.619Z" },
    { url = "https://files.pythonhosted.org/packages/ae/20/b499e9ab4364cd466016c33dcdf4f56629ca4c20b865bd4196d229f31d92/time_machine-2.19.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3ae0a8b869574301ec5637e32c270c7384cca5cd6e230f07af9d29271a7fa293", size = 35042, upload-time = "2025-08-19T17:21:09.622Z" },
    { url = "https://files.pythonhosted.org/packages/41/32/b252d3d32791eb16c07d553c820dbc33d9c7fa771de3d1c602190bded2b7/time_machine-2.19.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:554e4317de90e2f7605ff80d153c8bb56b38c0d0c0279feb17e799521e987b8c", size = 36535, upload-time = "2025-08-19T17:21:10.571Z" },
    { url = "https://files.pythonhosted.org/packages/98/cf/4d0470062b9742e1b040ab81bad04d1a5d1de09806507bb6188989cfa1a7/time_machine-2.19.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6567a5ec5538ed550539ac29be11b3cb36af1f9894e2a72940cba0292cc7c3c9", size = 34945, upload-time = "2025-08-19T17:21:11.538Z" },
    { url = "https://files.pythonhosted.org/packages/24/71/2f741b29d98b1c18f6777a32236497c3d3264b6077e431cea4695684c8a1/time_machine-2.19.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:82e9ffe8dfff07b0d810a2ad015a82cd78c6a237f6c7cf185fa7f747a3256f8a", size = 33014, upload-time = "2025-08-19T17:21:12.858Z" },
    { url = "https://files.pythonhosted.org/packages/e8/83/ca8dba6106562843fd99f672e5aaf95badbc10f4f13f7cfe8d8640a7019d/time_machine-2.19.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7e1c4e578cdd69b3531d8dd3fbcb92a0cd879dadb912ee37af99c3a9e3c0d285", size = 34350, upload-time = "2025-08-19T17:21:13.923Z" },
    { url = "https://files.pythonhosted.org/packages/21/7f/34fe540450e18d0a993240100e4b86e8d03d831b92af8bb6ddb2662dc6fc/time_machine-2.19.0-cp313-cp313-win32.whl", hash = "sha256:72dbd4cbc3d96dec9dd281ddfbb513982102776b63e4e039f83afb244802a9e5", size = 17047, upload-time = "2025-08-19T17:21:14.874Z" },
    { url = "https://files.pythonhosted.org/packages/bf/5d/c8be73df82c7ebe7cd133279670e89b8b110af3ce1412c551caa9d08e625/time_machine-2.19.0-cp313-cp313-win_amd64.whl", hash = "sha256:e17e3e089ac95f9a145ce07ff615e3c85674f7de36f2d92aaf588493a23ffb4b", size = 17868, upload-time = "2025-08-19T17:21:15.819Z" },
    { url = "https://files.pythonhosted.org/packages/92/13/2dfd3b8fb285308f61cd7aa9bfa96f46ddf916e3549a0f0afd094c556599/time_machine-2.19.0-cp313-cp313-win_arm64.whl", hash = "sha256:149072aff8e3690e14f4916103d898ea0d5d9c95531b6aa0995251c299533f7b", size = 16710, upload-time = "2025-08-19T17:21:16.748Z" },
    { url = "https://files.pythonhosted.org/packages/05/c1/deebb361727d2c5790f9d4d874be1b19afd41f4375581df465e6718b46a2/time_machine-2.19.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f3589fee1ed0ab6ee424a55b0ea1ec694c4ba64cc26895bcd7d99f3d1bc6a28a", size = 20053, upload-time = "2025-08-19T17:21:17.704Z" },
    { url = "https://files.pythonhosted.org/packages/45/e8/fe3376951e6118d8ec1d1f94066a169b791424fe4a26c7dfc069b153ee08/time_machine-2.19.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7887e85275c4975fe54df03dcdd5f38bd36be973adc68a8c77e17441c3b443d6", size = 15423, upload-time = "2025-08-19T17:21:18.668Z" },
    { url = "https://files.pythonhosted.org/packages/9c/c7/f88d95cd1a87c650cf3749b4d64afdaf580297aa18ad7f4b44ec9d252dfc/time_machine-2.19.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ce0be294c209928563fcce1c587963e60ec803436cf1e181acd5bc1e425d554b", size = 39630, upload-time = "2025-08-19T17:21:19.645Z" },
    { url = "https://files.pythonhosted.org/packages/cc/5d/65a5c48a65357e56ec6f032972e4abd1c02d4fca4b0717a3aaefd19014d4/time_machine-2.19.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a62fd1ab380012c86f4c042010418ed45eb31604f4bf4453e17c9fa60bc56a29", size = 41242, upload-time = "2025-08-19T17:21:20.979Z" },
    { url = "https://files.pythonhosted.org/packages/f6/f9/fe5209e1615fde0a8cad6c4e857157b150333ed1fe31a7632b08cfe0ebdd/time_machine-2.19.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b25ec853a4530a5800731257f93206b12cbdee85ede964ebf8011b66086a7914", size = 44278, upload-time = "2025-08-19T17:21:21.984Z" },
    { url = "https://files.pythonhosted.org/packages/4a/3a/a5e5fe9c5d614cde0a9387ff35e8dfd12c5ef6384e4c1a21b04e6e0b905d/time_machine-2.19.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a430e4d0e0556f021a9c78e9b9f68e5e8910bdace4aa34ed4d1a73e239ed9384", size = 42321, upload-time = "2025-08-19T17:21:23.755Z" },
    { url = "https://files.pythonhosted.org/packages/a1/c5/56eca774e9162bc1ce59111d2bd69140dc8908c9478c92ec7bd15d547600/time_machine-2.19.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2415b7495ec4364c8067071e964fbadfe746dd4cdb43983f2f0bd6ebed13315c", size = 39270, upload-time = "2025-08-19T17:21:26.009Z" },
    { url = "https://files.pythonhosted.org/packages/9b/69/5dd0c420667578169a12acc8c8fd7452e8cfb181e41c9b4ac7e88fa36686/time_machine-2.19.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dbfc6b90c10f288594e1bf89a728a98cc0030791fd73541bbdc6b090aff83143", size = 40193, upload-time = "2025-08-19T17:21:27.054Z" },
    { url = "https://files.pythonhosted.org/packages/75/a7/de974d421bd55c9355583427c2a38fb0237bb5fd6614af492ba89dacb2f9/time_machine-2.19.0-cp313-cp313t-win32.whl", hash = "sha256:16f5d81f650c0a4d117ab08036dc30b5f8b262e11a4a0becc458e7f1c011b228", size = 17542, upload-time = "2025-08-19T17:21:28.674Z" },
    { url = "https://files.pythonhosted.org/packages/76/0a/aa0d05becd5d06ae8d3f16d657dc8cc9400c8d79aef80299de196467ff12/time_machine-2.19.0-cp313-cp313t-win_amd64.whl", hash = "sha256:645699616ec14e147094f601e6ab9553ff6cea37fad9c42720a6d7ed04bcd5dc", size = 18703, upload-time = "2025-08-19T17:21:29.663Z" },
    { url = "https://files.pythonhosted.org/packages/1f/c0/f785a4c7c73aa176510f7c48b84b49c26be84af0d534deb222e0327f750e/time_machine-2.19.0-cp313-cp313t-win_arm64.whl", hash = "sha256:b32daa965d13237536ea3afaa5ad61ade2b2d9314bc3a20196a0d2e1d7b57c6a", size = 17020, upload-time = "2025-08-19T17:21:30.653Z" },
    { url = "https://files.pythonhosted.org/packages/ed/97/c5fb51def06c0b2b6735332ad118ab35b4d9b85368792e5b638e99b1b686/time_machine-2.19.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:31cb43c8fd2d961f31bed0ff4e0026964d2b35e5de9e0fabbfecf756906d3612", size = 19360, upload-time = "2025-08-19T17:21:31.94Z" },
    { url = "https://files.pythonhosted.org/packages/2d/4e/2d795f7d6b7f5205ffe737a05bb1cf19d8038233b797062b2ef412b8512b/time_machine-2.19.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:bdf481a75afc6bff3e520db594501975b652f7def21cd1de6aa971d35ba644e6", size = 15033, upload-time = "2025-08-19T17:21:32.934Z" },
    { url = "https://files.pythonhosted.org/packages/dd/32/9bad501e360b4e758c58fae616ca5f8c7ad974b343f2463a15b2bf77a366/time_machine-2.19.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:00bee4bb950ac6a08d62af78e4da0cf2b4fc2abf0de2320d0431bf610db06e7c", size = 33379, upload-time = "2025-08-19T17:21:33.925Z" },
    { url = "https://files.pythonhosted.org/packages/a3/45/eda0ca4d793dfd162478d6163759b1c6ce7f6e61daa7fd7d62b31f21f87f/time_machine-2.19.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9f02199490906582302ce09edd32394fb393271674c75d7aa76c7a3245f16003", size = 35123, upload-time = "2025-08-19T17:21:34.945Z" },
    { url = "https://files.pythonhosted.org/packages/f0/5a/97e16325442ae5731fcaac794f0a1ef9980eff8a5491e58201d7eb814a34/time_machine-2.19.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e35726c7ba625f844c13b1fc0d4f81f394eefaee1d3a094a9093251521f2ef15", size = 36588, upload-time = "2025-08-19T17:21:35.975Z" },
    { url = "https://files.pythonhosted.org/packages/e8/9d/bf0b2ccc930cc4a316f26f1c78d3f313cd0fa13bb7480369b730a8f129db/time_machine-2.19.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:304315023999cd401ff02698870932b893369e1cfeb2248d09f6490507a92e97", size = 35013, upload-time = "2025-08-19T17:21:37.017Z" },
    { url = "https://files.pythonhosted.org/packages/f0/5a/39ac6a3078174f9715d88364871348b249631f12e76de1b862433b3f8862/time_machine-2.19.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9765d4f003f263ea8bfd90d2d15447ca4b3dfa181922cf6cf808923b02ac180a", size = 33303, upload-time = "2025-08-19T17:21:38.352Z" },
    { url = "https://files.pythonhosted.org/packages/b3/ac/d8646baf9f95f2e792a6d7a7b35e92fca253c4a992afff801beafae0e5c2/time_machine-2.19.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7837ef3fd5911eb9b480909bb93d922737b6bdecea99dfcedb0a03807de9b2d3", size = 34440, upload-time = "2025-08-19T17:21:39.382Z" },
    { url = "https://files.pythonhosted.org/packages/ce/8b/8b6568c5ae966d80ead03ab537be3c6acf2af06fb501c2d466a3162c6295/time_machine-2.19.0-cp314-cp314-win32.whl", hash = "sha256:4bb5bd43b1bdfac3007b920b51d8e761f024ed465cfeec63ac4296922a4ec428", size = 17162, upload-time = "2025-08-19T17:21:40.381Z" },
    { url = "https://files.pythonhosted.org/packages/46/a5/211c1ab4566eba5308b2dc001b6349e3a032e3f6afa67ca2f27ea6b27af5/time_machine-2.19.0-cp314-cp314-win_amd64.whl", hash = "sha256:f583bbd0aa8ab4a7c45a684bf636d9e042d466e30bcbae1d13e7541e2cbe7207", size = 18040, upload-time = "2025-08-19T17:21:41.363Z" },
    { url = "https://files.pythonhosted.org/packages/b8/fc/4c2fb705f6371cb83824da45a8b967514a922fc092a0ef53979334d97a70/time_machine-2.19.0-cp314-cp314-win_arm64.whl", hash = "sha256:f379c6f8a6575a8284592179cf528ce89373f060301323edcc44f1fa1d37be12", size = 16752, upload-time = "2025-08-19T17:21:42.336Z" },
    { url = "https://files.pythonhosted.org/packages/79/ab/6437d18f31c666b5116c97572a282ac2590a82a0a9867746a6647eaf4613/time_machine-2.19.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:a3b8981f9c663b0906b05ab4d0ca211fae4b63b47c6ec26de5374fe56c836162", size = 20057, upload-time = "2025-08-19T17:21:43.35Z" },
    { url = "https://files.pythonhosted.org/packages/6c/a2/e03639ec2ba7200328bbcad8a2b2b1d5fccca9cceb9481b164a1cabdcb33/time_machine-2.19.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8e9c6363893e7f52c226afbebb23e825259222d100e67dfd24c8a6d35f1a1907", size = 15430, upload-time = "2025-08-19T17:21:44.725Z" },
    { url = "https://files.pythonhosted.org/packages/5d/ff/39e63a48e840f3e36ce24846ee51dd99c6dba635659b1750a2993771e88e/time_machine-2.19.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:206fcd6c9a6f00cac83db446ad1effc530a8cec244d2780af62db3a2d0a9871b", size = 39622, upload-time = "2025-08-19T17:21:45.821Z" },
    { url = "https://files.pythonhosted.org/packages/9a/2e/ee5ac79c4954768705801e54817c7d58e07e25a0bb227e775f501f3e2122/time_machine-2.19.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bf33016a1403c123373ffaeff25e26e69d63bf2c63b6163932efed94160db7ef", size = 41235, upload-time = "2025-08-19T17:21:46.783Z" },
    { url = "https://files.pythonhosted.org/packages/3a/3e/9af5f39525e779185c77285b8bbae15340eeeaa0afb33d458bc8b47d459b/time_machine-2.19.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9247c4bb9bbd3ff584ef4efbdec8efd9f37aa08bcfc4728bde1e489c2cb445bd", size = 44276, upload-time = "2025-08-19T17:21:47.759Z" },
    { url = "https://files.pythonhosted.org/packages/59/fe/572c7443cc27140bbeae3947279bbd4a120f9e8622253a20637f260b7813/time_machine-2.19.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:77f9bb0b86758d1f2d9352642c874946ad5815df53ef4ca22eb9d532179fe50d", size = 42330, upload-time = "2025-08-19T17:21:48.881Z" },
    { url = "https://files.pythonhosted.org/packages/cf/24/1a81c2e08ee7dae13ec8ceed27a29afa980c3d63852e42f1e023bf0faa03/time_machine-2.19.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0b529e262df3b9c449f427385f4d98250828c879168c2e00eec844439f40b370", size = 39281, upload-time = "2025-08-19T17:21:49.907Z" },
    { url = "https://files.pythonhosted.org/packages/d2/60/6f0d6e5108978ca1a2a4ffb4d1c7e176d9199bb109fd44efe2680c60b52a/time_machine-2.19.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9199246e31cdc810e5d89cb71d09144c4d745960fdb0824da4994d152aca3303", size = 40201, upload-time = "2025-08-19T17:21:50.953Z" },
    { url = "https://files.pythonhosted.org/packages/73/b9/3ea4951e8293b0643feb98c0b9a176fa822154f1810835db3f282968ab10/time_machine-2.19.0-cp314-cp314t-win32.whl", hash = "sha256:0fe81bae55b7aefc2c2a34eb552aa82e6c61a86b3353a3c70df79b9698cb02ca", size = 17743, upload-time = "2025-08-19T17:21:51.948Z" },
    { url = "https://files.pythonhosted.org/packages/e4/8b/cd802884ca8a98e2b6cdc2397d57dd12ff8a7d1481e06fc3fad3d4e7e5ff/time_machine-2.19.0-cp314-cp314t-win_amd64.whl", hash = "sha256:7253791b8d7e7399fbeed7a8193cb01bc004242864306288797056badbdaf80b", size = 18956, upload-time = "2025-08-19T17:21:52.997Z" },
    { url = "https://files.pythonhosted.org/packages/c6/49/cabb1593896082fd55e34768029b8b0ca23c9be8b2dc127e0fc14796d33e/time_machine-2.19.0-cp314-cp314t-win_arm64.whl", hash = "sha256:536bd1ac31ab06a1522e7bf287602188f502dc19d122b1502c4f60b1e8efac79", size = 17068, upload-time = "2025-08-19T17:21:54.064Z" },
    { url = "https://files.pythonhosted.org/packages/d6/05/0608376c3167afe6cf7cdfd2b05c142ea4c42616eee9ba06d1799965806a/time_machine-2.19.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d8bb00b30ec9fe56d01e9812df1ffe39f331437cef9bfaebcc81c83f7f8f8ee2", size = 19659, upload-time = "2025-08-19T17:21:55.426Z" },
    { url = "https://files.pythonhosted.org/packages/11/c4/72eb8c7b36830cf36c51d7bc2f1ac313d68881c3a58040fb6b42c4523d20/time_machine-2.19.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d821c60efc08a97cc11e5482798e6fd5eba5c0f22a02db246b50895dbdc0de41", size = 15153, upload-time = "2025-08-19T17:21:56.505Z" },
    { url = "https://files.pythonhosted.org/packages/89/1a/0782e1f5c8ab8809ebd992709e1bb69d67600191baa023af7a5d32023a3c/time_machine-2.19.0-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb051aec7b3b6e96a200d911c225901e6133ff3da11e470e24111a53bbc13637", size = 32555, upload-time = "2025-08-19T17:21:57.74Z" },
    { url = "https://files.pythonhosted.org/packages/94/b0/8ef58e2f6321851d5900ca3d18044938832c2ed42a2ac7570ca6aa29768a/time_machine-2.19.0-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fe59909d95a2ef5e01ce3354fdea3908404c2932c2069f00f66dff6f27e9363e", size = 34185, upload-time = "2025-08-19T17:21:59.361Z" },
    { url = "https://files.pythonhosted.org/packages/82/74/ce0c9867f788c1fb22c417ec1aae47a24117e53d51f6ff97d7c6ca5392f6/time_machine-2.19.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29e84b8682645b16eb6f9e8ec11c35324ad091841a11cf4fc3fc7f6119094c89", size = 35917, upload-time = "2025-08-19T17:22:00.421Z" },
    { url = "https://files.pythonhosted.org/packages/d2/70/6f97a8f552dbaa66feb10170b5726dab74bc531673d1ed9d6f271547e54c/time_machine-2.19.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4a11f1c0e0d06023dc01614c964e256138913551d3ae6dca5148f79081156336", size = 34584, upload-time = "2025-08-19T17:22:01.447Z" },
    { url = "https://files.pythonhosted.org/packages/48/c8/cf139088ce537c15d7f03cf56ec317d3a5cfb520e30aa711ea0248d0ae8a/time_machine-2.19.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:57a235a6307c54df50e69f1906e2f199e47da91bde4b886ee05aff57fe4b6bf6", size = 32608, upload-time = "2025-08-19T17:22:02.548Z" },
    { url = "https://files.pythonhosted.org/packages/b1/17/0ec41ef7a30c6753fb226a28b74162b264b35724905ced4098f2f5076ded/time_machine-2.19.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:426aba552f7af9604adad9ef570c859af7c1081d878db78089fac159cd911b0a", size = 33686, upload-time = "2025-08-19T17:22:03.606Z" },
    { url = "https://files.pythonhosted.org/packages/b0/19/586f15159083ec84f178d494c60758c46603b00c9641b04deb63f1950128/time_machine-2.19.0-cp39-cp39-win32.whl", hash = "sha256:67772c7197a3a712d1b970ed545c6e98db73524bd90e245fd3c8fa7ad7630768", size = 17133, upload-time = "2025-08-19T17:22:04.989Z" },
    { url = "https://files.pythonhosted.org/packages/6a/c2/bfe4b906a9fe0bf2d011534314212ed752d6b8f392c9c82f6ac63dccc5ab/time_machine-2.19.0-cp39-cp39-win_amd64.whl", hash = "sha256:011d7859089263204dc5fdf83dce7388f986fe833c9381d6106b4edfda2ebd3e", size = 17972, upload-time = "2025-08-19T17:22:06.026Z" },
    { url = "https://files.pythonhosted.org/packages/5d/73/182343eba05aa5787732aaa68f3b3feb5e40ddf86b928ae941be45646393/time_machine-2.19.0-cp39-cp39-win_arm64.whl", hash = "sha256:e1af66550fa4685434f00002808a525f176f1f92746646c0019bb86fbff48b27", size = 16820, upload-time = "2025-08-19T17:22:07.227Z" },
]

[[package]]
name = "time-machine"
version = "3.2.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
sdist = { url = "https://files.pythonhosted.org/packages/02/fc/37b02f6094dbb1f851145330460532176ed2f1dc70511a35828166c41e52/time_machine-3.2.0.tar.gz", hash = "sha256:a4ddd1cea17b8950e462d1805a42b20c81eb9aafc8f66b392dd5ce997e037d79", size = 14804, upload-time = "2025-12-17T23:33:02.599Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/9c/31/6bf41cb4a326230518d9b76c910dfc11d4fc23444d1cbfdf2d7652bd99f4/time_machine-3.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:68142c070e78b62215d8029ec7394905083a4f9aacb0a2a11514ce70b5951b13", size = 19447, upload-time = "2025-12-17T23:31:30.181Z" },
    { url = "https://files.pythonhosted.org/packages/fa/14/d71ce771712e1cbfa15d8c24452225109262b16cb6caaf967e9f60662b67/time_machine-3.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:161bbd0648802ffdfcb4bb297ecb26b3009684a47d3a4dedb90bc549df4fa2ad", size = 15432, upload-time = "2025-12-17T23:31:31.381Z" },
    { url = "https://files.pythonhosted.org/packages/8b/d6/dcb43a11f8029561996fad58ff9d3dc5e6d7f32b74f0745a2965d7e4b4f3/time_machine-3.2.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1359ba8c258be695ba69253bc84db882fd616fe69b426cc6056536da2c7bf68e", size = 32956, upload-time = "2025-12-17T23:31:32.469Z" },
    { url = "https://files.pythonhosted.org/packages/77/da/d802cd3c335c414f9b11b479f7459aa72df5de6485c799966cfdf8856d53/time_machine-3.2.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c85b169998ca2c24a78fb214586ec11c4cad56d9c38f55ad8326235cb481c884", size = 34556, upload-time = "2025-12-17T23:31:33.946Z" },
    { url = "https://files.pythonhosted.org/packages/85/ee/51ad553514ab0b940c7c82c6e1519dd10fd06ac07b32039a1d153ef09c88/time_machine-3.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65b9367cb8a10505bc8f67da0da514ba20fa816fc47e11f434f7c60350322b4c", size = 36101, upload-time = "2025-12-17T23:31:35.462Z" },
    { url = "https://files.pythonhosted.org/packages/11/39/938b111b5bb85a2b07502d0f9d8a704fc75bd760d62e76bce23c89ed16c9/time_machine-3.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9faca6a0f1973d7df3233c951fc2a11ff0c54df74087d8aaf41ae3deb19d0893", size = 34905, upload-time = "2025-12-17T23:31:36.543Z" },
    { url = "https://files.pythonhosted.org/packages/dd/50/0951f73b23e76455de0b4a3a58ac5a24bd8d10489624b1c5e03f10c6fc0b/time_machine-3.2.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:213b1ada7f385d467e598999b642eda4a8e89ae10ad5dc4f5d8f672cbf604261", size = 33012, upload-time = "2025-12-17T23:31:37.967Z" },
    { url = "https://files.pythonhosted.org/packages/4f/95/5304912d3dcecc4e14ed222dbe0396352efdf8497534abc3c9edd67a7528/time_machine-3.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:160b6afd94c39855af04d39c58e4cf602406abd6d79427ab80e830ea71789cfb", size = 34104, upload-time = "2025-12-17T23:31:39.449Z" },
    { url = "https://files.pythonhosted.org/packages/d4/1c/af56518652ec7adac4ced193b7a42c4ff354fef28a412b3b5ffa5763aead/time_machine-3.2.0-cp310-cp310-win32.whl", hash = "sha256:c15d9ac257c78c124d112e4fc91fa9f3dcb004bdda913c19f0e7368d713cf080", size = 17468, upload-time = "2025-12-17T23:31:40.432Z" },
    { url = "https://files.pythonhosted.org/packages/48/15/0213f00ca3cf6fe1c9fdbd7fd467e801052fc85534f30c0e4684bd474190/time_machine-3.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:3bf0f428487f93b8fe9d27aa01eccc817885da3290b467341b4a4a795e1d1891", size = 18313, upload-time = "2025-12-17T23:31:41.617Z" },
    { url = "https://files.pythonhosted.org/packages/77/e4/811f96aa7a634b2b264d9a476f3400e710744dda503b4ad87a5c76db32c9/time_machine-3.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:347f6be2129fcd35b1c94b9387fcb2cbe7949b1e649228c5f22949a811b78976", size = 17037, upload-time = "2025-12-17T23:31:42.924Z" },
    { url = "https://files.pythonhosted.org/packages/f5/e1/03aae5fbaa53859f665094af696338fc7cae733d926a024af69982712350/time_machine-3.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c188a9dda9fcf975022f1b325b466651b96a4dfc223c523ed7ed8d979f9bf3e8", size = 19143, upload-time = "2025-12-17T23:31:44.258Z" },
    { url = "https://files.pythonhosted.org/packages/75/8f/98cb17bebb52b22ff4ec26984dd44280f9c71353c3bae0640a470e6683e5/time_machine-3.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17245f1cc2dd13f9d63a174be59bb2684a9e5e0a112ab707e37be92068cd655f", size = 15273, upload-time = "2025-12-17T23:31:45.246Z" },
    { url = "https://files.pythonhosted.org/packages/dd/2f/ca11e4a7897234bb9331fcc5f4ed4714481ba4012370cc79a0ae8c42ea0a/time_machine-3.2.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d9bd1de1996e76efd36ae15970206c5089fb3728356794455bd5cd8d392b5537", size = 31049, upload-time = "2025-12-17T23:31:46.613Z" },
    { url = "https://files.pythonhosted.org/packages/cf/ad/d17d83a59943094e6b6c6a3743caaf6811b12203c3e07a30cc7bcc2ab7ee/time_machine-3.2.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:98493cd50e8b7f941eab69b9e18e697ad69db1a0ec1959f78f3d7b0387107e5c", size = 32632, upload-time = "2025-12-17T23:31:47.72Z" },
    { url = "https://files.pythonhosted.org/packages/71/50/d60576d047a0dfb5638cdfb335e9c3deb6e8528544fa0b3966a8480f72b7/time_machine-3.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:31f2a33d595d9f91eb9bc7f157f0dc5721f5789f4c4a9e8b852cdedb2a7d9b16", size = 34289, upload-time = "2025-12-17T23:31:48.913Z" },
    { url = "https://files.pythonhosted.org/packages/fa/fe/4afa602dbdebddde6d0ea4a7fe849e49b9bb85dc3fb415725a87ccb4b471/time_machine-3.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9f78ac4213c10fbc44283edd1a29cfb7d3382484f4361783ddc057292aaa1889", size = 33175, upload-time = "2025-12-17T23:31:50.611Z" },
    { url = "https://files.pythonhosted.org/packages/0d/87/c152e23977c1d7d7c94eb3ed3ea45cc55971796205125c6fdff40db2c60f/time_machine-3.2.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c1326b09e947b360926d529a96d1d9e126ce120359b63b506ecdc6ee20755c23", size = 31170, upload-time = "2025-12-17T23:31:51.645Z" },
    { url = "https://files.pythonhosted.org/packages/80/af/54acf51d0f3ade3b51eab73df6192937c9a938753ef5456dff65eb8630be/time_machine-3.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9f2949f03d15264cc15c38918a2cda8966001f0f4ebe190cbfd9c56d91aed8ac", size = 32292, upload-time = "2025-12-17T23:31:52.803Z" },
    { url = "https://files.pythonhosted.org/packages/cc/bc/3745963f36e75661a807196428639327a366f4332f35f1f775c074d4062f/time_machine-3.2.0-cp311-cp311-win32.whl", hash = "sha256:6dfe48e0499e6e16751476b9799e67be7514e6ef04cdf39571ef95a279645831", size = 17349, upload-time = "2025-12-17T23:31:54.19Z" },
    { url = "https://files.pythonhosted.org/packages/82/a2/057469232a99d1f5a0160ae7c5bae7b095c9168b333dd598fcbcfbc1c87b/time_machine-3.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:809bdf267a29189c304154873620fe0bcc0c9513295fa46b19e21658231c4915", size = 18191, upload-time = "2025-12-17T23:31:55.472Z" },
    { url = "https://files.pythonhosted.org/packages/79/d8/bf9c8de57262ee7130d92a6ed49ed6a6e40a36317e46979428d373630c12/time_machine-3.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:a3f4c17fa90f54902a3f8692c75caf67be87edc3429eeb71cb4595da58198f8e", size = 16905, upload-time = "2025-12-17T23:31:56.658Z" },
    { url = "https://files.pythonhosted.org/packages/71/8b/080c8eedcd67921a52ba5bd0e075362062509ab63c86fc1a0442fad241a6/time_machine-3.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:cc4bee5b0214d7dc4ebc91f4a4c600f1a598e9b5606ac751f42cb6f6740b1dbb", size = 19255, upload-time = "2025-12-17T23:31:58.057Z" },
    { url = "https://files.pythonhosted.org/packages/66/17/0e5291e9eb705bf8a5a1305f826e979af307bbeb79def4ddbf4b3f9a81e0/time_machine-3.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3ca036304b4460ae2fdc1b52dd8b1fa7cf1464daa427fc49567413c09aa839c1", size = 15360, upload-time = "2025-12-17T23:31:59.048Z" },
    { url = "https://files.pythonhosted.org/packages/8b/e8/9ab87b71d2e2b62463b9b058b7ae7ac09fb57f8fcd88729dec169d304340/time_machine-3.2.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5442735b41d7a2abc2f04579b4ca6047ed4698a8338a4fec92c7c9423e7938cb", size = 33029, upload-time = "2025-12-17T23:32:00.413Z" },
    { url = "https://files.pythonhosted.org/packages/4b/26/b5ca19da6f25ea905b3e10a0ea95d697c1aeba0404803a43c68f1af253e6/time_machine-3.2.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:97da3e971e505cb637079fb07ab0bcd36e33279f8ecac888ff131f45ef1e4d8d", size = 34579, upload-time = "2025-12-17T23:32:01.431Z" },
    { url = "https://files.pythonhosted.org/packages/79/ca/6ac7ad5f10ea18cc1d9de49716ba38c32132c7b64532430d92ef240c116b/time_machine-3.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3cdda6dee4966e38aeb487309bb414c6cb23a81fc500291c77a8fcd3098832e7", size = 35961, upload-time = "2025-12-17T23:32:02.521Z" },
    { url = "https://files.pythonhosted.org/packages/33/67/390dd958bed395ab32d79a9fe61fe111825c0dd4ded54dbba7e867f171e6/time_machine-3.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:33d9efd302a6998bcc8baa4d84f259f8a4081105bd3d7f7af7f1d0abd3b1c8aa", size = 34668, upload-time = "2025-12-17T23:32:03.585Z" },
    { url = "https://files.pythonhosted.org/packages/da/57/c88fff034a4e9538b3ae7c68c9cfb283670b14d17522c5a8bc17d29f9a4b/time_machine-3.2.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3a0b0a33971f14145853c9bd95a6ab0353cf7e0019fa2a7aa1ae9fddfe8eab50", size = 32891, upload-time = "2025-12-17T23:32:04.656Z" },
    { url = "https://files.pythonhosted.org/packages/2d/70/ebbb76022dba0fec8f9156540fc647e4beae1680c787c01b1b6200e56d70/time_machine-3.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2d0be9e5f22c38082d247a2cdcd8a936504e9db60b7b3606855fb39f299e9548", size = 34080, upload-time = "2025-12-17T23:32:06.146Z" },
    { url = "https://files.pythonhosted.org/packages/db/9a/2ca9e7af3df540dc1c79e3de588adeddb7dcc2107829248e6969c4f14167/time_machine-3.2.0-cp312-cp312-win32.whl", hash = "sha256:3f74623648b936fdce5f911caf386c0a0b579456410975de8c0dfeaaffece1d8", size = 17371, upload-time = "2025-12-17T23:32:07.164Z" },
    { url = "https://files.pythonhosted.org/packages/d8/ce/21d23efc9c2151939af1b7ee4e60d86d661b74ef32b8eaa148f6fe8c899c/time_machine-3.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:34e26a41d994b5e4b205136a90e9578470386749cc9a2ecf51ca18f83ce25e23", size = 18132, upload-time = "2025-12-17T23:32:08.447Z" },
    { url = "https://files.pythonhosted.org/packages/2f/34/c2b70be483accf6db9e5d6c3139bce3c38fe51f898ccf64e8d3fe14fbf4d/time_machine-3.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:0615d3d82c418d6293f271c348945c5091a71f37e37173653d5c26d0e74b13a8", size = 16930, upload-time = "2025-12-17T23:32:09.477Z" },
    { url = "https://files.pythonhosted.org/packages/ee/cd/43ad5efc88298af3c59b66769cea7f055567a85071579ed40536188530c1/time_machine-3.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c421a8eb85a4418a7675a41bf8660224318c46cc62e4751c8f1ceca752059090", size = 19318, upload-time = "2025-12-17T23:32:10.518Z" },
    { url = "https://files.pythonhosted.org/packages/b0/f6/084010ef7f4a3f38b5a4900923d7c85b29e797655c4f6ee4ce54d903cca8/time_machine-3.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f4e758f7727d0058c4950c66b58200c187072122d6f7a98b610530a4233ea7b", size = 15390, upload-time = "2025-12-17T23:32:11.625Z" },
    { url = "https://files.pythonhosted.org/packages/25/aa/1cabb74134f492270dc6860cb7865859bf40ecf828be65972827646e91ad/time_machine-3.2.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:154bd3f75c81f70218b2585cc12b60762fb2665c507eec5ec5037d8756d9b4e0", size = 33115, upload-time = "2025-12-17T23:32:13.219Z" },
    { url = "https://files.pythonhosted.org/packages/5e/03/78c5d7dfa366924eb4dbfcc3fc917c39a4280ca234b12819cc1f16c03d88/time_machine-3.2.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d50cfe5ebea422c896ad8d278af9648412b7533b8ea6adeeee698a3fd9b1d3b7", size = 34705, upload-time = "2025-12-17T23:32:14.29Z" },
    { url = "https://files.pythonhosted.org/packages/86/93/d5e877c24541f674c6869ff6e9c56833369796010190252e92c9d7ae5f0f/time_machine-3.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:636576501724bd6a9124e69d86e5aef263479e89ef739c5db361469f0463a0a1", size = 36104, upload-time = "2025-12-17T23:32:15.354Z" },
    { url = "https://files.pythonhosted.org/packages/22/1c/d4bae72f388f67efc9609f89b012e434bb19d9549c7a7b47d6c7d9e5c55d/time_machine-3.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:40e6f40c57197fcf7ec32d2c563f4df0a82c42cdcc3cab27f688e98f6060df10", size = 34765, upload-time = "2025-12-17T23:32:16.434Z" },
    { url = "https://files.pythonhosted.org/packages/1d/c3/ac378cf301d527d8dfad2f0db6bad0dfb1ab73212eaa56d6b96ee5d9d20b/time_machine-3.2.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a1bcf0b846bbfc19a79bc19e3fa04d8c7b1e8101c1b70340ffdb689cd801ea53", size = 33010, upload-time = "2025-12-17T23:32:17.532Z" },
    { url = "https://files.pythonhosted.org/packages/06/35/7ce897319accda7a6970b288a9a8c52d25227342a7508505a2b3d235b649/time_machine-3.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ae55a56c179f4fe7a62575ad5148b6ed82f6c7e5cf2f9a9ec65f2f5b067db5f5", size = 34185, upload-time = "2025-12-17T23:32:18.566Z" },
    { url = "https://files.pythonhosted.org/packages/bf/28/f922022269749cb02eee2b62919671153c4088994fa955a6b0e50327ff81/time_machine-3.2.0-cp313-cp313-win32.whl", hash = "sha256:a66fe55a107e46916007a391d4030479df8864ec6ad6f6a6528221befc5c886e", size = 17397, upload-time = "2025-12-17T23:32:19.605Z" },
    { url = "https://files.pythonhosted.org/packages/ee/dc/fd87cde397f4a7bea493152f0aca8fd569ec709cad9e0f2ca7011eb8c7f7/time_machine-3.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:30c9ce57165df913e4f74e285a8ab829ff9b7aa3e5ec0973f88f642b9a7b3d15", size = 18139, upload-time = "2025-12-17T23:32:20.991Z" },
    { url = "https://files.pythonhosted.org/packages/75/81/b8ce58233addc5d7d54d2fabc49dcbc02d79e3f079d150aa1bec3d5275ef/time_machine-3.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:89cad7e179e9bdcc84dcf09efe52af232c4cc7a01b3de868356bbd59d95bd9b8", size = 16964, upload-time = "2025-12-17T23:32:22.075Z" },
    { url = "https://files.pythonhosted.org/packages/67/e7/487f0ba5fe6c58186a5e1af2a118dfa2c160fedb37ef53a7e972d410408e/time_machine-3.2.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:59d71545e62525a4b85b6de9ab5c02ee3c61110fd7f636139914a2335dcbfc9c", size = 20000, upload-time = "2025-12-17T23:32:23.058Z" },
    { url = "https://files.pythonhosted.org/packages/e1/17/eb2c0054c8d44dd42df84ccd434539249a9c7d0b8eb53f799be2102500ab/time_machine-3.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:999672c621c35362bc28e03ca0c7df21500195540773c25993421fd8d6cc5003", size = 15657, upload-time = "2025-12-17T23:32:24.125Z" },
    { url = "https://files.pythonhosted.org/packages/43/21/93443b5d1dd850f8bb9442e90d817a9033dcce6bfbdd3aabbb9786251c80/time_machine-3.2.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5faf7397f0580c7b9d67288522c8d7863e85f0cffadc0f1fccdb2c3dfce5783e", size = 39216, upload-time = "2025-12-17T23:32:25.542Z" },
    { url = "https://files.pythonhosted.org/packages/9f/9e/18544cf8acc72bb1dc03762231c82ecc259733f4bb6770a7bbe5cd138603/time_machine-3.2.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3dd886ec49f1fa5a00e844f5947e5c0f98ce574750c24b7424c6f77fc1c3e87", size = 40764, upload-time = "2025-12-17T23:32:26.643Z" },
    { url = "https://files.pythonhosted.org/packages/27/f7/9fe9ce2795636a3a7467307af6bdf38bb613ddb701a8a5cd50ec713beb5e/time_machine-3.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da0ecd96bc7bbe450acaaabe569d84e81688f1be8ad58d1470e42371d145fb53", size = 43526, upload-time = "2025-12-17T23:32:27.693Z" },
    { url = "https://files.pythonhosted.org/packages/03/c1/a93e975ba9dec22e87ec92d18c28e67d36bd536f9119ffa439b2892b0c9c/time_machine-3.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:158220e946c1c4fb8265773a0282c88c35a7e3bb5d78e3561214e3b3231166f3", size = 41727, upload-time = "2025-12-17T23:32:28.985Z" },
    { url = "https://files.pythonhosted.org/packages/5f/fb/e3633e5a6bbed1c76bb2e9810dabc2f8467532ffcd29b9aed404b473061a/time_machine-3.2.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8c1aee29bc54356f248d5d7dfdd131e12ca825e850a08c0ebdb022266d073013", size = 38952, upload-time = "2025-12-17T23:32:30.031Z" },
    { url = "https://files.pythonhosted.org/packages/82/3d/02e9fb2526b3d6b1b45bc8e4d912d95d1cd699d1a3f6df985817d37a0600/time_machine-3.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c8ed2224f09d25b1c2fc98683613aca12f90f682a427eabb68fc824d27014e4a", size = 39829, upload-time = "2025-12-17T23:32:31.075Z" },
    { url = "https://files.pythonhosted.org/packages/85/c8/c14265212436da8e0814c45463987b3f57de3eca4de023cc2eabb0c62ef3/time_machine-3.2.0-cp313-cp313t-win32.whl", hash = "sha256:3498719f8dab51da76d29a20c1b5e52ee7db083dddf3056af7fa69c1b94e1fe6", size = 17852, upload-time = "2025-12-17T23:32:32.079Z" },
    { url = "https://files.pythonhosted.org/packages/1d/bc/8acb13cf6149f47508097b158a9a8bec9ec4530a70cb406124e8023581f5/time_machine-3.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:e0d90bee170b219e1d15e6a58164aa808f5170090e4f090bd0670303e34181b1", size = 18918, upload-time = "2025-12-17T23:32:33.106Z" },
    { url = "https://files.pythonhosted.org/packages/24/87/c443ee508c2708fd2514ccce9052f5e48888783ce690506919629ebc8eb0/time_machine-3.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:051de220fdb6e20d648111bbad423d9506fdbb2e44d4429cef3dc0382abf1fc2", size = 17261, upload-time = "2025-12-17T23:32:34.446Z" },
    { url = "https://files.pythonhosted.org/packages/61/70/b4b980d126ed155c78d1879c50d60c8dcbd47bd11cb14ee7be50e0dfc07f/time_machine-3.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:1398980c017fe5744d66f419e0115ee48a53b00b146d738e1416c225eb610b82", size = 19303, upload-time = "2025-12-17T23:32:35.796Z" },
    { url = "https://files.pythonhosted.org/packages/73/73/eaa33603c69a68fe2b6f54f9dd75481693d62f1d29676531002be06e2d1c/time_machine-3.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4f8f4e35f4191ef70c2ab8ff490761ee9051b891afce2bf86dde3918eb7b537b", size = 15431, upload-time = "2025-12-17T23:32:37.244Z" },
    { url = "https://files.pythonhosted.org/packages/76/10/b81e138e86cc7bab40cdb59d294b341e172201f4a6c84bb0ec080407977a/time_machine-3.2.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6db498686ecf6163c5aa8cf0bcd57bbe0f4081184f247edf3ee49a2612b584f9", size = 33206, upload-time = "2025-12-17T23:32:38.713Z" },
    { url = "https://files.pythonhosted.org/packages/d3/72/4deab446b579e8bd5dca91de98595c5d6bd6a17ce162abf5c5f2ce40d3d8/time_machine-3.2.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:027c1807efb74d0cd58ad16524dec94212fbe900115d70b0123399883657ac0f", size = 34792, upload-time = "2025-12-17T23:32:40.223Z" },
    { url = "https://files.pythonhosted.org/packages/2c/39/439c6b587ddee76d533fe972289d0646e0a5520e14dc83d0a30aeb5565f7/time_machine-3.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92432610c05676edd5e6946a073c6f0c926923123ce7caee1018dc10782c713d", size = 36187, upload-time = "2025-12-17T23:32:41.705Z" },
    { url = "https://files.pythonhosted.org/packages/4b/db/2da4368db15180989bab83746a857bde05ad16e78f326801c142bb747a06/time_machine-3.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c25586b62480eb77ef3d953fba273209478e1ef49654592cd6a52a68dfe56a67", size = 34855, upload-time = "2025-12-17T23:32:42.817Z" },
    { url = "https://files.pythonhosted.org/packages/88/84/120a431fee50bc4c241425bee4d3a4910df4923b7ab5f7dff1bf0c772f08/time_machine-3.2.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6bf3a2fa738d15e0b95d14469a0b8ea42635467408d8b490e263d5d45c9a177f", size = 33222, upload-time = "2025-12-17T23:32:43.94Z" },
    { url = "https://files.pythonhosted.org/packages/f9/ea/89cfda82bb8c57ff91bb9a26751aa234d6d90e9b4d5ab0ad9dce0f9f0329/time_machine-3.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ce76b82276d7ad2a66cdc85dad4df19d1422b69183170a34e8fbc4c3f35502f7", size = 34270, upload-time = "2025-12-17T23:32:45.037Z" },
    { url = "https://files.pythonhosted.org/packages/8a/aa/235357da4f69a51a8d35fcbfcfa77cdc7dc24f62ae54025006570bda7e2d/time_machine-3.2.0-cp314-cp314-win32.whl", hash = "sha256:14d6778273c543441863dff712cd1d7803dee946b18de35921eb8df10714539d", size = 17544, upload-time = "2025-12-17T23:32:46.099Z" },
    { url = "https://files.pythonhosted.org/packages/7b/51/6c8405a7276be79693b792cff22ce41067ec05db26a7d02f2d5b06324434/time_machine-3.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:cbf821da96dbc80d349fa9e7c36e670b41d68a878d28c8850057992fed430eef", size = 18423, upload-time = "2025-12-17T23:32:47.468Z" },
    { url = "https://files.pythonhosted.org/packages/d9/03/a3cf419e20c35fc203c6e4fed48b5b667c1a2b4da456d9971e605f73ecef/time_machine-3.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:71c75d71f8e68abc8b669bca26ed2ddd558430a6c171e32b8620288565f18c0e", size = 17050, upload-time = "2025-12-17T23:32:48.91Z" },
    { url = "https://files.pythonhosted.org/packages/86/a1/142de946dc4393f910bf4564b5c3ba819906e1f49b06c9cb557519c849e4/time_machine-3.2.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4e374779021446fc2b5c29d80457ec9a3b1a5df043dc2aae07d7c1415d52323c", size = 19991, upload-time = "2025-12-17T23:32:49.933Z" },
    { url = "https://files.pythonhosted.org/packages/ee/62/7f17def6289901f94726921811a16b9adce46e666362c75d45730c60274f/time_machine-3.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:122310a6af9c36e9a636da32830e591e7923e8a07bdd0a43276c3a36c6821c90", size = 15707, upload-time = "2025-12-17T23:32:50.969Z" },
    { url = "https://files.pythonhosted.org/packages/5d/d3/3502fb9bd3acb159c18844b26c43220201a0d4a622c0c853785d07699a92/time_machine-3.2.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ba3eeb0f018cc362dd8128befa3426696a2e16dd223c3fb695fde184892d4d8c", size = 39207, upload-time = "2025-12-17T23:32:52.033Z" },
    { url = "https://files.pythonhosted.org/packages/5a/be/8b27f4aa296fda14a5a2ad7f588ddd450603c33415ab3f8e85b2f1a44678/time_machine-3.2.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:77d38ba664b381a7793f8786efc13b5004f0d5f672dae814430445b8202a67a6", size = 40764, upload-time = "2025-12-17T23:32:53.167Z" },
    { url = "https://files.pythonhosted.org/packages/42/cd/fe4c4e5c8ab6d48fab3624c32be9116fb120173a35fe67e482e5cf68b3d2/time_machine-3.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f09abeb8f03f044d72712207e0489a62098ad3ad16dac38927fcf80baca4d6a7", size = 43508, upload-time = "2025-12-17T23:32:54.597Z" },
    { url = "https://files.pythonhosted.org/packages/b4/28/5a3ba2fce85b97655a425d6bb20a441550acd2b304c96b2c19d3839f721a/time_machine-3.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6b28367ce4f73987a55e230e1d30a57a3af85da8eb1a140074eb6e8c7e6ef19f", size = 41712, upload-time = "2025-12-17T23:32:55.781Z" },
    { url = "https://files.pythonhosted.org/packages/81/58/e38084be7fdabb4835db68a3a47e58c34182d79fc35df1ecbe0db2c5359f/time_machine-3.2.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:903c7751c904581da9f7861c3015bed7cdc40047321291d3694a3cdc783bbca3", size = 38939, upload-time = "2025-12-17T23:32:56.867Z" },
    { url = "https://files.pythonhosted.org/packages/40/d0/ad3feb0a392ef4e0c08bc32024950373ddc0669002cbdcbb9f3bf0c2d114/time_machine-3.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:528217cad85ede5f85c8bc78b0341868d3c3cfefc6ecb5b622e1cacb6c73247b", size = 39837, upload-time = "2025-12-17T23:32:58.283Z" },
    { url = "https://files.pythonhosted.org/packages/5b/9e/5f4b2ea63b267bd78f3245e76f5528836611b5f2d30b5e7300a722fe4428/time_machine-3.2.0-cp314-cp314t-win32.whl", hash = "sha256:75724762ffd517e7e80aaec1fad1ff5a7414bd84e2b3ee7a0bacfeb67c14926e", size = 18091, upload-time = "2025-12-17T23:32:59.403Z" },
    { url = "https://files.pythonhosted.org/packages/39/6f/456b1f4d2700ae02b19eba830f870596a4b89b74bac3b6c80666f1b108c5/time_machine-3.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2526abbd053c5bca898d1b3e7898eec34626b12206718d8c7ce88fd12c1c9c5c", size = 19208, upload-time = "2025-12-17T23:33:00.488Z" },
    { url = "https://files.pythonhosted.org/packages/2f/22/8063101427ecd3d2652aada4d21d0876b07a3dc789125bca2ee858fec3ed/time_machine-3.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:7f2fb6784b414edbe2c0b558bfaab0c251955ba27edd62946cce4a01675a992c", size = 17359, upload-time = "2025-12-17T23:33:01.54Z" },
]

[[package]]
name = "tomli"
version = "2.4.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
    { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
    { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
    { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
    { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
    { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
    { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
    { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
    { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
    { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
    { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
    { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
    { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
    { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
    { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
    { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
    { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
    { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
    { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
    { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
    { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
    { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
    { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
    { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
    { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
    { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
    { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
    { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
    { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
    { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
    { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
    { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
    { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
    { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
    { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
    { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
    { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
    { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
    { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
    { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
    { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
    { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
    { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
    { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
    { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
    { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
]

[[package]]
name = "tomlkit"
version = "0.14.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/af/14b24e41977adb296d6bd1fb59402cf7d60ce364f90c890bd2ec65c43b5a/tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064", size = 187167, upload-time = "2026-01-13T01:14:53.304Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310, upload-time = "2026-01-13T01:14:51.965Z" },
]

[[package]]
name = "tracerite"
version = "2.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "html5tagger" },
]
sdist = { url = "https://files.pythonhosted.org/packages/15/b9/89b065c1818e5973c333a33311f823954ff4c7c48440c20b37669c5b752c/tracerite-2.3.1.tar.gz", hash = "sha256:f46ee672d240d500a2331781b09eb33564d473f6ae60cd871ebce6c2413cffa8", size = 61303, upload-time = "2025-12-30T22:51:19.32Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/b6/62/3f385a67ff3cc91209f107d20bbebdecf7a4e4aba55a43f9f71bddc424a9/tracerite-2.3.1-py3-none-any.whl", hash = "sha256:5f9595ba90f075b58e14a9baf84d8204fec3cdce50029f1c32d757af79d9ccbe", size = 65884, upload-time = "2025-12-30T22:51:18.1Z" },
]

[[package]]
name = "typer"
version = "0.23.2"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
dependencies = [
    { name = "annotated-doc", marker = "python_full_version < '3.10'" },
    { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "rich", marker = "python_full_version < '3.10'" },
    { name = "shellingham", marker = "python_full_version < '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d3/ae/93d16574e66dfe4c2284ffdaca4b0320ade32858cb2cc586c8dd79f127c5/typer-0.23.2.tar.gz", hash = "sha256:a99706a08e54f1aef8bb6a8611503808188a4092808e86addff1828a208af0de", size = 120162, upload-time = "2026-02-16T18:52:40.354Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/14/2c/dee705c427875402200fe779eb8a3c00ccb349471172c41178336e9599cc/typer-0.23.2-py3-none-any.whl", hash = "sha256:e9c8dc380f82450b3c851a9b9d5a0edf95d1d6456ae70c517d8b06a50c7a9978", size = 56834, upload-time = "2026-02-16T18:52:39.308Z" },
]

[[package]]
name = "typer"
version = "0.24.1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
dependencies = [
    { name = "annotated-doc", marker = "python_full_version >= '3.10'" },
    { name = "click", version = "8.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "rich", marker = "python_full_version >= '3.10'" },
    { name = "shellingham", marker = "python_full_version >= '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" },
]

[[package]]
name = "types-aiofiles"
version = "25.1.0.20251011"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/84/6c/6d23908a8217e36704aa9c79d99a620f2fdd388b66a4b7f72fbc6b6ff6c6/types_aiofiles-25.1.0.20251011.tar.gz", hash = "sha256:1c2b8ab260cb3cd40c15f9d10efdc05a6e1e6b02899304d80dfa0410e028d3ff", size = 14535, upload-time = "2025-10-11T02:44:51.237Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/71/0f/76917bab27e270bb6c32addd5968d69e558e5b6f7fb4ac4cbfa282996a96/types_aiofiles-25.1.0.20251011-py3-none-any.whl", hash = "sha256:8ff8de7f9d42739d8f0dadcceeb781ce27cd8d8c4152d4a7c52f6b20edb8149c", size = 14338, upload-time = "2025-10-11T02:44:50.054Z" },
]

[[package]]
name = "types-colorama"
version = "0.4.15.20250801"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/99/37/af713e7d73ca44738c68814cbacf7a655aa40ddd2e8513d431ba78ace7b3/types_colorama-0.4.15.20250801.tar.gz", hash = "sha256:02565d13d68963d12237d3f330f5ecd622a3179f7b5b14ee7f16146270c357f5", size = 10437, upload-time = "2025-08-01T03:48:22.605Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/95/3a/44ccbbfef6235aeea84c74041dc6dfee6c17ff3ddba782a0250e41687ec7/types_colorama-0.4.15.20250801-py3-none-any.whl", hash = "sha256:b6e89bd3b250fdad13a8b6a465c933f4a5afe485ea2e2f104d739be50b13eea9", size = 10743, upload-time = "2025-08-01T03:48:21.774Z" },
]

[[package]]
name = "types-cryptography"
version = "3.3.23.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/18/05/a57fe8bbed10fe4b739fac6e16c4e80c5199ce2f74ae67fa7d7f6e3750da/types-cryptography-3.3.23.2.tar.gz", hash = "sha256:09cc53f273dd4d8c29fa7ad11fefd9b734126d467960162397bc5e3e604dea75", size = 15461, upload-time = "2022-11-08T18:29:28.012Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/b6/36/92dfe7e5056694e78caefd05b383140c74c7fcbfc63d26ee514c77f2d8a2/types_cryptography-3.3.23.2-py3-none-any.whl", hash = "sha256:b965d548f148f8e87f353ccf2b7bd92719fdf6c845ff7cedf2abb393a0643e4f", size = 30223, upload-time = "2022-11-08T18:29:26.848Z" },
]

[[package]]
name = "types-docutils"
version = "0.22.3.20251115"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/d7/576ec24bf61a280f571e1f22284793adc321610b9bcfba1bf468cf7b334f/types_docutils-0.22.3.20251115.tar.gz", hash = "sha256:0f79ea6a7bd4d12d56c9f824a0090ffae0ea4204203eb0006392906850913e16", size = 56828, upload-time = "2025-11-15T02:59:57.371Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/9c/01/61ac9eb38f1f978b47443dc6fd2e0a3b0f647c2da741ddad30771f1b2b6f/types_docutils-0.22.3.20251115-py3-none-any.whl", hash = "sha256:c6e53715b65395d00a75a3a8a74e352c669bc63959e65a207dffaa22f4a2ad6e", size = 91951, upload-time = "2025-11-15T02:59:56.413Z" },
]

[[package]]
name = "types-docutils"
version = "0.22.3.20260322"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
sdist = { url = "https://files.pythonhosted.org/packages/44/bb/243a87fc1605a4a94c2c343d6dbddbf0d7ef7c0b9550f360b8cda8e82c39/types_docutils-0.22.3.20260322.tar.gz", hash = "sha256:e2450bb997283c3141ec5db3e436b91f0aa26efe35eb9165178ca976ccb4930b", size = 57311, upload-time = "2026-03-22T04:08:44.064Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/c6/4a/22c090cd4615a16917dff817cbe7c5956da376c961e024c241cd962d2c3d/types_docutils-0.22.3.20260322-py3-none-any.whl", hash = "sha256:681d4510ce9b80a0c6a593f0f9843d81f8caa786db7b39ba04d9fd5480ac4442", size = 91978, upload-time = "2026-03-22T04:08:43.117Z" },
]

[[package]]
name = "types-passlib"
version = "1.7.7.20250602"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
sdist = { url = "https://files.pythonhosted.org/packages/fa/3e/501a5832130e5f93450b1e02090e2ee27a37135d11378a47debf960e3131/types_passlib-1.7.7.20250602.tar.gz", hash = "sha256:cf2350e78d36b6b09e4db44284d96651b57285f499cfabf111b616065abab7b3", size = 25406, upload-time = "2025-06-02T03:14:56.033Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/39/fc/530236c21f1a0be84c42b23c91c250ef96404c475b739ac4479430ebd7d4/types_passlib-1.7.7.20250602-py3-none-any.whl", hash = "sha256:ed73a91be9a22484ebd62cc0d127675ded542b892b99776db92dab760bbfe274", size = 40410, upload-time = "2025-06-02T03:14:54.834Z" },
]

[[package]]
name = "types-passlib"
version = "1.7.7.20260211"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
sdist = { url = "https://files.pythonhosted.org/packages/b5/f4/718ff8cbef9366e597aefd58929321702d3e183998cea89949ab5423281f/types_passlib-1.7.7.20260211.tar.gz", hash = "sha256:af73afffe1ce94c95c7f6072bd261572c29845de74fdffa3a265fc7634bca056", size = 25666, upload-time = "2026-02-10T15:11:59.517Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/14/6a/e9fc6a5b8f9a380a4a56b9f1e4dba5c6899561868017b17f6de382808b6f/types_passlib-1.7.7.20260211-py3-none-any.whl", hash = "sha256:c0f1ad440c513a6c07f333b28249530686056fd54a7b3ac6128ae31fd46305d3", size = 40457, upload-time = "2026-02-10T15:11:58.647Z" },
]

[[package]]
name = "types-pillow"
version = "10.2.0.20240822"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/18/4a/4495264dddaa600d65d68bcedb64dcccf9d9da61adff51f7d2ffd8e4c9ce/types-Pillow-10.2.0.20240822.tar.gz", hash = "sha256:559fb52a2ef991c326e4a0d20accb3bb63a7ba8d40eb493e0ecb0310ba52f0d3", size = 35389, upload-time = "2024-08-22T02:32:48.15Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/66/23/e81a5354859831fcf54d488d33b80ba6133ea84f874a9c0ec40a4881e133/types_Pillow-10.2.0.20240822-py3-none-any.whl", hash = "sha256:d9dab025aba07aeb12fd50a6799d4eac52a9603488eca09d7662543983f16c5d", size = 54354, upload-time = "2024-08-22T02:32:46.664Z" },
]

[[package]]
name = "types-psycopg2"
version = "2.9.21.20251012"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
sdist = { url = "https://files.pythonhosted.org/packages/9b/b3/2d09eaf35a084cffd329c584970a3fa07101ca465c13cad1576d7c392587/types_psycopg2-2.9.21.20251012.tar.gz", hash = "sha256:4cdafd38927da0cfde49804f39ab85afd9c6e9c492800e42f1f0c1a1b0312935", size = 26710, upload-time = "2025-10-12T02:55:39.5Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/ec/0c/05feaf8cb51159f2c0af04b871dab7e98a2f83a3622f5f216331d2dd924c/types_psycopg2-2.9.21.20251012-py3-none-any.whl", hash = "sha256:712bad5c423fe979e357edbf40a07ca40ef775d74043de72bd4544ca328cc57e", size = 24883, upload-time = "2025-10-12T02:55:38.439Z" },
]

[[package]]
name = "types-psycopg2"
version = "2.9.21.20260223"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
sdist = { url = "https://files.pythonhosted.org/packages/55/1f/4daff0ce5e8e191844e65aaa793ed1b9cb40027dc2700906ecf2b6bcc0ed/types_psycopg2-2.9.21.20260223.tar.gz", hash = "sha256:78ed70de2e56bc6b5c26c8c1da8e9af54e49fdc3c94d1504609f3519e2b84f02", size = 27090, upload-time = "2026-02-23T04:11:18.177Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/8d/e7/c566df58410bc0728348b514e718f0b38fa0d248b5c10599a11494ba25d2/types_psycopg2-2.9.21.20260223-py3-none-any.whl", hash = "sha256:c6228ade72d813b0624f4c03feeb89471950ac27cd0506b5debed6f053086bc8", size = 24919, upload-time = "2026-02-23T04:11:17.214Z" },
]

[[package]]
name = "types-pygments"
version = "2.19.0.20251121"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
dependencies = [
    { name = "types-docutils", version = "0.22.3.20251115", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/90/3b/cd650700ce9e26b56bd1a6aa4af397bbbc1784e22a03971cb633cdb0b601/types_pygments-2.19.0.20251121.tar.gz", hash = "sha256:eef114fde2ef6265365522045eac0f8354978a566852f69e75c531f0553822b1", size = 18590, upload-time = "2025-11-21T03:03:46.623Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/99/8a/9244b21f1d60dcc62e261435d76b02f1853b4771663d7ec7d287e47a9ba9/types_pygments-2.19.0.20251121-py3-none-any.whl", hash = "sha256:cb3bfde34eb75b984c98fb733ce4f795213bd3378f855c32e75b49318371bb25", size = 25674, upload-time = "2025-11-21T03:03:45.72Z" },
]

[[package]]
name = "types-pygments"
version = "2.20.0.20260406"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
dependencies = [
    { name = "types-docutils", version = "0.22.3.20260322", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/08/bd/d17c28a4c65c556bc4c4bc8f363aa2fbfc91b397e3c0019839d74d9ead31/types_pygments-2.20.0.20260406.tar.gz", hash = "sha256:d3ed7ecd7c34a382459d28ce624b87e1dee03d6844e43aa7590ef4b8c7c9dfce", size = 19486, upload-time = "2026-04-06T04:33:59.632Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/eb/00/dca7518e6f99ce0f235ec1c6512593ee4bd25109ae1c912bf9ee836a26e1/types_pygments-2.20.0.20260406-py3-none-any.whl", hash = "sha256:6bb0c79874c304977e1c097f7007140e16fe78c443329154db803d7910d945b3", size = 27278, upload-time = "2026-04-06T04:33:58.744Z" },
]

[[package]]
name = "types-pymysql"
version = "1.1.0.20251220"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d3/59/e959dd6d2f8e3b3c3f058d79ac9ece328922a5a8770c707fe9c3a757481c/types_pymysql-1.1.0.20251220.tar.gz", hash = "sha256:ae1c3df32a777489431e2e9963880a0df48f6591e0aa2fd3a6fabd9dee6eca54", size = 22184, upload-time = "2025-12-20T03:07:38.689Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/8b/fa/4f4d3bfca9ef6dd17d69ed18b96564c53b32d3ce774132308d0bee849f10/types_pymysql-1.1.0.20251220-py3-none-any.whl", hash = "sha256:fa1082af7dea6c53b6caa5784241924b1296ea3a8d3bd060417352c5e10c0618", size = 23067, upload-time = "2025-12-20T03:07:37.766Z" },
]

[[package]]
name = "types-python-dateutil"
version = "2.9.0.20260124"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
sdist = { url = "https://files.pythonhosted.org/packages/fe/41/4f8eb1ce08688a9e3e23709ed07089ccdeaf95b93745bfb768c6da71197d/types_python_dateutil-2.9.0.20260124.tar.gz", hash = "sha256:7d2db9f860820c30e5b8152bfe78dbdf795f7d1c6176057424e8b3fdd1f581af", size = 16596, upload-time = "2026-01-24T03:18:42.975Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/5a/c2/aa5e3f4103cc8b1dcf92432415dde75d70021d634ecfd95b2e913cf43e17/types_python_dateutil-2.9.0.20260124-py3-none-any.whl", hash = "sha256:f802977ae08bf2260142e7ca1ab9d4403772a254409f7bbdf652229997124951", size = 18266, upload-time = "2026-01-24T03:18:42.155Z" },
]

[[package]]
name = "types-python-dateutil"
version = "2.9.0.20260402"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
sdist = { url = "https://files.pythonhosted.org/packages/a7/30/c5d9efbff5422b20c9551dc5af237d1ab0c3d33729a9b3239a876ca47dd4/types_python_dateutil-2.9.0.20260402.tar.gz", hash = "sha256:a980142b9966713acb382c467e35c5cc4208a2f91b10b8d785a0ae6765df6c0b", size = 16941, upload-time = "2026-04-02T04:18:35.834Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/e6/d7/fe753bf8329c8c3c1addcba1d2bf716c33898216757abb24f8b80f82d040/types_python_dateutil-2.9.0.20260402-py3-none-any.whl", hash = "sha256:7827e6a9c93587cc18e766944254d1351a2396262e4abe1510cbbd7601c5e01f", size = 18436, upload-time = "2026-04-02T04:18:34.806Z" },
]

[[package]]
name = "types-pytz"
version = "2025.2.0.20251108"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
sdist = { url = "https://files.pythonhosted.org/packages/40/ff/c047ddc68c803b46470a357454ef76f4acd8c1088f5cc4891cdd909bfcf6/types_pytz-2025.2.0.20251108.tar.gz", hash = "sha256:fca87917836ae843f07129567b74c1929f1870610681b4c92cb86a3df5817bdb", size = 10961, upload-time = "2025-11-08T02:55:57.001Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/e7/c1/56ef16bf5dcd255155cc736d276efa6ae0a5c26fd685e28f0412a4013c01/types_pytz-2025.2.0.20251108-py3-none-any.whl", hash = "sha256:0f1c9792cab4eb0e46c52f8845c8f77cf1e313cb3d68bf826aa867fe4717d91c", size = 10116, upload-time = "2025-11-08T02:55:56.194Z" },
]

[[package]]
name = "types-pytz"
version = "2026.1.1.20260402"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
sdist = { url = "https://files.pythonhosted.org/packages/18/ff/52b895d4cb5f51f4ae50de8ca6a0b4098a71fa174b63f14d80ba86fa0692/types_pytz-2026.1.1.20260402.tar.gz", hash = "sha256:79209aa51dc003a4a6a764234d92b14e5c09a1b7f24e0f00c493929fd33618e8", size = 10726, upload-time = "2026-04-02T04:17:52.603Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/79/ce/93d4fc3ba66be6cd50f3f22bbdb6fd953a02689398ea2c4a733e023c9227/types_pytz-2026.1.1.20260402-py3-none-any.whl", hash = "sha256:0d9a60ed1c6ad4fce7c6395b5bd2d9827db41d4b83de7c0322cf85869c2bfda3", size = 10122, upload-time = "2026-04-02T04:17:51.729Z" },
]

[[package]]
name = "types-pyyaml"
version = "6.0.12.20250915"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/69/3c51b36d04da19b92f9e815be12753125bd8bc247ba0470a982e6979e71c/types_pyyaml-6.0.12.20250915.tar.gz", hash = "sha256:0f8b54a528c303f0e6f7165687dd33fafa81c807fcac23f632b63aa624ced1d3", size = 17522, upload-time = "2025-09-15T03:01:00.728Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/bd/e0/1eed384f02555dde685fff1a1ac805c1c7dcb6dd019c916fe659b1c1f9ec/types_pyyaml-6.0.12.20250915-py3-none-any.whl", hash = "sha256:e7d4d9e064e89a3b3cae120b4990cd370874d2bf12fa5f46c97018dd5d3c9ab6", size = 20338, upload-time = "2025-09-15T03:00:59.218Z" },
]

[[package]]
name = "types-ujson"
version = "5.10.0.20250822"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5c/bd/d372d44534f84864a96c19a7059d9b4d29db8541828b8b9dc3040f7a46d0/types_ujson-5.10.0.20250822.tar.gz", hash = "sha256:0a795558e1f78532373cf3f03f35b1f08bc60d52d924187b97995ee3597ba006", size = 8437, upload-time = "2025-08-22T03:02:19.433Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/d7/f2/d812543c350674d8b3f6e17c8922248ee3bb752c2a76f64beb8c538b40cf/types_ujson-5.10.0.20250822-py3-none-any.whl", hash = "sha256:3e9e73a6dc62ccc03449d9ac2c580cd1b7a8e4873220db498f7dd056754be080", size = 7657, upload-time = "2025-08-22T03:02:18.699Z" },
]

[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]

[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]

[[package]]
name = "tzdata"
version = "2026.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/19/f5/cd531b2d15a671a40c0f66cf06bc3570a12cd56eef98960068ebbad1bf5a/tzdata-2026.1.tar.gz", hash = "sha256:67658a1903c75917309e753fdc349ac0efd8c27db7a0cb406a25be4840f87f98", size = 197639, upload-time = "2026-04-03T11:25:22.002Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/b0/70/d460bd685a170790ec89317e9bd33047988e4bce507b831f5db771e142de/tzdata-2026.1-py2.py3-none-any.whl", hash = "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9", size = 348952, upload-time = "2026-04-03T11:25:20.313Z" },
]

[[package]]
name = "ujson"
version = "5.11.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
sdist = { url = "https://files.pythonhosted.org/packages/43/d9/3f17e3c5773fb4941c68d9a37a47b1a79c9649d6c56aefbed87cc409d18a/ujson-5.11.0.tar.gz", hash = "sha256:e204ae6f909f099ba6b6b942131cee359ddda2b6e4ea39c12eb8b991fe2010e0", size = 7156583, upload-time = "2025-08-20T11:57:02.452Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/86/0c/8bf7a4fabfd01c7eed92d9b290930ce6d14910dec708e73538baa38885d1/ujson-5.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:446e8c11c06048611c9d29ef1237065de0af07cabdd97e6b5b527b957692ec25", size = 55248, upload-time = "2025-08-20T11:55:02.368Z" },
    { url = "https://files.pythonhosted.org/packages/7b/2e/eeab0b8b641817031ede4f790db4c4942df44a12f44d72b3954f39c6a115/ujson-5.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:16ccb973b7ada0455201808ff11d48fe9c3f034a6ab5bd93b944443c88299f89", size = 53157, upload-time = "2025-08-20T11:55:04.012Z" },
    { url = "https://files.pythonhosted.org/packages/21/1b/a4e7a41870797633423ea79618526747353fd7be9191f3acfbdee0bf264b/ujson-5.11.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3134b783ab314d2298d58cda7e47e7a0f7f71fc6ade6ac86d5dbeaf4b9770fa6", size = 57657, upload-time = "2025-08-20T11:55:05.169Z" },
    { url = "https://files.pythonhosted.org/packages/94/ae/4e0d91b8f6db7c9b76423b3649612189506d5a06ddd3b6334b6d37f77a01/ujson-5.11.0-cp310-cp310-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:185f93ebccffebc8baf8302c869fac70dd5dd78694f3b875d03a31b03b062cdb", size = 59780, upload-time = "2025-08-20T11:55:06.325Z" },
    { url = "https://files.pythonhosted.org/packages/b3/cc/46b124c2697ca2da7c65c4931ed3cb670646978157aa57a7a60f741c530f/ujson-5.11.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d06e87eded62ff0e5f5178c916337d2262fdbc03b31688142a3433eabb6511db", size = 57307, upload-time = "2025-08-20T11:55:07.493Z" },
    { url = "https://files.pythonhosted.org/packages/39/eb/20dd1282bc85dede2f1c62c45b4040bc4c389c80a05983515ab99771bca7/ujson-5.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:181fb5b15703a8b9370b25345d2a1fd1359f0f18776b3643d24e13ed9c036d4c", size = 1036369, upload-time = "2025-08-20T11:55:09.192Z" },
    { url = "https://files.pythonhosted.org/packages/64/a2/80072439065d493e3a4b1fbeec991724419a1b4c232e2d1147d257cac193/ujson-5.11.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:a4df61a6df0a4a8eb5b9b1ffd673429811f50b235539dac586bb7e9e91994138", size = 1195738, upload-time = "2025-08-20T11:55:11.402Z" },
    { url = "https://files.pythonhosted.org/packages/5d/7e/d77f9e9c039d58299c350c978e086a804d1fceae4fd4a1cc6e8d0133f838/ujson-5.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6eff24e1abd79e0ec6d7eae651dd675ddbc41f9e43e29ef81e16b421da896915", size = 1088718, upload-time = "2025-08-20T11:55:13.297Z" },
    { url = "https://files.pythonhosted.org/packages/ab/f1/697559d45acc849cada6b3571d53522951b1a64027400507aabc6a710178/ujson-5.11.0-cp310-cp310-win32.whl", hash = "sha256:30f607c70091483550fbd669a0b37471e5165b317d6c16e75dba2aa967608723", size = 39653, upload-time = "2025-08-20T11:55:14.869Z" },
    { url = "https://files.pythonhosted.org/packages/86/a2/70b73a0f55abe0e6b8046d365d74230c20c5691373e6902a599b2dc79ba1/ujson-5.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:3d2720e9785f84312b8e2cb0c2b87f1a0b1c53aaab3b2af3ab817d54409012e0", size = 43720, upload-time = "2025-08-20T11:55:15.897Z" },
    { url = "https://files.pythonhosted.org/packages/1c/5f/b19104afa455630b43efcad3a24495b9c635d92aa8f2da4f30e375deb1a2/ujson-5.11.0-cp310-cp310-win_arm64.whl", hash = "sha256:85e6796631165f719084a9af00c79195d3ebf108151452fefdcb1c8bb50f0105", size = 38410, upload-time = "2025-08-20T11:55:17.556Z" },
    { url = "https://files.pythonhosted.org/packages/da/ea/80346b826349d60ca4d612a47cdf3533694e49b45e9d1c07071bb867a184/ujson-5.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d7c46cb0fe5e7056b9acb748a4c35aa1b428025853032540bb7e41f46767321f", size = 55248, upload-time = "2025-08-20T11:55:19.033Z" },
    { url = "https://files.pythonhosted.org/packages/57/df/b53e747562c89515e18156513cc7c8ced2e5e3fd6c654acaa8752ffd7cd9/ujson-5.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d8951bb7a505ab2a700e26f691bdfacf395bc7e3111e3416d325b513eea03a58", size = 53156, upload-time = "2025-08-20T11:55:20.174Z" },
    { url = "https://files.pythonhosted.org/packages/41/b8/ab67ec8c01b8a3721fd13e5cb9d85ab2a6066a3a5e9148d661a6870d6293/ujson-5.11.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:952c0be400229940248c0f5356514123d428cba1946af6fa2bbd7503395fef26", size = 57657, upload-time = "2025-08-20T11:55:21.296Z" },
    { url = "https://files.pythonhosted.org/packages/7b/c7/fb84f27cd80a2c7e2d3c6012367aecade0da936790429801803fa8d4bffc/ujson-5.11.0-cp311-cp311-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:94fcae844f1e302f6f8095c5d1c45a2f0bfb928cccf9f1b99e3ace634b980a2a", size = 59779, upload-time = "2025-08-20T11:55:22.772Z" },
    { url = "https://files.pythonhosted.org/packages/5d/7c/48706f7c1e917ecb97ddcfb7b1d756040b86ed38290e28579d63bd3fcc48/ujson-5.11.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e0ec1646db172beb8d3df4c32a9d78015e671d2000af548252769e33079d9a6", size = 57284, upload-time = "2025-08-20T11:55:24.01Z" },
    { url = "https://files.pythonhosted.org/packages/ec/ce/48877c6eb4afddfd6bd1db6be34456538c07ca2d6ed233d3f6c6efc2efe8/ujson-5.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:da473b23e3a54448b008d33f742bcd6d5fb2a897e42d1fc6e7bf306ea5d18b1b", size = 1036395, upload-time = "2025-08-20T11:55:25.725Z" },
    { url = "https://files.pythonhosted.org/packages/8b/7a/2c20dc97ad70cd7c31ad0596ba8e2cf8794d77191ba4d1e0bded69865477/ujson-5.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:aa6b3d4f1c0d3f82930f4cbd7fe46d905a4a9205a7c13279789c1263faf06dba", size = 1195731, upload-time = "2025-08-20T11:55:27.915Z" },
    { url = "https://files.pythonhosted.org/packages/15/f5/ca454f2f6a2c840394b6f162fff2801450803f4ff56c7af8ce37640b8a2a/ujson-5.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4843f3ab4fe1cc596bb7e02228ef4c25d35b4bb0809d6a260852a4bfcab37ba3", size = 1088710, upload-time = "2025-08-20T11:55:29.426Z" },
    { url = "https://files.pythonhosted.org/packages/fe/d3/9ba310e07969bc9906eb7548731e33a0f448b122ad9705fed699c9b29345/ujson-5.11.0-cp311-cp311-win32.whl", hash = "sha256:e979fbc469a7f77f04ec2f4e853ba00c441bf2b06720aa259f0f720561335e34", size = 39648, upload-time = "2025-08-20T11:55:31.194Z" },
    { url = "https://files.pythonhosted.org/packages/57/f7/da05b4a8819f1360be9e71fb20182f0bb3ec611a36c3f213f4d20709e099/ujson-5.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:683f57f0dd3acdd7d9aff1de0528d603aafcb0e6d126e3dc7ce8b020a28f5d01", size = 43717, upload-time = "2025-08-20T11:55:32.241Z" },
    { url = "https://files.pythonhosted.org/packages/9a/cc/f3f9ac0f24f00a623a48d97dc3814df5c2dc368cfb00031aa4141527a24b/ujson-5.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:7855ccea3f8dad5e66d8445d754fc1cf80265a4272b5f8059ebc7ec29b8d0835", size = 38402, upload-time = "2025-08-20T11:55:33.641Z" },
    { url = "https://files.pythonhosted.org/packages/b9/ef/a9cb1fce38f699123ff012161599fb9f2ff3f8d482b4b18c43a2dc35073f/ujson-5.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7895f0d2d53bd6aea11743bd56e3cb82d729980636cd0ed9b89418bf66591702", size = 55434, upload-time = "2025-08-20T11:55:34.987Z" },
    { url = "https://files.pythonhosted.org/packages/b1/05/dba51a00eb30bd947791b173766cbed3492269c150a7771d2750000c965f/ujson-5.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:12b5e7e22a1fe01058000d1b317d3b65cc3daf61bd2ea7a2b76721fe160fa74d", size = 53190, upload-time = "2025-08-20T11:55:36.384Z" },
    { url = "https://files.pythonhosted.org/packages/03/3c/fd11a224f73fbffa299fb9644e425f38b38b30231f7923a088dd513aabb4/ujson-5.11.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0180a480a7d099082501cad1fe85252e4d4bf926b40960fb3d9e87a3a6fbbc80", size = 57600, upload-time = "2025-08-20T11:55:37.692Z" },
    { url = "https://files.pythonhosted.org/packages/55/b9/405103cae24899df688a3431c776e00528bd4799e7d68820e7ebcf824f92/ujson-5.11.0-cp312-cp312-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:fa79fdb47701942c2132a9dd2297a1a85941d966d8c87bfd9e29b0cf423f26cc", size = 59791, upload-time = "2025-08-20T11:55:38.877Z" },
    { url = "https://files.pythonhosted.org/packages/17/7b/2dcbc2bbfdbf68f2368fb21ab0f6735e872290bb604c75f6e06b81edcb3f/ujson-5.11.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8254e858437c00f17cb72e7a644fc42dad0ebb21ea981b71df6e84b1072aaa7c", size = 57356, upload-time = "2025-08-20T11:55:40.036Z" },
    { url = "https://files.pythonhosted.org/packages/d1/71/fea2ca18986a366c750767b694430d5ded6b20b6985fddca72f74af38a4c/ujson-5.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1aa8a2ab482f09f6c10fba37112af5f957689a79ea598399c85009f2f29898b5", size = 1036313, upload-time = "2025-08-20T11:55:41.408Z" },
    { url = "https://files.pythonhosted.org/packages/a3/bb/d4220bd7532eac6288d8115db51710fa2d7d271250797b0bfba9f1e755af/ujson-5.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a638425d3c6eed0318df663df44480f4a40dc87cc7c6da44d221418312f6413b", size = 1195782, upload-time = "2025-08-20T11:55:43.357Z" },
    { url = "https://files.pythonhosted.org/packages/80/47/226e540aa38878ce1194454385701d82df538ccb5ff8db2cf1641dde849a/ujson-5.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7e3cff632c1d78023b15f7e3a81c3745cd3f94c044d1e8fa8efbd6b161997bbc", size = 1088817, upload-time = "2025-08-20T11:55:45.262Z" },
    { url = "https://files.pythonhosted.org/packages/7e/81/546042f0b23c9040d61d46ea5ca76f0cc5e0d399180ddfb2ae976ebff5b5/ujson-5.11.0-cp312-cp312-win32.whl", hash = "sha256:be6b0eaf92cae8cdee4d4c9e074bde43ef1c590ed5ba037ea26c9632fb479c88", size = 39757, upload-time = "2025-08-20T11:55:46.522Z" },
    { url = "https://files.pythonhosted.org/packages/44/1b/27c05dc8c9728f44875d74b5bfa948ce91f6c33349232619279f35c6e817/ujson-5.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:b7b136cc6abc7619124fd897ef75f8e63105298b5ca9bdf43ebd0e1fa0ee105f", size = 43859, upload-time = "2025-08-20T11:55:47.987Z" },
    { url = "https://files.pythonhosted.org/packages/22/2d/37b6557c97c3409c202c838aa9c960ca3896843b4295c4b7bb2bbd260664/ujson-5.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:6cd2df62f24c506a0ba322d5e4fe4466d47a9467b57e881ee15a31f7ecf68ff6", size = 38361, upload-time = "2025-08-20T11:55:49.122Z" },
    { url = "https://files.pythonhosted.org/packages/1c/ec/2de9dd371d52c377abc05d2b725645326c4562fc87296a8907c7bcdf2db7/ujson-5.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:109f59885041b14ee9569bf0bb3f98579c3fa0652317b355669939e5fc5ede53", size = 55435, upload-time = "2025-08-20T11:55:50.243Z" },
    { url = "https://files.pythonhosted.org/packages/5b/a4/f611f816eac3a581d8a4372f6967c3ed41eddbae4008d1d77f223f1a4e0a/ujson-5.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a31c6b8004438e8c20fc55ac1c0e07dad42941db24176fe9acf2815971f8e752", size = 53193, upload-time = "2025-08-20T11:55:51.373Z" },
    { url = "https://files.pythonhosted.org/packages/e9/c5/c161940967184de96f5cbbbcce45b562a4bf851d60f4c677704b1770136d/ujson-5.11.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78c684fb21255b9b90320ba7e199780f653e03f6c2528663768965f4126a5b50", size = 57603, upload-time = "2025-08-20T11:55:52.583Z" },
    { url = "https://files.pythonhosted.org/packages/2b/d6/c7b2444238f5b2e2d0e3dab300b9ddc3606e4b1f0e4bed5a48157cebc792/ujson-5.11.0-cp313-cp313-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:4c9f5d6a27d035dd90a146f7761c2272cf7103de5127c9ab9c4cd39ea61e878a", size = 59794, upload-time = "2025-08-20T11:55:53.69Z" },
    { url = "https://files.pythonhosted.org/packages/fe/a3/292551f936d3d02d9af148f53e1bc04306b00a7cf1fcbb86fa0d1c887242/ujson-5.11.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:837da4d27fed5fdc1b630bd18f519744b23a0b5ada1bbde1a36ba463f2900c03", size = 57363, upload-time = "2025-08-20T11:55:54.843Z" },
    { url = "https://files.pythonhosted.org/packages/90/a6/82cfa70448831b1a9e73f882225980b5c689bf539ec6400b31656a60ea46/ujson-5.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:787aff4a84da301b7f3bac09bc696e2e5670df829c6f8ecf39916b4e7e24e701", size = 1036311, upload-time = "2025-08-20T11:55:56.197Z" },
    { url = "https://files.pythonhosted.org/packages/84/5c/96e2266be50f21e9b27acaee8ca8f23ea0b85cb998c33d4f53147687839b/ujson-5.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6dd703c3e86dc6f7044c5ac0b3ae079ed96bf297974598116aa5fb7f655c3a60", size = 1195783, upload-time = "2025-08-20T11:55:58.081Z" },
    { url = "https://files.pythonhosted.org/packages/8d/20/78abe3d808cf3bb3e76f71fca46cd208317bf461c905d79f0d26b9df20f1/ujson-5.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3772e4fe6b0c1e025ba3c50841a0ca4786825a4894c8411bf8d3afe3a8061328", size = 1088822, upload-time = "2025-08-20T11:55:59.469Z" },
    { url = "https://files.pythonhosted.org/packages/d8/50/8856e24bec5e2fc7f775d867aeb7a3f137359356200ac44658f1f2c834b2/ujson-5.11.0-cp313-cp313-win32.whl", hash = "sha256:8fa2af7c1459204b7a42e98263b069bd535ea0cd978b4d6982f35af5a04a4241", size = 39753, upload-time = "2025-08-20T11:56:01.345Z" },
    { url = "https://files.pythonhosted.org/packages/5b/d8/1baee0f4179a4d0f5ce086832147b6cc9b7731c24ca08e14a3fdb8d39c32/ujson-5.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:34032aeca4510a7c7102bd5933f59a37f63891f30a0706fb46487ab6f0edf8f0", size = 43866, upload-time = "2025-08-20T11:56:02.552Z" },
    { url = "https://files.pythonhosted.org/packages/a9/8c/6d85ef5be82c6d66adced3ec5ef23353ed710a11f70b0b6a836878396334/ujson-5.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:ce076f2df2e1aa62b685086fbad67f2b1d3048369664b4cdccc50707325401f9", size = 38363, upload-time = "2025-08-20T11:56:03.688Z" },
    { url = "https://files.pythonhosted.org/packages/28/08/4518146f4984d112764b1dfa6fb7bad691c44a401adadaa5e23ccd930053/ujson-5.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:65724738c73645db88f70ba1f2e6fb678f913281804d5da2fd02c8c5839af302", size = 55462, upload-time = "2025-08-20T11:56:04.873Z" },
    { url = "https://files.pythonhosted.org/packages/29/37/2107b9a62168867a692654d8766b81bd2fd1e1ba13e2ec90555861e02b0c/ujson-5.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29113c003ca33ab71b1b480bde952fbab2a0b6b03a4ee4c3d71687cdcbd1a29d", size = 53246, upload-time = "2025-08-20T11:56:06.054Z" },
    { url = "https://files.pythonhosted.org/packages/9b/f8/25583c70f83788edbe3ca62ce6c1b79eff465d78dec5eb2b2b56b3e98b33/ujson-5.11.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c44c703842024d796b4c78542a6fcd5c3cb948b9fc2a73ee65b9c86a22ee3638", size = 57631, upload-time = "2025-08-20T11:56:07.374Z" },
    { url = "https://files.pythonhosted.org/packages/ed/ca/19b3a632933a09d696f10dc1b0dfa1d692e65ad507d12340116ce4f67967/ujson-5.11.0-cp314-cp314-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:e750c436fb90edf85585f5c62a35b35082502383840962c6983403d1bd96a02c", size = 59877, upload-time = "2025-08-20T11:56:08.534Z" },
    { url = "https://files.pythonhosted.org/packages/55/7a/4572af5324ad4b2bfdd2321e898a527050290147b4ea337a79a0e4e87ec7/ujson-5.11.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f278b31a7c52eb0947b2db55a5133fbc46b6f0ef49972cd1a80843b72e135aba", size = 57363, upload-time = "2025-08-20T11:56:09.758Z" },
    { url = "https://files.pythonhosted.org/packages/7b/71/a2b8c19cf4e1efe53cf439cdf7198ac60ae15471d2f1040b490c1f0f831f/ujson-5.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ab2cb8351d976e788669c8281465d44d4e94413718af497b4e7342d7b2f78018", size = 1036394, upload-time = "2025-08-20T11:56:11.168Z" },
    { url = "https://files.pythonhosted.org/packages/7a/3e/7b98668cba3bb3735929c31b999b374ebc02c19dfa98dfebaeeb5c8597ca/ujson-5.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:090b4d11b380ae25453100b722d0609d5051ffe98f80ec52853ccf8249dfd840", size = 1195837, upload-time = "2025-08-20T11:56:12.6Z" },
    { url = "https://files.pythonhosted.org/packages/a1/ea/8870f208c20b43571a5c409ebb2fe9b9dba5f494e9e60f9314ac01ea8f78/ujson-5.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:80017e870d882d5517d28995b62e4e518a894f932f1e242cbc802a2fd64d365c", size = 1088837, upload-time = "2025-08-20T11:56:14.15Z" },
    { url = "https://files.pythonhosted.org/packages/63/b6/c0e6607e37fa47929920a685a968c6b990a802dec65e9c5181e97845985d/ujson-5.11.0-cp314-cp314-win32.whl", hash = "sha256:1d663b96eb34c93392e9caae19c099ec4133ba21654b081956613327f0e973ac", size = 41022, upload-time = "2025-08-20T11:56:15.509Z" },
    { url = "https://files.pythonhosted.org/packages/4e/56/f4fe86b4c9000affd63e9219e59b222dc48b01c534533093e798bf617a7e/ujson-5.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:849e65b696f0d242833f1df4182096cedc50d414215d1371fca85c541fbff629", size = 45111, upload-time = "2025-08-20T11:56:16.597Z" },
    { url = "https://files.pythonhosted.org/packages/0a/f3/669437f0280308db4783b12a6d88c00730b394327d8334cc7a32ef218e64/ujson-5.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:e73df8648c9470af2b6a6bf5250d4744ad2cf3d774dcf8c6e31f018bdd04d764", size = 39682, upload-time = "2025-08-20T11:56:17.763Z" },
    { url = "https://files.pythonhosted.org/packages/6e/cd/e9809b064a89fe5c4184649adeb13c1b98652db3f8518980b04227358574/ujson-5.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:de6e88f62796372fba1de973c11138f197d3e0e1d80bcb2b8aae1e826096d433", size = 55759, upload-time = "2025-08-20T11:56:18.882Z" },
    { url = "https://files.pythonhosted.org/packages/1b/be/ae26a6321179ebbb3a2e2685b9007c71bcda41ad7a77bbbe164005e956fc/ujson-5.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e56ef8066f11b80d620985ae36869a3ff7e4b74c3b6129182ec5d1df0255f3", size = 53634, upload-time = "2025-08-20T11:56:20.012Z" },
    { url = "https://files.pythonhosted.org/packages/ae/e9/fb4a220ee6939db099f4cfeeae796ecb91e7584ad4d445d4ca7f994a9135/ujson-5.11.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a325fd2c3a056cf6c8e023f74a0c478dd282a93141356ae7f16d5309f5ff823", size = 58547, upload-time = "2025-08-20T11:56:21.175Z" },
    { url = "https://files.pythonhosted.org/packages/bd/f8/fc4b952b8f5fea09ea3397a0bd0ad019e474b204cabcb947cead5d4d1ffc/ujson-5.11.0-cp314-cp314t-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:a0af6574fc1d9d53f4ff371f58c96673e6d988ed2b5bf666a6143c782fa007e9", size = 60489, upload-time = "2025-08-20T11:56:22.342Z" },
    { url = "https://files.pythonhosted.org/packages/2e/e5/af5491dfda4f8b77e24cf3da68ee0d1552f99a13e5c622f4cef1380925c3/ujson-5.11.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10f29e71ecf4ecd93a6610bd8efa8e7b6467454a363c3d6416db65de883eb076", size = 58035, upload-time = "2025-08-20T11:56:23.92Z" },
    { url = "https://files.pythonhosted.org/packages/c4/09/0945349dd41f25cc8c38d78ace49f14c5052c5bbb7257d2f466fa7bdb533/ujson-5.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1a0a9b76a89827a592656fe12e000cf4f12da9692f51a841a4a07aa4c7ecc41c", size = 1037212, upload-time = "2025-08-20T11:56:25.274Z" },
    { url = "https://files.pythonhosted.org/packages/49/44/8e04496acb3d5a1cbee3a54828d9652f67a37523efa3d3b18a347339680a/ujson-5.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b16930f6a0753cdc7d637b33b4e8f10d5e351e1fb83872ba6375f1e87be39746", size = 1196500, upload-time = "2025-08-20T11:56:27.517Z" },
    { url = "https://files.pythonhosted.org/packages/64/ae/4bc825860d679a0f208a19af2f39206dfd804ace2403330fdc3170334a2f/ujson-5.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:04c41afc195fd477a59db3a84d5b83a871bd648ef371cf8c6f43072d89144eef", size = 1089487, upload-time = "2025-08-20T11:56:29.07Z" },
    { url = "https://files.pythonhosted.org/packages/30/ed/5a057199fb0a5deabe0957073a1c1c1c02a3e99476cd03daee98ea21fa57/ujson-5.11.0-cp314-cp314t-win32.whl", hash = "sha256:aa6d7a5e09217ff93234e050e3e380da62b084e26b9f2e277d2606406a2fc2e5", size = 41859, upload-time = "2025-08-20T11:56:30.495Z" },
    { url = "https://files.pythonhosted.org/packages/aa/03/b19c6176bdf1dc13ed84b886e99677a52764861b6cc023d5e7b6ebda249d/ujson-5.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:48055e1061c1bb1f79e75b4ac39e821f3f35a9b82de17fce92c3140149009bec", size = 46183, upload-time = "2025-08-20T11:56:31.574Z" },
    { url = "https://files.pythonhosted.org/packages/5d/ca/a0413a3874b2dc1708b8796ca895bf363292f9c70b2e8ca482b7dbc0259d/ujson-5.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:1194b943e951092db611011cb8dbdb6cf94a3b816ed07906e14d3bc6ce0e90ab", size = 40264, upload-time = "2025-08-20T11:56:32.773Z" },
    { url = "https://files.pythonhosted.org/packages/39/bf/c6f59cdf74ce70bd937b97c31c42fd04a5ed1a9222d0197e77e4bd899841/ujson-5.11.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:65f3c279f4ed4bf9131b11972040200c66ae040368abdbb21596bf1564899694", size = 55283, upload-time = "2025-08-20T11:56:33.947Z" },
    { url = "https://files.pythonhosted.org/packages/8d/c1/a52d55638c0c644b8a63059f95ad5ffcb4ad8f60d8bc3e8680f78e77cc75/ujson-5.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:99c49400572cd77050894e16864a335225191fd72a818ea6423ae1a06467beac", size = 53168, upload-time = "2025-08-20T11:56:35.141Z" },
    { url = "https://files.pythonhosted.org/packages/75/6c/e64e19a01d59c8187d01ffc752ee3792a09f5edaaac2a0402de004459dd7/ujson-5.11.0-cp39-cp39-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0654a2691fc252c3c525e3d034bb27b8a7546c9d3eb33cd29ce6c9feda361a6a", size = 57809, upload-time = "2025-08-20T11:56:36.293Z" },
    { url = "https://files.pythonhosted.org/packages/9f/36/910117b7a8a1c188396f6194ca7bc8fd75e376d8f7e3cf5eb6219fc8b09d/ujson-5.11.0-cp39-cp39-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:6b6ec7e7321d7fc19abdda3ad809baef935f49673951a8bab486aea975007e02", size = 59797, upload-time = "2025-08-20T11:56:37.746Z" },
    { url = "https://files.pythonhosted.org/packages/c7/17/bcc85d282ee2f4cdef5f577e0a43533eedcae29cc6405edf8c62a7a50368/ujson-5.11.0-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f62b9976fabbcde3ab6e413f4ec2ff017749819a0786d84d7510171109f2d53c", size = 57378, upload-time = "2025-08-20T11:56:39.123Z" },
    { url = "https://files.pythonhosted.org/packages/ef/39/120bb76441bf835f3c3f42db9c206f31ba875711637a52a8209949ab04b0/ujson-5.11.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7f1a27ab91083b4770e160d17f61b407f587548f2c2b5fbf19f94794c495594a", size = 1036515, upload-time = "2025-08-20T11:56:40.848Z" },
    { url = "https://files.pythonhosted.org/packages/b6/ae/fe1b4ff6388f681f6710e9494656957725b1e73ae50421ec04567df9fb75/ujson-5.11.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:ecd6ff8a3b5a90c292c2396c2d63c687fd0ecdf17de390d852524393cd9ed052", size = 1195753, upload-time = "2025-08-20T11:56:42.341Z" },
    { url = "https://files.pythonhosted.org/packages/92/20/005b93f2cf846ae50b46812fcf24bbdd127521197e5f1e1a82e3b3e730a1/ujson-5.11.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9aacbeb23fdbc4b256a7d12e0beb9063a1ba5d9e0dbb2cfe16357c98b4334596", size = 1088844, upload-time = "2025-08-20T11:56:43.777Z" },
    { url = "https://files.pythonhosted.org/packages/41/9e/3142023c30008e2b24d7368a389b26d28d62fcd3f596d3d898a72dd09173/ujson-5.11.0-cp39-cp39-win32.whl", hash = "sha256:674f306e3e6089f92b126eb2fe41bcb65e42a15432c143365c729fdb50518547", size = 39652, upload-time = "2025-08-20T11:56:45.034Z" },
    { url = "https://files.pythonhosted.org/packages/ca/89/f4de0a3c485d0163f85f552886251876645fb62cbbe24fcdc0874b9fae03/ujson-5.11.0-cp39-cp39-win_amd64.whl", hash = "sha256:c6618f480f7c9ded05e78a1938873fde68baf96cdd74e6d23c7e0a8441175c4b", size = 43783, upload-time = "2025-08-20T11:56:46.156Z" },
    { url = "https://files.pythonhosted.org/packages/48/b1/2d50987a7b7cccb5c1fbe9ae7b184211106237b32c7039118c41d79632ea/ujson-5.11.0-cp39-cp39-win_arm64.whl", hash = "sha256:5600202a731af24a25e2d7b6eb3f648e4ecd4bb67c4d5cf12f8fab31677469c9", size = 38430, upload-time = "2025-08-20T11:56:47.653Z" },
    { url = "https://files.pythonhosted.org/packages/50/17/30275aa2933430d8c0c4ead951cc4fdb922f575a349aa0b48a6f35449e97/ujson-5.11.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:abae0fb58cc820092a0e9e8ba0051ac4583958495bfa5262a12f628249e3b362", size = 51206, upload-time = "2025-08-20T11:56:48.797Z" },
    { url = "https://files.pythonhosted.org/packages/c3/15/42b3924258eac2551f8f33fa4e35da20a06a53857ccf3d4deb5e5d7c0b6c/ujson-5.11.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fac6c0649d6b7c3682a0a6e18d3de6857977378dce8d419f57a0b20e3d775b39", size = 48907, upload-time = "2025-08-20T11:56:50.136Z" },
    { url = "https://files.pythonhosted.org/packages/94/7e/0519ff7955aba581d1fe1fb1ca0e452471250455d182f686db5ac9e46119/ujson-5.11.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b42c115c7c6012506e8168315150d1e3f76e7ba0f4f95616f4ee599a1372bbc", size = 50319, upload-time = "2025-08-20T11:56:51.63Z" },
    { url = "https://files.pythonhosted.org/packages/74/cf/209d90506b7d6c5873f82c5a226d7aad1a1da153364e9ebf61eff0740c33/ujson-5.11.0-pp311-pypy311_pp73-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:86baf341d90b566d61a394869ce77188cc8668f76d7bb2c311d77a00f4bdf844", size = 56584, upload-time = "2025-08-20T11:56:52.89Z" },
    { url = "https://files.pythonhosted.org/packages/e9/97/bd939bb76943cb0e1d2b692d7e68629f51c711ef60425fa5bb6968037ecd/ujson-5.11.0-pp311-pypy311_pp73-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4598bf3965fc1a936bd84034312bcbe00ba87880ef1ee33e33c1e88f2c398b49", size = 51588, upload-time = "2025-08-20T11:56:54.054Z" },
    { url = "https://files.pythonhosted.org/packages/52/5b/8c5e33228f7f83f05719964db59f3f9f276d272dc43752fa3bbf0df53e7b/ujson-5.11.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:416389ec19ef5f2013592f791486bef712ebce0cd59299bf9df1ba40bb2f6e04", size = 43835, upload-time = "2025-08-20T11:56:55.237Z" },
]

[[package]]
name = "ujson"
version = "5.12.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
sdist = { url = "https://files.pythonhosted.org/packages/cb/3e/c35530c5ffc25b71c59ae0cd7b8f99df37313daa162ce1e2f7925f7c2877/ujson-5.12.0.tar.gz", hash = "sha256:14b2e1eb528d77bc0f4c5bd1a7ebc05e02b5b41beefb7e8567c9675b8b13bcf4", size = 7158451, upload-time = "2026-03-11T22:19:30.397Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/db/ee/45c7c1f9268b0fecdd68f9ada490bc09632b74f5f90a9be759e51a746ddc/ujson-5.12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:38051f36423f084b909aaadb3b41c9c6a2958e86956ba21a8489636911e87504", size = 56145, upload-time = "2026-03-11T22:17:49.409Z" },
    { url = "https://files.pythonhosted.org/packages/6d/dc/ed181dbfb2beee598e91280c6903ba71e10362b051716317e2d3664614bb/ujson-5.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:457fabc2700a8e6ddb85bc5a1d30d3345fe0d3ec3ee8161a4e032ec585801dfa", size = 53839, upload-time = "2026-03-11T22:17:50.973Z" },
    { url = "https://files.pythonhosted.org/packages/e4/d8/eb9ef42c660f431deeedc2e1b09c4ba29aa22818a439ddda7da6ae23ddfa/ujson-5.12.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57930ac9519099b852e190d2c04b1fb5d97ea128db33bce77ed874eccb4c7f09", size = 57844, upload-time = "2026-03-11T22:17:53.029Z" },
    { url = "https://files.pythonhosted.org/packages/68/37/0b586d079d3f2a5be5aa58ab5c423cbb4fae2ee4e65369c87aa74ac7e113/ujson-5.12.0-cp310-cp310-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:9b3b86ec3e818f3dd3e13a9de628e88a9990f4af68ecb0b12dd3de81227f0a26", size = 59923, upload-time = "2026-03-11T22:17:54.332Z" },
    { url = "https://files.pythonhosted.org/packages/28/ed/6a4b69eb397502767f438b5a2b4c066dccc9e3b263115f5ee07510250fc7/ujson-5.12.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:460e76a4daff214ae33ab959494962c93918cb44714ea3e3f748b14aa37f8a87", size = 57427, upload-time = "2026-03-11T22:17:55.317Z" },
    { url = "https://files.pythonhosted.org/packages/bb/4b/ae118440a72e85e68ee8dd26cfc47ea7857954a3341833cde9da7dc40ca3/ujson-5.12.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e584d0cdd37cac355aca52ed788d1a2d939d6837e2870d3b70e585db24025a50", size = 1037301, upload-time = "2026-03-11T22:17:56.427Z" },
    { url = "https://files.pythonhosted.org/packages/c2/76/834caa7905f65d3a695e4f5ff8d5d4a98508e396a9e8ab0739ab4fe2d422/ujson-5.12.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0fe9128e75c6aa6e9ae06c1408d6edd9179a2fef0fe6d9cda3166b887eba521d", size = 1196664, upload-time = "2026-03-11T22:17:58.061Z" },
    { url = "https://files.pythonhosted.org/packages/f2/33/1f3c1543c1d3f18c54bb3f8c1e74314fd6ad3c1aa375f01433e89a86bfa6/ujson-5.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3ed5cb149892141b1e77ef312924a327f2cc718b34247dae346ed66329e1b8be", size = 1089668, upload-time = "2026-03-11T22:17:59.617Z" },
    { url = "https://files.pythonhosted.org/packages/10/22/fd22e2f6766bae934d3050517ca47d463016bd8688508d1ecc1baa18a7ad/ujson-5.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:58a11cb49482f1a095a2bd9a1d81dd7c8fb5d2357f959ece85db4e46a825fd00", size = 56139, upload-time = "2026-03-11T22:18:04.591Z" },
    { url = "https://files.pythonhosted.org/packages/c6/fd/6839adff4fc0164cbcecafa2857ba08a6eaeedd7e098d6713cb899a91383/ujson-5.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9b3cf13facf6f77c283af0e1713e5e8c47a0fe295af81326cb3cb4380212e797", size = 53836, upload-time = "2026-03-11T22:18:05.662Z" },
    { url = "https://files.pythonhosted.org/packages/f9/b0/0c19faac62d68ceeffa83a08dc3d71b8462cf5064d0e7e0b15ba19898dad/ujson-5.12.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb94245a715b4d6e24689de12772b85329a1f9946cbf6187923a64ecdea39e65", size = 57851, upload-time = "2026-03-11T22:18:06.744Z" },
    { url = "https://files.pythonhosted.org/packages/04/f6/e7fd283788de73b86e99e08256726bb385923249c21dcd306e59d532a1a1/ujson-5.12.0-cp311-cp311-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:0fe6b8b8968e11dd9b2348bd508f0f57cf49ab3512064b36bc4117328218718e", size = 59906, upload-time = "2026-03-11T22:18:07.791Z" },
    { url = "https://files.pythonhosted.org/packages/d7/3a/b100735a2b43ee6e8fe4c883768e362f53576f964d4ea841991060aeaf35/ujson-5.12.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89e302abd3749f6d6699691747969a5d85f7c73081d5ed7e2624c7bd9721a2ab", size = 57409, upload-time = "2026-03-11T22:18:08.79Z" },
    { url = "https://files.pythonhosted.org/packages/5c/fa/f97cc20c99ca304662191b883ae13ae02912ca7244710016ba0cb8a5be34/ujson-5.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0727363b05ab05ee737a28f6200dc4078bce6b0508e10bd8aab507995a15df61", size = 1037339, upload-time = "2026-03-11T22:18:10.424Z" },
    { url = "https://files.pythonhosted.org/packages/10/7a/53ddeda0ffe1420db2f9999897b3cbb920fbcff1849d1f22b196d0f34785/ujson-5.12.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b62cb9a7501e1f5c9ffe190485501349c33e8862dde4377df774e40b8166871f", size = 1196625, upload-time = "2026-03-11T22:18:11.82Z" },
    { url = "https://files.pythonhosted.org/packages/0d/1a/4c64a6bef522e9baf195dd5be151bc815cd4896c50c6e2489599edcda85f/ujson-5.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a6ec5bf6bc361f2f0f9644907a36ce527715b488988a8df534120e5c34eeda94", size = 1089669, upload-time = "2026-03-11T22:18:13.343Z" },
    { url = "https://files.pythonhosted.org/packages/84/f6/ac763d2108d28f3a40bb3ae7d2fafab52ca31b36c2908a4ad02cd3ceba2a/ujson-5.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:09b4beff9cc91d445d5818632907b85fb06943b61cb346919ce202668bf6794a", size = 56326, upload-time = "2026-03-11T22:18:18.467Z" },
    { url = "https://files.pythonhosted.org/packages/25/46/d0b3af64dcdc549f9996521c8be6d860ac843a18a190ffc8affeb7259687/ujson-5.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ca0c7ce828bb76ab78b3991904b477c2fd0f711d7815c252d1ef28ff9450b052", size = 53910, upload-time = "2026-03-11T22:18:19.502Z" },
    { url = "https://files.pythonhosted.org/packages/9a/10/853c723bcabc3e9825a079019055fc99e71b85c6bae600607a2b9d31d18d/ujson-5.12.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2d79c6635ccffcbfc1d5c045874ba36b594589be81d50d43472570bb8de9c57", size = 57754, upload-time = "2026-03-11T22:18:20.874Z" },
    { url = "https://files.pythonhosted.org/packages/f9/c6/6e024830d988f521f144ead641981c1f7a82c17ad1927c22de3242565f5c/ujson-5.12.0-cp312-cp312-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:7e07f6f644d2c44d53b7a320a084eef98063651912c1b9449b5f45fcbdc6ccd2", size = 59936, upload-time = "2026-03-11T22:18:21.924Z" },
    { url = "https://files.pythonhosted.org/packages/34/c9/c5f236af5abe06b720b40b88819d00d10182d2247b1664e487b3ed9229cf/ujson-5.12.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:085b6ce182cdd6657481c7c4003a417e0655c4f6e58b76f26ee18f0ae21db827", size = 57463, upload-time = "2026-03-11T22:18:22.924Z" },
    { url = "https://files.pythonhosted.org/packages/ae/04/41342d9ef68e793a87d84e4531a150c2b682f3bcedfe59a7a5e3f73e9213/ujson-5.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:16b4fe9c97dc605f5e1887a9e1224287291e35c56cbc379f8aa44b6b7bcfe2bb", size = 1037239, upload-time = "2026-03-11T22:18:24.04Z" },
    { url = "https://files.pythonhosted.org/packages/d4/81/dc2b7617d5812670d4ff4a42f6dd77926430ee52df0dedb2aec7990b2034/ujson-5.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0d2e8db5ade3736a163906154ca686203acc7d1d30736cbf577c730d13653d84", size = 1196713, upload-time = "2026-03-11T22:18:25.391Z" },
    { url = "https://files.pythonhosted.org/packages/b6/9c/80acff0504f92459ed69e80a176286e32ca0147ac6a8252cd0659aad3227/ujson-5.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:93bc91fdadcf046da37a214eaa714574e7e9b1913568e93bb09527b2ceb7f759", size = 1089742, upload-time = "2026-03-11T22:18:26.738Z" },
    { url = "https://files.pythonhosted.org/packages/3f/f1/0ef0eeab1db8493e1833c8b440fe32cf7538f7afa6e7f7c7e9f62cef464d/ujson-5.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:15d416440148f3e56b9b244fdaf8a09fcf5a72e4944b8e119f5bf60417a2bfc8", size = 56331, upload-time = "2026-03-11T22:18:31.539Z" },
    { url = "https://files.pythonhosted.org/packages/b0/2f/9159f6f399b3f572d20847a2b80d133e3a03c14712b0da4971a36879fb64/ujson-5.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e0dd3676ea0837cd70ea1879765e9e9f6be063be0436de9b3ea4b775caf83654", size = 53910, upload-time = "2026-03-11T22:18:32.829Z" },
    { url = "https://files.pythonhosted.org/packages/e5/a9/f96376818d71495d1a4be19a0ab6acf0cc01dd8826553734c3d4dac685b2/ujson-5.12.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7bbf05c38debc90d1a195b11340cc85cb43ab3e753dc47558a3a84a38cbc72da", size = 57757, upload-time = "2026-03-11T22:18:33.866Z" },
    { url = "https://files.pythonhosted.org/packages/98/8d/dd4a151caac6fdcb77f024fbe7f09d465ebf347a628ed6dd581a0a7f6364/ujson-5.12.0-cp313-cp313-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:3c2f947e55d3c7cfe124dd4521ee481516f3007d13c6ad4bf6aeb722e190eb1b", size = 59940, upload-time = "2026-03-11T22:18:35.276Z" },
    { url = "https://files.pythonhosted.org/packages/c7/17/0d36c2fee0a8d8dc37b011ccd5bbdcfaff8b8ec2bcfc5be998661cdc935b/ujson-5.12.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ea6206043385343aff0b7da65cf73677f6f5e50de8f1c879e557f4298cac36a", size = 57465, upload-time = "2026-03-11T22:18:36.644Z" },
    { url = "https://files.pythonhosted.org/packages/8c/04/b0ee4a4b643a01ba398441da1e357480595edb37c6c94c508dbe0eb9eb60/ujson-5.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bb349dbba57c76eec25e5917e07f35aabaf0a33b9e67fc13d188002500106487", size = 1037236, upload-time = "2026-03-11T22:18:37.743Z" },
    { url = "https://files.pythonhosted.org/packages/2d/08/0e7780d0bbb48fe57ded91f550144bcc99c03b5360bf2886dd0dae0ea8f5/ujson-5.12.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:937794042342006f707837f38d721426b11b0774d327a2a45c0bd389eb750a87", size = 1196717, upload-time = "2026-03-11T22:18:39.101Z" },
    { url = "https://files.pythonhosted.org/packages/ba/4c/e0e34107715bb4dd2d4dcc1ce244d2f074638837adf38aff85a37506efe4/ujson-5.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6ad57654570464eb1b040b5c353dee442608e06cff9102b8fcb105565a44c9ed", size = 1089748, upload-time = "2026-03-11T22:18:40.473Z" },
    { url = "https://files.pythonhosted.org/packages/10/bd/9a8d693254bada62bfea75a507e014afcfdb6b9d047b6f8dd134bfefaf67/ujson-5.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85833bca01aa5cae326ac759276dc175c5fa3f7b3733b7d543cf27f2df12d1ef", size = 56499, upload-time = "2026-03-11T22:18:45.431Z" },
    { url = "https://files.pythonhosted.org/packages/bd/2d/285a83df8176e18dcd675d1a4cff8f7620f003f30903ea43929406e98986/ujson-5.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d22cad98c2a10bbf6aa083a8980db6ed90d4285a841c4de892890c2b28286ef9", size = 53998, upload-time = "2026-03-11T22:18:47.184Z" },
    { url = "https://files.pythonhosted.org/packages/bf/8b/e2f09e16dabfa91f6a84555df34a4329fa7621e92ed054d170b9054b9bb2/ujson-5.12.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99cc80facad240b0c2fb5a633044420878aac87a8e7c348b9486450cba93f27c", size = 57783, upload-time = "2026-03-11T22:18:48.271Z" },
    { url = "https://files.pythonhosted.org/packages/68/fb/ba1d06f3658a0c36d0ab3869ec3914f202bad0a9bde92654e41516c7bb13/ujson-5.12.0-cp314-cp314-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:d1831c07bd4dce53c4b666fa846c7eba4b7c414f2e641a4585b7f50b72f502dc", size = 60011, upload-time = "2026-03-11T22:18:49.284Z" },
    { url = "https://files.pythonhosted.org/packages/64/2b/3e322bf82d926d9857206cd5820438d78392d1f523dacecb8bd899952f73/ujson-5.12.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e00cec383eab2406c9e006bd4edb55d284e94bb943fda558326048178d26961", size = 57465, upload-time = "2026-03-11T22:18:50.584Z" },
    { url = "https://files.pythonhosted.org/packages/e9/fd/af72d69603f9885e5136509a529a4f6d88bf652b457263ff96aefcd3ab7d/ujson-5.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f19b3af31d02a2e79c5f9a6deaab0fb3c116456aeb9277d11720ad433de6dfc6", size = 1037275, upload-time = "2026-03-11T22:18:51.998Z" },
    { url = "https://files.pythonhosted.org/packages/9c/a7/a2411ec81aef7872578e56304c3e41b3a544a9809e95c8e1df46923fc40b/ujson-5.12.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bacbd3c69862478cbe1c7ed4325caedec580d8acf31b8ee1b9a1e02a56295cad", size = 1196758, upload-time = "2026-03-11T22:18:53.548Z" },
    { url = "https://files.pythonhosted.org/packages/ed/85/aa18ae175dd03a118555aa14304d4f466f9db61b924c97c6f84388ecacb1/ujson-5.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94c5f1621cbcab83c03be46441f090b68b9f307b6c7ec44d4e3f6d5997383df4", size = 1089760, upload-time = "2026-03-11T22:18:55.336Z" },
    { url = "https://files.pythonhosted.org/packages/c3/71/9b4dacb177d3509077e50497222d39eec04c8b41edb1471efc764d645237/ujson-5.12.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ddb08b3c2f9213df1f2e3eb2fbea4963d80ec0f8de21f0b59898e34f3b3d96d", size = 56845, upload-time = "2026-03-11T22:18:59.629Z" },
    { url = "https://files.pythonhosted.org/packages/24/c2/8abffa3be1f3d605c4a62445fab232b3e7681512ce941c6b23014f404d36/ujson-5.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0a3ae28f0b209be5af50b54ca3e2123a3de3a57d87b75f1e5aa3d7961e041983", size = 54463, upload-time = "2026-03-11T22:19:00.697Z" },
    { url = "https://files.pythonhosted.org/packages/db/2e/60114a35d1d6796eb428f7affcba00a921831ff604a37d9142c3d8bbe5c5/ujson-5.12.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d30ad4359413c8821cc7b3707f7ca38aa8bc852ba3b9c5a759ee2d7740157315", size = 58689, upload-time = "2026-03-11T22:19:01.739Z" },
    { url = "https://files.pythonhosted.org/packages/c8/ad/010925c2116c21ce119f9c2ff18d01f48a19ade3ff4c5795da03ce5829fc/ujson-5.12.0-cp314-cp314t-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:02f93da7a4115e24f886b04fd56df1ee8741c2ce4ea491b7ab3152f744ad8f8e", size = 60618, upload-time = "2026-03-11T22:19:03.101Z" },
    { url = "https://files.pythonhosted.org/packages/9b/74/db7f638bf20282b1dccf454386cbd483faaaed3cdbb9cb27e06f74bb109e/ujson-5.12.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3ff4ede90ed771140caa7e1890de17431763a483c54b3c1f88bd30f0cc1affc0", size = 58151, upload-time = "2026-03-11T22:19:04.175Z" },
    { url = "https://files.pythonhosted.org/packages/9c/7e/3ebaecfa70a2e8ce623db8e21bd5cb05d42a5ef943bcbb3309d71b5de68d/ujson-5.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bf9cc97f05048ac8f3e02cd58f0fe62b901453c24345bfde287f4305dcc31c", size = 1038117, upload-time = "2026-03-11T22:19:05.558Z" },
    { url = "https://files.pythonhosted.org/packages/2e/aa/e073eda7f0036c2973b28db7bb99faba17a932e7b52d801f9bb3e726271f/ujson-5.12.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2324d9a0502317ffc35d38e153c1b2fa9610ae03775c9d0f8d0cca7b8572b04e", size = 1197434, upload-time = "2026-03-11T22:19:06.92Z" },
    { url = "https://files.pythonhosted.org/packages/1c/01/b9a13f058fdd50c746b192c4447ca8d6352e696dcda912ccee10f032ff85/ujson-5.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:50524f4f6a1c839714dbaff5386a1afb245d2d5ec8213a01fbc99cea7307811e", size = 1090401, upload-time = "2026-03-11T22:19:08.383Z" },
    { url = "https://files.pythonhosted.org/packages/95/3c/5ee154d505d1aad2debc4ba38b1a60ae1949b26cdb5fa070e85e320d6b64/ujson-5.12.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:bf85a00ac3b56a1e7a19c5be7b02b5180a0895ac4d3c234d717a55e86960691c", size = 54494, upload-time = "2026-03-11T22:19:13.035Z" },
    { url = "https://files.pythonhosted.org/packages/ce/b3/9496ec399ec921e434a93b340bd5052999030b7ac364be4cbe5365ac6b20/ujson-5.12.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:64df53eef4ac857eb5816a56e2885ccf0d7dff6333c94065c93b39c51063e01d", size = 57999, upload-time = "2026-03-11T22:19:14.385Z" },
    { url = "https://files.pythonhosted.org/packages/0e/da/e9ae98133336e7c0d50b43626c3f2327937cecfa354d844e02ac17379ed1/ujson-5.12.0-graalpy312-graalpy250_312_native-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c0aed6a4439994c9666fb8a5b6c4eac94d4ef6ddc95f9b806a599ef83547e3b", size = 54518, upload-time = "2026-03-11T22:19:15.4Z" },
    { url = "https://files.pythonhosted.org/packages/58/10/978d89dded6bb1558cd46ba78f4351198bd2346db8a8ee1a94119022ce40/ujson-5.12.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efae5df7a8cc8bdb1037b0f786b044ce281081441df5418c3a0f0e1f86fe7bb3", size = 55736, upload-time = "2026-03-11T22:19:16.496Z" },
    { url = "https://files.pythonhosted.org/packages/19/fa/f4a957dddb99bd68c8be91928c0b6fefa7aa8aafc92c93f5d1e8b32f6702/ujson-5.12.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:871c0e5102e47995b0e37e8df7819a894a6c3da0d097545cd1f9f1f7d7079927", size = 52145, upload-time = "2026-03-11T22:19:18.566Z" },
    { url = "https://files.pythonhosted.org/packages/55/6e/50b5cf612de1ca06c7effdc5a5d7e815774dee85a5858f1882c425553b82/ujson-5.12.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:56ba3f7abbd6b0bb282a544dc38406d1a188d8bb9164f49fdb9c2fee62cb29da", size = 49577, upload-time = "2026-03-11T22:19:19.627Z" },
    { url = "https://files.pythonhosted.org/packages/6e/24/b6713fa9897774502cd4c2d6955bb4933349f7d84c3aa805531c382a4209/ujson-5.12.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c5a52987a990eb1bae55f9000994f1afdb0326c154fb089992f839ab3c30688", size = 50807, upload-time = "2026-03-11T22:19:20.778Z" },
    { url = "https://files.pythonhosted.org/packages/1f/b6/c0e0f7901180ef80d16f3a4bccb5dc8b01515a717336a62928963a07b80b/ujson-5.12.0-pp311-pypy311_pp73-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:adf28d13a33f9d750fe7a78fb481cac298fa257d8863d8727b2ea4455ea41235", size = 56972, upload-time = "2026-03-11T22:19:21.84Z" },
    { url = "https://files.pythonhosted.org/packages/02/a9/05d91b4295ea7239151eb08cf240e5a2ba969012fda50bc27bcb1ea9cd71/ujson-5.12.0-pp311-pypy311_pp73-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51acc750ec7a2df786cdc868fb16fa04abd6269a01d58cf59bafc57978773d8e", size = 52045, upload-time = "2026-03-11T22:19:22.879Z" },
]

[[package]]
name = "urllib3"
version = "1.26.20"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/e8/6ff5e6bc22095cfc59b6ea711b687e2b7ed4bdb373f7eeec370a97d7392f/urllib3-1.26.20.tar.gz", hash = "sha256:40c2dc0c681e47eb8f90e7e27bf6ff7df2e677421fd46756da1161c39ca70d32", size = 307380, upload-time = "2024-08-29T15:43:11.37Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/33/cf/8435d5a7159e2a9c83a95896ed596f68cf798005fe107cc655b5c5c14704/urllib3-1.26.20-py2.py3-none-any.whl", hash = "sha256:0ed14ccfbf1c30a9072c7ca157e4319b70d65f623e91e7b32fadb2853431016e", size = 144225, upload-time = "2024-08-29T15:43:08.921Z" },
]

[[package]]
name = "urllib3"
version = "2.6.3"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
]

[[package]]
name = "uuid-utils"
version = "0.14.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7b/d1/38a573f0c631c062cf42fa1f5d021d4dd3c31fb23e4376e4b56b0c9fbbed/uuid_utils-0.14.1.tar.gz", hash = "sha256:9bfc95f64af80ccf129c604fb6b8ca66c6f256451e32bc4570f760e4309c9b69", size = 22195, upload-time = "2026-02-20T22:50:38.833Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/43/b7/add4363039a34506a58457d96d4aa2126061df3a143eb4d042aedd6a2e76/uuid_utils-0.14.1-cp39-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:93a3b5dc798a54a1feb693f2d1cb4cf08258c32ff05ae4929b5f0a2ca624a4f0", size = 604679, upload-time = "2026-02-20T22:50:27.469Z" },
    { url = "https://files.pythonhosted.org/packages/dd/84/d1d0bef50d9e66d31b2019997c741b42274d53dde2e001b7a83e9511c339/uuid_utils-0.14.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:ccd65a4b8e83af23eae5e56d88034b2fe7264f465d3e830845f10d1591b81741", size = 309346, upload-time = "2026-02-20T22:50:31.857Z" },
    { url = "https://files.pythonhosted.org/packages/ef/ed/b6d6fd52a6636d7c3eddf97d68da50910bf17cd5ac221992506fb56cf12e/uuid_utils-0.14.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b56b0cacd81583834820588378e432b0696186683b813058b707aedc1e16c4b1", size = 344714, upload-time = "2026-02-20T22:50:42.642Z" },
    { url = "https://files.pythonhosted.org/packages/a8/a7/a19a1719fb626fe0b31882db36056d44fe904dc0cf15b06fdf56b2679cf7/uuid_utils-0.14.1-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb3cf14de789097320a3c56bfdfdd51b1225d11d67298afbedee7e84e3837c96", size = 350914, upload-time = "2026-02-20T22:50:36.487Z" },
    { url = "https://files.pythonhosted.org/packages/1d/fc/f6690e667fdc3bb1a73f57951f97497771c56fe23e3d302d7404be394d4f/uuid_utils-0.14.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60e0854a90d67f4b0cc6e54773deb8be618f4c9bad98d3326f081423b5d14fae", size = 482609, upload-time = "2026-02-20T22:50:37.511Z" },
    { url = "https://files.pythonhosted.org/packages/54/6e/dcd3fa031320921a12ec7b4672dea3bd1dd90ddffa363a91831ba834d559/uuid_utils-0.14.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce6743ba194de3910b5feb1a62590cd2587e33a73ab6af8a01b642ceb5055862", size = 345699, upload-time = "2026-02-20T22:50:46.87Z" },
    { url = "https://files.pythonhosted.org/packages/04/28/e5220204b58b44ac0047226a9d016a113fde039280cc8732d9e6da43b39f/uuid_utils-0.14.1-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:043fb58fde6cf1620a6c066382f04f87a8e74feb0f95a585e4ed46f5d44af57b", size = 372205, upload-time = "2026-02-20T22:50:28.438Z" },
    { url = "https://files.pythonhosted.org/packages/c7/d9/3d2eb98af94b8dfffc82b6a33b4dfc87b0a5de2c68a28f6dde0db1f8681b/uuid_utils-0.14.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c915d53f22945e55fe0d3d3b0b87fd965a57f5fd15666fd92d6593a73b1dd297", size = 521836, upload-time = "2026-02-20T22:50:23.057Z" },
    { url = "https://files.pythonhosted.org/packages/a8/15/0eb106cc6fe182f7577bc0ab6e2f0a40be247f35c5e297dbf7bbc460bd02/uuid_utils-0.14.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:0972488e3f9b449e83f006ead5a0e0a33ad4a13e4462e865b7c286ab7d7566a3", size = 625260, upload-time = "2026-02-20T22:50:25.949Z" },
    { url = "https://files.pythonhosted.org/packages/3c/17/f539507091334b109e7496830af2f093d9fc8082411eafd3ece58af1f8ba/uuid_utils-0.14.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:1c238812ae0c8ffe77d8d447a32c6dfd058ea4631246b08b5a71df586ff08531", size = 587824, upload-time = "2026-02-20T22:50:35.225Z" },
    { url = "https://files.pythonhosted.org/packages/2e/c2/d37a7b2e41f153519367d4db01f0526e0d4b06f1a4a87f1c5dfca5d70a8b/uuid_utils-0.14.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:bec8f8ef627af86abf8298e7ec50926627e29b34fa907fcfbedb45aaa72bca43", size = 551407, upload-time = "2026-02-20T22:50:44.915Z" },
    { url = "https://files.pythonhosted.org/packages/65/36/2d24b2cbe78547c6532da33fb8613debd3126eccc33a6374ab788f5e46e9/uuid_utils-0.14.1-cp39-abi3-win32.whl", hash = "sha256:b54d6aa6252d96bac1fdbc80d26ba71bad9f220b2724d692ad2f2310c22ef523", size = 183476, upload-time = "2026-02-20T22:50:32.745Z" },
    { url = "https://files.pythonhosted.org/packages/83/92/2d7e90df8b1a69ec4cff33243ce02b7a62f926ef9e2f0eca5a026889cd73/uuid_utils-0.14.1-cp39-abi3-win_amd64.whl", hash = "sha256:fc27638c2ce267a0ce3e06828aff786f91367f093c80625ee21dad0208e0f5ba", size = 187147, upload-time = "2026-02-20T22:50:45.807Z" },
    { url = "https://files.pythonhosted.org/packages/d9/26/529f4beee17e5248e37e0bc17a2761d34c0fa3b1e5729c88adb2065bae6e/uuid_utils-0.14.1-cp39-abi3-win_arm64.whl", hash = "sha256:b04cb49b42afbc4ff8dbc60cf054930afc479d6f4dd7f1ec3bbe5dbfdde06b7a", size = 188132, upload-time = "2026-02-20T22:50:41.718Z" },
    { url = "https://files.pythonhosted.org/packages/91/f9/6c64bdbf71f58ccde7919e00491812556f446a5291573af92c49a5e9aaef/uuid_utils-0.14.1-pp311-pypy311_pp73-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:b197cd5424cf89fb019ca7f53641d05bfe34b1879614bed111c9c313b5574cd8", size = 591617, upload-time = "2026-02-20T22:50:24.532Z" },
    { url = "https://files.pythonhosted.org/packages/d0/f0/758c3b0fb0c4871c7704fef26a5bc861de4f8a68e4831669883bebe07b0f/uuid_utils-0.14.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:12c65020ba6cb6abe1d57fcbfc2d0ea0506c67049ee031714057f5caf0f9bc9c", size = 303702, upload-time = "2026-02-20T22:50:40.687Z" },
    { url = "https://files.pythonhosted.org/packages/85/89/d91862b544c695cd58855efe3201f83894ed82fffe34500774238ab8eba7/uuid_utils-0.14.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b5d2ad28063d422ccc2c28d46471d47b61a58de885d35113a8f18cb547e25bf", size = 337678, upload-time = "2026-02-20T22:50:39.768Z" },
    { url = "https://files.pythonhosted.org/packages/ee/6b/cf342ba8a898f1de024be0243fac67c025cad530c79ea7f89c4ce718891a/uuid_utils-0.14.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:da2234387b45fde40b0fedfee64a0ba591caeea9c48c7698ab6e2d85c7991533", size = 343711, upload-time = "2026-02-20T22:50:43.965Z" },
    { url = "https://files.pythonhosted.org/packages/b3/20/049418d094d396dfa6606b30af925cc68a6670c3b9103b23e6990f84b589/uuid_utils-0.14.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50fffc2827348c1e48972eed3d1c698959e63f9d030aa5dd82ba451113158a62", size = 476731, upload-time = "2026-02-20T22:50:30.589Z" },
    { url = "https://files.pythonhosted.org/packages/77/a1/0857f64d53a90321e6a46a3d4cc394f50e1366132dcd2ae147f9326ca98b/uuid_utils-0.14.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1dbe718765f70f5b7f9b7f66b6a937802941b1cc56bcf642ce0274169741e01", size = 338902, upload-time = "2026-02-20T22:50:33.927Z" },
    { url = "https://files.pythonhosted.org/packages/ed/d0/5bf7cbf1ac138c92b9ac21066d18faf4d7e7f651047b700eb192ca4b9fdb/uuid_utils-0.14.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:258186964039a8e36db10810c1ece879d229b01331e09e9030bc5dcabe231bd2", size = 364700, upload-time = "2026-02-20T22:50:21.732Z" },
]

[[package]]
name = "uvicorn"
version = "0.39.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
dependencies = [
    { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "h11", marker = "python_full_version < '3.10'" },
    { name = "typing-extensions", marker = "python_full_version < '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ae/4f/f9fdac7cf6dd79790eb165639b5c452ceeabc7bbabbba4569155470a287d/uvicorn-0.39.0.tar.gz", hash = "sha256:610512b19baa93423d2892d7823741f6d27717b642c8964000d7194dded19302", size = 82001, upload-time = "2025-12-21T13:05:17.973Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/6b/25/db2b1c6c35bf22e17fe5412d2ee5d3fd7a20d07ebc9dac8b58f7db2e23a0/uvicorn-0.39.0-py3-none-any.whl", hash = "sha256:7beec21bd2693562b386285b188a7963b06853c0d006302b3e4cfed950c9929a", size = 68491, upload-time = "2025-12-21T13:05:16.291Z" },
]

[package.optional-dependencies]
standard = [
    { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" },
    { name = "httptools", marker = "python_full_version < '3.10'" },
    { name = "python-dotenv", version = "1.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "pyyaml", marker = "python_full_version < '3.10'" },
    { name = "uvloop", marker = "python_full_version < '3.10' and platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
    { name = "watchfiles", marker = "python_full_version < '3.10'" },
    { name = "websockets", version = "15.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
]

[[package]]
name = "uvicorn"
version = "0.44.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
dependencies = [
    { name = "click", version = "8.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "h11", marker = "python_full_version >= '3.10'" },
    { name = "typing-extensions", marker = "python_full_version == '3.10.*'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5e/da/6eee1ff8b6cbeed47eeb5229749168e81eb4b7b999a1a15a7176e51410c9/uvicorn-0.44.0.tar.gz", hash = "sha256:6c942071b68f07e178264b9152f1f16dfac5da85880c4ce06366a96d70d4f31e", size = 86947, upload-time = "2026-04-06T09:23:22.826Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/b7/23/a5bbd9600dd607411fa644c06ff4951bec3a4d82c4b852374024359c19c0/uvicorn-0.44.0-py3-none-any.whl", hash = "sha256:ce937c99a2cc70279556967274414c087888e8cec9f9c94644dfca11bd3ced89", size = 69425, upload-time = "2026-04-06T09:23:21.524Z" },
]

[package.optional-dependencies]
standard = [
    { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" },
    { name = "httptools", marker = "python_full_version >= '3.10'" },
    { name = "python-dotenv", version = "1.2.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "pyyaml", marker = "python_full_version >= '3.10'" },
    { name = "uvloop", marker = "python_full_version >= '3.10' and platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
    { name = "watchfiles", marker = "python_full_version >= '3.10'" },
    { name = "websockets", version = "16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]

[[package]]
name = "uvloop"
version = "0.22.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/eb/14/ecceb239b65adaaf7fde510aa8bd534075695d1e5f8dadfa32b5723d9cfb/uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c", size = 1343335, upload-time = "2025-10-16T22:16:11.43Z" },
    { url = "https://files.pythonhosted.org/packages/ba/ae/6f6f9af7f590b319c94532b9567409ba11f4fa71af1148cab1bf48a07048/uvloop-0.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792", size = 742903, upload-time = "2025-10-16T22:16:12.979Z" },
    { url = "https://files.pythonhosted.org/packages/09/bd/3667151ad0702282a1f4d5d29288fce8a13c8b6858bf0978c219cd52b231/uvloop-0.22.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac33ed96229b7790eb729702751c0e93ac5bc3bcf52ae9eccbff30da09194b86", size = 3648499, upload-time = "2025-10-16T22:16:14.451Z" },
    { url = "https://files.pythonhosted.org/packages/b3/f6/21657bb3beb5f8c57ce8be3b83f653dd7933c2fd00545ed1b092d464799a/uvloop-0.22.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:481c990a7abe2c6f4fc3d98781cc9426ebd7f03a9aaa7eb03d3bfc68ac2a46bd", size = 3700133, upload-time = "2025-10-16T22:16:16.272Z" },
    { url = "https://files.pythonhosted.org/packages/09/e0/604f61d004ded805f24974c87ddd8374ef675644f476f01f1df90e4cdf72/uvloop-0.22.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a592b043a47ad17911add5fbd087c76716d7c9ccc1d64ec9249ceafd735f03c2", size = 3512681, upload-time = "2025-10-16T22:16:18.07Z" },
    { url = "https://files.pythonhosted.org/packages/bb/ce/8491fd370b0230deb5eac69c7aae35b3be527e25a911c0acdffb922dc1cd/uvloop-0.22.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1489cf791aa7b6e8c8be1c5a080bae3a672791fcb4e9e12249b05862a2ca9cec", size = 3615261, upload-time = "2025-10-16T22:16:19.596Z" },
    { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" },
    { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" },
    { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" },
    { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" },
    { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" },
    { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" },
    { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" },
    { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" },
    { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" },
    { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" },
    { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" },
    { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" },
    { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" },
    { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" },
    { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" },
    { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" },
    { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" },
    { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" },
    { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" },
    { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" },
    { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" },
    { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" },
    { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" },
    { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" },
    { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" },
    { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" },
    { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" },
    { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" },
    { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" },
    { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
    { url = "https://files.pythonhosted.org/packages/bd/1b/6fbd611aeba01ef802c5876c94d7be603a9710db055beacbad39e75a31aa/uvloop-0.22.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b45649628d816c030dba3c80f8e2689bab1c89518ed10d426036cdc47874dfc4", size = 1345858, upload-time = "2025-10-16T22:17:11.106Z" },
    { url = "https://files.pythonhosted.org/packages/9e/91/2c84f00bdbe3c51023cc83b027bac1fe959ba4a552e970da5ef0237f7945/uvloop-0.22.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ea721dd3203b809039fcc2983f14608dae82b212288b346e0bfe46ec2fab0b7c", size = 743913, upload-time = "2025-10-16T22:17:12.165Z" },
    { url = "https://files.pythonhosted.org/packages/cc/10/76aec83886d41a88aca5681db6a2c0601622d0d2cb66cd0d200587f962ad/uvloop-0.22.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ae676de143db2b2f60a9696d7eca5bb9d0dd6cc3ac3dad59a8ae7e95f9e1b54", size = 3635818, upload-time = "2025-10-16T22:17:13.812Z" },
    { url = "https://files.pythonhosted.org/packages/d5/9a/733fcb815d345979fc54d3cdc3eb50bc75a47da3e4003ea7ada58e6daa65/uvloop-0.22.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17d4e97258b0172dfa107b89aa1eeba3016f4b1974ce85ca3ef6a66b35cbf659", size = 3685477, upload-time = "2025-10-16T22:17:15.307Z" },
    { url = "https://files.pythonhosted.org/packages/83/fb/bee1eb11cc92bd91f76d97869bb6a816e80d59fd73721b0a3044dc703d9c/uvloop-0.22.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:05e4b5f86e621cf3927631789999e697e58f0d2d32675b67d9ca9eb0bca55743", size = 3496128, upload-time = "2025-10-16T22:17:16.558Z" },
    { url = "https://files.pythonhosted.org/packages/76/ee/3fdfeaa9776c0fd585d358c92b1dbca669720ffa476f0bbe64ed8f245bd7/uvloop-0.22.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:286322a90bea1f9422a470d5d2ad82d38080be0a29c4dd9b3e6384320a4d11e7", size = 3602565, upload-time = "2025-10-16T22:17:17.755Z" },
]

[[package]]
name = "virtualenv"
version = "21.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "distlib" },
    { name = "filelock", version = "3.19.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "filelock", version = "3.25.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "platformdirs", version = "4.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "platformdirs", version = "4.9.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
    { name = "python-discovery" },
    { name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/aa/92/58199fe10049f9703c2666e809c4f686c54ef0a68b0f6afccf518c0b1eb9/virtualenv-21.2.0.tar.gz", hash = "sha256:1720dc3a62ef5b443092e3f499228599045d7fea4c79199770499df8becf9098", size = 5840618, upload-time = "2026-03-09T17:24:38.013Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/c6/59/7d02447a55b2e55755011a647479041bc92a82e143f96a8195cb33bd0a1c/virtualenv-21.2.0-py3-none-any.whl", hash = "sha256:1bd755b504931164a5a496d217c014d098426cddc79363ad66ac78125f9d908f", size = 5825084, upload-time = "2026-03-09T17:24:35.378Z" },
]

[[package]]
name = "watchfiles"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "anyio", version = "4.12.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
    { name = "anyio", version = "4.13.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/a7/1a/206e8cf2dd86fddf939165a57b4df61607a1e0add2785f170a3f616b7d9f/watchfiles-1.1.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:eef58232d32daf2ac67f42dea51a2c80f0d03379075d44a587051e63cc2e368c", size = 407318, upload-time = "2025-10-14T15:04:18.753Z" },
    { url = "https://files.pythonhosted.org/packages/b3/0f/abaf5262b9c496b5dad4ed3c0e799cbecb1f8ea512ecb6ddd46646a9fca3/watchfiles-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03fa0f5237118a0c5e496185cafa92878568b652a2e9a9382a5151b1a0380a43", size = 394478, upload-time = "2025-10-14T15:04:20.297Z" },
    { url = "https://files.pythonhosted.org/packages/b1/04/9cc0ba88697b34b755371f5ace8d3a4d9a15719c07bdc7bd13d7d8c6a341/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ca65483439f9c791897f7db49202301deb6e15fe9f8fe2fed555bf986d10c31", size = 449894, upload-time = "2025-10-14T15:04:21.527Z" },
    { url = "https://files.pythonhosted.org/packages/d2/9c/eda4615863cd8621e89aed4df680d8c3ec3da6a4cf1da113c17decd87c7f/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0ab1c1af0cb38e3f598244c17919fb1a84d1629cc08355b0074b6d7f53138ac", size = 459065, upload-time = "2025-10-14T15:04:22.795Z" },
    { url = "https://files.pythonhosted.org/packages/84/13/f28b3f340157d03cbc8197629bc109d1098764abe1e60874622a0be5c112/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bc570d6c01c206c46deb6e935a260be44f186a2f05179f52f7fcd2be086a94d", size = 488377, upload-time = "2025-10-14T15:04:24.138Z" },
    { url = "https://files.pythonhosted.org/packages/86/93/cfa597fa9389e122488f7ffdbd6db505b3b915ca7435ecd7542e855898c2/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e84087b432b6ac94778de547e08611266f1f8ffad28c0ee4c82e028b0fc5966d", size = 595837, upload-time = "2025-10-14T15:04:25.057Z" },
    { url = "https://files.pythonhosted.org/packages/57/1e/68c1ed5652b48d89fc24d6af905d88ee4f82fa8bc491e2666004e307ded1/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:620bae625f4cb18427b1bb1a2d9426dc0dd5a5ba74c7c2cdb9de405f7b129863", size = 473456, upload-time = "2025-10-14T15:04:26.497Z" },
    { url = "https://files.pythonhosted.org/packages/d5/dc/1a680b7458ffa3b14bb64878112aefc8f2e4f73c5af763cbf0bd43100658/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:544364b2b51a9b0c7000a4b4b02f90e9423d97fbbf7e06689236443ebcad81ab", size = 455614, upload-time = "2025-10-14T15:04:27.539Z" },
    { url = "https://files.pythonhosted.org/packages/61/a5/3d782a666512e01eaa6541a72ebac1d3aae191ff4a31274a66b8dd85760c/watchfiles-1.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:bbe1ef33d45bc71cf21364df962af171f96ecaeca06bd9e3d0b583efb12aec82", size = 630690, upload-time = "2025-10-14T15:04:28.495Z" },
    { url = "https://files.pythonhosted.org/packages/9b/73/bb5f38590e34687b2a9c47a244aa4dd50c56a825969c92c9c5fc7387cea1/watchfiles-1.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1a0bb430adb19ef49389e1ad368450193a90038b5b752f4ac089ec6942c4dff4", size = 622459, upload-time = "2025-10-14T15:04:29.491Z" },
    { url = "https://files.pythonhosted.org/packages/f1/ac/c9bb0ec696e07a20bd58af5399aeadaef195fb2c73d26baf55180fe4a942/watchfiles-1.1.1-cp310-cp310-win32.whl", hash = "sha256:3f6d37644155fb5beca5378feb8c1708d5783145f2a0f1c4d5a061a210254844", size = 272663, upload-time = "2025-10-14T15:04:30.435Z" },
    { url = "https://files.pythonhosted.org/packages/11/a0/a60c5a7c2ec59fa062d9a9c61d02e3b6abd94d32aac2d8344c4bdd033326/watchfiles-1.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a36d8efe0f290835fd0f33da35042a1bb5dc0e83cbc092dcf69bce442579e88e", size = 287453, upload-time = "2025-10-14T15:04:31.53Z" },
    { url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529, upload-time = "2025-10-14T15:04:32.899Z" },
    { url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384, upload-time = "2025-10-14T15:04:33.761Z" },
    { url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789, upload-time = "2025-10-14T15:04:34.679Z" },
    { url = "https://files.pythonhosted.org/packages/4a/24/33e71113b320030011c8e4316ccca04194bf0cbbaeee207f00cbc7d6b9f5/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b", size = 460521, upload-time = "2025-10-14T15:04:35.963Z" },
    { url = "https://files.pythonhosted.org/packages/f4/c3/3c9a55f255aa57b91579ae9e98c88704955fa9dac3e5614fb378291155df/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14", size = 488722, upload-time = "2025-10-14T15:04:37.091Z" },
    { url = "https://files.pythonhosted.org/packages/49/36/506447b73eb46c120169dc1717fe2eff07c234bb3232a7200b5f5bd816e9/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d", size = 596088, upload-time = "2025-10-14T15:04:38.39Z" },
    { url = "https://files.pythonhosted.org/packages/82/ab/5f39e752a9838ec4d52e9b87c1e80f1ee3ccdbe92e183c15b6577ab9de16/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff", size = 472923, upload-time = "2025-10-14T15:04:39.666Z" },
    { url = "https://files.pythonhosted.org/packages/af/b9/a419292f05e302dea372fa7e6fda5178a92998411f8581b9830d28fb9edb/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606", size = 456080, upload-time = "2025-10-14T15:04:40.643Z" },
    { url = "https://files.pythonhosted.org/packages/b0/c3/d5932fd62bde1a30c36e10c409dc5d54506726f08cb3e1d8d0ba5e2bc8db/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701", size = 629432, upload-time = "2025-10-14T15:04:41.789Z" },
    { url = "https://files.pythonhosted.org/packages/f7/77/16bddd9779fafb795f1a94319dc965209c5641db5bf1edbbccace6d1b3c0/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10", size = 623046, upload-time = "2025-10-14T15:04:42.718Z" },
    { url = "https://files.pythonhosted.org/packages/46/ef/f2ecb9a0f342b4bfad13a2787155c6ee7ce792140eac63a34676a2feeef2/watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849", size = 271473, upload-time = "2025-10-14T15:04:43.624Z" },
    { url = "https://files.pythonhosted.org/packages/94/bc/f42d71125f19731ea435c3948cad148d31a64fccde3867e5ba4edee901f9/watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4", size = 287598, upload-time = "2025-10-14T15:04:44.516Z" },
    { url = "https://files.pythonhosted.org/packages/57/c9/a30f897351f95bbbfb6abcadafbaca711ce1162f4db95fc908c98a9165f3/watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e", size = 277210, upload-time = "2025-10-14T15:04:45.883Z" },
    { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" },
    { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" },
    { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" },
    { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" },
    { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" },
    { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" },
    { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" },
    { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" },
    { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" },
    { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" },
    { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" },
    { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" },
    { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" },
    { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" },
    { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" },
    { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" },
    { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" },
    { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" },
    { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" },
    { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" },
    { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" },
    { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" },
    { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" },
    { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" },
    { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" },
    { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" },
    { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" },
    { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" },
    { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" },
    { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" },
    { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" },
    { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" },
    { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" },
    { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" },
    { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" },
    { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" },
    { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" },
    { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" },
    { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" },
    { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" },
    { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" },
    { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" },
    { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" },
    { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" },
    { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" },
    { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" },
    { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" },
    { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" },
    { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" },
    { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" },
    { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" },
    { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" },
    { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" },
    { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" },
    { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" },
    { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" },
    { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" },
    { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" },
    { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" },
    { url = "https://files.pythonhosted.org/packages/a4/68/a7303a15cc797ab04d58f1fea7f67c50bd7f80090dfd7e750e7576e07582/watchfiles-1.1.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:c882d69f6903ef6092bedfb7be973d9319940d56b8427ab9187d1ecd73438a70", size = 409220, upload-time = "2025-10-14T15:05:51.917Z" },
    { url = "https://files.pythonhosted.org/packages/99/b8/d1857ce9ac76034c053fa7ef0e0ef92d8bd031e842ea6f5171725d31e88f/watchfiles-1.1.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d6ff426a7cb54f310d51bfe83fe9f2bbe40d540c741dc974ebc30e6aa238f52e", size = 396712, upload-time = "2025-10-14T15:05:53.437Z" },
    { url = "https://files.pythonhosted.org/packages/41/7a/da7ada566f48beaa6a30b13335b49d1f6febaf3a5ddbd1d92163a1002cf4/watchfiles-1.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79ff6c6eadf2e3fc0d7786331362e6ef1e51125892c75f1004bd6b52155fb956", size = 451462, upload-time = "2025-10-14T15:05:54.742Z" },
    { url = "https://files.pythonhosted.org/packages/e2/b2/7cb9e0d5445a8d45c4cccd68a590d9e3a453289366b96ff37d1075aaebef/watchfiles-1.1.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c1f5210f1b8fc91ead1283c6fd89f70e76fb07283ec738056cf34d51e9c1d62c", size = 460811, upload-time = "2025-10-14T15:05:55.743Z" },
    { url = "https://files.pythonhosted.org/packages/04/9d/b07d4491dde6db6ea6c680fdec452f4be363d65c82004faf2d853f59b76f/watchfiles-1.1.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9c4702f29ca48e023ffd9b7ff6b822acdf47cb1ff44cb490a3f1d5ec8987e9c", size = 490576, upload-time = "2025-10-14T15:05:56.983Z" },
    { url = "https://files.pythonhosted.org/packages/56/03/e64dcab0a1806157db272a61b7891b062f441a30580a581ae72114259472/watchfiles-1.1.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:acb08650863767cbc58bca4813b92df4d6c648459dcaa3d4155681962b2aa2d3", size = 597726, upload-time = "2025-10-14T15:05:57.986Z" },
    { url = "https://files.pythonhosted.org/packages/5c/8e/a827cf4a8d5f2903a19a934dcf512082eb07675253e154d4cd9367978a58/watchfiles-1.1.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08af70fd77eee58549cd69c25055dc344f918d992ff626068242259f98d598a2", size = 474900, upload-time = "2025-10-14T15:05:59.378Z" },
    { url = "https://files.pythonhosted.org/packages/dc/a6/94fed0b346b85b22303a12eee5f431006fae6af70d841cac2f4403245533/watchfiles-1.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c3631058c37e4a0ec440bf583bc53cdbd13e5661bb6f465bc1d88ee9a0a4d02", size = 457521, upload-time = "2025-10-14T15:06:00.419Z" },
    { url = "https://files.pythonhosted.org/packages/c4/64/bc3331150e8f3c778d48a4615d4b72b3d2d87868635e6c54bbd924946189/watchfiles-1.1.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:cf57a27fb986c6243d2ee78392c503826056ffe0287e8794503b10fb51b881be", size = 632191, upload-time = "2025-10-14T15:06:01.621Z" },
    { url = "https://files.pythonhosted.org/packages/e4/84/f39e19549c2f3ec97225dcb2ceb9a7bb3c5004ed227aad1f321bf0ff2051/watchfiles-1.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d7e7067c98040d646982daa1f37a33d3544138ea155536c2e0e63e07ff8a7e0f", size = 623923, upload-time = "2025-10-14T15:06:02.671Z" },
    { url = "https://files.pythonhosted.org/packages/0e/24/0759ae15d9a0c9c5fe946bd4cf45ab9e7bad7cfede2c06dc10f59171b29f/watchfiles-1.1.1-cp39-cp39-win32.whl", hash = "sha256:6c9c9262f454d1c4d8aaa7050121eb4f3aea197360553699520767daebf2180b", size = 274010, upload-time = "2025-10-14T15:06:03.779Z" },
    { url = "https://files.pythonhosted.org/packages/7e/3b/eb26cddd4dfa081e2bf6918be3b2fc05ee3b55c1d21331d5562ee0c6aaad/watchfiles-1.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:74472234c8370669850e1c312490f6026d132ca2d396abfad8830b4f1c096957", size = 289090, upload-time = "2025-10-14T15:06:04.821Z" },
    { url = "https://files.pythonhosted.org/packages/ba/4c/a888c91e2e326872fa4705095d64acd8aa2fb9c1f7b9bd0588f33850516c/watchfiles-1.1.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:17ef139237dfced9da49fb7f2232c86ca9421f666d78c264c7ffca6601d154c3", size = 409611, upload-time = "2025-10-14T15:06:05.809Z" },
    { url = "https://files.pythonhosted.org/packages/1e/c7/5420d1943c8e3ce1a21c0a9330bcf7edafb6aa65d26b21dbb3267c9e8112/watchfiles-1.1.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:672b8adf25b1a0d35c96b5888b7b18699d27d4194bac8beeae75be4b7a3fc9b2", size = 396889, upload-time = "2025-10-14T15:06:07.035Z" },
    { url = "https://files.pythonhosted.org/packages/0c/e5/0072cef3804ce8d3aaddbfe7788aadff6b3d3f98a286fdbee9fd74ca59a7/watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a13aea58bc2b90173bc69f2a90de8e282648939a00a602e1dc4ee23e26b66d", size = 451616, upload-time = "2025-10-14T15:06:08.072Z" },
    { url = "https://files.pythonhosted.org/packages/83/4e/b87b71cbdfad81ad7e83358b3e447fedd281b880a03d64a760fe0a11fc2e/watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b495de0bb386df6a12b18335a0285dda90260f51bdb505503c02bcd1ce27a8b", size = 458413, upload-time = "2025-10-14T15:06:09.209Z" },
    { url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250, upload-time = "2025-10-14T15:06:10.264Z" },
    { url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117, upload-time = "2025-10-14T15:06:11.28Z" },
    { url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493, upload-time = "2025-10-14T15:06:12.321Z" },
    { url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" },
    { url = "https://files.pythonhosted.org/packages/00/db/38a2c52fdbbfe2fc7ffaaaaaebc927d52b9f4d5139bba3186c19a7463001/watchfiles-1.1.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:cdab464fee731e0884c35ae3588514a9bcf718d0e2c82169c1c4a85cc19c3c7f", size = 409210, upload-time = "2025-10-14T15:06:14.492Z" },
    { url = "https://files.pythonhosted.org/packages/d1/43/d7e8b71f6c21ff813ee8da1006f89b6c7fff047fb4c8b16ceb5e840599c5/watchfiles-1.1.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:3dbd8cbadd46984f802f6d479b7e3afa86c42d13e8f0f322d669d79722c8ec34", size = 397286, upload-time = "2025-10-14T15:06:16.177Z" },
    { url = "https://files.pythonhosted.org/packages/1f/5d/884074a5269317e75bd0b915644b702b89de73e61a8a7446e2b225f45b1f/watchfiles-1.1.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5524298e3827105b61951a29c3512deb9578586abf3a7c5da4a8069df247cccc", size = 451768, upload-time = "2025-10-14T15:06:18.266Z" },
    { url = "https://files.pythonhosted.org/packages/17/71/7ffcaa9b5e8961a25026058058c62ec8f604d2a6e8e1e94bee8a09e1593f/watchfiles-1.1.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b943d3668d61cfa528eb949577479d3b077fd25fb83c641235437bc0b5bc60e", size = 458561, upload-time = "2025-10-14T15:06:19.323Z" },
]

[[package]]
name = "wcmatch"
version = "10.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "bracex" },
]
sdist = { url = "https://files.pythonhosted.org/packages/79/3e/c0bdc27cf06f4e47680bd5803a07cb3dfd17de84cde92dd217dcb9e05253/wcmatch-10.1.tar.gz", hash = "sha256:f11f94208c8c8484a16f4f48638a85d771d9513f4ab3f37595978801cb9465af", size = 117421, upload-time = "2025-06-22T19:14:02.49Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/eb/d8/0d1d2e9d3fabcf5d6840362adcf05f8cf3cd06a73358140c3a97189238ae/wcmatch-10.1-py3-none-any.whl", hash = "sha256:5848ace7dbb0476e5e55ab63c6bbd529745089343427caa5537f230cc01beb8a", size = 39854, upload-time = "2025-06-22T19:14:00.978Z" },
]

[[package]]
name = "wcwidth"
version = "0.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" },
]

[[package]]
name = "websockets"
version = "15.0.1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423, upload-time = "2025-03-05T20:01:35.363Z" },
    { url = "https://files.pythonhosted.org/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", size = 173080, upload-time = "2025-03-05T20:01:37.304Z" },
    { url = "https://files.pythonhosted.org/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", size = 173329, upload-time = "2025-03-05T20:01:39.668Z" },
    { url = "https://files.pythonhosted.org/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", size = 182312, upload-time = "2025-03-05T20:01:41.815Z" },
    { url = "https://files.pythonhosted.org/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", size = 181319, upload-time = "2025-03-05T20:01:43.967Z" },
    { url = "https://files.pythonhosted.org/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", size = 181631, upload-time = "2025-03-05T20:01:46.104Z" },
    { url = "https://files.pythonhosted.org/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", size = 182016, upload-time = "2025-03-05T20:01:47.603Z" },
    { url = "https://files.pythonhosted.org/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", size = 181426, upload-time = "2025-03-05T20:01:48.949Z" },
    { url = "https://files.pythonhosted.org/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", size = 181360, upload-time = "2025-03-05T20:01:50.938Z" },
    { url = "https://files.pythonhosted.org/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", size = 176388, upload-time = "2025-03-05T20:01:52.213Z" },
    { url = "https://files.pythonhosted.org/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", size = 176830, upload-time = "2025-03-05T20:01:53.922Z" },
    { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" },
    { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" },
    { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" },
    { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" },
    { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" },
    { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" },
    { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" },
    { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" },
    { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" },
    { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" },
    { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" },
    { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" },
    { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" },
    { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" },
    { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" },
    { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" },
    { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" },
    { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" },
    { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" },
    { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" },
    { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" },
    { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" },
    { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" },
    { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" },
    { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" },
    { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" },
    { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" },
    { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" },
    { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" },
    { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" },
    { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" },
    { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" },
    { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" },
    { url = "https://files.pythonhosted.org/packages/36/db/3fff0bcbe339a6fa6a3b9e3fbc2bfb321ec2f4cd233692272c5a8d6cf801/websockets-15.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5f4c04ead5aed67c8a1a20491d54cdfba5884507a48dd798ecaf13c74c4489f5", size = 175424, upload-time = "2025-03-05T20:02:56.505Z" },
    { url = "https://files.pythonhosted.org/packages/46/e6/519054c2f477def4165b0ec060ad664ed174e140b0d1cbb9fafa4a54f6db/websockets-15.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abdc0c6c8c648b4805c5eacd131910d2a7f6455dfd3becab248ef108e89ab16a", size = 173077, upload-time = "2025-03-05T20:02:58.37Z" },
    { url = "https://files.pythonhosted.org/packages/1a/21/c0712e382df64c93a0d16449ecbf87b647163485ca1cc3f6cbadb36d2b03/websockets-15.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a625e06551975f4b7ea7102bc43895b90742746797e2e14b70ed61c43a90f09b", size = 173324, upload-time = "2025-03-05T20:02:59.773Z" },
    { url = "https://files.pythonhosted.org/packages/1c/cb/51ba82e59b3a664df54beed8ad95517c1b4dc1a913730e7a7db778f21291/websockets-15.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d591f8de75824cbb7acad4e05d2d710484f15f29d4a915092675ad3456f11770", size = 182094, upload-time = "2025-03-05T20:03:01.827Z" },
    { url = "https://files.pythonhosted.org/packages/fb/0f/bf3788c03fec679bcdaef787518dbe60d12fe5615a544a6d4cf82f045193/websockets-15.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:47819cea040f31d670cc8d324bb6435c6f133b8c7a19ec3d61634e62f8d8f9eb", size = 181094, upload-time = "2025-03-05T20:03:03.123Z" },
    { url = "https://files.pythonhosted.org/packages/5e/da/9fb8c21edbc719b66763a571afbaf206cb6d3736d28255a46fc2fe20f902/websockets-15.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac017dd64572e5c3bd01939121e4d16cf30e5d7e110a119399cf3133b63ad054", size = 181397, upload-time = "2025-03-05T20:03:04.443Z" },
    { url = "https://files.pythonhosted.org/packages/2e/65/65f379525a2719e91d9d90c38fe8b8bc62bd3c702ac651b7278609b696c4/websockets-15.0.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4a9fac8e469d04ce6c25bb2610dc535235bd4aa14996b4e6dbebf5e007eba5ee", size = 181794, upload-time = "2025-03-05T20:03:06.708Z" },
    { url = "https://files.pythonhosted.org/packages/d9/26/31ac2d08f8e9304d81a1a7ed2851c0300f636019a57cbaa91342015c72cc/websockets-15.0.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363c6f671b761efcb30608d24925a382497c12c506b51661883c3e22337265ed", size = 181194, upload-time = "2025-03-05T20:03:08.844Z" },
    { url = "https://files.pythonhosted.org/packages/98/72/1090de20d6c91994cd4b357c3f75a4f25ee231b63e03adea89671cc12a3f/websockets-15.0.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2034693ad3097d5355bfdacfffcbd3ef5694f9718ab7f29c29689a9eae841880", size = 181164, upload-time = "2025-03-05T20:03:10.242Z" },
    { url = "https://files.pythonhosted.org/packages/2d/37/098f2e1c103ae8ed79b0e77f08d83b0ec0b241cf4b7f2f10edd0126472e1/websockets-15.0.1-cp39-cp39-win32.whl", hash = "sha256:3b1ac0d3e594bf121308112697cf4b32be538fb1444468fb0a6ae4feebc83411", size = 176381, upload-time = "2025-03-05T20:03:12.77Z" },
    { url = "https://files.pythonhosted.org/packages/75/8b/a32978a3ab42cebb2ebdd5b05df0696a09f4d436ce69def11893afa301f0/websockets-15.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:b7643a03db5c95c799b89b31c036d5f27eeb4d259c798e878d6937d71832b1e4", size = 176841, upload-time = "2025-03-05T20:03:14.367Z" },
    { url = "https://files.pythonhosted.org/packages/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109, upload-time = "2025-03-05T20:03:17.769Z" },
    { url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343, upload-time = "2025-03-05T20:03:19.094Z" },
    { url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599, upload-time = "2025-03-05T20:03:21.1Z" },
    { url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207, upload-time = "2025-03-05T20:03:23.221Z" },
    { url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155, upload-time = "2025-03-05T20:03:25.321Z" },
    { url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884, upload-time = "2025-03-05T20:03:27.934Z" },
    { url = "https://files.pythonhosted.org/packages/b7/48/4b67623bac4d79beb3a6bb27b803ba75c1bdedc06bd827e465803690a4b2/websockets-15.0.1-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7f493881579c90fc262d9cdbaa05a6b54b3811c2f300766748db79f098db9940", size = 173106, upload-time = "2025-03-05T20:03:29.404Z" },
    { url = "https://files.pythonhosted.org/packages/ed/f0/adb07514a49fe5728192764e04295be78859e4a537ab8fcc518a3dbb3281/websockets-15.0.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:47b099e1f4fbc95b701b6e85768e1fcdaf1630f3cbe4765fa216596f12310e2e", size = 173339, upload-time = "2025-03-05T20:03:30.755Z" },
    { url = "https://files.pythonhosted.org/packages/87/28/bd23c6344b18fb43df40d0700f6d3fffcd7cef14a6995b4f976978b52e62/websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67f2b6de947f8c757db2db9c71527933ad0019737ec374a8a6be9a956786aaf9", size = 174597, upload-time = "2025-03-05T20:03:32.247Z" },
    { url = "https://files.pythonhosted.org/packages/6d/79/ca288495863d0f23a60f546f0905ae8f3ed467ad87f8b6aceb65f4c013e4/websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d08eb4c2b7d6c41da6ca0600c077e93f5adcfd979cd777d747e9ee624556da4b", size = 174205, upload-time = "2025-03-05T20:03:33.731Z" },
    { url = "https://files.pythonhosted.org/packages/04/e4/120ff3180b0872b1fe6637f6f995bcb009fb5c87d597c1fc21456f50c848/websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b826973a4a2ae47ba357e4e82fa44a463b8f168e1ca775ac64521442b19e87f", size = 174150, upload-time = "2025-03-05T20:03:35.757Z" },
    { url = "https://files.pythonhosted.org/packages/cb/c3/30e2f9c539b8da8b1d76f64012f3b19253271a63413b2d3adb94b143407f/websockets-15.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:21c1fa28a6a7e3cbdc171c694398b6df4744613ce9b36b1a498e816787e28123", size = 176877, upload-time = "2025-03-05T20:03:37.199Z" },
    { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
]

[[package]]
name = "websockets"
version = "16.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/20/74/221f58decd852f4b59cc3354cccaf87e8ef695fede361d03dc9a7396573b/websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a", size = 177343, upload-time = "2026-01-10T09:22:21.28Z" },
    { url = "https://files.pythonhosted.org/packages/19/0f/22ef6107ee52ab7f0b710d55d36f5a5d3ef19e8a205541a6d7ffa7994e5a/websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0", size = 175021, upload-time = "2026-01-10T09:22:22.696Z" },
    { url = "https://files.pythonhosted.org/packages/10/40/904a4cb30d9b61c0e278899bf36342e9b0208eb3c470324a9ecbaac2a30f/websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957", size = 175320, upload-time = "2026-01-10T09:22:23.94Z" },
    { url = "https://files.pythonhosted.org/packages/9d/2f/4b3ca7e106bc608744b1cdae041e005e446124bebb037b18799c2d356864/websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72", size = 183815, upload-time = "2026-01-10T09:22:25.469Z" },
    { url = "https://files.pythonhosted.org/packages/86/26/d40eaa2a46d4302becec8d15b0fc5e45bdde05191e7628405a19cf491ccd/websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde", size = 185054, upload-time = "2026-01-10T09:22:27.101Z" },
    { url = "https://files.pythonhosted.org/packages/b0/ba/6500a0efc94f7373ee8fefa8c271acdfd4dca8bd49a90d4be7ccabfc397e/websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3", size = 184565, upload-time = "2026-01-10T09:22:28.293Z" },
    { url = "https://files.pythonhosted.org/packages/04/b4/96bf2cee7c8d8102389374a2616200574f5f01128d1082f44102140344cc/websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3", size = 183848, upload-time = "2026-01-10T09:22:30.394Z" },
    { url = "https://files.pythonhosted.org/packages/02/8e/81f40fb00fd125357814e8c3025738fc4ffc3da4b6b4a4472a82ba304b41/websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9", size = 178249, upload-time = "2026-01-10T09:22:32.083Z" },
    { url = "https://files.pythonhosted.org/packages/b4/5f/7e40efe8df57db9b91c88a43690ac66f7b7aa73a11aa6a66b927e44f26fa/websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35", size = 178685, upload-time = "2026-01-10T09:22:33.345Z" },
    { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" },
    { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" },
    { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" },
    { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" },
    { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" },
    { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" },
    { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" },
    { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" },
    { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" },
    { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" },
    { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" },
    { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" },
    { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" },
    { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" },
    { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" },
    { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" },
    { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" },
    { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" },
    { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" },
    { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" },
    { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" },
    { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" },
    { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" },
    { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" },
    { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" },
    { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" },
    { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" },
    { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
    { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
    { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
    { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
    { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
    { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
    { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
    { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
    { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
    { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
    { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
    { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
    { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
    { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
    { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
    { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
    { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
    { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
    { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" },
    { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" },
    { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" },
    { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" },
    { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" },
    { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
]

[[package]]
name = "werkzeug"
version = "3.1.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" },
]

[[package]]
name = "wheel"
version = "0.46.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "packaging" },
]
sdist = { url = "https://files.pythonhosted.org/packages/89/24/a2eb353a6edac9a0303977c4cb048134959dd2a51b48a269dfc9dde00c8a/wheel-0.46.3.tar.gz", hash = "sha256:e3e79874b07d776c40bd6033f8ddf76a7dad46a7b8aa1b2787a83083519a1803", size = 60605, upload-time = "2026-01-22T12:39:49.136Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/87/22/b76d483683216dde3d67cba61fb2444be8d5be289bf628c13fc0fd90e5f9/wheel-0.46.3-py3-none-any.whl", hash = "sha256:4b399d56c9d9338230118d705d9737a2a468ccca63d5e813e2a4fc7815d8bc4d", size = 30557, upload-time = "2026-01-22T12:39:48.099Z" },
]

[[package]]
name = "wrapt"
version = "1.17.3"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/3f/23/bb82321b86411eb51e5a5db3fb8f8032fd30bd7c2d74bfe936136b2fa1d6/wrapt-1.17.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88bbae4d40d5a46142e70d58bf664a89b6b4befaea7b2ecc14e03cedb8e06c04", size = 53482, upload-time = "2025-08-12T05:51:44.467Z" },
    { url = "https://files.pythonhosted.org/packages/45/69/f3c47642b79485a30a59c63f6d739ed779fb4cc8323205d047d741d55220/wrapt-1.17.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6b13af258d6a9ad602d57d889f83b9d5543acd471eee12eb51f5b01f8eb1bc2", size = 38676, upload-time = "2025-08-12T05:51:32.636Z" },
    { url = "https://files.pythonhosted.org/packages/d1/71/e7e7f5670c1eafd9e990438e69d8fb46fa91a50785332e06b560c869454f/wrapt-1.17.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd341868a4b6714a5962c1af0bd44f7c404ef78720c7de4892901e540417111c", size = 38957, upload-time = "2025-08-12T05:51:54.655Z" },
    { url = "https://files.pythonhosted.org/packages/de/17/9f8f86755c191d6779d7ddead1a53c7a8aa18bccb7cea8e7e72dfa6a8a09/wrapt-1.17.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f9b2601381be482f70e5d1051a5965c25fb3625455a2bf520b5a077b22afb775", size = 81975, upload-time = "2025-08-12T05:52:30.109Z" },
    { url = "https://files.pythonhosted.org/packages/f2/15/dd576273491f9f43dd09fce517f6c2ce6eb4fe21681726068db0d0467096/wrapt-1.17.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:343e44b2a8e60e06a7e0d29c1671a0d9951f59174f3709962b5143f60a2a98bd", size = 83149, upload-time = "2025-08-12T05:52:09.316Z" },
    { url = "https://files.pythonhosted.org/packages/0c/c4/5eb4ce0d4814521fee7aa806264bf7a114e748ad05110441cd5b8a5c744b/wrapt-1.17.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:33486899acd2d7d3066156b03465b949da3fd41a5da6e394ec49d271baefcf05", size = 82209, upload-time = "2025-08-12T05:52:10.331Z" },
    { url = "https://files.pythonhosted.org/packages/31/4b/819e9e0eb5c8dc86f60dfc42aa4e2c0d6c3db8732bce93cc752e604bb5f5/wrapt-1.17.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e6f40a8aa5a92f150bdb3e1c44b7e98fb7113955b2e5394122fa5532fec4b418", size = 81551, upload-time = "2025-08-12T05:52:31.137Z" },
    { url = "https://files.pythonhosted.org/packages/f8/83/ed6baf89ba3a56694700139698cf703aac9f0f9eb03dab92f57551bd5385/wrapt-1.17.3-cp310-cp310-win32.whl", hash = "sha256:a36692b8491d30a8c75f1dfee65bef119d6f39ea84ee04d9f9311f83c5ad9390", size = 36464, upload-time = "2025-08-12T05:53:01.204Z" },
    { url = "https://files.pythonhosted.org/packages/2f/90/ee61d36862340ad7e9d15a02529df6b948676b9a5829fd5e16640156627d/wrapt-1.17.3-cp310-cp310-win_amd64.whl", hash = "sha256:afd964fd43b10c12213574db492cb8f73b2f0826c8df07a68288f8f19af2ebe6", size = 38748, upload-time = "2025-08-12T05:53:00.209Z" },
    { url = "https://files.pythonhosted.org/packages/bd/c3/cefe0bd330d389c9983ced15d326f45373f4073c9f4a8c2f99b50bfea329/wrapt-1.17.3-cp310-cp310-win_arm64.whl", hash = "sha256:af338aa93554be859173c39c85243970dc6a289fa907402289eeae7543e1ae18", size = 36810, upload-time = "2025-08-12T05:52:51.906Z" },
    { url = "https://files.pythonhosted.org/packages/52/db/00e2a219213856074a213503fdac0511203dceefff26e1daa15250cc01a0/wrapt-1.17.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:273a736c4645e63ac582c60a56b0acb529ef07f78e08dc6bfadf6a46b19c0da7", size = 53482, upload-time = "2025-08-12T05:51:45.79Z" },
    { url = "https://files.pythonhosted.org/packages/5e/30/ca3c4a5eba478408572096fe9ce36e6e915994dd26a4e9e98b4f729c06d9/wrapt-1.17.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5531d911795e3f935a9c23eb1c8c03c211661a5060aab167065896bbf62a5f85", size = 38674, upload-time = "2025-08-12T05:51:34.629Z" },
    { url = "https://files.pythonhosted.org/packages/31/25/3e8cc2c46b5329c5957cec959cb76a10718e1a513309c31399a4dad07eb3/wrapt-1.17.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0610b46293c59a3adbae3dee552b648b984176f8562ee0dba099a56cfbe4df1f", size = 38959, upload-time = "2025-08-12T05:51:56.074Z" },
    { url = "https://files.pythonhosted.org/packages/5d/8f/a32a99fc03e4b37e31b57cb9cefc65050ea08147a8ce12f288616b05ef54/wrapt-1.17.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b32888aad8b6e68f83a8fdccbf3165f5469702a7544472bdf41f582970ed3311", size = 82376, upload-time = "2025-08-12T05:52:32.134Z" },
    { url = "https://files.pythonhosted.org/packages/31/57/4930cb8d9d70d59c27ee1332a318c20291749b4fba31f113c2f8ac49a72e/wrapt-1.17.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cccf4f81371f257440c88faed6b74f1053eef90807b77e31ca057b2db74edb1", size = 83604, upload-time = "2025-08-12T05:52:11.663Z" },
    { url = "https://files.pythonhosted.org/packages/a8/f3/1afd48de81d63dd66e01b263a6fbb86e1b5053b419b9b33d13e1f6d0f7d0/wrapt-1.17.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8a210b158a34164de8bb68b0e7780041a903d7b00c87e906fb69928bf7890d5", size = 82782, upload-time = "2025-08-12T05:52:12.626Z" },
    { url = "https://files.pythonhosted.org/packages/1e/d7/4ad5327612173b144998232f98a85bb24b60c352afb73bc48e3e0d2bdc4e/wrapt-1.17.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:79573c24a46ce11aab457b472efd8d125e5a51da2d1d24387666cd85f54c05b2", size = 82076, upload-time = "2025-08-12T05:52:33.168Z" },
    { url = "https://files.pythonhosted.org/packages/bb/59/e0adfc831674a65694f18ea6dc821f9fcb9ec82c2ce7e3d73a88ba2e8718/wrapt-1.17.3-cp311-cp311-win32.whl", hash = "sha256:c31eebe420a9a5d2887b13000b043ff6ca27c452a9a22fa71f35f118e8d4bf89", size = 36457, upload-time = "2025-08-12T05:53:03.936Z" },
    { url = "https://files.pythonhosted.org/packages/83/88/16b7231ba49861b6f75fc309b11012ede4d6b0a9c90969d9e0db8d991aeb/wrapt-1.17.3-cp311-cp311-win_amd64.whl", hash = "sha256:0b1831115c97f0663cb77aa27d381237e73ad4f721391a9bfb2fe8bc25fa6e77", size = 38745, upload-time = "2025-08-12T05:53:02.885Z" },
    { url = "https://files.pythonhosted.org/packages/9a/1e/c4d4f3398ec073012c51d1c8d87f715f56765444e1a4b11e5180577b7e6e/wrapt-1.17.3-cp311-cp311-win_arm64.whl", hash = "sha256:5a7b3c1ee8265eb4c8f1b7d29943f195c00673f5ab60c192eba2d4a7eae5f46a", size = 36806, upload-time = "2025-08-12T05:52:53.368Z" },
    { url = "https://files.pythonhosted.org/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", size = 53998, upload-time = "2025-08-12T05:51:47.138Z" },
    { url = "https://files.pythonhosted.org/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", size = 39020, upload-time = "2025-08-12T05:51:35.906Z" },
    { url = "https://files.pythonhosted.org/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", size = 39098, upload-time = "2025-08-12T05:51:57.474Z" },
    { url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036, upload-time = "2025-08-12T05:52:34.784Z" },
    { url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156, upload-time = "2025-08-12T05:52:13.599Z" },
    { url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102, upload-time = "2025-08-12T05:52:14.56Z" },
    { url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732, upload-time = "2025-08-12T05:52:36.165Z" },
    { url = "https://files.pythonhosted.org/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", size = 36705, upload-time = "2025-08-12T05:53:07.123Z" },
    { url = "https://files.pythonhosted.org/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", size = 38877, upload-time = "2025-08-12T05:53:05.436Z" },
    { url = "https://files.pythonhosted.org/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", size = 36885, upload-time = "2025-08-12T05:52:54.367Z" },
    { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload-time = "2025-08-12T05:51:48.627Z" },
    { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload-time = "2025-08-12T05:51:37.156Z" },
    { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload-time = "2025-08-12T05:51:58.425Z" },
    { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072, upload-time = "2025-08-12T05:52:37.53Z" },
    { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214, upload-time = "2025-08-12T05:52:15.886Z" },
    { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105, upload-time = "2025-08-12T05:52:17.914Z" },
    { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766, upload-time = "2025-08-12T05:52:39.243Z" },
    { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" },
    { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" },
    { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" },
    { url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132, upload-time = "2025-08-12T05:51:49.864Z" },
    { url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091, upload-time = "2025-08-12T05:51:38.935Z" },
    { url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172, upload-time = "2025-08-12T05:51:59.365Z" },
    { url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163, upload-time = "2025-08-12T05:52:40.965Z" },
    { url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963, upload-time = "2025-08-12T05:52:20.326Z" },
    { url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945, upload-time = "2025-08-12T05:52:21.581Z" },
    { url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857, upload-time = "2025-08-12T05:52:43.043Z" },
    { url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178, upload-time = "2025-08-12T05:53:12.605Z" },
    { url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310, upload-time = "2025-08-12T05:53:11.106Z" },
    { url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266, upload-time = "2025-08-12T05:52:56.531Z" },
    { url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544, upload-time = "2025-08-12T05:51:51.109Z" },
    { url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283, upload-time = "2025-08-12T05:51:39.912Z" },
    { url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366, upload-time = "2025-08-12T05:52:00.693Z" },
    { url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571, upload-time = "2025-08-12T05:52:44.521Z" },
    { url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094, upload-time = "2025-08-12T05:52:22.618Z" },
    { url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659, upload-time = "2025-08-12T05:52:24.057Z" },
    { url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946, upload-time = "2025-08-12T05:52:45.976Z" },
    { url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717, upload-time = "2025-08-12T05:53:15.214Z" },
    { url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334, upload-time = "2025-08-12T05:53:14.178Z" },
    { url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471, upload-time = "2025-08-12T05:52:57.784Z" },
    { url = "https://files.pythonhosted.org/packages/41/be/be9b3b0a461ee3e30278706f3f3759b9b69afeedef7fe686036286c04ac6/wrapt-1.17.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:30ce38e66630599e1193798285706903110d4f057aab3168a34b7fdc85569afc", size = 53485, upload-time = "2025-08-12T05:51:53.11Z" },
    { url = "https://files.pythonhosted.org/packages/b3/a8/8f61d6b8f526efc8c10e12bf80b4206099fea78ade70427846a37bc9cbea/wrapt-1.17.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:65d1d00fbfb3ea5f20add88bbc0f815150dbbde3b026e6c24759466c8b5a9ef9", size = 38675, upload-time = "2025-08-12T05:51:42.885Z" },
    { url = "https://files.pythonhosted.org/packages/48/f1/23950c29a25637b74b322f9e425a17cc01a478f6afb35138ecb697f9558d/wrapt-1.17.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a7c06742645f914f26c7f1fa47b8bc4c91d222f76ee20116c43d5ef0912bba2d", size = 38956, upload-time = "2025-08-12T05:52:03.149Z" },
    { url = "https://files.pythonhosted.org/packages/43/46/dd0791943613885f62619f18ee6107e6133237a6b6ed8a9ecfac339d0b4f/wrapt-1.17.3-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7e18f01b0c3e4a07fe6dfdb00e29049ba17eadbc5e7609a2a3a4af83ab7d710a", size = 81745, upload-time = "2025-08-12T05:52:49.62Z" },
    { url = "https://files.pythonhosted.org/packages/dd/ec/bb2d19bd1a614cc4f438abac13ae26c57186197920432d2a915183b15a8b/wrapt-1.17.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f5f51a6466667a5a356e6381d362d259125b57f059103dd9fdc8c0cf1d14139", size = 82833, upload-time = "2025-08-12T05:52:27.738Z" },
    { url = "https://files.pythonhosted.org/packages/8d/eb/66579aea6ad36f07617fedca8e282e49c7c9bab64c63b446cfe4f7f47a49/wrapt-1.17.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:59923aa12d0157f6b82d686c3fd8e1166fa8cdfb3e17b42ce3b6147ff81528df", size = 81889, upload-time = "2025-08-12T05:52:29.023Z" },
    { url = "https://files.pythonhosted.org/packages/04/9c/a56b5ac0e2473bdc3fb11b22dd69ff423154d63861cf77911cdde5e38fd2/wrapt-1.17.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:46acc57b331e0b3bcb3e1ca3b421d65637915cfcd65eb783cb2f78a511193f9b", size = 81344, upload-time = "2025-08-12T05:52:50.869Z" },
    { url = "https://files.pythonhosted.org/packages/93/4c/9bd735c42641d81cb58d7bfb142c58f95c833962d15113026705add41a07/wrapt-1.17.3-cp39-cp39-win32.whl", hash = "sha256:3e62d15d3cfa26e3d0788094de7b64efa75f3a53875cdbccdf78547aed547a81", size = 36462, upload-time = "2025-08-12T05:53:19.623Z" },
    { url = "https://files.pythonhosted.org/packages/f0/ea/0b72f29cb5ebc16eb55c57dc0c98e5de76fc97f435fd407f7d409459c0a6/wrapt-1.17.3-cp39-cp39-win_amd64.whl", hash = "sha256:1f23fa283f51c890eda8e34e4937079114c74b4c81d2b2f1f1d94948f5cc3d7f", size = 38740, upload-time = "2025-08-12T05:53:18.271Z" },
    { url = "https://files.pythonhosted.org/packages/c3/8b/9eae65fb92321e38dbfec7719b87d840a4b92fde83fd1bbf238c5488d055/wrapt-1.17.3-cp39-cp39-win_arm64.whl", hash = "sha256:24c2ed34dc222ed754247a2702b1e1e89fdbaa4016f324b4b8f1a802d4ffe87f", size = 36806, upload-time = "2025-08-12T05:52:58.765Z" },
    { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" },
]

[[package]]
name = "wrapt"
version = "2.1.2"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
sdist = { url = "https://files.pythonhosted.org/packages/2e/64/925f213fdcbb9baeb1530449ac71a4d57fc361c053d06bf78d0c5c7cd80c/wrapt-2.1.2.tar.gz", hash = "sha256:3996a67eecc2c68fd47b4e3c564405a5777367adfd9b8abb58387b63ee83b21e", size = 81678, upload-time = "2026-03-06T02:53:25.134Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/da/d2/387594fb592d027366645f3d7cc9b4d7ca7be93845fbaba6d835a912ef3c/wrapt-2.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4b7a86d99a14f76facb269dc148590c01aaf47584071809a70da30555228158c", size = 60669, upload-time = "2026-03-06T02:52:40.671Z" },
    { url = "https://files.pythonhosted.org/packages/c9/18/3f373935bc5509e7ac444c8026a56762e50c1183e7061797437ca96c12ce/wrapt-2.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a819e39017f95bf7aede768f75915635aa8f671f2993c036991b8d3bfe8dbb6f", size = 61603, upload-time = "2026-03-06T02:54:21.032Z" },
    { url = "https://files.pythonhosted.org/packages/c2/7a/32758ca2853b07a887a4574b74e28843919103194bb47001a304e24af62f/wrapt-2.1.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5681123e60aed0e64c7d44f72bbf8b4ce45f79d81467e2c4c728629f5baf06eb", size = 113632, upload-time = "2026-03-06T02:53:54.121Z" },
    { url = "https://files.pythonhosted.org/packages/1d/d5/eeaa38f670d462e97d978b3b0d9ce06d5b91e54bebac6fbed867809216e7/wrapt-2.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b8b28e97a44d21836259739ae76284e180b18abbb4dcfdff07a415cf1016c3e", size = 115644, upload-time = "2026-03-06T02:54:53.33Z" },
    { url = "https://files.pythonhosted.org/packages/e3/09/2a41506cb17affb0bdf9d5e2129c8c19e192b388c4c01d05e1b14db23c00/wrapt-2.1.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cef91c95a50596fcdc31397eb6955476f82ae8a3f5a8eabdc13611b60ee380ba", size = 112016, upload-time = "2026-03-06T02:54:43.274Z" },
    { url = "https://files.pythonhosted.org/packages/64/15/0e6c3f5e87caadc43db279724ee36979246d5194fa32fed489c73643ba59/wrapt-2.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dad63212b168de8569b1c512f4eac4b57f2c6934b30df32d6ee9534a79f1493f", size = 114823, upload-time = "2026-03-06T02:54:29.392Z" },
    { url = "https://files.pythonhosted.org/packages/56/b2/0ad17c8248f4e57bedf44938c26ec3ee194715f812d2dbbd9d7ff4be6c06/wrapt-2.1.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d307aa6888d5efab2c1cde09843d48c843990be13069003184b67d426d145394", size = 111244, upload-time = "2026-03-06T02:54:02.149Z" },
    { url = "https://files.pythonhosted.org/packages/ff/04/bcdba98c26f2c6522c7c09a726d5d9229120163493620205b2f76bd13c01/wrapt-2.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c87cf3f0c85e27b3ac7d9ad95da166bf8739ca215a8b171e8404a2d739897a45", size = 113307, upload-time = "2026-03-06T02:54:12.428Z" },
    { url = "https://files.pythonhosted.org/packages/0e/1b/5e2883c6bc14143924e465a6fc5a92d09eeabe35310842a481fb0581f832/wrapt-2.1.2-cp310-cp310-win32.whl", hash = "sha256:d1c5fea4f9fe3762e2b905fdd67df51e4be7a73b7674957af2d2ade71a5c075d", size = 57986, upload-time = "2026-03-06T02:54:26.823Z" },
    { url = "https://files.pythonhosted.org/packages/42/5a/4efc997bccadd3af5749c250b49412793bc41e13a83a486b2b54a33e240c/wrapt-2.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:d8f7740e1af13dff2684e4d56fe604a7e04d6c94e737a60568d8d4238b9a0c71", size = 60336, upload-time = "2026-03-06T02:54:18Z" },
    { url = "https://files.pythonhosted.org/packages/c1/f5/a2bb833e20181b937e87c242645ed5d5aa9c373006b0467bfe1a35c727d0/wrapt-2.1.2-cp310-cp310-win_arm64.whl", hash = "sha256:1c6cc827c00dc839350155f316f1f8b4b0c370f52b6a19e782e2bda89600c7dc", size = 58757, upload-time = "2026-03-06T02:53:51.545Z" },
    { url = "https://files.pythonhosted.org/packages/c7/81/60c4471fce95afa5922ca09b88a25f03c93343f759aae0f31fb4412a85c7/wrapt-2.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:96159a0ee2b0277d44201c3b5be479a9979cf154e8c82fa5df49586a8e7679bb", size = 60666, upload-time = "2026-03-06T02:52:58.934Z" },
    { url = "https://files.pythonhosted.org/packages/6b/be/80e80e39e7cb90b006a0eaf11c73ac3a62bbfb3068469aec15cc0bc795de/wrapt-2.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:98ba61833a77b747901e9012072f038795de7fc77849f1faa965464f3f87ff2d", size = 61601, upload-time = "2026-03-06T02:53:00.487Z" },
    { url = "https://files.pythonhosted.org/packages/b0/be/d7c88cd9293c859fc74b232abdc65a229bb953997995d6912fc85af18323/wrapt-2.1.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:767c0dbbe76cae2a60dd2b235ac0c87c9cccf4898aef8062e57bead46b5f6894", size = 114057, upload-time = "2026-03-06T02:52:44.08Z" },
    { url = "https://files.pythonhosted.org/packages/ea/25/36c04602831a4d685d45a93b3abea61eca7fe35dab6c842d6f5d570ef94a/wrapt-2.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c691a6bc752c0cc4711cc0c00896fcd0f116abc253609ef64ef930032821842", size = 116099, upload-time = "2026-03-06T02:54:56.74Z" },
    { url = "https://files.pythonhosted.org/packages/5c/4e/98a6eb417ef551dc277bec1253d5246b25003cf36fdf3913b65cb7657a56/wrapt-2.1.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f3b7d73012ea75aee5844de58c88f44cf62d0d62711e39da5a82824a7c4626a8", size = 112457, upload-time = "2026-03-06T02:53:52.842Z" },
    { url = "https://files.pythonhosted.org/packages/cb/a6/a6f7186a5297cad8ec53fd7578533b28f795fdf5372368c74bd7e6e9841c/wrapt-2.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:577dff354e7acd9d411eaf4bfe76b724c89c89c8fc9b7e127ee28c5f7bcb25b6", size = 115351, upload-time = "2026-03-06T02:53:32.684Z" },
    { url = "https://files.pythonhosted.org/packages/97/6f/06e66189e721dbebd5cf20e138acc4d1150288ce118462f2fcbff92d38db/wrapt-2.1.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3d7b6fd105f8b24e5bd23ccf41cb1d1099796524bcc6f7fbb8fe576c44befbc9", size = 111748, upload-time = "2026-03-06T02:53:08.455Z" },
    { url = "https://files.pythonhosted.org/packages/ef/43/4808b86f499a51370fbdbdfa6cb91e9b9169e762716456471b619fca7a70/wrapt-2.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:866abdbf4612e0b34764922ef8b1c5668867610a718d3053d59e24a5e5fcfc15", size = 113783, upload-time = "2026-03-06T02:53:02.02Z" },
    { url = "https://files.pythonhosted.org/packages/91/2c/a3f28b8fa7ac2cefa01cfcaca3471f9b0460608d012b693998cd61ef43df/wrapt-2.1.2-cp311-cp311-win32.whl", hash = "sha256:5a0a0a3a882393095573344075189eb2d566e0fd205a2b6414e9997b1b800a8b", size = 57977, upload-time = "2026-03-06T02:53:27.844Z" },
    { url = "https://files.pythonhosted.org/packages/3f/c3/2b1c7bd07a27b1db885a2fab469b707bdd35bddf30a113b4917a7e2139d2/wrapt-2.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:64a07a71d2730ba56f11d1a4b91f7817dc79bc134c11516b75d1921a7c6fcda1", size = 60336, upload-time = "2026-03-06T02:54:28.104Z" },
    { url = "https://files.pythonhosted.org/packages/ec/5c/76ece7b401b088daa6503d6264dd80f9a727df3e6042802de9a223084ea2/wrapt-2.1.2-cp311-cp311-win_arm64.whl", hash = "sha256:b89f095fe98bc12107f82a9f7d570dc83a0870291aeb6b1d7a7d35575f55d98a", size = 58756, upload-time = "2026-03-06T02:53:16.319Z" },
    { url = "https://files.pythonhosted.org/packages/4c/b6/1db817582c49c7fcbb7df6809d0f515af29d7c2fbf57eb44c36e98fb1492/wrapt-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ff2aad9c4cda28a8f0653fc2d487596458c2a3f475e56ba02909e950a9efa6a9", size = 61255, upload-time = "2026-03-06T02:52:45.663Z" },
    { url = "https://files.pythonhosted.org/packages/a2/16/9b02a6b99c09227c93cd4b73acc3678114154ec38da53043c0ddc1fba0dc/wrapt-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6433ea84e1cfacf32021d2a4ee909554ade7fd392caa6f7c13f1f4bf7b8e8748", size = 61848, upload-time = "2026-03-06T02:53:48.728Z" },
    { url = "https://files.pythonhosted.org/packages/af/aa/ead46a88f9ec3a432a4832dfedb84092fc35af2d0ba40cd04aea3889f247/wrapt-2.1.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c20b757c268d30d6215916a5fa8461048d023865d888e437fab451139cad6c8e", size = 121433, upload-time = "2026-03-06T02:54:40.328Z" },
    { url = "https://files.pythonhosted.org/packages/3a/9f/742c7c7cdf58b59085a1ee4b6c37b013f66ac33673a7ef4aaed5e992bc33/wrapt-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79847b83eb38e70d93dc392c7c5b587efe65b3e7afcc167aa8abd5d60e8761c8", size = 123013, upload-time = "2026-03-06T02:53:26.58Z" },
    { url = "https://files.pythonhosted.org/packages/e8/44/2c3dd45d53236b7ed7c646fcf212251dc19e48e599debd3926b52310fafb/wrapt-2.1.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f8fba1bae256186a83d1875b2b1f4e2d1242e8fac0f58ec0d7e41b26967b965c", size = 117326, upload-time = "2026-03-06T02:53:11.547Z" },
    { url = "https://files.pythonhosted.org/packages/74/e2/b17d66abc26bd96f89dec0ecd0ef03da4a1286e6ff793839ec431b9fae57/wrapt-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e3d3b35eedcf5f7d022291ecd7533321c4775f7b9cd0050a31a68499ba45757c", size = 121444, upload-time = "2026-03-06T02:54:09.5Z" },
    { url = "https://files.pythonhosted.org/packages/3c/62/e2977843fdf9f03daf1586a0ff49060b1b2fc7ff85a7ea82b6217c1ae36e/wrapt-2.1.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6f2c5390460de57fa9582bc8a1b7a6c86e1a41dfad74c5225fc07044c15cc8d1", size = 116237, upload-time = "2026-03-06T02:54:03.884Z" },
    { url = "https://files.pythonhosted.org/packages/88/dd/27fc67914e68d740bce512f11734aec08696e6b17641fef8867c00c949fc/wrapt-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7dfa9f2cf65d027b951d05c662cc99ee3bd01f6e4691ed39848a7a5fffc902b2", size = 120563, upload-time = "2026-03-06T02:53:20.412Z" },
    { url = "https://files.pythonhosted.org/packages/ec/9f/b750b3692ed2ef4705cb305bd68858e73010492b80e43d2a4faa5573cbe7/wrapt-2.1.2-cp312-cp312-win32.whl", hash = "sha256:eba8155747eb2cae4a0b913d9ebd12a1db4d860fc4c829d7578c7b989bd3f2f0", size = 58198, upload-time = "2026-03-06T02:53:37.732Z" },
    { url = "https://files.pythonhosted.org/packages/8e/b2/feecfe29f28483d888d76a48f03c4c4d8afea944dbee2b0cd3380f9df032/wrapt-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1c51c738d7d9faa0b3601708e7e2eda9bf779e1b601dce6c77411f2a1b324a63", size = 60441, upload-time = "2026-03-06T02:52:47.138Z" },
    { url = "https://files.pythonhosted.org/packages/44/e1/e328f605d6e208547ea9fd120804fcdec68536ac748987a68c47c606eea8/wrapt-2.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:c8e46ae8e4032792eb2f677dbd0d557170a8e5524d22acc55199f43efedd39bf", size = 58836, upload-time = "2026-03-06T02:53:22.053Z" },
    { url = "https://files.pythonhosted.org/packages/4c/7a/d936840735c828b38d26a854e85d5338894cda544cb7a85a9d5b8b9c4df7/wrapt-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787fd6f4d67befa6fe2abdffcbd3de2d82dfc6fb8a6d850407c53332709d030b", size = 61259, upload-time = "2026-03-06T02:53:41.922Z" },
    { url = "https://files.pythonhosted.org/packages/5e/88/9a9b9a90ac8ca11c2fdb6a286cb3a1fc7dd774c00ed70929a6434f6bc634/wrapt-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4bdf26e03e6d0da3f0e9422fd36bcebf7bc0eeb55fdf9c727a09abc6b9fe472e", size = 61851, upload-time = "2026-03-06T02:52:48.672Z" },
    { url = "https://files.pythonhosted.org/packages/03/a9/5b7d6a16fd6533fed2756900fc8fc923f678179aea62ada6d65c92718c00/wrapt-2.1.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bbac24d879aa22998e87f6b3f481a5216311e7d53c7db87f189a7a0266dafffb", size = 121446, upload-time = "2026-03-06T02:54:14.013Z" },
    { url = "https://files.pythonhosted.org/packages/45/bb/34c443690c847835cfe9f892be78c533d4f32366ad2888972c094a897e39/wrapt-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16997dfb9d67addc2e3f41b62a104341e80cac52f91110dece393923c0ebd5ca", size = 123056, upload-time = "2026-03-06T02:54:10.829Z" },
    { url = "https://files.pythonhosted.org/packages/93/b9/ff205f391cb708f67f41ea148545f2b53ff543a7ac293b30d178af4d2271/wrapt-2.1.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:162e4e2ba7542da9027821cb6e7c5e068d64f9a10b5f15512ea28e954893a267", size = 117359, upload-time = "2026-03-06T02:53:03.623Z" },
    { url = "https://files.pythonhosted.org/packages/1f/3d/1ea04d7747825119c3c9a5e0874a40b33594ada92e5649347c457d982805/wrapt-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f29c827a8d9936ac320746747a016c4bc66ef639f5cd0d32df24f5eacbf9c69f", size = 121479, upload-time = "2026-03-06T02:53:45.844Z" },
    { url = "https://files.pythonhosted.org/packages/78/cc/ee3a011920c7a023b25e8df26f306b2484a531ab84ca5c96260a73de76c0/wrapt-2.1.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a9dd9813825f7ecb018c17fd147a01845eb330254dff86d3b5816f20f4d6aaf8", size = 116271, upload-time = "2026-03-06T02:54:46.356Z" },
    { url = "https://files.pythonhosted.org/packages/98/fd/e5ff7ded41b76d802cf1191288473e850d24ba2e39a6ec540f21ae3b57cb/wrapt-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f8dbdd3719e534860d6a78526aafc220e0241f981367018c2875178cf83a413", size = 120573, upload-time = "2026-03-06T02:52:50.163Z" },
    { url = "https://files.pythonhosted.org/packages/47/c5/242cae3b5b080cd09bacef0591691ba1879739050cc7c801ff35c8886b66/wrapt-2.1.2-cp313-cp313-win32.whl", hash = "sha256:5c35b5d82b16a3bc6e0a04349b606a0582bc29f573786aebe98e0c159bc48db6", size = 58205, upload-time = "2026-03-06T02:53:47.494Z" },
    { url = "https://files.pythonhosted.org/packages/12/69/c358c61e7a50f290958809b3c61ebe8b3838ea3e070d7aac9814f95a0528/wrapt-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:f8bc1c264d8d1cf5b3560a87bbdd31131573eb25f9f9447bb6252b8d4c44a3a1", size = 60452, upload-time = "2026-03-06T02:53:30.038Z" },
    { url = "https://files.pythonhosted.org/packages/8e/66/c8a6fcfe321295fd8c0ab1bd685b5a01462a9b3aa2f597254462fc2bc975/wrapt-2.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:3beb22f674550d5634642c645aba4c72a2c66fb185ae1aebe1e955fae5a13baf", size = 58842, upload-time = "2026-03-06T02:52:52.114Z" },
    { url = "https://files.pythonhosted.org/packages/da/55/9c7052c349106e0b3f17ae8db4b23a691a963c334de7f9dbd60f8f74a831/wrapt-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0fc04bc8664a8bc4c8e00b37b5355cffca2535209fba1abb09ae2b7c76ddf82b", size = 63075, upload-time = "2026-03-06T02:53:19.108Z" },
    { url = "https://files.pythonhosted.org/packages/09/a8/ce7b4006f7218248dd71b7b2b732d0710845a0e49213b18faef64811ffef/wrapt-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a9b9d50c9af998875a1482a038eb05755dfd6fe303a313f6a940bb53a83c3f18", size = 63719, upload-time = "2026-03-06T02:54:33.452Z" },
    { url = "https://files.pythonhosted.org/packages/e4/e5/2ca472e80b9e2b7a17f106bb8f9df1db11e62101652ce210f66935c6af67/wrapt-2.1.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2d3ff4f0024dd224290c0eabf0240f1bfc1f26363431505fb1b0283d3b08f11d", size = 152643, upload-time = "2026-03-06T02:52:42.721Z" },
    { url = "https://files.pythonhosted.org/packages/36/42/30f0f2cefca9d9cbf6835f544d825064570203c3e70aa873d8ae12e23791/wrapt-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3278c471f4468ad544a691b31bb856374fbdefb7fee1a152153e64019379f015", size = 158805, upload-time = "2026-03-06T02:54:25.441Z" },
    { url = "https://files.pythonhosted.org/packages/bb/67/d08672f801f604889dcf58f1a0b424fe3808860ede9e03affc1876b295af/wrapt-2.1.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8914c754d3134a3032601c6984db1c576e6abaf3fc68094bb8ab1379d75ff92", size = 145990, upload-time = "2026-03-06T02:53:57.456Z" },
    { url = "https://files.pythonhosted.org/packages/68/a7/fd371b02e73babec1de6ade596e8cd9691051058cfdadbfd62a5898f3295/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ff95d4264e55839be37bafe1536db2ab2de19da6b65f9244f01f332b5286cfbf", size = 155670, upload-time = "2026-03-06T02:54:55.309Z" },
    { url = "https://files.pythonhosted.org/packages/86/2d/9fe0095dfdb621009f40117dcebf41d7396c2c22dca6eac779f4c007b86c/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:76405518ca4e1b76fbb1b9f686cff93aebae03920cc55ceeec48ff9f719c5f67", size = 144357, upload-time = "2026-03-06T02:54:24.092Z" },
    { url = "https://files.pythonhosted.org/packages/0e/b6/ec7b4a254abbe4cde9fa15c5d2cca4518f6b07d0f1b77d4ee9655e30280e/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c0be8b5a74c5824e9359b53e7e58bef71a729bacc82e16587db1c4ebc91f7c5a", size = 150269, upload-time = "2026-03-06T02:53:31.268Z" },
    { url = "https://files.pythonhosted.org/packages/6e/6b/2fabe8ebf148f4ee3c782aae86a795cc68ffe7d432ef550f234025ce0cfa/wrapt-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:f01277d9a5fc1862f26f7626da9cf443bebc0abd2f303f41c5e995b15887dabd", size = 59894, upload-time = "2026-03-06T02:54:15.391Z" },
    { url = "https://files.pythonhosted.org/packages/ca/fb/9ba66fc2dedc936de5f8073c0217b5d4484e966d87723415cc8262c5d9c2/wrapt-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:84ce8f1c2104d2f6daa912b1b5b039f331febfeee74f8042ad4e04992bd95c8f", size = 63197, upload-time = "2026-03-06T02:54:41.943Z" },
    { url = "https://files.pythonhosted.org/packages/c0/1c/012d7423c95d0e337117723eb8ecf73c622ce15a97847e84cf3f8f26cd7e/wrapt-2.1.2-cp313-cp313t-win_arm64.whl", hash = "sha256:a93cd767e37faeddbe07d8fc4212d5cba660af59bdb0f6372c93faaa13e6e679", size = 60363, upload-time = "2026-03-06T02:54:48.093Z" },
    { url = "https://files.pythonhosted.org/packages/39/25/e7ea0b417db02bb796182a5316398a75792cd9a22528783d868755e1f669/wrapt-2.1.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1370e516598854e5b4366e09ce81e08bfe94d42b0fd569b88ec46cc56d9164a9", size = 61418, upload-time = "2026-03-06T02:53:55.706Z" },
    { url = "https://files.pythonhosted.org/packages/ec/0f/fa539e2f6a770249907757eaeb9a5ff4deb41c026f8466c1c6d799088a9b/wrapt-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6de1a3851c27e0bd6a04ca993ea6f80fc53e6c742ee1601f486c08e9f9b900a9", size = 61914, upload-time = "2026-03-06T02:52:53.37Z" },
    { url = "https://files.pythonhosted.org/packages/53/37/02af1867f5b1441aaeda9c82deed061b7cd1372572ddcd717f6df90b5e93/wrapt-2.1.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:de9f1a2bbc5ac7f6012ec24525bdd444765a2ff64b5985ac6e0692144838542e", size = 120417, upload-time = "2026-03-06T02:54:30.74Z" },
    { url = "https://files.pythonhosted.org/packages/c3/b7/0138a6238c8ba7476c77cf786a807f871672b37f37a422970342308276e7/wrapt-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:970d57ed83fa040d8b20c52fe74a6ae7e3775ae8cff5efd6a81e06b19078484c", size = 122797, upload-time = "2026-03-06T02:54:51.539Z" },
    { url = "https://files.pythonhosted.org/packages/e1/ad/819ae558036d6a15b7ed290d5b14e209ca795dd4da9c58e50c067d5927b0/wrapt-2.1.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3969c56e4563c375861c8df14fa55146e81ac11c8db49ea6fb7f2ba58bc1ff9a", size = 117350, upload-time = "2026-03-06T02:54:37.651Z" },
    { url = "https://files.pythonhosted.org/packages/8b/2d/afc18dc57a4600a6e594f77a9ae09db54f55ba455440a54886694a84c71b/wrapt-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:57d7c0c980abdc5f1d98b11a2aa3bb159790add80258c717fa49a99921456d90", size = 121223, upload-time = "2026-03-06T02:54:35.221Z" },
    { url = "https://files.pythonhosted.org/packages/b9/5b/5ec189b22205697bc56eb3b62aed87a1e0423e9c8285d0781c7a83170d15/wrapt-2.1.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:776867878e83130c7a04237010463372e877c1c994d449ca6aaafeab6aab2586", size = 116287, upload-time = "2026-03-06T02:54:19.654Z" },
    { url = "https://files.pythonhosted.org/packages/f7/2d/f84939a7c9b5e6cdd8a8d0f6a26cabf36a0f7e468b967720e8b0cd2bdf69/wrapt-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fab036efe5464ec3291411fabb80a7a39e2dd80bae9bcbeeca5087fdfa891e19", size = 119593, upload-time = "2026-03-06T02:54:16.697Z" },
    { url = "https://files.pythonhosted.org/packages/0b/fe/ccd22a1263159c4ac811ab9374c061bcb4a702773f6e06e38de5f81a1bdc/wrapt-2.1.2-cp314-cp314-win32.whl", hash = "sha256:e6ed62c82ddf58d001096ae84ce7f833db97ae2263bff31c9b336ba8cfe3f508", size = 58631, upload-time = "2026-03-06T02:53:06.498Z" },
    { url = "https://files.pythonhosted.org/packages/65/0a/6bd83be7bff2e7efaac7b4ac9748da9d75a34634bbbbc8ad077d527146df/wrapt-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:467e7c76315390331c67073073d00662015bb730c566820c9ca9b54e4d67fd04", size = 60875, upload-time = "2026-03-06T02:53:50.252Z" },
    { url = "https://files.pythonhosted.org/packages/6c/c0/0b3056397fe02ff80e5a5d72d627c11eb885d1ca78e71b1a5c1e8c7d45de/wrapt-2.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:da1f00a557c66225d53b095a97eace0fc5349e3bfda28fa34ffae238978ee575", size = 59164, upload-time = "2026-03-06T02:53:59.128Z" },
    { url = "https://files.pythonhosted.org/packages/71/ed/5d89c798741993b2371396eb9d4634f009ff1ad8a6c78d366fe2883ea7a6/wrapt-2.1.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:62503ffbc2d3a69891cf29beeaccdb4d5e0a126e2b6a851688d4777e01428dbb", size = 63163, upload-time = "2026-03-06T02:52:54.873Z" },
    { url = "https://files.pythonhosted.org/packages/c6/8c/05d277d182bf36b0a13d6bd393ed1dec3468a25b59d01fba2dd70fe4d6ae/wrapt-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c7e6cd120ef837d5b6f860a6ea3745f8763805c418bb2f12eeb1fa6e25f22d22", size = 63723, upload-time = "2026-03-06T02:52:56.374Z" },
    { url = "https://files.pythonhosted.org/packages/f4/27/6c51ec1eff4413c57e72d6106bb8dec6f0c7cdba6503d78f0fa98767bcc9/wrapt-2.1.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3769a77df8e756d65fbc050333f423c01ae012b4f6731aaf70cf2bef61b34596", size = 152652, upload-time = "2026-03-06T02:53:23.79Z" },
    { url = "https://files.pythonhosted.org/packages/db/4c/d7dd662d6963fc7335bfe29d512b02b71cdfa23eeca7ab3ac74a67505deb/wrapt-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a76d61a2e851996150ba0f80582dd92a870643fa481f3b3846f229de88caf044", size = 158807, upload-time = "2026-03-06T02:53:35.742Z" },
    { url = "https://files.pythonhosted.org/packages/b4/4d/1e5eea1a78d539d346765727422976676615814029522c76b87a95f6bcdd/wrapt-2.1.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6f97edc9842cf215312b75fe737ee7c8adda75a89979f8e11558dfff6343cc4b", size = 146061, upload-time = "2026-03-06T02:52:57.574Z" },
    { url = "https://files.pythonhosted.org/packages/89/bc/62cabea7695cd12a288023251eeefdcb8465056ddaab6227cb78a2de005b/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4006c351de6d5007aa33a551f600404ba44228a89e833d2fadc5caa5de8edfbf", size = 155667, upload-time = "2026-03-06T02:53:39.422Z" },
    { url = "https://files.pythonhosted.org/packages/e9/99/6f2888cd68588f24df3a76572c69c2de28287acb9e1972bf0c83ce97dbc1/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a9372fc3639a878c8e7d87e1556fa209091b0a66e912c611e3f833e2c4202be2", size = 144392, upload-time = "2026-03-06T02:54:22.41Z" },
    { url = "https://files.pythonhosted.org/packages/40/51/1dfc783a6c57971614c48e361a82ca3b6da9055879952587bc99fe1a7171/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3144b027ff30cbd2fca07c0a87e67011adb717eb5f5bd8496325c17e454257a3", size = 150296, upload-time = "2026-03-06T02:54:07.848Z" },
    { url = "https://files.pythonhosted.org/packages/6c/38/cbb8b933a0201076c1f64fc42883b0023002bdc14a4964219154e6ff3350/wrapt-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:3b8d15e52e195813efe5db8cec156eebe339aaf84222f4f4f051a6c01f237ed7", size = 60539, upload-time = "2026-03-06T02:54:00.594Z" },
    { url = "https://files.pythonhosted.org/packages/82/dd/e5176e4b241c9f528402cebb238a36785a628179d7d8b71091154b3e4c9e/wrapt-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:08ffa54146a7559f5b8df4b289b46d963a8e74ed16ba3687f99896101a3990c5", size = 63969, upload-time = "2026-03-06T02:54:39Z" },
    { url = "https://files.pythonhosted.org/packages/5c/99/79f17046cf67e4a95b9987ea129632ba8bcec0bc81f3fb3d19bdb0bd60cd/wrapt-2.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:72aaa9d0d8e4ed0e2e98019cea47a21f823c9dd4b43c7b77bba6679ffcca6a00", size = 60554, upload-time = "2026-03-06T02:53:14.132Z" },
    { url = "https://files.pythonhosted.org/packages/f7/ea/fe375f8a012e5f25b2cd31b093860c8c6540be445345c6f886e5d8bca9ef/wrapt-2.1.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5e0fa9cc32300daf9eb09a1f5bdc6deb9a79defd70d5356ba453bcd50aef3742", size = 60661, upload-time = "2026-03-06T02:54:06.572Z" },
    { url = "https://files.pythonhosted.org/packages/d8/2a/0dff969ddf4d3f69f051c8f81afbd3a9fc9fb08ab993b1061ee582b6543c/wrapt-2.1.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:710f6e5dfaf6a5d5c397d2d6758a78fecd9649deb21f1b645f5b57a328d63050", size = 61602, upload-time = "2026-03-06T02:53:44.48Z" },
    { url = "https://files.pythonhosted.org/packages/25/62/b80dd7a6c21486a7b8aea63b6bac509b2e4ea184b0eefe3795aa7202a92c/wrapt-2.1.2-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:305d8a1755116bfdad5dda9e771dcb2138990a1d66e9edd81658816edf51aed1", size = 113340, upload-time = "2026-03-06T02:54:44.626Z" },
    { url = "https://files.pythonhosted.org/packages/82/06/adbe093e07a775d8687cc45329cda9e1b33779357d146c688accbc3a9f1f/wrapt-2.1.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0d8fc30a43b5fe191cf2b1a0c82bab2571dadd38e7c0062ee87d6df858dd06e", size = 115305, upload-time = "2026-03-06T02:53:04.929Z" },
    { url = "https://files.pythonhosted.org/packages/3f/dd/31c2596c6bf6bfb1874aa637c66e3028baa83d00708d1439db3b395f8371/wrapt-2.1.2-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a5d516e22aedb7c9c1d47cba1c63160b1a6f61ec2f3948d127cd38d5cfbb556f", size = 111691, upload-time = "2026-03-06T02:53:17.845Z" },
    { url = "https://files.pythonhosted.org/packages/03/92/e9ba179f4a00b7eb7ab8afc1f729fc3be8bd468b9f1d33be1fd99476493a/wrapt-2.1.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:45914e8efbe4b9d5102fcf0e8e2e3258b83a5d5fba9f8f7b6d15681e9d29ffe0", size = 114507, upload-time = "2026-03-06T02:54:49.398Z" },
    { url = "https://files.pythonhosted.org/packages/0f/dd/5ce1332e824503fb7041a8f8b51ec1f06e7033834e38c01416fa1c599668/wrapt-2.1.2-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:478282ebd3795a089154fb16d3db360e103aa13d3b2ad30f8f6aac0d2207de0e", size = 110945, upload-time = "2026-03-06T02:54:32.088Z" },
    { url = "https://files.pythonhosted.org/packages/1b/17/d1c1d7b63a029205fe8add19db654fd105e2a92a3776c1312e74456ce3ab/wrapt-2.1.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:3756219045f73fb28c5d7662778e4156fbd06cf823c4d2d4b19f97305e52819c", size = 113107, upload-time = "2026-03-06T02:54:05.226Z" },
    { url = "https://files.pythonhosted.org/packages/85/9f/aa5b1570ca36a0533ad5fc9d9e436047b9af187f9bd182f5eb6b718fe28b/wrapt-2.1.2-cp39-cp39-win32.whl", hash = "sha256:b8aefb4dbb18d904b96827435a763fa42fc1f08ea096a391710407a60983ced8", size = 57984, upload-time = "2026-03-06T02:53:10.07Z" },
    { url = "https://files.pythonhosted.org/packages/71/3a/a0c92e4c8b6cd8ef179c62249f03f5ce50c142f71fe04c2a14279bd826b4/wrapt-2.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:e5aeab8fe15c3dff75cfee94260dcd9cded012d4ff06add036c28fae7718593b", size = 60334, upload-time = "2026-03-06T02:53:34.183Z" },
    { url = "https://files.pythonhosted.org/packages/75/87/2725632aa7f1f70a9730952444e2ba856bd15ce8ee0210afcdb50f48ab69/wrapt-2.1.2-cp39-cp39-win_arm64.whl", hash = "sha256:f069e113743a21a3defac6677f000068ebb931639f789b5b226598e247a4c89e", size = 58759, upload-time = "2026-03-06T02:53:43.16Z" },
    { url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" },
]

[[package]]
name = "yarl"
version = "1.22.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version < '3.10'",
]
dependencies = [
    { name = "idna", marker = "python_full_version < '3.10'" },
    { name = "multidict", marker = "python_full_version < '3.10'" },
    { name = "propcache", marker = "python_full_version < '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/d1/43/a2204825342f37c337f5edb6637040fa14e365b2fcc2346960201d457579/yarl-1.22.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c7bd6683587567e5a49ee6e336e0612bec8329be1b7d4c8af5687dcdeb67ee1e", size = 140517, upload-time = "2025-10-06T14:08:42.494Z" },
    { url = "https://files.pythonhosted.org/packages/44/6f/674f3e6f02266428c56f704cd2501c22f78e8b2eeb23f153117cc86fb28a/yarl-1.22.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5cdac20da754f3a723cceea5b3448e1a2074866406adeb4ef35b469d089adb8f", size = 93495, upload-time = "2025-10-06T14:08:46.2Z" },
    { url = "https://files.pythonhosted.org/packages/b8/12/5b274d8a0f30c07b91b2f02cba69152600b47830fcfb465c108880fcee9c/yarl-1.22.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:07a524d84df0c10f41e3ee918846e1974aba4ec017f990dc735aad487a0bdfdf", size = 94400, upload-time = "2025-10-06T14:08:47.855Z" },
    { url = "https://files.pythonhosted.org/packages/e2/7f/df1b6949b1fa1aa9ff6de6e2631876ad4b73c4437822026e85d8acb56bb1/yarl-1.22.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1b329cb8146d7b736677a2440e422eadd775d1806a81db2d4cded80a48efc1a", size = 347545, upload-time = "2025-10-06T14:08:49.683Z" },
    { url = "https://files.pythonhosted.org/packages/84/09/f92ed93bd6cd77872ab6c3462df45ca45cd058d8f1d0c9b4f54c1704429f/yarl-1.22.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:75976c6945d85dbb9ee6308cd7ff7b1fb9409380c82d6119bd778d8fcfe2931c", size = 319598, upload-time = "2025-10-06T14:08:51.215Z" },
    { url = "https://files.pythonhosted.org/packages/c3/97/ac3f3feae7d522cf7ccec3d340bb0b2b61c56cb9767923df62a135092c6b/yarl-1.22.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:80ddf7a5f8c86cb3eb4bc9028b07bbbf1f08a96c5c0bc1244be5e8fefcb94147", size = 363893, upload-time = "2025-10-06T14:08:53.144Z" },
    { url = "https://files.pythonhosted.org/packages/06/49/f3219097403b9c84a4d079b1d7bda62dd9b86d0d6e4428c02d46ab2c77fc/yarl-1.22.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d332fc2e3c94dad927f2112395772a4e4fedbcf8f80efc21ed7cdfae4d574fdb", size = 371240, upload-time = "2025-10-06T14:08:55.036Z" },
    { url = "https://files.pythonhosted.org/packages/35/9f/06b765d45c0e44e8ecf0fe15c9eacbbde342bb5b7561c46944f107bfb6c3/yarl-1.22.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0cf71bf877efeac18b38d3930594c0948c82b64547c1cf420ba48722fe5509f6", size = 346965, upload-time = "2025-10-06T14:08:56.722Z" },
    { url = "https://files.pythonhosted.org/packages/c5/69/599e7cea8d0fcb1694323b0db0dda317fa3162f7b90166faddecf532166f/yarl-1.22.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:663e1cadaddae26be034a6ab6072449a8426ddb03d500f43daf952b74553bba0", size = 342026, upload-time = "2025-10-06T14:08:58.563Z" },
    { url = "https://files.pythonhosted.org/packages/95/6f/9dfd12c8bc90fea9eab39832ee32ea48f8e53d1256252a77b710c065c89f/yarl-1.22.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:6dcbb0829c671f305be48a7227918cfcd11276c2d637a8033a99a02b67bf9eda", size = 335637, upload-time = "2025-10-06T14:09:00.506Z" },
    { url = "https://files.pythonhosted.org/packages/57/2e/34c5b4eb9b07e16e873db5b182c71e5f06f9b5af388cdaa97736d79dd9a6/yarl-1.22.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f0d97c18dfd9a9af4490631905a3f131a8e4c9e80a39353919e2cfed8f00aedc", size = 359082, upload-time = "2025-10-06T14:09:01.936Z" },
    { url = "https://files.pythonhosted.org/packages/31/71/fa7e10fb772d273aa1f096ecb8ab8594117822f683bab7d2c5a89914c92a/yarl-1.22.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:437840083abe022c978470b942ff832c3940b2ad3734d424b7eaffcd07f76737", size = 357811, upload-time = "2025-10-06T14:09:03.445Z" },
    { url = "https://files.pythonhosted.org/packages/26/da/11374c04e8e1184a6a03cf9c8f5688d3e5cec83ed6f31ad3481b3207f709/yarl-1.22.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a899cbd98dce6f5d8de1aad31cb712ec0a530abc0a86bd6edaa47c1090138467", size = 351223, upload-time = "2025-10-06T14:09:05.401Z" },
    { url = "https://files.pythonhosted.org/packages/82/8f/e2d01f161b0c034a30410e375e191a5d27608c1f8693bab1a08b089ca096/yarl-1.22.0-cp310-cp310-win32.whl", hash = "sha256:595697f68bd1f0c1c159fcb97b661fc9c3f5db46498043555d04805430e79bea", size = 82118, upload-time = "2025-10-06T14:09:11.148Z" },
    { url = "https://files.pythonhosted.org/packages/62/46/94c76196642dbeae634c7a61ba3da88cd77bed875bf6e4a8bed037505aa6/yarl-1.22.0-cp310-cp310-win_amd64.whl", hash = "sha256:cb95a9b1adaa48e41815a55ae740cfda005758104049a640a398120bf02515ca", size = 86852, upload-time = "2025-10-06T14:09:12.958Z" },
    { url = "https://files.pythonhosted.org/packages/af/af/7df4f179d3b1a6dcb9a4bd2ffbc67642746fcafdb62580e66876ce83fff4/yarl-1.22.0-cp310-cp310-win_arm64.whl", hash = "sha256:b85b982afde6df99ecc996990d4ad7ccbdbb70e2a4ba4de0aecde5922ba98a0b", size = 82012, upload-time = "2025-10-06T14:09:14.664Z" },
    { url = "https://files.pythonhosted.org/packages/4d/27/5ab13fc84c76a0250afd3d26d5936349a35be56ce5785447d6c423b26d92/yarl-1.22.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ab72135b1f2db3fed3997d7e7dc1b80573c67138023852b6efb336a5eae6511", size = 141607, upload-time = "2025-10-06T14:09:16.298Z" },
    { url = "https://files.pythonhosted.org/packages/6a/a1/d065d51d02dc02ce81501d476b9ed2229d9a990818332242a882d5d60340/yarl-1.22.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:669930400e375570189492dc8d8341301578e8493aec04aebc20d4717f899dd6", size = 94027, upload-time = "2025-10-06T14:09:17.786Z" },
    { url = "https://files.pythonhosted.org/packages/c1/da/8da9f6a53f67b5106ffe902c6fa0164e10398d4e150d85838b82f424072a/yarl-1.22.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:792a2af6d58177ef7c19cbf0097aba92ca1b9cb3ffdd9c7470e156c8f9b5e028", size = 94963, upload-time = "2025-10-06T14:09:19.662Z" },
    { url = "https://files.pythonhosted.org/packages/68/fe/2c1f674960c376e29cb0bec1249b117d11738db92a6ccc4a530b972648db/yarl-1.22.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ea66b1c11c9150f1372f69afb6b8116f2dd7286f38e14ea71a44eee9ec51b9d", size = 368406, upload-time = "2025-10-06T14:09:21.402Z" },
    { url = "https://files.pythonhosted.org/packages/95/26/812a540e1c3c6418fec60e9bbd38e871eaba9545e94fa5eff8f4a8e28e1e/yarl-1.22.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3e2daa88dc91870215961e96a039ec73e4937da13cf77ce17f9cad0c18df3503", size = 336581, upload-time = "2025-10-06T14:09:22.98Z" },
    { url = "https://files.pythonhosted.org/packages/0b/f5/5777b19e26fdf98563985e481f8be3d8a39f8734147a6ebf459d0dab5a6b/yarl-1.22.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba440ae430c00eee41509353628600212112cd5018d5def7e9b05ea7ac34eb65", size = 388924, upload-time = "2025-10-06T14:09:24.655Z" },
    { url = "https://files.pythonhosted.org/packages/86/08/24bd2477bd59c0bbd994fe1d93b126e0472e4e3df5a96a277b0a55309e89/yarl-1.22.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e6438cc8f23a9c1478633d216b16104a586b9761db62bfacb6425bac0a36679e", size = 392890, upload-time = "2025-10-06T14:09:26.617Z" },
    { url = "https://files.pythonhosted.org/packages/46/00/71b90ed48e895667ecfb1eaab27c1523ee2fa217433ed77a73b13205ca4b/yarl-1.22.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c52a6e78aef5cf47a98ef8e934755abf53953379b7d53e68b15ff4420e6683d", size = 365819, upload-time = "2025-10-06T14:09:28.544Z" },
    { url = "https://files.pythonhosted.org/packages/30/2d/f715501cae832651d3282387c6a9236cd26bd00d0ff1e404b3dc52447884/yarl-1.22.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3b06bcadaac49c70f4c88af4ffcfbe3dc155aab3163e75777818092478bcbbe7", size = 363601, upload-time = "2025-10-06T14:09:30.568Z" },
    { url = "https://files.pythonhosted.org/packages/f8/f9/a678c992d78e394e7126ee0b0e4e71bd2775e4334d00a9278c06a6cce96a/yarl-1.22.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6944b2dc72c4d7f7052683487e3677456050ff77fcf5e6204e98caf785ad1967", size = 358072, upload-time = "2025-10-06T14:09:32.528Z" },
    { url = "https://files.pythonhosted.org/packages/2c/d1/b49454411a60edb6fefdcad4f8e6dbba7d8019e3a508a1c5836cba6d0781/yarl-1.22.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d5372ca1df0f91a86b047d1277c2aaf1edb32d78bbcefffc81b40ffd18f027ed", size = 385311, upload-time = "2025-10-06T14:09:34.634Z" },
    { url = "https://files.pythonhosted.org/packages/87/e5/40d7a94debb8448c7771a916d1861d6609dddf7958dc381117e7ba36d9e8/yarl-1.22.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:51af598701f5299012b8416486b40fceef8c26fc87dc6d7d1f6fc30609ea0aa6", size = 381094, upload-time = "2025-10-06T14:09:36.268Z" },
    { url = "https://files.pythonhosted.org/packages/35/d8/611cc282502381ad855448643e1ad0538957fc82ae83dfe7762c14069e14/yarl-1.22.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b266bd01fedeffeeac01a79ae181719ff848a5a13ce10075adbefc8f1daee70e", size = 370944, upload-time = "2025-10-06T14:09:37.872Z" },
    { url = "https://files.pythonhosted.org/packages/2d/df/fadd00fb1c90e1a5a8bd731fa3d3de2e165e5a3666a095b04e31b04d9cb6/yarl-1.22.0-cp311-cp311-win32.whl", hash = "sha256:a9b1ba5610a4e20f655258d5a1fdc7ebe3d837bb0e45b581398b99eb98b1f5ca", size = 81804, upload-time = "2025-10-06T14:09:39.359Z" },
    { url = "https://files.pythonhosted.org/packages/b5/f7/149bb6f45f267cb5c074ac40c01c6b3ea6d8a620d34b337f6321928a1b4d/yarl-1.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:078278b9b0b11568937d9509b589ee83ef98ed6d561dfe2020e24a9fd08eaa2b", size = 86858, upload-time = "2025-10-06T14:09:41.068Z" },
    { url = "https://files.pythonhosted.org/packages/2b/13/88b78b93ad3f2f0b78e13bfaaa24d11cbc746e93fe76d8c06bf139615646/yarl-1.22.0-cp311-cp311-win_arm64.whl", hash = "sha256:b6a6f620cfe13ccec221fa312139135166e47ae169f8253f72a0abc0dae94376", size = 81637, upload-time = "2025-10-06T14:09:42.712Z" },
    { url = "https://files.pythonhosted.org/packages/75/ff/46736024fee3429b80a165a732e38e5d5a238721e634ab41b040d49f8738/yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f", size = 142000, upload-time = "2025-10-06T14:09:44.631Z" },
    { url = "https://files.pythonhosted.org/packages/5a/9a/b312ed670df903145598914770eb12de1bac44599549b3360acc96878df8/yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2", size = 94338, upload-time = "2025-10-06T14:09:46.372Z" },
    { url = "https://files.pythonhosted.org/packages/ba/f5/0601483296f09c3c65e303d60c070a5c19fcdbc72daa061e96170785bc7d/yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74", size = 94909, upload-time = "2025-10-06T14:09:48.648Z" },
    { url = "https://files.pythonhosted.org/packages/60/41/9a1fe0b73dbcefce72e46cf149b0e0a67612d60bfc90fb59c2b2efdfbd86/yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df", size = 372940, upload-time = "2025-10-06T14:09:50.089Z" },
    { url = "https://files.pythonhosted.org/packages/17/7a/795cb6dfee561961c30b800f0ed616b923a2ec6258b5def2a00bf8231334/yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb", size = 345825, upload-time = "2025-10-06T14:09:52.142Z" },
    { url = "https://files.pythonhosted.org/packages/d7/93/a58f4d596d2be2ae7bab1a5846c4d270b894958845753b2c606d666744d3/yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2", size = 386705, upload-time = "2025-10-06T14:09:54.128Z" },
    { url = "https://files.pythonhosted.org/packages/61/92/682279d0e099d0e14d7fd2e176bd04f48de1484f56546a3e1313cd6c8e7c/yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82", size = 396518, upload-time = "2025-10-06T14:09:55.762Z" },
    { url = "https://files.pythonhosted.org/packages/db/0f/0d52c98b8a885aeda831224b78f3be7ec2e1aa4a62091f9f9188c3c65b56/yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a", size = 377267, upload-time = "2025-10-06T14:09:57.958Z" },
    { url = "https://files.pythonhosted.org/packages/22/42/d2685e35908cbeaa6532c1fc73e89e7f2efb5d8a7df3959ea8e37177c5a3/yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124", size = 365797, upload-time = "2025-10-06T14:09:59.527Z" },
    { url = "https://files.pythonhosted.org/packages/a2/83/cf8c7bcc6355631762f7d8bdab920ad09b82efa6b722999dfb05afa6cfac/yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa", size = 365535, upload-time = "2025-10-06T14:10:01.139Z" },
    { url = "https://files.pythonhosted.org/packages/25/e1/5302ff9b28f0c59cac913b91fe3f16c59a033887e57ce9ca5d41a3a94737/yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7", size = 382324, upload-time = "2025-10-06T14:10:02.756Z" },
    { url = "https://files.pythonhosted.org/packages/bf/cd/4617eb60f032f19ae3a688dc990d8f0d89ee0ea378b61cac81ede3e52fae/yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d", size = 383803, upload-time = "2025-10-06T14:10:04.552Z" },
    { url = "https://files.pythonhosted.org/packages/59/65/afc6e62bb506a319ea67b694551dab4a7e6fb7bf604e9bd9f3e11d575fec/yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520", size = 374220, upload-time = "2025-10-06T14:10:06.489Z" },
    { url = "https://files.pythonhosted.org/packages/e7/3d/68bf18d50dc674b942daec86a9ba922d3113d8399b0e52b9897530442da2/yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8", size = 81589, upload-time = "2025-10-06T14:10:09.254Z" },
    { url = "https://files.pythonhosted.org/packages/c8/9a/6ad1a9b37c2f72874f93e691b2e7ecb6137fb2b899983125db4204e47575/yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c", size = 87213, upload-time = "2025-10-06T14:10:11.369Z" },
    { url = "https://files.pythonhosted.org/packages/44/c5/c21b562d1680a77634d748e30c653c3ca918beb35555cff24986fff54598/yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74", size = 81330, upload-time = "2025-10-06T14:10:13.112Z" },
    { url = "https://files.pythonhosted.org/packages/ea/f3/d67de7260456ee105dc1d162d43a019ecad6b91e2f51809d6cddaa56690e/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53", size = 139980, upload-time = "2025-10-06T14:10:14.601Z" },
    { url = "https://files.pythonhosted.org/packages/01/88/04d98af0b47e0ef42597b9b28863b9060bb515524da0a65d5f4db160b2d5/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a", size = 93424, upload-time = "2025-10-06T14:10:16.115Z" },
    { url = "https://files.pythonhosted.org/packages/18/91/3274b215fd8442a03975ce6bee5fe6aa57a8326b29b9d3d56234a1dca244/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c", size = 93821, upload-time = "2025-10-06T14:10:17.993Z" },
    { url = "https://files.pythonhosted.org/packages/61/3a/caf4e25036db0f2da4ca22a353dfeb3c9d3c95d2761ebe9b14df8fc16eb0/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601", size = 373243, upload-time = "2025-10-06T14:10:19.44Z" },
    { url = "https://files.pythonhosted.org/packages/6e/9e/51a77ac7516e8e7803b06e01f74e78649c24ee1021eca3d6a739cb6ea49c/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a", size = 342361, upload-time = "2025-10-06T14:10:21.124Z" },
    { url = "https://files.pythonhosted.org/packages/d4/f8/33b92454789dde8407f156c00303e9a891f1f51a0330b0fad7c909f87692/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df", size = 387036, upload-time = "2025-10-06T14:10:22.902Z" },
    { url = "https://files.pythonhosted.org/packages/d9/9a/c5db84ea024f76838220280f732970aa4ee154015d7f5c1bfb60a267af6f/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2", size = 397671, upload-time = "2025-10-06T14:10:24.523Z" },
    { url = "https://files.pythonhosted.org/packages/11/c9/cd8538dc2e7727095e0c1d867bad1e40c98f37763e6d995c1939f5fdc7b1/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b", size = 377059, upload-time = "2025-10-06T14:10:26.406Z" },
    { url = "https://files.pythonhosted.org/packages/a1/b9/ab437b261702ced75122ed78a876a6dec0a1b0f5e17a4ac7a9a2482d8abe/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273", size = 365356, upload-time = "2025-10-06T14:10:28.461Z" },
    { url = "https://files.pythonhosted.org/packages/b2/9d/8e1ae6d1d008a9567877b08f0ce4077a29974c04c062dabdb923ed98e6fe/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a", size = 361331, upload-time = "2025-10-06T14:10:30.541Z" },
    { url = "https://files.pythonhosted.org/packages/ca/5a/09b7be3905962f145b73beb468cdd53db8aa171cf18c80400a54c5b82846/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", size = 382590, upload-time = "2025-10-06T14:10:33.352Z" },
    { url = "https://files.pythonhosted.org/packages/aa/7f/59ec509abf90eda5048b0bc3e2d7b5099dffdb3e6b127019895ab9d5ef44/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", size = 385316, upload-time = "2025-10-06T14:10:35.034Z" },
    { url = "https://files.pythonhosted.org/packages/e5/84/891158426bc8036bfdfd862fabd0e0fa25df4176ec793e447f4b85cf1be4/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", size = 374431, upload-time = "2025-10-06T14:10:37.76Z" },
    { url = "https://files.pythonhosted.org/packages/bb/49/03da1580665baa8bef5e8ed34c6df2c2aca0a2f28bf397ed238cc1bbc6f2/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", size = 81555, upload-time = "2025-10-06T14:10:39.649Z" },
    { url = "https://files.pythonhosted.org/packages/9a/ee/450914ae11b419eadd067c6183ae08381cfdfcb9798b90b2b713bbebddda/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", size = 86965, upload-time = "2025-10-06T14:10:41.313Z" },
    { url = "https://files.pythonhosted.org/packages/98/4d/264a01eae03b6cf629ad69bae94e3b0e5344741e929073678e84bf7a3e3b/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", size = 81205, upload-time = "2025-10-06T14:10:43.167Z" },
    { url = "https://files.pythonhosted.org/packages/88/fc/6908f062a2f77b5f9f6d69cecb1747260831ff206adcbc5b510aff88df91/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", size = 146209, upload-time = "2025-10-06T14:10:44.643Z" },
    { url = "https://files.pythonhosted.org/packages/65/47/76594ae8eab26210b4867be6f49129861ad33da1f1ebdf7051e98492bf62/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", size = 95966, upload-time = "2025-10-06T14:10:46.554Z" },
    { url = "https://files.pythonhosted.org/packages/ab/ce/05e9828a49271ba6b5b038b15b3934e996980dd78abdfeb52a04cfb9467e/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", size = 97312, upload-time = "2025-10-06T14:10:48.007Z" },
    { url = "https://files.pythonhosted.org/packages/d1/c5/7dffad5e4f2265b29c9d7ec869c369e4223166e4f9206fc2243ee9eea727/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f", size = 361967, upload-time = "2025-10-06T14:10:49.997Z" },
    { url = "https://files.pythonhosted.org/packages/50/b2/375b933c93a54bff7fc041e1a6ad2c0f6f733ffb0c6e642ce56ee3b39970/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0", size = 323949, upload-time = "2025-10-06T14:10:52.004Z" },
    { url = "https://files.pythonhosted.org/packages/66/50/bfc2a29a1d78644c5a7220ce2f304f38248dc94124a326794e677634b6cf/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e", size = 361818, upload-time = "2025-10-06T14:10:54.078Z" },
    { url = "https://files.pythonhosted.org/packages/46/96/f3941a46af7d5d0f0498f86d71275696800ddcdd20426298e572b19b91ff/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708", size = 372626, upload-time = "2025-10-06T14:10:55.767Z" },
    { url = "https://files.pythonhosted.org/packages/c1/42/8b27c83bb875cd89448e42cd627e0fb971fa1675c9ec546393d18826cb50/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f", size = 341129, upload-time = "2025-10-06T14:10:57.985Z" },
    { url = "https://files.pythonhosted.org/packages/49/36/99ca3122201b382a3cf7cc937b95235b0ac944f7e9f2d5331d50821ed352/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d", size = 346776, upload-time = "2025-10-06T14:10:59.633Z" },
    { url = "https://files.pythonhosted.org/packages/85/b4/47328bf996acd01a4c16ef9dcd2f59c969f495073616586f78cd5f2efb99/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8", size = 334879, upload-time = "2025-10-06T14:11:01.454Z" },
    { url = "https://files.pythonhosted.org/packages/c2/ad/b77d7b3f14a4283bffb8e92c6026496f6de49751c2f97d4352242bba3990/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", size = 350996, upload-time = "2025-10-06T14:11:03.452Z" },
    { url = "https://files.pythonhosted.org/packages/81/c8/06e1d69295792ba54d556f06686cbd6a7ce39c22307100e3fb4a2c0b0a1d/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", size = 356047, upload-time = "2025-10-06T14:11:05.115Z" },
    { url = "https://files.pythonhosted.org/packages/4b/b8/4c0e9e9f597074b208d18cef227d83aac36184bfbc6eab204ea55783dbc5/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", size = 342947, upload-time = "2025-10-06T14:11:08.137Z" },
    { url = "https://files.pythonhosted.org/packages/e0/e5/11f140a58bf4c6ad7aca69a892bff0ee638c31bea4206748fc0df4ebcb3a/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", size = 86943, upload-time = "2025-10-06T14:11:10.284Z" },
    { url = "https://files.pythonhosted.org/packages/31/74/8b74bae38ed7fe6793d0c15a0c8207bbb819cf287788459e5ed230996cdd/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249", size = 93715, upload-time = "2025-10-06T14:11:11.739Z" },
    { url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857, upload-time = "2025-10-06T14:11:13.586Z" },
    { url = "https://files.pythonhosted.org/packages/46/b3/e20ef504049f1a1c54a814b4b9bed96d1ac0e0610c3b4da178f87209db05/yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4", size = 140520, upload-time = "2025-10-06T14:11:15.465Z" },
    { url = "https://files.pythonhosted.org/packages/e4/04/3532d990fdbab02e5ede063676b5c4260e7f3abea2151099c2aa745acc4c/yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683", size = 93504, upload-time = "2025-10-06T14:11:17.106Z" },
    { url = "https://files.pythonhosted.org/packages/11/63/ff458113c5c2dac9a9719ac68ee7c947cb621432bcf28c9972b1c0e83938/yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b", size = 94282, upload-time = "2025-10-06T14:11:19.064Z" },
    { url = "https://files.pythonhosted.org/packages/a7/bc/315a56aca762d44a6aaaf7ad253f04d996cb6b27bad34410f82d76ea8038/yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e", size = 372080, upload-time = "2025-10-06T14:11:20.996Z" },
    { url = "https://files.pythonhosted.org/packages/3f/3f/08e9b826ec2e099ea6e7c69a61272f4f6da62cb5b1b63590bb80ca2e4a40/yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590", size = 338696, upload-time = "2025-10-06T14:11:22.847Z" },
    { url = "https://files.pythonhosted.org/packages/e3/9f/90360108e3b32bd76789088e99538febfea24a102380ae73827f62073543/yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2", size = 387121, upload-time = "2025-10-06T14:11:24.889Z" },
    { url = "https://files.pythonhosted.org/packages/98/92/ab8d4657bd5b46a38094cfaea498f18bb70ce6b63508fd7e909bd1f93066/yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da", size = 394080, upload-time = "2025-10-06T14:11:27.307Z" },
    { url = "https://files.pythonhosted.org/packages/f5/e7/d8c5a7752fef68205296201f8ec2bf718f5c805a7a7e9880576c67600658/yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784", size = 372661, upload-time = "2025-10-06T14:11:29.387Z" },
    { url = "https://files.pythonhosted.org/packages/b6/2e/f4d26183c8db0bb82d491b072f3127fb8c381a6206a3a56332714b79b751/yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b", size = 364645, upload-time = "2025-10-06T14:11:31.423Z" },
    { url = "https://files.pythonhosted.org/packages/80/7c/428e5812e6b87cd00ee8e898328a62c95825bf37c7fa87f0b6bb2ad31304/yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694", size = 355361, upload-time = "2025-10-06T14:11:33.055Z" },
    { url = "https://files.pythonhosted.org/packages/ec/2a/249405fd26776f8b13c067378ef4d7dd49c9098d1b6457cdd152a99e96a9/yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d", size = 381451, upload-time = "2025-10-06T14:11:35.136Z" },
    { url = "https://files.pythonhosted.org/packages/67/a8/fb6b1adbe98cf1e2dd9fad71003d3a63a1bc22459c6e15f5714eb9323b93/yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd", size = 383814, upload-time = "2025-10-06T14:11:37.094Z" },
    { url = "https://files.pythonhosted.org/packages/d9/f9/3aa2c0e480fb73e872ae2814c43bc1e734740bb0d54e8cb2a95925f98131/yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da", size = 370799, upload-time = "2025-10-06T14:11:38.83Z" },
    { url = "https://files.pythonhosted.org/packages/50/3c/af9dba3b8b5eeb302f36f16f92791f3ea62e3f47763406abf6d5a4a3333b/yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2", size = 82990, upload-time = "2025-10-06T14:11:40.624Z" },
    { url = "https://files.pythonhosted.org/packages/ac/30/ac3a0c5bdc1d6efd1b41fa24d4897a4329b3b1e98de9449679dd327af4f0/yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79", size = 88292, upload-time = "2025-10-06T14:11:42.578Z" },
    { url = "https://files.pythonhosted.org/packages/df/0a/227ab4ff5b998a1b7410abc7b46c9b7a26b0ca9e86c34ba4b8d8bc7c63d5/yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33", size = 82888, upload-time = "2025-10-06T14:11:44.863Z" },
    { url = "https://files.pythonhosted.org/packages/06/5e/a15eb13db90abd87dfbefb9760c0f3f257ac42a5cac7e75dbc23bed97a9f/yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1", size = 146223, upload-time = "2025-10-06T14:11:46.796Z" },
    { url = "https://files.pythonhosted.org/packages/18/82/9665c61910d4d84f41a5bf6837597c89e665fa88aa4941080704645932a9/yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca", size = 95981, upload-time = "2025-10-06T14:11:48.845Z" },
    { url = "https://files.pythonhosted.org/packages/5d/9a/2f65743589809af4d0a6d3aa749343c4b5f4c380cc24a8e94a3c6625a808/yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53", size = 97303, upload-time = "2025-10-06T14:11:50.897Z" },
    { url = "https://files.pythonhosted.org/packages/b0/ab/5b13d3e157505c43c3b43b5a776cbf7b24a02bc4cccc40314771197e3508/yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c", size = 361820, upload-time = "2025-10-06T14:11:52.549Z" },
    { url = "https://files.pythonhosted.org/packages/fb/76/242a5ef4677615cf95330cfc1b4610e78184400699bdda0acb897ef5e49a/yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf", size = 323203, upload-time = "2025-10-06T14:11:54.225Z" },
    { url = "https://files.pythonhosted.org/packages/8c/96/475509110d3f0153b43d06164cf4195c64d16999e0c7e2d8a099adcd6907/yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face", size = 363173, upload-time = "2025-10-06T14:11:56.069Z" },
    { url = "https://files.pythonhosted.org/packages/c9/66/59db471aecfbd559a1fd48aedd954435558cd98c7d0da8b03cc6c140a32c/yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b", size = 373562, upload-time = "2025-10-06T14:11:58.783Z" },
    { url = "https://files.pythonhosted.org/packages/03/1f/c5d94abc91557384719da10ff166b916107c1b45e4d0423a88457071dd88/yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486", size = 339828, upload-time = "2025-10-06T14:12:00.686Z" },
    { url = "https://files.pythonhosted.org/packages/5f/97/aa6a143d3afba17b6465733681c70cf175af89f76ec8d9286e08437a7454/yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138", size = 347551, upload-time = "2025-10-06T14:12:02.628Z" },
    { url = "https://files.pythonhosted.org/packages/43/3c/45a2b6d80195959239a7b2a8810506d4eea5487dce61c2a3393e7fc3c52e/yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a", size = 334512, upload-time = "2025-10-06T14:12:04.871Z" },
    { url = "https://files.pythonhosted.org/packages/86/a0/c2ab48d74599c7c84cb104ebd799c5813de252bea0f360ffc29d270c2caa/yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529", size = 352400, upload-time = "2025-10-06T14:12:06.624Z" },
    { url = "https://files.pythonhosted.org/packages/32/75/f8919b2eafc929567d3d8411f72bdb1a2109c01caaab4ebfa5f8ffadc15b/yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093", size = 357140, upload-time = "2025-10-06T14:12:08.362Z" },
    { url = "https://files.pythonhosted.org/packages/cf/72/6a85bba382f22cf78add705d8c3731748397d986e197e53ecc7835e76de7/yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c", size = 341473, upload-time = "2025-10-06T14:12:10.994Z" },
    { url = "https://files.pythonhosted.org/packages/35/18/55e6011f7c044dc80b98893060773cefcfdbf60dfefb8cb2f58b9bacbd83/yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e", size = 89056, upload-time = "2025-10-06T14:12:13.317Z" },
    { url = "https://files.pythonhosted.org/packages/f9/86/0f0dccb6e59a9e7f122c5afd43568b1d31b8ab7dda5f1b01fb5c7025c9a9/yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27", size = 96292, upload-time = "2025-10-06T14:12:15.398Z" },
    { url = "https://files.pythonhosted.org/packages/48/b7/503c98092fb3b344a179579f55814b613c1fbb1c23b3ec14a7b008a66a6e/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", size = 85171, upload-time = "2025-10-06T14:12:16.935Z" },
    { url = "https://files.pythonhosted.org/packages/94/fd/6480106702a79bcceda5fd9c63cb19a04a6506bd5ce7fd8d9b63742f0021/yarl-1.22.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3aa27acb6de7a23785d81557577491f6c38a5209a254d1191519d07d8fe51748", size = 141301, upload-time = "2025-10-06T14:12:19.01Z" },
    { url = "https://files.pythonhosted.org/packages/42/e1/6d95d21b17a93e793e4ec420a925fe1f6a9342338ca7a563ed21129c0990/yarl-1.22.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:af74f05666a5e531289cb1cc9c883d1de2088b8e5b4de48004e5ca8a830ac859", size = 93864, upload-time = "2025-10-06T14:12:21.05Z" },
    { url = "https://files.pythonhosted.org/packages/32/58/b8055273c203968e89808413ea4c984988b6649baabf10f4522e67c22d2f/yarl-1.22.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:62441e55958977b8167b2709c164c91a6363e25da322d87ae6dd9c6019ceecf9", size = 94706, upload-time = "2025-10-06T14:12:23.287Z" },
    { url = "https://files.pythonhosted.org/packages/18/91/d7bfbc28a88c2895ecd0da6a874def0c147de78afc52c773c28e1aa233a3/yarl-1.22.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b580e71cac3f8113d3135888770903eaf2f507e9421e5697d6ee6d8cd1c7f054", size = 347100, upload-time = "2025-10-06T14:12:28.527Z" },
    { url = "https://files.pythonhosted.org/packages/bd/e8/37a1e7b99721c0564b1fc7b0a4d1f595ef6fb8060d82ca61775b644185f7/yarl-1.22.0-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e81fda2fb4a07eda1a2252b216aa0df23ebcd4d584894e9612e80999a78fd95b", size = 318902, upload-time = "2025-10-06T14:12:30.528Z" },
    { url = "https://files.pythonhosted.org/packages/1c/ef/34724449d7ef2db4f22df644f2dac0b8a275d20f585e526937b3ae47b02d/yarl-1.22.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:99b6fc1d55782461b78221e95fc357b47ad98b041e8e20f47c1411d0aacddc60", size = 363302, upload-time = "2025-10-06T14:12:32.295Z" },
    { url = "https://files.pythonhosted.org/packages/8a/04/88a39a5dad39889f192cce8d66cc4c58dbeca983e83f9b6bf23822a7ed91/yarl-1.22.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:088e4e08f033db4be2ccd1f34cf29fe994772fb54cfe004bbf54db320af56890", size = 370816, upload-time = "2025-10-06T14:12:34.01Z" },
    { url = "https://files.pythonhosted.org/packages/6b/1f/5e895e547129413f56c76be2c3ce4b96c797d2d0ff3e16a817d9269b12e6/yarl-1.22.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e4e1f6f0b4da23e61188676e3ed027ef0baa833a2e633c29ff8530800edccba", size = 346465, upload-time = "2025-10-06T14:12:35.977Z" },
    { url = "https://files.pythonhosted.org/packages/11/13/a750e9fd6f9cc9ed3a52a70fe58ffe505322f0efe0d48e1fd9ffe53281f5/yarl-1.22.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:84fc3ec96fce86ce5aa305eb4aa9358279d1aa644b71fab7b8ed33fe3ba1a7ca", size = 341506, upload-time = "2025-10-06T14:12:37.788Z" },
    { url = "https://files.pythonhosted.org/packages/3c/67/bb6024de76e7186611ebe626aec5b71a2d2ecf9453e795f2dbd80614784c/yarl-1.22.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5dbeefd6ca588b33576a01b0ad58aa934bc1b41ef89dee505bf2932b22ddffba", size = 335030, upload-time = "2025-10-06T14:12:39.775Z" },
    { url = "https://files.pythonhosted.org/packages/a2/be/50b38447fd94a7992996a62b8b463d0579323fcfc08c61bdba949eef8a5d/yarl-1.22.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:14291620375b1060613f4aab9ebf21850058b6b1b438f386cc814813d901c60b", size = 358560, upload-time = "2025-10-06T14:12:41.547Z" },
    { url = "https://files.pythonhosted.org/packages/e2/89/c020b6f547578c4e3dbb6335bf918f26e2f34ad0d1e515d72fd33ac0c635/yarl-1.22.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:a4fcfc8eb2c34148c118dfa02e6427ca278bfd0f3df7c5f99e33d2c0e81eae3e", size = 357290, upload-time = "2025-10-06T14:12:43.861Z" },
    { url = "https://files.pythonhosted.org/packages/8c/52/c49a619ee35a402fa3a7019a4fa8d26878fec0d1243f6968bbf516789578/yarl-1.22.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:029866bde8d7b0878b9c160e72305bbf0a7342bcd20b9999381704ae03308dc8", size = 350700, upload-time = "2025-10-06T14:12:46.868Z" },
    { url = "https://files.pythonhosted.org/packages/ab/c9/f5042d87777bf6968435f04a2bbb15466b2f142e6e47fa4f34d1a3f32f0c/yarl-1.22.0-cp39-cp39-win32.whl", hash = "sha256:4dcc74149ccc8bba31ce1944acee24813e93cfdee2acda3c172df844948ddf7b", size = 82323, upload-time = "2025-10-06T14:12:48.633Z" },
    { url = "https://files.pythonhosted.org/packages/fd/58/d00f7cad9eba20c4eefac2682f34661d1d1b3a942fc0092eb60e78cfb733/yarl-1.22.0-cp39-cp39-win_amd64.whl", hash = "sha256:10619d9fdee46d20edc49d3479e2f8269d0779f1b031e6f7c2aa1c76be04b7ed", size = 87145, upload-time = "2025-10-06T14:12:50.241Z" },
    { url = "https://files.pythonhosted.org/packages/c2/a3/70904f365080780d38b919edd42d224b8c4ce224a86950d2eaa2a24366ad/yarl-1.22.0-cp39-cp39-win_arm64.whl", hash = "sha256:dd7afd3f8b0bfb4e0d9fc3c31bfe8a4ec7debe124cfd90619305def3c8ca8cd2", size = 82173, upload-time = "2025-10-06T14:12:51.869Z" },
    { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" },
]

[[package]]
name = "yarl"
version = "1.23.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
    "python_full_version >= '3.14'",
    "python_full_version == '3.13.*'",
    "python_full_version == '3.12.*'",
    "python_full_version == '3.11.*'",
    "python_full_version == '3.10.*'",
]
dependencies = [
    { name = "idna", marker = "python_full_version >= '3.10'" },
    { name = "multidict", marker = "python_full_version >= '3.10'" },
    { name = "propcache", marker = "python_full_version >= '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/8b/0d/9cc638702f6fc3c7a3685bcc8cf2a9ed7d6206e932a49f5242658047ef51/yarl-1.23.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cff6d44cb13d39db2663a22b22305d10855efa0fa8015ddeacc40bc59b9d8107", size = 123764, upload-time = "2026-03-01T22:04:09.7Z" },
    { url = "https://files.pythonhosted.org/packages/7a/35/5a553687c5793df5429cd1db45909d4f3af7eee90014888c208d086a44f0/yarl-1.23.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e4c53f8347cd4200f0d70a48ad059cabaf24f5adc6ba08622a23423bc7efa10d", size = 86282, upload-time = "2026-03-01T22:04:11.892Z" },
    { url = "https://files.pythonhosted.org/packages/68/2e/c5a2234238f8ce37a8312b52801ee74117f576b1539eec8404a480434acc/yarl-1.23.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a6940a074fb3c48356ed0158a3ca5699c955ee4185b4d7d619be3c327143e05", size = 86053, upload-time = "2026-03-01T22:04:13.292Z" },
    { url = "https://files.pythonhosted.org/packages/74/3f/bbd8ff36fb038622797ffbaf7db314918bb4d76f1cc8a4f9ca7a55fe5195/yarl-1.23.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ed5f69ce7be7902e5c70ea19eb72d20abf7d725ab5d49777d696e32d4fc1811d", size = 99395, upload-time = "2026-03-01T22:04:15.133Z" },
    { url = "https://files.pythonhosted.org/packages/77/04/9516bc4e269d2a3ec9c6779fcdeac51ce5b3a9b0156f06ac7152e5bba864/yarl-1.23.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:389871e65468400d6283c0308e791a640b5ab5c83bcee02a2f51295f95e09748", size = 92143, upload-time = "2026-03-01T22:04:16.829Z" },
    { url = "https://files.pythonhosted.org/packages/c7/63/88802d1f6b1cb1fc67d67a58cd0cf8a1790de4ce7946e434240f1d60ab4a/yarl-1.23.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dda608c88cf709b1d406bdfcd84d8d63cff7c9e577a403c6108ce8ce9dcc8764", size = 107643, upload-time = "2026-03-01T22:04:18.519Z" },
    { url = "https://files.pythonhosted.org/packages/8e/db/4f9b838f4d8bdd6f0f385aed8bbf21c71ed11a0b9983305c302cbd557815/yarl-1.23.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8c4fe09e0780c6c3bf2b7d4af02ee2394439d11a523bbcf095cf4747c2932007", size = 108700, upload-time = "2026-03-01T22:04:20.373Z" },
    { url = "https://files.pythonhosted.org/packages/50/12/95a1d33f04a79c402664070d43b8b9f72dc18914e135b345b611b0b1f8cc/yarl-1.23.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31c9921eb8bd12633b41ad27686bbb0b1a2a9b8452bfdf221e34f311e9942ed4", size = 102769, upload-time = "2026-03-01T22:04:23.055Z" },
    { url = "https://files.pythonhosted.org/packages/86/65/91a0285f51321369fd1a8308aa19207520c5f0587772cfc2e03fc2467e90/yarl-1.23.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5f10fd85e4b75967468af655228fbfd212bdf66db1c0d135065ce288982eda26", size = 101114, upload-time = "2026-03-01T22:04:25.031Z" },
    { url = "https://files.pythonhosted.org/packages/58/80/c7c8244fc3e5bc483dc71a09560f43b619fab29301a0f0a8f936e42865c7/yarl-1.23.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dbf507e9ef5688bada447a24d68b4b58dd389ba93b7afc065a2ba892bea54769", size = 98883, upload-time = "2026-03-01T22:04:27.281Z" },
    { url = "https://files.pythonhosted.org/packages/86/e7/71ca9cc9ca79c0b7d491216177d1aed559d632947b8ffb0ee60f7d8b23e3/yarl-1.23.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:85e9beda1f591bc73e77ea1c51965c68e98dafd0fec72cdd745f77d727466716", size = 94172, upload-time = "2026-03-01T22:04:28.554Z" },
    { url = "https://files.pythonhosted.org/packages/6a/3f/6c6c8a0fe29c26fb2db2e8d32195bb84ec1bfb8f1d32e7f73b787fcf349b/yarl-1.23.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:0e1fdaa14ef51366d7757b45bde294e95f6c8c049194e793eedb8387c86d5993", size = 107010, upload-time = "2026-03-01T22:04:30.385Z" },
    { url = "https://files.pythonhosted.org/packages/56/38/12730c05e5ad40a76374d440ed8b0899729a96c250516d91c620a6e38fc2/yarl-1.23.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:75e3026ab649bf48f9a10c0134512638725b521340293f202a69b567518d94e0", size = 100285, upload-time = "2026-03-01T22:04:31.752Z" },
    { url = "https://files.pythonhosted.org/packages/34/92/6a7be9239f2347234e027284e7a5f74b1140cc86575e7b469d13fba1ebfe/yarl-1.23.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:80e6d33a3d42a7549b409f199857b4fb54e2103fc44fb87605b6663b7a7ff750", size = 108230, upload-time = "2026-03-01T22:04:33.844Z" },
    { url = "https://files.pythonhosted.org/packages/5e/81/4aebccfa9376bd98b9d8bfad20621a57d3e8cfc5b8631c1fa5f62cdd03f4/yarl-1.23.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5ec2f42d41ccbd5df0270d7df31618a8ee267bfa50997f5d720ddba86c4a83a6", size = 103008, upload-time = "2026-03-01T22:04:35.856Z" },
    { url = "https://files.pythonhosted.org/packages/38/0f/0b4e3edcec794a86b853b0c6396c0a888d72dfce19b2d88c02ac289fb6c1/yarl-1.23.0-cp310-cp310-win32.whl", hash = "sha256:debe9c4f41c32990771be5c22b56f810659f9ddf3d63f67abfdcaa2c6c9c5c1d", size = 83073, upload-time = "2026-03-01T22:04:38.268Z" },
    { url = "https://files.pythonhosted.org/packages/a0/71/ad95c33da18897e4c636528bbc24a1dd23fe16797de8bc4ec667b8db0ba4/yarl-1.23.0-cp310-cp310-win_amd64.whl", hash = "sha256:ab5f043cb8a2d71c981c09c510da013bc79fd661f5c60139f00dd3c3cc4f2ffb", size = 87328, upload-time = "2026-03-01T22:04:39.558Z" },
    { url = "https://files.pythonhosted.org/packages/e2/14/dfa369523c79bccf9c9c746b0a63eb31f65db9418ac01275f7950962e504/yarl-1.23.0-cp310-cp310-win_arm64.whl", hash = "sha256:263cd4f47159c09b8b685890af949195b51d1aa82ba451c5847ca9bc6413c220", size = 82463, upload-time = "2026-03-01T22:04:41.454Z" },
    { url = "https://files.pythonhosted.org/packages/a2/aa/60da938b8f0997ba3a911263c40d82b6f645a67902a490b46f3355e10fae/yarl-1.23.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b35d13d549077713e4414f927cdc388d62e543987c572baee613bf82f11a4b99", size = 123641, upload-time = "2026-03-01T22:04:42.841Z" },
    { url = "https://files.pythonhosted.org/packages/24/84/e237607faf4e099dbb8a4f511cfd5efcb5f75918baad200ff7380635631b/yarl-1.23.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cbb0fef01f0c6b38cb0f39b1f78fc90b807e0e3c86a7ff3ce74ad77ce5c7880c", size = 86248, upload-time = "2026-03-01T22:04:44.757Z" },
    { url = "https://files.pythonhosted.org/packages/b2/0d/71ceabc14c146ba8ee3804ca7b3d42b1664c8440439de5214d366fec7d3a/yarl-1.23.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc52310451fc7c629e13c4e061cbe2dd01684d91f2f8ee2821b083c58bd72432", size = 85988, upload-time = "2026-03-01T22:04:46.365Z" },
    { url = "https://files.pythonhosted.org/packages/8c/6c/4a90d59c572e46b270ca132aca66954f1175abd691f74c1ef4c6711828e2/yarl-1.23.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2c6b50c7b0464165472b56b42d4c76a7b864597007d9c085e8b63e185cf4a7a", size = 100566, upload-time = "2026-03-01T22:04:47.639Z" },
    { url = "https://files.pythonhosted.org/packages/49/fb/c438fb5108047e629f6282a371e6e91cf3f97ee087c4fb748a1f32ceef55/yarl-1.23.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aafe5dcfda86c8af00386d7781d4c2181b5011b7be3f2add5e99899ea925df05", size = 92079, upload-time = "2026-03-01T22:04:48.925Z" },
    { url = "https://files.pythonhosted.org/packages/d9/13/d269aa1aed3e4f50a5a103f96327210cc5fa5dd2d50882778f13c7a14606/yarl-1.23.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9ee33b875f0b390564c1fb7bc528abf18c8ee6073b201c6ae8524aca778e2d83", size = 108741, upload-time = "2026-03-01T22:04:50.838Z" },
    { url = "https://files.pythonhosted.org/packages/85/fb/115b16f22c37ea4437d323e472945bea97301c8ec6089868fa560abab590/yarl-1.23.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4c41e021bc6d7affb3364dc1e1e5fa9582b470f283748784bd6ea0558f87f42c", size = 108099, upload-time = "2026-03-01T22:04:52.499Z" },
    { url = "https://files.pythonhosted.org/packages/9a/64/c53487d9f4968045b8afa51aed7ca44f58b2589e772f32745f3744476c82/yarl-1.23.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99c8a9ed30f4164bc4c14b37a90208836cbf50d4ce2a57c71d0f52c7fb4f7598", size = 102678, upload-time = "2026-03-01T22:04:55.176Z" },
    { url = "https://files.pythonhosted.org/packages/85/59/cd98e556fbb2bf8fab29c1a722f67ad45c5f3447cac798ab85620d1e70af/yarl-1.23.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2af5c81a1f124609d5f33507082fc3f739959d4719b56877ab1ee7e7b3d602b", size = 100803, upload-time = "2026-03-01T22:04:56.588Z" },
    { url = "https://files.pythonhosted.org/packages/9e/c0/b39770b56d4a9f0bb5f77e2f1763cd2d75cc2f6c0131e3b4c360348fcd65/yarl-1.23.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6b41389c19b07c760c7e427a3462e8ab83c4bb087d127f0e854c706ce1b9215c", size = 100163, upload-time = "2026-03-01T22:04:58.492Z" },
    { url = "https://files.pythonhosted.org/packages/e7/64/6980f99ab00e1f0ff67cb84766c93d595b067eed07439cfccfc8fb28c1a6/yarl-1.23.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:1dc702e42d0684f42d6519c8d581e49c96cefaaab16691f03566d30658ee8788", size = 93859, upload-time = "2026-03-01T22:05:00.268Z" },
    { url = "https://files.pythonhosted.org/packages/38/69/912e6c5e146793e5d4b5fe39ff5b00f4d22463dfd5a162bec565ac757673/yarl-1.23.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0e40111274f340d32ebcc0a5668d54d2b552a6cca84c9475859d364b380e3222", size = 108202, upload-time = "2026-03-01T22:05:02.273Z" },
    { url = "https://files.pythonhosted.org/packages/59/97/35ca6767524687ad64e5f5c31ad54bc76d585585a9fcb40f649e7e82ffed/yarl-1.23.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4764a6a7588561a9aef92f65bda2c4fb58fe7c675c0883862e6df97559de0bfb", size = 99866, upload-time = "2026-03-01T22:05:03.597Z" },
    { url = "https://files.pythonhosted.org/packages/d3/1c/1a3387ee6d73589f6f2a220ae06f2984f6c20b40c734989b0a44f5987308/yarl-1.23.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:03214408cfa590df47728b84c679ae4ef00be2428e11630277be0727eba2d7cc", size = 107852, upload-time = "2026-03-01T22:05:04.986Z" },
    { url = "https://files.pythonhosted.org/packages/a4/b8/35c0750fcd5a3f781058bfd954515dd4b1eab45e218cbb85cf11132215f1/yarl-1.23.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:170e26584b060879e29fac213e4228ef063f39128723807a312e5c7fec28eff2", size = 102919, upload-time = "2026-03-01T22:05:06.397Z" },
    { url = "https://files.pythonhosted.org/packages/e5/1c/9a1979aec4a81896d597bcb2177827f2dbee3f5b7cc48b2d0dadb644b41d/yarl-1.23.0-cp311-cp311-win32.whl", hash = "sha256:51430653db848d258336cfa0244427b17d12db63d42603a55f0d4546f50f25b5", size = 82602, upload-time = "2026-03-01T22:05:08.444Z" },
    { url = "https://files.pythonhosted.org/packages/93/22/b85eca6fa2ad9491af48c973e4c8cf6b103a73dbb271fe3346949449fca0/yarl-1.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:bf49a3ae946a87083ef3a34c8f677ae4243f5b824bfc4c69672e72b3d6719d46", size = 87461, upload-time = "2026-03-01T22:05:10.145Z" },
    { url = "https://files.pythonhosted.org/packages/93/95/07e3553fe6f113e6864a20bdc53a78113cda3b9ced8784ee52a52c9f80d8/yarl-1.23.0-cp311-cp311-win_arm64.whl", hash = "sha256:b39cb32a6582750b6cc77bfb3c49c0f8760dc18dc96ec9fb55fbb0f04e08b928", size = 82336, upload-time = "2026-03-01T22:05:11.554Z" },
    { url = "https://files.pythonhosted.org/packages/88/8a/94615bc31022f711add374097ad4144d569e95ff3c38d39215d07ac153a0/yarl-1.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1932b6b8bba8d0160a9d1078aae5838a66039e8832d41d2992daa9a3a08f7860", size = 124737, upload-time = "2026-03-01T22:05:12.897Z" },
    { url = "https://files.pythonhosted.org/packages/e3/6f/c6554045d59d64052698add01226bc867b52fe4a12373415d7991fdca95d/yarl-1.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:411225bae281f114067578891bc75534cfb3d92a3b4dfef7a6ca78ba354e6069", size = 87029, upload-time = "2026-03-01T22:05:14.376Z" },
    { url = "https://files.pythonhosted.org/packages/19/2a/725ecc166d53438bc88f76822ed4b1e3b10756e790bafd7b523fe97c322d/yarl-1.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:13a563739ae600a631c36ce096615fe307f131344588b0bc0daec108cdb47b25", size = 86310, upload-time = "2026-03-01T22:05:15.71Z" },
    { url = "https://files.pythonhosted.org/packages/99/30/58260ed98e6ff7f90ba84442c1ddd758c9170d70327394a6227b310cd60f/yarl-1.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cbf44c5cb4a7633d078788e1b56387e3d3cf2b8139a3be38040b22d6c3221c8", size = 97587, upload-time = "2026-03-01T22:05:17.384Z" },
    { url = "https://files.pythonhosted.org/packages/76/0a/8b08aac08b50682e65759f7f8dde98ae8168f72487e7357a5d684c581ef9/yarl-1.23.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53ad387048f6f09a8969631e4de3f1bf70c50e93545d64af4f751b2498755072", size = 92528, upload-time = "2026-03-01T22:05:18.804Z" },
    { url = "https://files.pythonhosted.org/packages/52/07/0b7179101fe5f8385ec6c6bb5d0cb9f76bd9fb4a769591ab6fb5cdbfc69a/yarl-1.23.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4a59ba56f340334766f3a4442e0efd0af895fae9e2b204741ef885c446b3a1a8", size = 105339, upload-time = "2026-03-01T22:05:20.235Z" },
    { url = "https://files.pythonhosted.org/packages/d3/8a/36d82869ab5ec829ca8574dfcb92b51286fcfb1e9c7a73659616362dc880/yarl-1.23.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:803a3c3ce4acc62eaf01eaca1208dcf0783025ef27572c3336502b9c232005e7", size = 105061, upload-time = "2026-03-01T22:05:22.268Z" },
    { url = "https://files.pythonhosted.org/packages/66/3e/868e5c3364b6cee19ff3e1a122194fa4ce51def02c61023970442162859e/yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3d2bff8f37f8d0f96c7ec554d16945050d54462d6e95414babaa18bfafc7f51", size = 100132, upload-time = "2026-03-01T22:05:23.638Z" },
    { url = "https://files.pythonhosted.org/packages/cf/26/9c89acf82f08a52cb52d6d39454f8d18af15f9d386a23795389d1d423823/yarl-1.23.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c75eb09e8d55bceb4367e83496ff8ef2bc7ea6960efb38e978e8073ea59ecb67", size = 99289, upload-time = "2026-03-01T22:05:25.749Z" },
    { url = "https://files.pythonhosted.org/packages/6f/54/5b0db00d2cb056922356104468019c0a132e89c8d3ab67d8ede9f4483d2a/yarl-1.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877b0738624280e34c55680d6054a307aa94f7d52fa0e3034a9cc6e790871da7", size = 96950, upload-time = "2026-03-01T22:05:27.318Z" },
    { url = "https://files.pythonhosted.org/packages/f6/40/10fa93811fd439341fad7e0718a86aca0de9548023bbb403668d6555acab/yarl-1.23.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b5405bb8f0e783a988172993cfc627e4d9d00432d6bbac65a923041edacf997d", size = 93960, upload-time = "2026-03-01T22:05:28.738Z" },
    { url = "https://files.pythonhosted.org/packages/bc/d2/8ae2e6cd77d0805f4526e30ec43b6f9a3dfc542d401ac4990d178e4bf0cf/yarl-1.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c3a3598a832590c5a3ce56ab5576361b5688c12cb1d39429cf5dba30b510760", size = 104703, upload-time = "2026-03-01T22:05:30.438Z" },
    { url = "https://files.pythonhosted.org/packages/2f/0c/b3ceacf82c3fe21183ce35fa2acf5320af003d52bc1fcf5915077681142e/yarl-1.23.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8419ebd326430d1cbb7efb5292330a2cf39114e82df5cc3d83c9a0d5ebeaf2f2", size = 98325, upload-time = "2026-03-01T22:05:31.835Z" },
    { url = "https://files.pythonhosted.org/packages/9d/e0/12900edd28bdab91a69bd2554b85ad7b151f64e8b521fe16f9ad2f56477a/yarl-1.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:be61f6fff406ca40e3b1d84716fde398fc08bc63dd96d15f3a14230a0973ed86", size = 105067, upload-time = "2026-03-01T22:05:33.358Z" },
    { url = "https://files.pythonhosted.org/packages/15/61/74bb1182cf79c9bbe4eb6b1f14a57a22d7a0be5e9cedf8e2d5c2086474c3/yarl-1.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ceb13c5c858d01321b5d9bb65e4cf37a92169ea470b70fec6f236b2c9dd7e34", size = 100285, upload-time = "2026-03-01T22:05:35.4Z" },
    { url = "https://files.pythonhosted.org/packages/69/7f/cd5ef733f2550de6241bd8bd8c3febc78158b9d75f197d9c7baa113436af/yarl-1.23.0-cp312-cp312-win32.whl", hash = "sha256:fffc45637bcd6538de8b85f51e3df3223e4ad89bccbfca0481c08c7fc8b7ed7d", size = 82359, upload-time = "2026-03-01T22:05:36.811Z" },
    { url = "https://files.pythonhosted.org/packages/f5/be/25216a49daeeb7af2bec0db22d5e7df08ed1d7c9f65d78b14f3b74fd72fc/yarl-1.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:f69f57305656a4852f2a7203efc661d8c042e6cc67f7acd97d8667fb448a426e", size = 87674, upload-time = "2026-03-01T22:05:38.171Z" },
    { url = "https://files.pythonhosted.org/packages/d2/35/aeab955d6c425b227d5b7247eafb24f2653fedc32f95373a001af5dfeb9e/yarl-1.23.0-cp312-cp312-win_arm64.whl", hash = "sha256:6e87a6e8735b44816e7db0b2fbc9686932df473c826b0d9743148432e10bb9b9", size = 81879, upload-time = "2026-03-01T22:05:40.006Z" },
    { url = "https://files.pythonhosted.org/packages/9a/4b/a0a6e5d0ee8a2f3a373ddef8a4097d74ac901ac363eea1440464ccbe0898/yarl-1.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:16c6994ac35c3e74fb0ae93323bf8b9c2a9088d55946109489667c510a7d010e", size = 123796, upload-time = "2026-03-01T22:05:41.412Z" },
    { url = "https://files.pythonhosted.org/packages/67/b6/8925d68af039b835ae876db5838e82e76ec87b9782ecc97e192b809c4831/yarl-1.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4a42e651629dafb64fd5b0286a3580613702b5809ad3f24934ea87595804f2c5", size = 86547, upload-time = "2026-03-01T22:05:42.841Z" },
    { url = "https://files.pythonhosted.org/packages/ae/50/06d511cc4b8e0360d3c94af051a768e84b755c5eb031b12adaaab6dec6e5/yarl-1.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c6b9461a2a8b47c65eef63bb1c76a4f1c119618ffa99ea79bc5bb1e46c5821b", size = 85854, upload-time = "2026-03-01T22:05:44.85Z" },
    { url = "https://files.pythonhosted.org/packages/c4/f4/4e30b250927ffdab4db70da08b9b8d2194d7c7b400167b8fbeca1e4701ca/yarl-1.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2569b67d616eab450d262ca7cb9f9e19d2f718c70a8b88712859359d0ab17035", size = 98351, upload-time = "2026-03-01T22:05:46.836Z" },
    { url = "https://files.pythonhosted.org/packages/86/fc/4118c5671ea948208bdb1492d8b76bdf1453d3e73df051f939f563e7dcc5/yarl-1.23.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e9d9a4d06d3481eab79803beb4d9bd6f6a8e781ec078ac70d7ef2dcc29d1bea5", size = 92711, upload-time = "2026-03-01T22:05:48.316Z" },
    { url = "https://files.pythonhosted.org/packages/56/11/1ed91d42bd9e73c13dc9e7eb0dd92298d75e7ac4dd7f046ad0c472e231cd/yarl-1.23.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f514f6474e04179d3d33175ed3f3e31434d3130d42ec153540d5b157deefd735", size = 106014, upload-time = "2026-03-01T22:05:50.028Z" },
    { url = "https://files.pythonhosted.org/packages/ce/c9/74e44e056a23fbc33aca71779ef450ca648a5bc472bdad7a82339918f818/yarl-1.23.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fda207c815b253e34f7e1909840fd14299567b1c0eb4908f8c2ce01a41265401", size = 105557, upload-time = "2026-03-01T22:05:51.416Z" },
    { url = "https://files.pythonhosted.org/packages/66/fe/b1e10b08d287f518994f1e2ff9b6d26f0adeecd8dd7d533b01bab29a3eda/yarl-1.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34b6cf500e61c90f305094911f9acc9c86da1a05a7a3f5be9f68817043f486e4", size = 101559, upload-time = "2026-03-01T22:05:52.872Z" },
    { url = "https://files.pythonhosted.org/packages/72/59/c5b8d94b14e3d3c2a9c20cb100119fd534ab5a14b93673ab4cc4a4141ea5/yarl-1.23.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7504f2b476d21653e4d143f44a175f7f751cd41233525312696c76aa3dbb23f", size = 100502, upload-time = "2026-03-01T22:05:54.954Z" },
    { url = "https://files.pythonhosted.org/packages/77/4f/96976cb54cbfc5c9fd73ed4c51804f92f209481d1fb190981c0f8a07a1d7/yarl-1.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:578110dd426f0d209d1509244e6d4a3f1a3e9077655d98c5f22583d63252a08a", size = 98027, upload-time = "2026-03-01T22:05:56.409Z" },
    { url = "https://files.pythonhosted.org/packages/63/6e/904c4f476471afdbad6b7e5b70362fb5810e35cd7466529a97322b6f5556/yarl-1.23.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:609d3614d78d74ebe35f54953c5bbd2ac647a7ddb9c30a5d877580f5e86b22f2", size = 95369, upload-time = "2026-03-01T22:05:58.141Z" },
    { url = "https://files.pythonhosted.org/packages/9d/40/acfcdb3b5f9d68ef499e39e04d25e141fe90661f9d54114556cf83be8353/yarl-1.23.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4966242ec68afc74c122f8459abd597afd7d8a60dc93d695c1334c5fd25f762f", size = 105565, upload-time = "2026-03-01T22:06:00.286Z" },
    { url = "https://files.pythonhosted.org/packages/5e/c6/31e28f3a6ba2869c43d124f37ea5260cac9c9281df803c354b31f4dd1f3c/yarl-1.23.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e0fd068364a6759bc794459f0a735ab151d11304346332489c7972bacbe9e72b", size = 99813, upload-time = "2026-03-01T22:06:01.712Z" },
    { url = "https://files.pythonhosted.org/packages/08/1f/6f65f59e72d54aa467119b63fc0b0b1762eff0232db1f4720cd89e2f4a17/yarl-1.23.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:39004f0ad156da43e86aa71f44e033de68a44e5a31fc53507b36dd253970054a", size = 105632, upload-time = "2026-03-01T22:06:03.188Z" },
    { url = "https://files.pythonhosted.org/packages/a3/c4/18b178a69935f9e7a338127d5b77d868fdc0f0e49becd286d51b3a18c61d/yarl-1.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e5723c01a56c5028c807c701aa66722916d2747ad737a046853f6c46f4875543", size = 101895, upload-time = "2026-03-01T22:06:04.651Z" },
    { url = "https://files.pythonhosted.org/packages/8f/54/f5b870b5505663911dba950a8e4776a0dbd51c9c54c0ae88e823e4b874a0/yarl-1.23.0-cp313-cp313-win32.whl", hash = "sha256:1b6b572edd95b4fa8df75de10b04bc81acc87c1c7d16bcdd2035b09d30acc957", size = 82356, upload-time = "2026-03-01T22:06:06.04Z" },
    { url = "https://files.pythonhosted.org/packages/7a/84/266e8da36879c6edcd37b02b547e2d9ecdfea776be49598e75696e3316e1/yarl-1.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:baaf55442359053c7d62f6f8413a62adba3205119bcb6f49594894d8be47e5e3", size = 87515, upload-time = "2026-03-01T22:06:08.107Z" },
    { url = "https://files.pythonhosted.org/packages/00/fd/7e1c66efad35e1649114fa13f17485f62881ad58edeeb7f49f8c5e748bf9/yarl-1.23.0-cp313-cp313-win_arm64.whl", hash = "sha256:fb4948814a2a98e3912505f09c9e7493b1506226afb1f881825368d6fb776ee3", size = 81785, upload-time = "2026-03-01T22:06:10.181Z" },
    { url = "https://files.pythonhosted.org/packages/9c/fc/119dd07004f17ea43bb91e3ece6587759edd7519d6b086d16bfbd3319982/yarl-1.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:aecfed0b41aa72b7881712c65cf764e39ce2ec352324f5e0837c7048d9e6daaa", size = 130719, upload-time = "2026-03-01T22:06:11.708Z" },
    { url = "https://files.pythonhosted.org/packages/e6/0d/9f2348502fbb3af409e8f47730282cd6bc80dec6630c1e06374d882d6eb2/yarl-1.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a41bcf68efd19073376eb8cf948b8d9be0af26256403e512bb18f3966f1f9120", size = 89690, upload-time = "2026-03-01T22:06:13.429Z" },
    { url = "https://files.pythonhosted.org/packages/50/93/e88f3c80971b42cfc83f50a51b9d165a1dbf154b97005f2994a79f212a07/yarl-1.23.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cde9a2ecd91668bcb7f077c4966d8ceddb60af01b52e6e3e2680e4cf00ad1a59", size = 89851, upload-time = "2026-03-01T22:06:15.53Z" },
    { url = "https://files.pythonhosted.org/packages/1c/07/61c9dd8ba8f86473263b4036f70fb594c09e99c0d9737a799dfd8bc85651/yarl-1.23.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5023346c4ee7992febc0068e7593de5fa2bf611848c08404b35ebbb76b1b0512", size = 95874, upload-time = "2026-03-01T22:06:17.553Z" },
    { url = "https://files.pythonhosted.org/packages/9e/e9/f9ff8ceefba599eac6abddcfb0b3bee9b9e636e96dbf54342a8577252379/yarl-1.23.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1009abedb49ae95b136a8904a3f71b342f849ffeced2d3747bf29caeda218c4", size = 88710, upload-time = "2026-03-01T22:06:19.004Z" },
    { url = "https://files.pythonhosted.org/packages/eb/78/0231bfcc5d4c8eec220bc2f9ef82cb4566192ea867a7c5b4148f44f6cbcd/yarl-1.23.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a8d00f29b42f534cc8aa3931cfe773b13b23e561e10d2b26f27a8d309b0e82a1", size = 101033, upload-time = "2026-03-01T22:06:21.203Z" },
    { url = "https://files.pythonhosted.org/packages/cd/9b/30ea5239a61786f18fd25797151a17fbb3be176977187a48d541b5447dd4/yarl-1.23.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:95451e6ce06c3e104556d73b559f5da6c34a069b6b62946d3ad66afcd51642ea", size = 100817, upload-time = "2026-03-01T22:06:22.738Z" },
    { url = "https://files.pythonhosted.org/packages/62/e2/a4980481071791bc83bce2b7a1a1f7adcabfa366007518b4b845e92eeee3/yarl-1.23.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531ef597132086b6cf96faa7c6c1dcd0361dd5f1694e5cc30375907b9b7d3ea9", size = 97482, upload-time = "2026-03-01T22:06:24.21Z" },
    { url = "https://files.pythonhosted.org/packages/e5/1e/304a00cf5f6100414c4b5a01fc7ff9ee724b62158a08df2f8170dfc72a2d/yarl-1.23.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88f9fb0116fbfcefcab70f85cf4b74a2b6ce5d199c41345296f49d974ddb4123", size = 95949, upload-time = "2026-03-01T22:06:25.697Z" },
    { url = "https://files.pythonhosted.org/packages/68/03/093f4055ed4cae649ac53bca3d180bd37102e9e11d048588e9ab0c0108d0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e7b0460976dc75cb87ad9cc1f9899a4b97751e7d4e77ab840fc9b6d377b8fd24", size = 95839, upload-time = "2026-03-01T22:06:27.309Z" },
    { url = "https://files.pythonhosted.org/packages/b9/28/4c75ebb108f322aa8f917ae10a8ffa4f07cae10a8a627b64e578617df6a0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:115136c4a426f9da976187d238e84139ff6b51a20839aa6e3720cd1026d768de", size = 90696, upload-time = "2026-03-01T22:06:29.048Z" },
    { url = "https://files.pythonhosted.org/packages/23/9c/42c2e2dd91c1a570402f51bdf066bfdb1241c2240ba001967bad778e77b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ead11956716a940c1abc816b7df3fa2b84d06eaed8832ca32f5c5e058c65506b", size = 100865, upload-time = "2026-03-01T22:06:30.525Z" },
    { url = "https://files.pythonhosted.org/packages/74/05/1bcd60a8a0a914d462c305137246b6f9d167628d73568505fce3f1cb2e65/yarl-1.23.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:fe8f8f5e70e6dbdfca9882cd9deaac058729bcf323cf7a58660901e55c9c94f6", size = 96234, upload-time = "2026-03-01T22:06:32.692Z" },
    { url = "https://files.pythonhosted.org/packages/90/b2/f52381aac396d6778ce516b7bc149c79e65bfc068b5de2857ab69eeea3b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:a0e317df055958a0c1e79e5d2aa5a5eaa4a6d05a20d4b0c9c3f48918139c9fc6", size = 100295, upload-time = "2026-03-01T22:06:34.268Z" },
    { url = "https://files.pythonhosted.org/packages/e5/e8/638bae5bbf1113a659b2435d8895474598afe38b4a837103764f603aba56/yarl-1.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f0fd84de0c957b2d280143522c4f91a73aada1923caee763e24a2b3fda9f8a5", size = 97784, upload-time = "2026-03-01T22:06:35.864Z" },
    { url = "https://files.pythonhosted.org/packages/80/25/a3892b46182c586c202629fc2159aa13975d3741d52ebd7347fd501d48d5/yarl-1.23.0-cp313-cp313t-win32.whl", hash = "sha256:93a784271881035ab4406a172edb0faecb6e7d00f4b53dc2f55919d6c9688595", size = 88313, upload-time = "2026-03-01T22:06:37.39Z" },
    { url = "https://files.pythonhosted.org/packages/43/68/8c5b36aa5178900b37387937bc2c2fe0e9505537f713495472dcf6f6fccc/yarl-1.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dd00607bffbf30250fe108065f07453ec124dbf223420f57f5e749b04295e090", size = 94932, upload-time = "2026-03-01T22:06:39.579Z" },
    { url = "https://files.pythonhosted.org/packages/c6/cc/d79ba8292f51f81f4dc533a8ccfb9fc6992cabf0998ed3245de7589dc07c/yarl-1.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ac09d42f48f80c9ee1635b2fcaa819496a44502737660d3c0f2ade7526d29144", size = 84786, upload-time = "2026-03-01T22:06:41.988Z" },
    { url = "https://files.pythonhosted.org/packages/90/98/b85a038d65d1b92c3903ab89444f48d3cee490a883477b716d7a24b1a78c/yarl-1.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912", size = 124455, upload-time = "2026-03-01T22:06:43.615Z" },
    { url = "https://files.pythonhosted.org/packages/39/54/bc2b45559f86543d163b6e294417a107bb87557609007c007ad889afec18/yarl-1.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474", size = 86752, upload-time = "2026-03-01T22:06:45.425Z" },
    { url = "https://files.pythonhosted.org/packages/24/f9/e8242b68362bffe6fb536c8db5076861466fc780f0f1b479fc4ffbebb128/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719", size = 86291, upload-time = "2026-03-01T22:06:46.974Z" },
    { url = "https://files.pythonhosted.org/packages/ea/d8/d1cb2378c81dd729e98c716582b1ccb08357e8488e4c24714658cc6630e8/yarl-1.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319", size = 99026, upload-time = "2026-03-01T22:06:48.459Z" },
    { url = "https://files.pythonhosted.org/packages/0a/ff/7196790538f31debe3341283b5b0707e7feb947620fc5e8236ef28d44f72/yarl-1.23.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434", size = 92355, upload-time = "2026-03-01T22:06:50.306Z" },
    { url = "https://files.pythonhosted.org/packages/c1/56/25d58c3eddde825890a5fe6aa1866228377354a3c39262235234ab5f616b/yarl-1.23.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723", size = 106417, upload-time = "2026-03-01T22:06:52.1Z" },
    { url = "https://files.pythonhosted.org/packages/51/8a/882c0e7bc8277eb895b31bce0138f51a1ba551fc2e1ec6753ffc1e7c1377/yarl-1.23.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039", size = 106422, upload-time = "2026-03-01T22:06:54.424Z" },
    { url = "https://files.pythonhosted.org/packages/42/2b/fef67d616931055bf3d6764885990a3ac647d68734a2d6a9e1d13de437a2/yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52", size = 101915, upload-time = "2026-03-01T22:06:55.895Z" },
    { url = "https://files.pythonhosted.org/packages/18/6a/530e16aebce27c5937920f3431c628a29a4b6b430fab3fd1c117b26ff3f6/yarl-1.23.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c", size = 100690, upload-time = "2026-03-01T22:06:58.21Z" },
    { url = "https://files.pythonhosted.org/packages/88/08/93749219179a45e27b036e03260fda05190b911de8e18225c294ac95bbc9/yarl-1.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae", size = 98750, upload-time = "2026-03-01T22:06:59.794Z" },
    { url = "https://files.pythonhosted.org/packages/d9/cf/ea424a004969f5d81a362110a6ac1496d79efdc6d50c2c4b2e3ea0fc2519/yarl-1.23.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e", size = 94685, upload-time = "2026-03-01T22:07:01.375Z" },
    { url = "https://files.pythonhosted.org/packages/e2/b7/14341481fe568e2b0408bcf1484c652accafe06a0ade9387b5d3fd9df446/yarl-1.23.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85", size = 106009, upload-time = "2026-03-01T22:07:03.151Z" },
    { url = "https://files.pythonhosted.org/packages/0a/e6/5c744a9b54f4e8007ad35bce96fbc9218338e84812d36f3390cea616881a/yarl-1.23.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd", size = 100033, upload-time = "2026-03-01T22:07:04.701Z" },
    { url = "https://files.pythonhosted.org/packages/0c/23/e3bfc188d0b400f025bc49d99793d02c9abe15752138dcc27e4eaf0c4a9e/yarl-1.23.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6", size = 106483, upload-time = "2026-03-01T22:07:06.231Z" },
    { url = "https://files.pythonhosted.org/packages/72/42/f0505f949a90b3f8b7a363d6cbdf398f6e6c58946d85c6d3a3bc70595b26/yarl-1.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe", size = 102175, upload-time = "2026-03-01T22:07:08.4Z" },
    { url = "https://files.pythonhosted.org/packages/aa/65/b39290f1d892a9dd671d1c722014ca062a9c35d60885d57e5375db0404b5/yarl-1.23.0-cp314-cp314-win32.whl", hash = "sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169", size = 83871, upload-time = "2026-03-01T22:07:09.968Z" },
    { url = "https://files.pythonhosted.org/packages/a9/5b/9b92f54c784c26e2a422e55a8d2607ab15b7ea3349e28359282f84f01d43/yarl-1.23.0-cp314-cp314-win_amd64.whl", hash = "sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70", size = 89093, upload-time = "2026-03-01T22:07:11.501Z" },
    { url = "https://files.pythonhosted.org/packages/e0/7d/8a84dc9381fd4412d5e7ff04926f9865f6372b4c2fd91e10092e65d29eb8/yarl-1.23.0-cp314-cp314-win_arm64.whl", hash = "sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e", size = 83384, upload-time = "2026-03-01T22:07:13.069Z" },
    { url = "https://files.pythonhosted.org/packages/dd/8d/d2fad34b1c08aa161b74394183daa7d800141aaaee207317e82c790b418d/yarl-1.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679", size = 131019, upload-time = "2026-03-01T22:07:14.903Z" },
    { url = "https://files.pythonhosted.org/packages/19/ff/33009a39d3ccf4b94d7d7880dfe17fb5816c5a4fe0096d9b56abceea9ac7/yarl-1.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412", size = 89894, upload-time = "2026-03-01T22:07:17.372Z" },
    { url = "https://files.pythonhosted.org/packages/0c/f1/dab7ac5e7306fb79c0190766a3c00b4cb8d09a1f390ded68c85a5934faf5/yarl-1.23.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4", size = 89979, upload-time = "2026-03-01T22:07:19.361Z" },
    { url = "https://files.pythonhosted.org/packages/aa/b1/08e95f3caee1fad6e65017b9f26c1d79877b502622d60e517de01e72f95d/yarl-1.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c", size = 95943, upload-time = "2026-03-01T22:07:21.266Z" },
    { url = "https://files.pythonhosted.org/packages/c0/cc/6409f9018864a6aa186c61175b977131f373f1988e198e031236916e87e4/yarl-1.23.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4", size = 88786, upload-time = "2026-03-01T22:07:23.129Z" },
    { url = "https://files.pythonhosted.org/packages/76/40/cc22d1d7714b717fde2006fad2ced5efe5580606cb059ae42117542122f3/yarl-1.23.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94", size = 101307, upload-time = "2026-03-01T22:07:24.689Z" },
    { url = "https://files.pythonhosted.org/packages/8f/0d/476c38e85ddb4c6ec6b20b815bdd779aa386a013f3d8b85516feee55c8dc/yarl-1.23.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28", size = 100904, upload-time = "2026-03-01T22:07:26.287Z" },
    { url = "https://files.pythonhosted.org/packages/72/32/0abe4a76d59adf2081dcb0397168553ece4616ada1c54d1c49d8936c74f8/yarl-1.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6", size = 97728, upload-time = "2026-03-01T22:07:27.906Z" },
    { url = "https://files.pythonhosted.org/packages/b7/35/7b30f4810fba112f60f5a43237545867504e15b1c7647a785fbaf588fac2/yarl-1.23.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277", size = 95964, upload-time = "2026-03-01T22:07:30.198Z" },
    { url = "https://files.pythonhosted.org/packages/2d/86/ed7a73ab85ef00e8bb70b0cb5421d8a2a625b81a333941a469a6f4022828/yarl-1.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4", size = 95882, upload-time = "2026-03-01T22:07:32.132Z" },
    { url = "https://files.pythonhosted.org/packages/19/90/d56967f61a29d8498efb7afb651e0b2b422a1e9b47b0ab5f4e40a19b699b/yarl-1.23.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a", size = 90797, upload-time = "2026-03-01T22:07:34.404Z" },
    { url = "https://files.pythonhosted.org/packages/72/00/8b8f76909259f56647adb1011d7ed8b321bcf97e464515c65016a47ecdf0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb", size = 101023, upload-time = "2026-03-01T22:07:35.953Z" },
    { url = "https://files.pythonhosted.org/packages/ac/e2/cab11b126fb7d440281b7df8e9ddbe4851e70a4dde47a202b6642586b8d9/yarl-1.23.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41", size = 96227, upload-time = "2026-03-01T22:07:37.594Z" },
    { url = "https://files.pythonhosted.org/packages/c2/9b/2c893e16bfc50e6b2edf76c1a9eb6cb0c744346197e74c65e99ad8d634d0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2", size = 100302, upload-time = "2026-03-01T22:07:39.334Z" },
    { url = "https://files.pythonhosted.org/packages/28/ec/5498c4e3a6d5f1003beb23405671c2eb9cdbf3067d1c80f15eeafe301010/yarl-1.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4", size = 98202, upload-time = "2026-03-01T22:07:41.717Z" },
    { url = "https://files.pythonhosted.org/packages/fe/c3/cd737e2d45e70717907f83e146f6949f20cc23cd4bf7b2688727763aa458/yarl-1.23.0-cp314-cp314t-win32.whl", hash = "sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4", size = 90558, upload-time = "2026-03-01T22:07:43.433Z" },
    { url = "https://files.pythonhosted.org/packages/e1/19/3774d162f6732d1cfb0b47b4140a942a35ca82bb19b6db1f80e9e7bdc8f8/yarl-1.23.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2", size = 97610, upload-time = "2026-03-01T22:07:45.773Z" },
    { url = "https://files.pythonhosted.org/packages/51/47/3fa2286c3cb162c71cdb34c4224d5745a1ceceb391b2bd9b19b668a8d724/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", size = 86041, upload-time = "2026-03-01T22:07:49.026Z" },
    { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" },
]

[[package]]
name = "zipp"
version = "3.23.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" },
]