pax_global_header00006660000000000000000000000064151224522430014512gustar00rootroot0000000000000052 comment=412ed0bed4d446117ffcf2b8decf5c69a848a017 django-pgschemas-1.2.0/000077500000000000000000000000001512245224300147245ustar00rootroot00000000000000django-pgschemas-1.2.0/.github/000077500000000000000000000000001512245224300162645ustar00rootroot00000000000000django-pgschemas-1.2.0/.github/dependabot.yml000066400000000000000000000001541512245224300211140ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: "pip" directory: "/" schedule: interval: "monthly" django-pgschemas-1.2.0/.github/workflows/000077500000000000000000000000001512245224300203215ustar00rootroot00000000000000django-pgschemas-1.2.0/.github/workflows/code.yaml000066400000000000000000000037511512245224300221250ustar00rootroot00000000000000name: code on: pull_request: push: branches: - master paths: - pyproject.toml - uv.lock - "**.py" jobs: django-tests: runs-on: ubuntu-latest strategy: max-parallel: 4 matrix: python-version: ["3.12", "3.13", "3.14"] django-version: ["5.2", "6.0"] psycopg-version: ["psycopg", "psycopg2"] services: postgres: image: postgres:18 env: POSTGRES_PASSWORD: postgres ports: - 5432:5432 options: --name postgres --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 steps: - uses: actions/checkout@v3 - name: Install uv uses: astral-sh/setup-uv@v4 with: version: "latest" - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install Dependencies run: uv sync --group dev - name: Install Django specific version ${{ matrix.django-version }} run: uv pip install "Django~=${{ matrix.django-version }}" - name: Install psycopg specific version ${{ matrix.psycopg-version }} run: uv pip install ${{ matrix.psycopg-version }} - name: Run Tests run: | uv run pytest --cov="django_pgschemas" sandbox/tests uv run coverage lcov -o ./coverage/lcov.info - name: Upload coverage to Coveralls in parallel uses: coverallsapp/github-action@master with: github-token: ${{ secrets.GITHUB_TOKEN }} flag-name: run-py${{ matrix.python-version }}-Django${{ matrix.django-version }}-${{ matrix.psycopg-version }} parallel: true finish: needs: django-tests runs-on: ubuntu-latest steps: - name: Finish report to Coveralls uses: coverallsapp/github-action@master with: github-token: ${{ secrets.GITHUB_TOKEN }} parallel-finished: true django-pgschemas-1.2.0/.github/workflows/deploy.yaml000066400000000000000000000011111512245224300224730ustar00rootroot00000000000000name: deploy on: push: tags: - "v*" workflow_dispatch: jobs: build-n-publish: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Install uv uses: astral-sh/setup-uv@v4 with: version: "latest" - name: Set up Python uses: actions/setup-python@v4 with: python-version: "3.x" - name: Install Dependencies run: uv sync - name: Build Package run: uv build - name: Publish distribution to PyPI uses: pypa/gh-action-pypi-publish@release/v1 django-pgschemas-1.2.0/.github/workflows/linters.yaml000066400000000000000000000012361512245224300226670ustar00rootroot00000000000000name: linters on: pull_request: push: branches: - master paths: - pyproject.toml - uv.lock - "**.py" jobs: check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Install uv uses: astral-sh/setup-uv@v4 with: version: "latest" - name: Set up Python uses: actions/setup-python@v4 with: python-version: "3.12" # lowest python supported - name: Install Dependencies run: uv sync --group dev --group psycopg - name: Run pre-commit uses: pre-commit/action@v3.0.1 - name: Run mypy run: uv run mypy . django-pgschemas-1.2.0/.github/workflows/postgres.yaml000066400000000000000000000023071512245224300230550ustar00rootroot00000000000000name: postgres on: pull_request: push: branches: - master paths: - pyproject.toml - uv.lock - "**.py" jobs: postgres-version: runs-on: ubuntu-latest strategy: max-parallel: 4 matrix: postgres-version: [14, 15, 16, 17, 18] psycopg-version: ["psycopg", "psycopg2"] services: postgres: image: postgres:${{ matrix.postgres-version }} env: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_DB: sandbox ports: - 5432:5432 options: --name postgres --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 steps: - uses: actions/checkout@v3 - name: Install uv uses: astral-sh/setup-uv@v4 with: version: "latest" - name: Set up Python uses: actions/setup-python@v4 with: python-version: "3.x" - name: Install Dependencies run: uv sync --group dev - name: Install psycopg specific version ${{ matrix.psycopg-version }} run: uv pip install ${{ matrix.psycopg-version }} - name: Run Tests run: uv run pytest sandbox/tests django-pgschemas-1.2.0/.gitignore000066400000000000000000000024051512245224300167150ustar00rootroot00000000000000# MacOS .DS_Store # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # 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/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ # vscode .vscode/ # local only by design setup.py .postgres/ django-pgschemas-1.2.0/.mise.toml000066400000000000000000000011421512245224300166320ustar00rootroot00000000000000[tools] python = "3.12" uv = "latest" prek = "latest" [tasks] install = "uv sync --group dev --group psycopg" test = "uv run pytest sandbox/tests --reuse-db" coverage = [ "uv run pytest --cov=\"django_pgschemas\" sandbox/tests --reuse-db", "uv run coverage html", ] types = "uv run mypy ." down = "docker compose down" up = ["docker compose up --wait", "uv run sandbox/manage.py migrate"] docs = "uv run mkdocs serve -a localhost:9005" update-clone-schema = "curl https://raw.githubusercontent.com/denishpatel/pg-clone-schema/master/clone_schema.sql | python -m gzip - > django_pgschemas/clone_schema.gz" django-pgschemas-1.2.0/.pre-commit-config.yaml000066400000000000000000000014631512245224300212110ustar00rootroot00000000000000repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: - id: check-added-large-files - id: check-case-conflict - id: check-merge-conflict - id: check-symlinks - id: check-toml - id: check-yaml - id: end-of-file-fixer - id: mixed-line-ending args: ["--fix=lf"] - id: trailing-whitespace - repo: local hooks: - id: ruff name: ruff entry: uv run ruff check --fix language: system types: [python] - id: ruff-format name: ruff-format entry: uv run ruff format language: system types: [python] - repo: https://github.com/adamchainz/django-upgrade rev: "1.29.1" hooks: - id: django-upgrade args: [--target-version, "6.0"] django-pgschemas-1.2.0/.readthedocs.yaml000066400000000000000000000002441512245224300201530ustar00rootroot00000000000000version: 2 build: os: ubuntu-22.04 tools: python: "3.12" mkdocs: configuration: mkdocs.yml python: install: - requirements: docs/requirements.txt django-pgschemas-1.2.0/CODE_OF_CONDUCT.md000066400000000000000000000064261512245224300175330ustar00rootroot00000000000000# Contributor Covenant Code of Conduct ## Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Examples of behavior that contributes to creating a positive environment include: * Using welcoming and inclusive language * Being respectful of differing viewpoints and experiences * Gracefully accepting constructive criticism * Focusing on what is best for the community * Showing empathy towards other community members Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or advances * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic address, without explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at lorinkoz@gmail.com. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see https://www.contributor-covenant.org/faq django-pgschemas-1.2.0/CONTRIBUTING.md000066400000000000000000000012161512245224300171550ustar00rootroot00000000000000# Contributing Thank you for your interest in contributing to django-pgschemas! ## Getting Started - Join the discussion at https://github.com/lorinkoz/django-pgschemas/discussions. - PRs are welcome! If you have questions or comments, please use the discussions link above. ## Running Tests To run the test suite run `mise run test` or `mise run coverage`. The tests for this project live inside a small django project called `sandbox`. ## Development Setup 1. Install dependencies: `mise run install` 2. Start the database: `mise run up` 3. Run tests: `mise run test` 4. Run type checking: `mise run types` 5. View available tasks: `mise tasks` django-pgschemas-1.2.0/LICENSE000066400000000000000000000020511512245224300157270ustar00rootroot00000000000000MIT License Copyright (c) Lorenzo Peña 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. django-pgschemas-1.2.0/README.md000066400000000000000000000035631512245224300162120ustar00rootroot00000000000000# django-pgschemas [![Build status](https://github.com/lorinkoz/django-pgschemas/workflows/code/badge.svg)](https://github.com/lorinkoz/django-pgschemas/actions) [![Documentation status](https://readthedocs.org/projects/django-pgschemas/badge/?version=latest)](https://django-pgschemas.readthedocs.io/) [![Code coverage](https://coveralls.io/repos/github/lorinkoz/django-pgschemas/badge.svg?branch=master)](https://coveralls.io/github/lorinkoz/django-pgschemas?branch=master) [![PyPi version](https://badge.fury.io/py/django-pgschemas.svg)](http://badge.fury.io/py/django-pgschemas) [![Downloads](https://pepy.tech/badge/django-pgschemas/month)](https://pepy.tech/project/django-pgschemas/) This package uses Postgres schemas to support data multi-tenancy in a single Django project. It is a fork of [django-tenants](https://github.com/django-tenants/django-tenants) with some conceptual changes: - There are static tenants and dynamic tenants. Static tenants can have their own apps and urlconf. - Tenants can be routed via: - URL using subdomain or subfolder on shared subdomain - Session - Headers - Public schema should not be used for storing the main site data, but the true shared data across all tenants. Table "overriding" via search path is not encouraged. - Management commands can be run on multiple schemas via wildcards, either sequentially or in parallel using multithreading. ## Documentation https://django-pgschemas.readthedocs.io/ ## Contributing See [CONTRIBUTING.md](https://github.com/lorinkoz/django-pgschemas?tab=contributing-ov-file) for details on how to contribute to this project. ## Credits - Tom Turner for [django-tenants](https://github.com/django-tenants/django-tenants). - Bernardo Pires for [django-tenant-schemas](https://github.com/bernardopires/django-tenant-schemas). - Denish Patel for [pg-clone-schema](https://github.com/denishpatel/pg-clone-schema) django-pgschemas-1.2.0/SECURITY.md000066400000000000000000000012241512245224300165140ustar00rootroot00000000000000# Security Policy ## Supported Versions | Version | Supported | | ------- | ------------------ | | < 1.0 | :x: | | >= 1.0 | :white_check_mark: | ## Reporting a Vulnerability In order to report a vulnerability, please DO NOT create an issue on this repository. Instead, write an email to lorinkoz@gmail.com with full details of the finding. Alternatively, use GitHub's integrated vulnerability report. Expect a response in 24 to 48 hours. If you report is accepted, we will work to publish a patch as soon as possible. We will also provide an advisory covering the details of the vulnerability, as well as the affected versions. django-pgschemas-1.2.0/compose.yaml000066400000000000000000000006121512245224300172540ustar00rootroot00000000000000services: postgres: image: postgres:17-alpine environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_DB: sandbox ports: - 5432:5432 volumes: - postgres:/var/lib/postgresql/data healthcheck: test: ["CMD", "pg_isready", "-U", "postgres"] start_period: 1s interval: 1s retries: 5 volumes: postgres: django-pgschemas-1.2.0/django_pgschemas/000077500000000000000000000000001512245224300202205ustar00rootroot00000000000000django-pgschemas-1.2.0/django_pgschemas/__init__.py000066400000000000000000000003371512245224300223340ustar00rootroot00000000000000from .schema import ( Schema, activate, activate_public, deactivate, get_current_schema, ) __all__ = [ "Schema", "activate", "activate_public", "deactivate", "get_current_schema", ] django-pgschemas-1.2.0/django_pgschemas/apps.py000066400000000000000000000010621512245224300215340ustar00rootroot00000000000000from django.apps import AppConfig as BaseAppConfig class AppConfig(BaseAppConfig): name = "django_pgschemas" def ready(self) -> None: from . import checks # noqa from .checks import ( ensure_tenant_dict, ensure_public_schema, ensure_default_schemas, ensure_overall_schemas, ensure_extra_search_paths, ) ensure_tenant_dict() ensure_public_schema() ensure_default_schemas() ensure_overall_schemas() ensure_extra_search_paths() django-pgschemas-1.2.0/django_pgschemas/checks.py000066400000000000000000000227221512245224300220370ustar00rootroot00000000000000from typing import Any from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.sessions.base_session import AbstractBaseSession from django.core import checks from django.core.exceptions import ImproperlyConfigured from django.db import connection from django.db.utils import ProgrammingError from django.utils.module_loading import import_module from django_pgschemas.settings import get_extra_search_paths from django_pgschemas.utils import ( get_clone_reference, get_domain_model, get_tenant_model, is_valid_schema_name, ) def get_tenant_app() -> str | None: model = get_tenant_model() if model is None: return None return model._meta.app_config.name def get_domain_app() -> str | None: model = get_domain_model() if model is None: return None return model._meta.app_config.name def get_user_app() -> str | None: try: return get_user_model()._meta.app_config.name except (AttributeError, ImproperlyConfigured): return None def get_session_app() -> str | None: engine = import_module(settings.SESSION_ENGINE) store = engine.SessionStore if hasattr(store, "get_model_class"): session_model = store.get_model_class() if issubclass(session_model, AbstractBaseSession): return session_model._meta.app_config.name return None def ensure_tenant_dict() -> None: if not isinstance(getattr(settings, "TENANTS", None), dict): raise ImproperlyConfigured("TENANTS dict setting not set.") def ensure_public_schema() -> None: if not isinstance(settings.TENANTS.get("public"), dict): raise ImproperlyConfigured("TENANTS must contain a 'public' dict.") tenants_public = settings.TENANTS["public"] if "URLCONF" in tenants_public: raise ImproperlyConfigured("TENANTS['public'] cannot contain a 'URLCONF' key.") if "WS_URLCONF" in tenants_public: raise ImproperlyConfigured("TENANTS['public'] cannot contain a 'WS_URLCONF' key.") if "DOMAINS" in tenants_public: raise ImproperlyConfigured("TENANTS['public'] cannot contain a 'DOMAINS' key.") if "SESSION_KEY" in tenants_public: raise ImproperlyConfigured("TENANTS['public'] cannot contain a 'SESSION_KEY' key.") if "HEADER" in tenants_public: raise ImproperlyConfigured("TENANTS['public'] cannot contain a 'HEADER' key.") if "FALLBACK_DOMAINS" in tenants_public: raise ImproperlyConfigured("TENANTS['public'] cannot contain a 'FALLBACK_DOMAINS' key.") def ensure_default_schemas() -> None: if "default" not in settings.TENANTS: return # Escape hatch for static only configs if not isinstance(settings.TENANTS["default"], dict): raise ImproperlyConfigured("TENANTS must contain a 'default' dict.") tenants_default = settings.TENANTS["default"] if "TENANT_MODEL" not in tenants_default: raise ImproperlyConfigured("TENANTS['default'] must contain a 'TENANT_MODEL' key.") if "URLCONF" not in tenants_default: raise ImproperlyConfigured("TENANTS['default'] must contain a 'URLCONF' key.") if "DOMAINS" in tenants_default: raise ImproperlyConfigured("TENANTS['default'] cannot contain a 'DOMAINS' key.") if "SESSION_KEY" in tenants_default: raise ImproperlyConfigured("TENANTS['default'] cannot contain a 'SESSION_KEY' key.") if "HEADER" in tenants_default: raise ImproperlyConfigured("TENANTS['default'] cannot contain a 'HEADER' key.") if "FALLBACK_DOMAINS" in tenants_default: raise ImproperlyConfigured("TENANTS['default'] cannot contain a 'FALLBACK_DOMAINS' key.") if tenants_default.get("CLONE_REFERENCE") in settings.TENANTS: raise ImproperlyConfigured( "TENANTS['default']['CLONE_REFERENCE'] must be a unique schema name." ) def ensure_overall_schemas() -> None: for schema in settings.TENANTS: if schema not in ["public", "default"]: if not is_valid_schema_name(schema): raise ImproperlyConfigured(f"'{schema}' is not a valid schema name.") def ensure_extra_search_paths() -> None: if not (extra_search_paths := get_extra_search_paths()): return TenantModel = get_tenant_model() dynamic_tenants = [] if "default" in settings.TENANTS and "CLONE_REFERENCE" in settings.TENANTS["default"]: dynamic_tenants.append(settings.TENANTS["default"]["CLONE_REFERENCE"]) if TenantModel is not None: with connection.cursor() as cursor: cursor.execute( "SELECT 1 FROM information_schema.tables WHERE table_name = %s;", [TenantModel._meta.db_table], ) if cursor.fetchone(): dynamic_tenants += list(TenantModel.objects.values_list("schema_name", flat=True)) invalid_schemas = set(extra_search_paths) & ( set(settings.TENANTS.keys()) | set(dynamic_tenants) ) if invalid_schemas: invalid = ", ".join(invalid_schemas) raise ImproperlyConfigured(f"Do not include '{invalid}' on PGSCHEMAS_EXTRA_SEARCH_PATHS.") @checks.register() def check_principal_apps(app_configs: Any, **kwargs: Any) -> list: errors = [] tenant_app = get_tenant_app() domain_app = get_domain_app() tenants_public = settings.TENANTS["public"] if tenant_app is not None and tenant_app not in tenants_public.get("APPS", []): errors.append( checks.Error( f"Your tenant app '{tenant_app}' must be on the 'public' schema.", id="pgschemas.W001", ) ) if domain_app is not None and domain_app not in tenants_public.get("APPS", []): errors.append( checks.Error( f"Your domain app '{domain_app}' must be on the 'public' schema.", id="pgschemas.W001", ) ) for schema in settings.TENANTS: schema_apps = settings.TENANTS[schema].get("APPS", []) if schema == "public": continue if tenant_app is not None and tenant_app in schema_apps: errors.append( checks.Error( f"Your tenant app '{tenant_app}' in TENANTS['{schema}']['APPS'] " "must be on the 'public' schema only.", id="pgschemas.W001", ) ) if domain_app is not None and domain_app in schema_apps: errors.append( checks.Error( f"Your domain app '{domain_app}' in TENANTS['{schema}']['APPS'] " "must be on the 'public' schema only.", id="pgschemas.W001", ) ) return errors @checks.register() def check_other_apps(app_configs: Any, **kwargs: Any) -> list: errors = [] user_app = get_user_app() session_app = get_session_app() if "django.contrib.contenttypes" in settings.TENANTS.get("default", {}).get("APPS", []): errors.append( checks.Warning( "'django.contrib.contenttypes' in TENANTS['default']['APPS'] " "must be on 'public' schema only.", id="pgschemas.W002", ) ) for schema in settings.TENANTS: schema_apps = settings.TENANTS[schema].get("APPS", []) if schema not in ["public", "default"]: if "django.contrib.contenttypes" in schema_apps: errors.append( checks.Warning( f"'django.contrib.contenttypes' in TENANTS['{schema}']['APPS'] " "must be on 'public' schema only.", id="pgschemas.W002", ) ) if user_app and session_app: if session_app in schema_apps and user_app not in schema_apps: errors.append( checks.Warning( f"'{user_app}' must be together with '{session_app}' in " f"TENANTS['{schema}']['APPS'].", id="pgschemas.W003", ) ) elif ( user_app in schema_apps and session_app not in schema_apps and session_app in settings.INSTALLED_APPS ): errors.append( checks.Warning( f"'{session_app}' must be together with '{user_app}' in " f"TENANTS['{schema}']['APPS'].", id="pgschemas.W003", ) ) return errors @checks.register(checks.Tags.database) def check_schema_names(app_configs: Any, **kwargs: Any) -> list: errors = [] static_names = set(settings.TENANTS.keys()) clone_reference = get_clone_reference() TenantModel = get_tenant_model() if TenantModel is None: return [] if clone_reference: static_names.add(clone_reference) try: dynamic_names = set(TenantModel.objects.values_list("schema_name", flat=True)) except ProgrammingError: # This happens on the first run of migrate, with empty database. # It can also happen when the tenant model contains unapplied migrations that break. dynamic_names = set() intersection = static_names & dynamic_names if intersection: errors.append( checks.Critical( f"Name clash found between static and dynamic tenants: {intersection}", id="pgschemas.W004", ) ) return errors django-pgschemas-1.2.0/django_pgschemas/clone_schema.gz000066400000000000000000001362201512245224300232060ustar00rootroot00000000000000‹ÿÀhÿì½k[Ç–0úÙüŠïäe Ù|“79#ƒlk‚�ϼóò4RKj¥»ffÏùígÝêÖI`ì$³­'1RWWÕªªU«V­ëƆڽ ¦¡z¥YœÜ´ÕÚÆ†Ú|¼ÙÚx¼ÿ)õîßÞ«×½_Úêuô)©Y<›�ƒ,Ч*>WYp6Sue—*‰¯S•†C,kª‡góóó0y¨®‚$·Ôu�ªiœ©heQ0Žþ ZÆI5Æ7Mu’Âïixm*4Tv6žü«ó蓊²æèzi:ÿ²µ­Ôõe˜„jž†ÉÆ(<�¦P˜ÝÌÖhªFáp@©‹9ŸOé{ª†Á!IæÓ,š„*L’8Iý~·«ú}¢û�%ÑU4/ ¿ó8qZ¿ÆRœ…³0š^™�êŸf—ØB|‘úY<9SÁt¤. Q(v‡ÒÞNh ðó2ÈTzÏÇ#êä2¸ ÕEœeá�øÃxR5Œ§J~ÓòR×ÜK|ö+@ªfa2‰Ò´ZƒÏtƒz"Œmš¥*¸`=pІAêÉ `àa†¢Àúóqˆ1ž‡¹¥x^Õ%œÓïKËi­Í&Ñx Ë>ç��è ìÀôlc³U“¡"It3cú$ÃK °èÐþ5î-Ø 8¹g!tŒ+w¿I›¥ªû§-Ýý$¦³{>¥žqËŽÃIÚTïâSXÿSZhœ¨õ:ö~$ˆŒ#¿.Ìægãhˆ'  §Ãxz]@5"L“YvHŸ`©³E²ä†öHL§ˆ÷Ö(’I7PsÊ®6ÒM½g‡ñ÷oüh:™ò¦[·³ú¯óèbO¯ê0Ùƒ fö:ãD0ʵá#©ÍfÕ¡ú6"ðj¤™¢£™Îj. ›#JÒaG¶ž+5þÈS(““£ùØ�5®÷´ ¨'ˆû!�f¡.ÁU�ƒ³heDÒpŒèk&78H£èÙÅ) Ì�éLYgœjÂ*ÆÈn|š. kr¥&Álfˆï0L2<1ƒ‹‹$¼@êiX Ý’œzS˜è( prFðíJÖ·*qà©;18ÙaŒÐ Ws´?°m- ¥ˆOü›Î�ˆ›Ix'¶K$‰7ÀÙƒ�ξvcQmµ~ŸÑñÆä7$Ìô76 g!ôFGŠŠçY�ìÔ6pÉL}8VÖëÍ•Ø6Æ3Ú¬Ä#�ã‹hÈUøiÎʘiï3G.p¨0AÌò‡°Äñ1e:‡¾iÀÏá'�AÁº÷®ë€ü¤zï?�ÝÖ™Ð#"HÙ œWˆViCÎåÙÅë8™ÀW?+¤£p~?USól×fÃbà<¢i0Öl™9h É$žÀ° }a ³—æ%r˜2ê«UµÉŸñQnyx}Ž�Èùtç".)*hòžJRXˆ9îNÄMÁJYKÓ�3ø§­ÊÁoë“Ób7ò?4\fòNÓaJ³�ÇV˜f§<9Mgˆx5~›w ”¶Íx}Ô„÷‡IDhUw <€6ð‚í ?ò„w 2ôÀ‡¾êvõ hßâqî.8¹W:&v°Ã`x@œúp&ð©@«Ž;ÂÝðV ¡‚sB* {{À‰¦„Ú>`U׌g@³€&a% U8¥MËÔsO-TªKeÈ0 ^fÒ71á8Ë7Â%h52ŸNÕñì9£rŽi8McÝù…[¢iîFH‚€8áða’–q0 :–�ÿ�sÞpÒ8X¨_ç»òøÐÜê.‚»jÈ•äšî_p Æ~± \åTáíxLàѪÁ[“yŠ`àYFä¾€X«�!\lá|Ê8¸²Tße ’Žëp |¤YÕÝêÙ ™O¡¥€Ñ„?ÄpWPÎ<ï/³½{pøAÁó2y³²Yu�yþ¸¼ï$œÅ ’Y} ‚$Ipã"¡+ò€³îÏ/ö¬§Ÿ3×ÉÃ] zÙšHÈ Dd4Æú'ûû©*cô^T¯õs PD�2~/;SFxì dZÝ©'þ` um;åãècd+ÍÞôM—G àf„sC;�¸Ê 'ôÊêtì¬úîAp|Ôéõ�Õ^÷uJ»¡Ö„ir�ìõ÷º¿Ø+$��#!î/¨èЦat£ôø±9ÓáÀƒxóYİÄW@Tg0À¼¥Ä$§Šw2nudö`g|ñ ¦ðö¶×rå\<Å–7ÿªW@3Pv�€Ì‡ln p*'©÷oÉÉj�=ÃÎæÿ6�p£xŽÄ‘Î{<ŸRÇ"$d^«õWx1LáÄÂÒ˜žZ›Õ, Øs»H±ñ_êäÐÆ�và é:ÑzÚ„öT'.iˆÄÔrÉ«u-».¾†™L/£nx}k¨æ žjxÙÏèˆb•Ì-R7„:™O§¼–ð ŸÈMµÒõŒe=)Lw‚(UñÐfÄËnÄ< ’Ò,‰Ç+Ž öDzæH ižÚêRl¼;bÇÐf¨§—EaZƒ, –ßcsð™°à£'Ä#�î(z T1k/™ß=œŒ´"p–ñ˜q‹Å’ô›wŽ :òÑxh^œZÔ7­·¼?pAš«C7Ü­)µÒîlÖaÑÈngÐUƒc ïº@yÒ›i <¶@“†±…èa·8îjxºM°ÏgMÃSCÄþuû{ü 1 å¬S+=qˆ–}ÕÙñâ‰s‡ƒ§„h�:»û€šÞE©:#^àqìràïiké‹“^�àIÎqöo¨XKǩɠx;ч¾eh"¦¢$›8 aÅÊÞóÀ®¢¶/^˜ó�e ,»Ùø$š Ë©w w36‹ |Ëò÷"¬ÕÁ”Ä{Ü‹G±ÞáMé~ M³éê+q|‰²‡$Íôè m‘°Ã�ßè“ʈÚPæÃ|!|\Ë-Í&·»‰Š­iøÿÒÇÎó3"+Ý÷?ñ<¿ q›�èAe®m7Ή ¼>éï÷úŒ:r}—ëÀ:"ACñ¿Tu'éü§w]÷Þ:‹á|¦æK]­<ƒ×—Ñð –¸…ŸÍXG¨€OG°p„8ƒ|©7 ZOBÂJ¼Ò–A™5‹Â4T%í(‹�ªŸðƺ»þyõ™Û‚óìD' /³ñ´–º”Í\ÉÍcXÐ=x mLBŸ:ý¾¨>1ZP¢>4\#ò¸päCÑ‹¦êMç‘Z!›-‡Ó XzX)-†–³/ñ€æZ~µ‰èðD‹g‡¢1A=Ò.¸ ³*éFõªP?oC×ã8 …ß Nœø9ANÒscì: †„ ,<çŽüÜï©ãƒ‡Òã6Qå'*zlm·‘¥ÃÖôM—O~³%è1`§pLó�º œŒœ$Uköµâsln!„ÖuûoÛtLÌIC§ ßQÓ߯tÚ Ï ôˆ5öjX¤í ê^Ë•‡[k«�“Æ8!¾Íà*"µG|¤“ç·y0~¨—Ô‰è0’ÓD˜i!§|}Æ×$.”kz0V1Ù­V«-RQÛ¢ÖW3MסÏ33o¨_‘ݧË8Êt€{%äxýS}¯káO�ÙMXH=™9–ðë8Q|&*câ$æ3¸ñ“„o–,,òGS¹á[Oõhø$bixQì«Y/—C¼Ö|&“/-©*TNiWGÂÒú€U œZ­gô€ö(�14 x‘Jqÿ¶EÝv\\ Êé�¦›|¶E˜·5R"»!êRqòP_3w™¡ˆšÅ•|1DáȺÙì‡VÉ9m¶rÃÀ„ó,. „¨ëHUon0½ãx ¦t>F©¾äã|þ %áueÄ„]›Kº}¬Z™üêg»ú™[€m@Ä!˜²èý"ºœŸ!= I)NöL$¢I�Ü�5áË^”êWêuA8†KJPë—Y6KÛ�]ÏÏš°ûÉ+{¯:�rĨÞô&·’Bl>n‹ØL´�¼P0+²û‘݉hw ã#*Í)芫öwWD�bËqx‘9R 9@n4ÀÛÃ6€×´ „®>ün:?c 2ON “@ÌË9œëd‘¥Þw�Žz{½þ5ø0€{®zßÙ?éú�WÒ©M ¥{" †+4ôLoÈ–„ÔÖ7ÀëLø¤r\`HÔÚ|Ò62u¹‚9—7sºÒÌÑ(žÁ|;z�rD,@…GCý�»ÂÊ?žþMˆÿŠFŸ Ò;}וÂS*Dù=ÐýYœFÚ¦à£$&öŒo–p�Aa�1bE¡mä@|ƒ‚fnh•©Çâ!I•P½µçõIŽ…7ÂtÑßñÌÂþúxC¬|ê6]iÉÕÚzÞÆUÉ‹ ßߨ+äqø:�·gmÚ1Š'A4u¨r«µ€„m½hk$LÂlžLY*EgúûƒÞmÚþq÷ \3H)̶­°†"³×|Šßa%c¶ ÓÅ’ÓG² Ö²�tNëñÙ¯§øH"|åï 2\Õ�ñ;ÿ¨[¹wD|žµü„ÙÇ«Ù\ÈÅÙ´¥1 <I�™qeóÄÑåüFËŸ0õ'¶Òp¡µýX„kDˆ¦ñtðéíÉô­˜v³kZ�ŒÓ©/íÂÆéÅ/ÒH�œµªU†­m ÕÇq†v&Ä"C‹Ì ¹7à+²Þ ÷2|cøM2ƒ¦ ¦Dô±=Ì®àpÕûÄ«šáÝÞÒçÔ¢1Ä,¬FÓöM}176FŽYva…6X/ €§¾B°¶ØÞÃI‹©Ô.*žÐ’Kã‘)4î©zñï8¤ÍgMG¸‚;&�,̮蜆c¶N•K,qÙž¼*È„ëS’ 9¦Å }5ž<¹ã‚<è_Í/R· •ЯÖc€ €ÖöÓÛ`­{¦á'BBG 욦•f>­m ­¢srÖ¼}y"W°2ƒ*"²M™Æ× ¾#úœ_D*Al ‘kc¾ýT«.è&"…ȱš ÈJUÞ(«ñô¹7JÇþäøÃa×5J@u—P"úÄp•¡-„KtK¬íŒ¡ˆPåaºý¢  “A÷hc¯ûº×ïî™Ë¼©Åºm̶mªŸáN/Gq”jKN’„÷ö»d˜Áûʯz_?A¢N÷é!Jr‘ɇ;þAÿƒlÕ¶ZoÕÕ!ðÔ0I'ýÞßOºVí„'ÀÃ^ÿõA[©‡Mµ¾Y—ã;7T--§ �ýc”ð¡ =TÙÒU¼w†—ÑØHûsº;ïß}|¨´Ž¦sTFÑg}».ƃî±::ØïŠ_ÛŒB–ªs²Ìåù]�g࡞Á,ž®$SÅ‚¬‡¨‡Æ{�L›kkÀ€~÷�ZûÛßH¿pz6އüqm¯»»ß9ê®! Ã)ú+e/×^ußôúðhïèà�ðZõ^«î/½Áñ@Ûo[A>ªkw;{Ý—PA»/`•â‹��êöOÞ©õrøµ†ªõø[Mp¾õHBKß:»ûð.å¯]|«ûêä üÕ¨¨uéîÖê¬ê”{¯_®¡Í•3Vý˵5‘ÑVF…;‘ez§i6ÉÈzÖULT¨'HmÅFÐê9I«¬±> €gå|õŽA¦ðàHu÷;»] î‚6×”5¥dc „ž²U\þiÀ· ýóì¾ì¢É¹ŒÈx!½ÆiH¯ô4oSúÊZ}í¨{|rÔP£kû�þ›“ÎÀ‚ñìåÒïö;ǰlk�ÁÚwß!n9ˆ³GÍÑ(_ Y¦9lO@o*½Ò£Årl]µá®_{)…@ÙR¹¿”žÂÊ”¾ËU¶XøØ©‘ÊØ%DÈLêîð±Þ.J½†e3uå ÏkUA¬‘üÞ\COi¹Þ;µ­‰þ6—½ú5à'’raþ‡¤nÕø];è@-­9�Ÿ¢É|rÊÚ,‹ÒSͳK¢é@ÖŸLçàx‡§À% £”Ž0û0N1”f43L?åû©ˆd¥¼ee´2<›«”ðÆIçcõ¸ýzz¨ÒŸtBHMŽú´ƒ„÷´q´¾c E`·ÎÿzTü‹¿£Ö=êÐP2³gå«(KÁü)IÐÖ»Þ?88(€TjÄv&…n"bSÇo»á�}b_x)…ÝýA·ì=�R™—…˜ó¯5] wîŽüý«j½´[ã¨ÓtQžÖÊɧƒ vç{…xƒ3‚_µÉ&~‡éÇðÆè|œá×(Õ«D¿ð;,—˜UÚù¾¦g³agÈÅOóÐb©7“þK9ü2Ï]ü±K°È­a |Y²ŠN‹¸JºƒšêÀt/j_ììÿÜù0È­·%q(¿¿ED�~ŒfÅ[±–'‘Ž-¿t@!£L§=¬ëþÖlÕKóОI;ê8qK ˆ€w>lpæ(Ÿ-@$×@ÍI·,Hd¸–’‚p¨=r]£rÀ"“-”»Ô¿xØž›u»Ã«f“@“È3-Ãë•–á¹`™ò«¡¾o® ª–aêK§‡üdë}X‚G­q8î�#7(]'Å÷ vÎñ�Ò}äBMY–RN!M�sò—7§þñUÓJ5üeH–4åQnŸ¾ôú¡-!„îè–ƒsG@<’z»½ X܉GŠK»ý¢ÃÌ÷�½«…Ä?°y¸jxíß#^ñ�%�üÞØv ¿$Þy²î3¿ø”ðÕûíAx�¤qéü¢èŽÓ\9IÎ/üŽü(üÒwFGoœ¡yltŽf(úT…\ÈQÒΆÐÈ�\’‚umÙ¤óéúsç¨�¶µ~±Q‘B´¿g‹BPëSzR¯2úà6k2CN5ƒÐÏ®ÑeŠôïZ@¢A°¼b�Ô SXÁy2‹±I¼ª£sÎy(¶ÍúA ÐÞƒ‡IÈ¡@"žú³1a^b °ê*"c¤KQó!>þ‰h/34Þb74Ø h2˜]σó=ØÉ\â:¶å)Ù}F¢‡ ûysm­×‡ÙTÝ÷èw7xÛÙßwgN½ÂPQ1 ÈO§ÿAvŽŽ?�0z¯wÔ£�~¾üMQƒÃîn¯³êõwÉÈ @ôõíþý­Î:ûj¯ó®ó¦;Àv÷Oè$Ù? ’êàuïžwŽz|zpr¬^ã�ŠGµFß{58x} ŒT—dKP0c÷;¨ÓhР�Mpô¶3€AÁóÎÞ{`ÄötÃk‡ƒAïUoíáàÙàd÷­@“ä¶@£{ÝÛ…©ú“0ØÝïôÞ hr˜£ ÎTC½:!,†™|×;†.�`j°ÓÞ»Ãý<°õ°ëwݣݷð³#Ðàè`BúÝÁ€&¾C‹ÐÛ=Ùï©Ã“#€¼ÛäÍŒÀ¾‡™ßS(¿=é£$&ì†ÝWa zƒ‡êUgÐÃY†æó3´(qðj¿÷†fr€‹/-ªwèªÝíwú»ÝÌÒááÁ ÁÉá^ç‡Ýí¿Å"\…­ù»ƒ=š-j¦ò¾)~�x!qÕ±ñûÐútíÙ0X°c>x¹—�ò‚=¢q�:ÐÁX$ø ÇK oŸtÁVÌÄ«¥`B§M­�>?¢¼ðö3Y\“ "<Ú|òbëùã­G—ñõFoh�Ü€ V9o�ßHo>~(ÖF0Ý?Eä+ïDÓ ö¥÷Ñ"Š?:ŒUÁ�M¥óz+ß › FLR(¾ú Í|—¶úsïø­¶+–�>dÖ2¸ðüÁL@öeMC?�%š~šwìšm�ÇsO«¹½†¶KrBAmœ‘“™Z[ž mø¢ù”š‡Å³>H‹¡> ­×ñ%¾ƒ5ó¢©`�-ù £ÿÙDÜaå81€‘>l¬Ë;‡ôÌ`ò1!ŽÍ¶2ö壅Œñ©,�V#bïbûrœóc@F¨³·çDz(¸À;s-:L2Y6F¯45¨àO½šÏ¼š$ˆw=LxÑý8:¨û4@Û·t»[dÅmèiÛ@D.Z¬.EÇuTÖㇿâÄÕPáñòKLLŒêÎ w±eºxÖÆ�ÌufJüLØŽë©[�ö�Ä�€jÐøUÏS2!Äë{Š16çhp¥]*È»k=ø¨ÿ3ò¼"Ù¸ðŒ�¿á¤4+új·º CˆÇÑHäþm‰b^`�ârÙ Éa9“8‹I~ ½:f©Æ ×ðÃm�àl™}ÃÞ±ùH;»¢c�D2Ê;ÌâFžp¸-["õ`aÆ@[nâ9–>Œ�-9ɦ®Ùä9>œ\R‹eÆØñTg_R‹•­'mÒOͳ‘¾º>{–_dßE;¢!Ñß¼ø|Bä]Gýnž¶9ÌE”Y£J²ïÄᨇˆ@BÌÝËAbòLw¬Ã©ywÊžUY°ù1ü¸ÞÓ\½ªS` çÌfR¬écŸi2JÌ·í5ýÜ6ýV‘ëFìEì»È>oçüO9l‰5áð\îžXþØ UüjIŒÈ» �Äy<íþrÜ=êó�†ÜÔí#žXJvt¦�)‡Ð"äD{ië6fQy.L-;ÐÍMwÔ?è£ý‚Æ]éžÐkÏ!e™0±ñ)¸]ç¬Ú²kÊWõr�<é‹Ü^¬L‰ß Óm<=/.U/§Ñ�ì²§§!ڙØ ®Zžmö¹áÀ¡ÚµQ õé ‡„0bîÍ2 ±gçH€HÈÛ䣸§IÄ­pM"MsÑ~“ÜÐ¥=ï¥!ú8wå½¢4hOÐ<ê.37ŸF° ‹×c`H´ñ?ÜåpµaÝeе›‘(´Ñ¥bjñÕo.ïD¥�‚=סHÚrYœ‹ ÒPØÖœjßsÉrèýæ‹¶„—Óvìt³Ý3ò~¨Û¼+•Ž÷üX[ƒD8g |Â%½hf1š–i¿a ô—õØø“í­Í§ue;j¹ìzÌìú´QûÉ8ÁOŠÑŒ�n¶ûyÕð Úœ�épŒ  €�†1ÉÄ:ZcüRà 1Άªç}Œˆ9Ê6Õú¹DÌÕåÐÉZU m HÉÌRÈQ,^Wçjðöàg¼½ THVŒÆM,ïi ×Ή÷j³ùXõ»?«×°GæIÈöì@ýAóBÞiÓ'Dgjd‹zŠv¨ÞÒ8$}ë‰Ûú9´žÌuë3Ã[ mIhq=®)íß�|ì†Ôß©uö÷„A­�á�ÇsÚj3s=!‡ŽY[/8,˜0÷ž›c)zàˆâ¢ˆí”$�hE‚Ϊ˜°º–š³ƒ?ç;`G«Ç&à ¹ZÓ¦qðÛñæÚt)ÏÖ³(sLAI–ŸIÁ¤âœç9™5ŸܦYK]“æƒfû–µg¦cE.xF×5ïGƒe:[í ªæëød÷à dl)žÅø‹¥TÝÓã£Þ›7Ý£[N{?ÍÎÀ€+§bDÍ?öÈŽ¿ì÷v{ÝZ¯YKÒX• #ôQÀZ)“¿e _¢Q�mž}sêÒêdclÿ±&ÓË �ý†ÖÐJ7oÈ Ox¹O´¯ƒ<�êH¬às/¦ËÚpùJ#y›_��Ó""ûh³øh+ÿHÛ˜Z(ñ©¶ÆTd¡üRÁ#sÂI]|v¤h´è ‘ã3 ô‰éÆöêˬX+A e7†q|Ñd"N¶]ëA6<|‹ÐqM¾Ob¸­’ÎËÌ£Ñiõ™“a­ 0Î�¾Ce‘àô›îžÁìÉ´9Mgd±ã¬9Ê@§MävÔ°™„cÛ ™G\‰Ñ \[ ØAaM¨d>Q?ªÇöUhFúÀ¾Ìc<Ú©Òcc8«Ûx©ð%–ym™m4ÕSE5­…j¶ëv; /hÂXïÞ°#ð-ð4?»™áÌúJ Å,{Ãr(Z"àй†G,ãRÝ‘ EäûQÅ› §¯×þùøÿ9þ³ù?5‚C<áô •]—Æ4Žàxgà ÇËŒšÁHϾ³´X†K¥tþÀ’µl2ÁÄñ}�^>‹¦ §I´‚®‰yæz­F Ñu©Äórjf`}¥Å¨×m!)Uk5û ^«óÞ¢©sfMÀ¥É¬Á„ÙéR\¡ÆÐÔì+Ï�wø#rÞÚ,¼•NàL×ï¡‚—,~VÚÕø6oÖ¶SÃ}ÛÝÌßvÃ?¨ÞïÓðº°ß¨êA׌·eí6î±J¿VvícxS»ÿ!øv;Ö<€ßöÄެÝû¨¯€!�“t«dÜÀš|‘ak‚hÇ,ÜQË#9¯‰5ÉúPv!!�E$'tY˜YíÄìžÊL»Éqf­ÆjZF¤C„'ÕŸl5Ú pðù¡`hZϤƒø%M¬WLn O­A‡ÞzÅ»‚U–P4ŸÌ½!ŒìM.Þ «!³Þ4ôÓ´/¿‘% Ï+ †K²tDâhn‰}ã?[øÇÚ2gøÝBsuê€bX3jÂëhñpî þçÂ^ ®{h 5WÿvÐë;Ô5×ëÅmV7ï å7¯yÛ®®ö»¯�Í»sPhÖáìþ‡­¾c¸„ºYL‘w!õø‘"8–#Õê~mM(PžÄK’b€ÈY8ò Ñ$Ú…Î/r.ÌŸ]·; ^: ðׂ×53°×ï|ÙaÈí¡Ç”ë�¯“VÖâ?›üg ÿhÅÆñJœ°ÔÛ •ßø¾f]@QrÂ2ð7 ¼ `Cvo ÞΖpvÄ•ëh¥÷ƒÝ/õqöµ×*X®Î¼”ã˜æþý}Û¤otý‡™w…V‚2}1—Íø4œ~2�¢ÖJ>z¾r¼Á_óßìûÕyVøåýÌ­ø‘éÐñ?üoÛðç\�B×Ê�27æ¨Ìùn.6‚†¾dé'§¸,Îe 2kôGB«ÅŒ–Á+ƒV÷‰Uå”Ç‘ûB ³nлù‚÷¯ Œy(�Èsû½À �}45r×$Æ$!3„È­)¦3^JH1á]G可Z£Ñb-ƒ€¥hWo`þ6L*‡P×”›Ü!Ì™ßp“äÏnžúŽÚ —k‡É«ã=oŸnòÕǵ16ÆúnäÙ/J\#–T&z°ÂÈ Â�–„yÈ·eü¿Ñ.õ°�âôÁˆÿã?o#Ä4}ybLm”âJ2�g§Z9¥;‡gï;G½Î^oM] "éÿøOE¤öß(‰)yãóB5\�êØDò1a6›Ûp ÃÉÌÚævƒäû&Ö‚ ›P¤Aä¨Å2 4ÌJøb™17‘ìS¼ÈQ,Ó†dðC9Xýx± í6tˆ|RT«Ø×‹UlWìWæì”Ñ4¸e3ر§ZÈ\:Aç+¿Q%ÃÄ6.”¥’ŽŠò3 äA-z»Â#W|oJŸRÿm Xfšr b|f÷¹r 6½Fà‹g(øÌ#K<.4V{83¥~ò@œ øºnäôôðШ/ýç='7¥_r7˜tV^Íâns„¶6»:ÿF(y+í€Îè5nê{öÞ§$ØÝæ©”)à.GŠ}¸™{è\=�Çþ¦ÌSlH¿Xð)�xÄ~k ö� ¨ UÅÖp“˾:Y)Þz¡é{ »€Ê‚á¸lñÇÚ—36µÆŽ:lPù‹ø¼þ`öщ,##2ÁeœW—¸½r¯t8ÉüÊ^)Š—pô©~Ç+…ñÑðMùÒÑpœ‹ˆã–r|÷bËøkö‘eÄæSP•âdúJR®y¾RÍœú”«âLy•K«ºzR®$ãpêtið ß‹Ô)“M…S€EnI¾;‹*6Ï©ŽÆŸ’”ç¢Q\´ª‹6«‹¶ª‹¶«‹žT=- Í r„Q̆ãÉù¸– Z^aÄÁ ³ C™h`» C‚DC„$\GÑõU8?m ¬cRÊomhm¥Ç ´¯¸ÌOjZfºÅtN ørŠ¢{—Ô&™ƒ�‘ †jx•:ò^µž¡±p[g«ËÙ2Ø÷FÇ#Yç=Íòj4'›g‰X´þ0^­ºµ¼¥Ùù¼.Z´Š!è“`Æy2g^Æê/K}:ÇqŒJ’9v–º%п·ÿ´Æð»íºûšNãadñ)ç ‘À¥þùá‡jZÑŒ}X×Å%ë /“/XJëöÝöË‚S>ÚÅuvßÊîîõ:ù|€WÝØG«öű€°¥bGz=¨Ÿ"IÃD®Ì؆””‘eHY褨‰ÿ:Á‘ÜW$\ Ãê•K¼Ó_ÁöăÆ[ÝRpôy¶c¾•‚dˆùJ@ùæ5åÏdfÕó0óçá|ò¯,ÃXì”C ‡õŽþR€ÁJ¶Ô�ò«S4êù,]Ø­µõ)ï×cvüŸÃÂ1˜6Ÿ-ÂÚ•ár;Þ¯2ViëÉR�ÐÒ© apvœï«³¸2 Œ‘U%$–™ÚñVÂShŇ¢$ÖˆË�ÀÈáGÕ2F?Ýsñ.(€.å"uÒeˆ5#È:#ãiò‚mªñÜüRH©â™Ló~Ä gÞ4Qp¡rêU€¥ï«+ƒ$ÝøÛ¯ªYa¦–Ã4»ÕÍ>.èP"!!·É8­ ws ¸<âr;è¬1ïÊ๽­d1ÓÖ¡H·ƒÛP¾Õáv:»Ø[OÜ—K† Tìö:y+ð¹£[ †GÕnŸ&¡·Òt¸ œþŒ2ý¤óŸþYsžæÅ÷-µ¡³˜•sõbšïK>«”­‹í¥²¹´áBÂaªÛ|qTž:r¦îæ3ò§s,ݳ8v/[¤¤o¸vŸrBCë³›h´N*!jÀ˜{!kÃHU´…€ÓŠ6 ð4ûN¹c6†Zþró‚u©N¬R]]jZÆ“Dw�,—DHÐð`ç[ÿQð ­ÎéXKòùt¦™œOì[7ï™óȱEvœE–ÇdƒXnŒŠEe‡MÝ—`š\™ë‰@—6("™bj]$"a§.HVZsý¶£œòÖÖ-çI\w•ÃŒ-ˆBhÝçRÃAsF\„0z÷üI‡œ›v+µ,öØäèpÔó…PfŽMšÝܹdö) šNî«q—aæ€gø§[û?SûeÙÈѵ/ ”ìŽóžñä”)áHסt5WÁ˜Ï(3nm±%2F‡r¦«jnи˜Ýÿ’ð*þˆ¹ÉÐÿß”XLZ;ê¾?ø©«0ÐMžBS’]4ÝðΔÙ"ÕÔ çb눢Î@?XÅämFËšE;%u=·."¯±­*n=fÀ”Ã�ZI±Á‚×€»SK¶êÃÅeFäåü£Ÿ7@jÖ‹=1¶™EÉs˜…5È󦹕Àâ9ï¿ýèμzstpr¨—ÃýŸëÃ,\ËÉjA³Å�³v§ò_�f”m¢üF2›ÉÇl^pWú.v=Û;Ñ)ò’u(½;ò€râ)‚¨öà�5`w©.TÀÜÅÑ8¼%7D‹ýÈChú;î68 cã¨û0¯¿Û4�†ˆ»áïI·orP­š¸ÜÀ®¬±­ü炳I!0ÿ2�óϵ¡\¤/ßC8¦ßoÿ`M󸓪íÕrö�Ý÷:*ç ÇTöIÚäåâ³È#ΟsªéóŒz Ô×dD–œ�w�± À*ÙÒ‡Y(ßÏVˆ¼ø˜<Ú<2霸 #W†ú,4 í¶œ“Е‚·$÷„µÄù^~ ÞYA)Œ$¹KyºÄZ*×Iž’¤JJÃ8ɯ©%'³&@8“¨”WĒöïÊVG?Ùë5²;d;ï$4„n1/J¿y6Ôìv›3·g°“Uw ¼Å»›ËN̤ü´ØÑã®<0ì¢xLÞgo‚nŸÚ9:øYíwßw÷a¹vOŽzÇ^Þö,¢¼¥WìÅU^x�éè‰ßã¬Ä˜ž}xÑ ?‘á)†Õj›8€×××MŽÏ¦¿�›qrñhÓG"Py4�756/³Éxñ1èKç 64ì²ÊW§Ù¯"æ°×÷%¢BZ5œ…¯«S’”¼Š¡HEé¿È3unóP¶sDšÿ~ü?úˆ”=ãPí¼æŸ~¯‹Y@Âã1¶ ©çÖÙ&„‘ï�ë¶çº¤ˆ>»ÚÝ€­ÁǪ.–± '#g51é% ôa�Í´ÒNÏíµ(lwü¨¦%<Èâv�ÞõƒÞ{Ü$LN“lÁŸY ¯xý&zNƒa´CpP]tíiö…‹Fn³B& jŠÜ�ì.ïdºÍúëÄ¿7T€½|“8’½Œ'—/×+´Ào™Øš@³ 0Û°"â¢wßvwª«¢­RØ4³ð!õ'=׬conyVIŒA ¬’Ô³ç�º³t'W¥ÅÝô.E“y,™>ú1QºSŒ]Æ`'HªB‹“Öƒ)]š„ÙewÇÅc2qh23ë·ÄŽœKCÃÝ ÿT¸sŸXƒv§9W‘ÜåkÉ¡Ó2,\–€IG˜ËÀÙØÐ±YM°ìÒ˜®$#¶®~CÀ�lýM ÝQªä]¢eËdõsÑ…Žºoº¿ž¾ë÷3XOiQCÕÖÿ£³ñïÿù×:°ÀN,é"UNî*ódS;iQT•9Ýí¤yI•_1ä¶´�ÑÑ/ꌯ�}¤Kw­®‰ŽâO1Õ_hžý¸ ¹©õÌEV˜ÍdFó8ÿ‚òvyQR�ŒÀàïû:6°ŠBÔ¨@æ1‰ îýò8Ž?Îg6>PÀFÛ §"1êrÎYÃ>ĺy-Ü:8éû«¿i¸x<tãõº¬AQ‘Í‘øì Æ#š«#Cø>·7oIr=83OÙÅj–×§’ø-B}ô¡ç¬Ýþ*¦b5†_çNž2ñÛ°N¨ÈìyÎÚÚÁUÇ=ôìèÙÃuU³7 ù8Ý,ïãêóºÈ÷;à"0e°ÜðïØQ.³- òßæ“JýœÚ(<Ö!ZQ'La¡ÉfßyIŸš9çbS††ZK_ª‰»¢.Z¹ûCV'}ÈÁëê7lɉì#¹É•šî¾â˜£�ØA•Iwï‚+Ž¥4Ëç ;›¦h¿“üê9Ïïa×ýžë¦Gòù[ú÷_±’,ÄÿÛõϽCÿ·oÊ?ë>ü¬­—û]qp›ôQ+án¶)ú> “Ë`–ª×?…7æ€/¹?Xê!ºÞªkc\ô´=’¶Ø´šÕxé³#’J¡êªmí.£%œóé8¾¸Àü22b2‹Ì’#V¥]mI‚.q7$̉ Å‘“ÌìGÄ û¾L'mA©ÅÂ%óZÞ¢�ôÒˆÅ'ýýƒ7oº{¦.lä|õ¬ºúq÷ÝáÁQçèƒ[¿[öfm™xD‹˜É‚ñŒ%ôN¾-¼Ñ%áÅ8C�è Ö/„ù¾fÚˆR3¡B9•̘rdZ¼Ô(’î;ë¼mV”Ð,ûà  ¤b)MNŽûŸë?ƒ˜F¹b‡)+ÏBžcÁª�÷¼õmù±õ2vé}­À;•tUž�ûK ¾„gø2C_­£*R]îx·ÙVË�j‡ÐK&42çô‹:k•¤[dྠ�gk" xVfPäHðЄÈD´ÆóQf ð»6)†Z:"Þé$øMæ‰8‚eQê6ˆÓ >¡l=ÑðÔ$Zt¦Ã€ëA361ü”ï: ²Tƒ·œ˘\—’F+LÝèt¾¥B eÓë4š¶î›R[ „ÖÁ–ú[ÿ£º5+ˆ“f“5¡d¥sߺ`yPÒÝ—'& 9/¦)Š`œ$T6Yên pÈëËfõ(ioÝ ;Ó(ñÖ5kh½€~ÇQ,cî0ž‚­LYxs´ÄysF½x2ïfݸdÞ%^áLSr¬éHy®�ó˜Cä‚Ѭ:Ÿ÷;¤ÛO½*¶‡6ä<ô’W�¢óóo ã›Ü¼-¶ªÈ­wY:¢²pÓeÃn�"ÝÏ›QâÃvœ“(Ý•8ç%áÅ ­Wáì\‡Æ)¦ Ãn(¤7ò~Žü«�Šúmcy»dâçø�±�¿ýÖ§ºµF˜ò�r9V:O�ƒÐ<³ça¾²9ÝL�0™Ç¥çSC£xÃÙãþ€þ8S[ù¹ËÔº£0Í%¤ã Ìójƒ+nŒ’"ç6]©ûÍŒ%ê_µHcZ}ñӼ˪7?omV½úéNRgÔ«\ÿÈ�¹òúÇ¥@;iA³|›{¸[·\Yí^ KÖïºûtøºZvUàVÔ<äî\îOº?©‡rÏ*�†¯–. ùkië‹÷Û®Þz<à2Z¡zû½Ÿº&Û×÷eq¸¼¼<ÂÁR¡¥’ÖÏÎs§‘F” U2fŽPî< ¦@�š~?Æo¦H y´†#2�»ËfGFV:q§”>²¯õÏ�N‹´˜Z8´Š9s²;—fu¦®’ðn±ì®‰Ó5wp#ALßYwÜKå¤ ÔdŽY­1<ŠÎ�Å)S/q«ƒãÎq£î@ãàtÏÎRN\ ÞtûÝ#¨°§xÈ¥4‹Oõ ë5Lr1ÄÓóè¢Û·³ßìv×30ÅŒ·ë@h"8CjÿÚÜ€wG(à*çQÀˆ&úk]Õʃ£îÞ-1¡XÊW kë>UN ·Oô¡Ëö:q­^ºò:á1 �e¿Ht£JÃZ8„ãZ{Ìé$QÝÏçÓ!&¼�²ª ¸ óþ€>‹YwyÇ›ëÏcÝÙö¥nÙ(ïµ§ý¾xÉÎâO›#Œ)A_ÎåÛ=‚µ¹H‚Ùå�ü µ%:E¢Ñ'ü›Î.Ã$ލ….B“¦ëä…‰œÈ˜E<ÖPajœ¥p[LG=Í {´Ô®×ïîÕôÒ•à…V¸ºEå�—(  •vñ¬BxQÌ1\G%;[¼+/ê.-.æ¥W\Šxk�H’™¥§ž˜‡Ü²Ò*ómg/ÜÙîü&íË Ôܵ½G1ZÏ~aRéîM(ÔT}è¦mk£ò˜ ½�bJB5 q×Çb·GÙ 5kû ÙWDvgAÜ — Éz95*$Þ©¡5CñÄäTœ(G¼-LŠŠJŒ‘ù(YÒÕN 9=w>˧xÐC¤½=`n{Ç4/­ÛÌqø&‡LÄjØéß{¿¨Iq/,ÚË¿và·J¸2Õ¬'·æÍC~vWhûÕsZ¡ýü ÑY¾¶»›Ñä'Íæçç6)Åf¼4¬ªc�1ÅPw¸©‰ GêÊXç7S1ëµã_Ù<ñþµúË…ÀÔo&s\áRÆ¡ÁJÀæa-ö†/5Ê_âþþáD"+_U¹{âœ>òÜ:5æ È8m½fæcT ÐõrsL_&€Çm‰@½¦u‚µira8ÁèÆ¤tÓR6…èbŠWY•?ímT¿ÜàdåtŠ!o‰r»¾P«ÊË%ý9Æ©¥›³©Ææn•÷R»�½V ¥ ™Iõ'�}ȽÏ톱2ôÊ·—·Ÿ›¢RÂ.ØZH©áe,8Z,o1IA MúG�1¹y½c+þ�ê×Ã÷JlU+SP $ªn‘lÐ$K+[YÙ‹—KW¨Ð*"´újT¦l¹¥–΅ቌúÛ”|19åùbi™®Ýï@›¦ìäMR*Ĥ‹ŒRu[ LQí\•8Xföºy ³W~ß°†jc¨ÛÆl^�ò¡báè¡äá- `ŽÝnå¾ÚhÆMXb;O›¦¬ÁHU.©_C—/ë—“îËAa”ezM×1·>P¢™ì«ÎÄ,‰&h΋ilÃKÛ{Yµ‰m̧œc·©~.Õ·°º› âm ¥�mïM’» =$àÖ:M‚éGwºÌ•DwJY;ã)'÷§Úrq¾ñO.4¹©€uË‚“³ñsŽ1ÜüÑ6Í<8ö _Ó<�QTótf«šR›¸°ÃKeÖÔ¥ú.1š6fGfÆÊì�Š©'Û;yLoæ^yY^›’L.®mç><̯a .FA•:{i²Lžž\ ¹òô*ä÷ œòÎ1€§Pþy3rŸ3cÛZª0ã\]a[e×±ÂË€",J(´^ÿY³|ù²ÃMrÅ.˜ËÅ­4úäæW?‡ÎÂådP4t«‚X•‡{Òñ�JIW«‚v�ר%TÍïÁ é´YBÔÊX7›]}ÇûE8d概ãYìLî’›ƒÀuV= S8°R…|1�M[/DîM1ô¥ˆ?¼æ¿Ú^]iŸ®¾G—ïÏE{ÓNwnú£ó…‚ “ÅÈÖaGË®|©@Ò¢°ÃOöÞÞÉ=È�¶äzÒn›ÎåÒ/ œ`”�Ê�ªV‹Ívfö-9µ„‹Â E&®ùoç¾ÉQVÿ&GùÃÊQ ×'¹@n «FÞõÐåˆæ¢�{���i(F‰ŒUîµošoš?;{ïš?³”åOz-Ï]R¾Ý³ïïž]=¯ßîÜßîÜßîÜw¼sS‰?Ì…bEÛƒË�}»¹»¹ÿ9nîå†2~N_ŽvI<£Eƒ½›±"ˤ•†› ¦ª¶Q§`µ³pBÚ¯/£á¥J/ãùx„ Õ“èâ2Ó5© À˜„2›'Se uÏrÊn·Õ$ømÏuE‚3¼‘&“pSbeqÿ‘déüL²¼8˜ÛPlÓè>«« T«Uwójås£îÆäãz™ ±#Ýù}=®ì®žÛó·±uÙ*·u�±N¼~r�Þî7…n2Èøò:‹ó¯C HW€Wa"éÌç,Ï �·N‡ã˜Ò„0½J•~)(—ÍçömCæ¡òÖ�Š·,¢Q_¼ï¥—4!&Z�ÓòÁX£*ðôªŠA–òDqþm÷¨w¬Ý—††¬;Q¹p•(§Å+LyÉ0�{ûÏsqGò• ÈyÚyÛ±ç›þ¢3�Âè¥%ÜÑÞÆhÒG zT�yì´iztrµ^–üójR:a‰\éVÃ{‘ôà‡¨Á— �¤|-#ä3UÑÝÉáá<Ì%vj �{�=è«õŠ uµß}}lj9À WG"½u“icÖÄW$• å‹%÷ðfÕëj¤w§Æ±Ý—ô”^yIÄî ²\¶S¬`çiè–C“Ý6ðÁ ÓY8ŒÎ —禀÷’8…èQ=&ß�ÔmméF²‘Z_}pc°Úa¬¸5¼œ÷ÒiiëÆ<{A\ô¥ýßmó{0¸“NGåa>·wÿ*EIWUc‘H<‹šåù‡–ˆ"bä!ïîwN Úlj¯xŒÞ�Ũ °®ÃÒ²ë8\¬„�7¡ÚàäÕàø¨×³ÎÏÜ`E UZ£µ ÒR‡Af£I_58ììšPÙfà>k­!/»„Ø�G‰2ín{vu˶7�R™£Òa­}Ãøì2FkÇzfkçHØã¡ÖV,j8ÉP\ô±g~3g2çî–…GÇÁ;Œ�fÑ[‚•Ë›�Ô$9Â=4ƒ,£-é íj:²úíêûùÆwöOÞõ‹�8¦o˜A¾Û{Ó7‘Ü‹ê]{Ú~\ZZl}¢[géQ¯³ßû÷îžzßëþ\ Ì•~½ò�H¿ÁrÙ+:dFmÐ…“»OxÌùÒùÅdL/ÓÇô'�ëcç:Ð>ÅáÞO=<[à£Ã0غÅû:©=v€9ÅÍèÆ\µƒñú¨9 ÓaÍ�i1Êw`�е–®¶9!éré"©Ó¡±pTª�šñÙ¯tÆ{` It6Ç‹Þë4ÌØÙœOˆÞ�Y?�–Óù´mGŧž7–î–¾@ÆúqSa« šF�~x|Úçyí-Ð6Se ¹ã9»«¼¾¯pI�’Ó¤p¯º�¶ïù-NŽºEXµ mEÑëïîŸìuO�xó¦{4p'14Ф pçÑ #\B#aÂJ/ÇŸ"øBá$öV”Ò*2659vMMPbaÞ0¸.�T¶ÐÂÍése)þÝsEéXÃEk:ÄZ,‰RmVá nqý�élÚy¹rí½ñniOoú)úíF¦B¯æt½ðH:%^N­±`¼‡(ðú/ÞÅUu~™³Çêª}ð™rºø÷:ý¤,­“•(`¿K"eú©Zbû" Ó0“€Óðjv©Î‚áG´¥¹ÆHœQ¦®ƒÔ'A›/Xåt%ŽàâèâØ 5BÑé„h¢©%‹\€)ïí¾=쿵”Òéû4�Ø<à샔�JÏ£‹õšS¥�ðÿ9Ê­J3üyãÏ�£~¯ÿFŽðiîAhñzåj™Î6r1ã)?rDÝ_º»'˜ækÐ=ö&r‡Q"7¾û‡+ß·_\í£îñÉQßW&2+×ýe·{ˆ’"úE—2óLí:¼ê¾14ê tpÜÙý ns{½Î›þÁ¶´<Š‚‹ ü]w0è¼f°ûËqCž£héðÍ©éët¯{Üéíëâ­|ñ[8ttá¶Úð»{§ƒ¿ïSèt]ø„k¢ÖÀéîi¾=)wæ7kFÔ�øÙÀ\™îa”t]C OìK-Þ÷MçÑfñÑ–¡ùè±7ÏŒ>"«aL"o­ �†Ã0MƒD'øÑ~´{*DÍ÷1%}èúòÓE$þävà ;ÊÞ™– ãõÌ㛦R⹚7NàÊ_ç)Q•I0�c|I_…²"_™Ô}îh_†b°CÕ�á›ÊÊ>ªY +ZbúûÝwð}íÑwø<‚jJí¡5loJ±æ‰?ÉÑ´ßH cšM˜`EÕîÔÛÚj‘¤›6LLu‚’uÎ ÀÿžÅñ8 ¦Å/ul`ïèàP½>éïÒ,Þ¡‘—ÅVïE«'a|Wn…ßµ-*€Ãr’þÇB;¢ÒM4b¶Í²–Òxž M–j–ž�Â4+>}ß9êuöz»‡ÜkS€°1ÿûjívÙ+È·ÙËõÖ ÕF+穵y¦Û9å@~ÐÛÃ-ˆþ9p?犌Ê@¹‘‹é ç³~"¯áÃï^ì}øŽÐE£­ŸÁMR¨<ä`B‹Šžþb@aØWQx�ªÿÇTL¤`zÃAî�êé"äâ5ìØã óîp¿«v;ûûí5ÎÈ›ûZPæ Ì@ßN©x³FèÐÝÝï!I“!2�v{ÃÜÂÙÙ¸ô9BëÈóøìW=Ø�6{?›cxìʂͪ‚­bÀ `•4Evª¤@ž§…‰Y¬ñ[r£TYSÑ'ä#NmON,MI�ÍÁ�kʹÎ÷áä:ÈMÉÜRdZ¥·6ì’¡³ýè¼MÝ3%&w® 9\qvÉ£¶tva»¦ ®NsUtŽ=/ÂB DDÃî–àp ŽÓ¬ ¯Ð™ŽƒÃ?VdÅŒ£ÿÒy 0#+, ¥ \¢‡^ @að,âòÒyesù+[Ë_Ù®~eoo¿3ÎŽÏÆ{!ï–ÒWvÉÄ¢÷ ¿TÞJ†÷Ý…­ð+dYR|EÑÏàìÆã ˆù-—¶·ñ—Àlú°ö˜·§°hT†Å‚Y]ÙL\NÐÕ«À%gÑÜt¨è·Sôw8…òyX(šŸl‰_DnS¦Ð+Š¦Ã„Ftzv“o0šV58„‰+LO1�@È{TŽjœ`m9$ïÜ sïÍIO‘IBªòe~wgÌ)ƒ6ýýofó7·'ø _Šä¤0¾žÚ2·–6›/–¤a…æ…Ê’SÔU ôö"Lˆ¤ý ʯÓ2ø ¹Ÿ£îîÁÑQô)¯¦Èir8ͼZ¹²Ö‚²Íe[ ʶ«ÊF¸5“p„Ç?Á唡…BU½5I‚¥•_I¤�vYžT^/ðg‰àô�Mgg•ŒÂ™{p8´8ËL oú öHSå5$"±€ó:•¨ “B Üêvû)o›…¿ùKïÌ ÜÊ*Ë4uK‡ñ,2x//à%ÓñéÐ?|ÝYg^БÏþ£â£ÝÓƒŸJÖÌ4/t�ŽÊ_ [[âo…b 0‚ò…ä;cÉìëpEA ³¦åÛUOª ž–Ì'“›Ê‚ͪ‚­’‚æ9|†�`ïè&_2WTpšMfùË€<•°ïQúƤÐó^uß~Ñ’·aÙÊ1�yï©À]j¯ H.Òª‚i,î"e*vCY5-°4縑ñ K0ŸV®ïm it¶'�;‘Ιĝπó8˜Žo¿¤ÕH‹[ ÏæÝOæÀ¨~iLý]–¾tà-ZÙK0áãÐ¥ ËäNd€%nAáÊûÒ_šçðkrå³¶E.w¥U/a¡Gª5\“+Ÿ<È�Ô Çº/êJú®|³n­Öc ð~ö:‡»ñÌŒ¦lj±I•ûdÑn(pùÍþ‹|·Çñðã©y¸N¼M² íò%åxú¤¿9ü–!ô5]ìÝYýâ³×”�Ö~^'�oÀv^Å«,©*aº¨ÍÍfë¹Ú ‡˜R3Q­ µùxs]à´¤š‚›é‹:Ÿ°¢”ÊcÊ—ƒRD-ûd-ÆõeˆÙ`£\à{á§aHV#iSk—Eð°k±ELÁ­ ûã0#‰&?hm·ûçðÄlóóu̺Ï3Ž`�¢îê;…õÝš£p“X­U3Dé�qUu2¦õB¥/;p¡b8Ž�{Gf}‚å‹­AkÓ8‹†!ŸÈ¾UŠ#LÑ�ôD,+¿hÛõ^‹ŽíŒvT§ÿA­·%é…¼OïˆL±ÖûîÑ«ƒA·V¬¥É[y=ê õ+%5-Í+Ö¥=‚Jxj LOmµ #Ç…þž5Gß7i �;GǤ�÷äy WŒ×(n1µ¡²—¾a‚AÀW"Q’è�ad�òŽkÇH#à˜—‚®£˜’ðèÄ+´èމ¸�è,…XËN f.mp�V—LŸœ™rRò±‰h®çü�ézT®aUïHŸ¼’‚5DxŽÐË�“†\±>@—¬hOd,vˆ•Ó) Zˆ-·‚{kH¾S×à £Á¹H×T¯$ŽØ§ˆuÌ!Ùž”X¸‰^S€N!q|¹€tãÄÞHÊLê'N=YB•‡ß!ˆ=ÑêåLŒuñ@•æS :6ÓüÍ0+'{¡R ÎpJW»�i&À${ÏÑVœ+ |ïþí½zÝû¥­þ²ý¬â Ó‚Äü™åœX;žÝK÷úYb“æ¼+ÖpÔ�¬ˆ€ �‡õ_é® 29x{ oµºùæ”KcWEZròÎ]wKâ7.cqQ ZرSi×WM)ÉÔS›JC/ý6kU¼GN |oˇ�—Ðíë¥åaßãh„[±�ÈŽ$eÊÇlï©mÉì1iú´´ÕàxyLo6?Át…É §"ÏÂdB©1šZ³ß˜ €( •㘡Û�R�1zùÏ3yŸ™e–êý˜ÆÜÈxÜŒd=¨ .H,=šãu–ˆüÀÉjèøÃa·h1Ÿýú«$Å^öê F61ïšs@‘·QSb\Mj Ìw‹*!OäÅÄ:ÃÀÃö8Ÿéuz¬=H(Jïºëy©ÝM†µfÞ9cnÛd½¾ö@Ç ãÑëÆ[• ÇÒœ‰™Oí¡æœlÃá1üd9.–‘Iúß?ààsj­;íÿuUû¿ë<ùõïjj÷`­¶œWt lÌyÌÊ7‚N» ¡ …lÉÍ‚cznŒ¹ázR�ŠE�"Ž<0nRßÖÿϹþv¯ŽîÎà Å�°[0)|·òN­³ër­Aίð‡½�á‹öc…¯ÚŸî¾í¾ëÀ”\¼ê º`£¤/ ˜Ó/TïõÝ#ürr-a'{PD_Ž� ;~vÔ} ‹@ è9I±Úx#¬Ô+|‘3�Àï÷¹‡ãî»Ãƒ£ÎчZ=Çó=)e¿mÔ¿œrg7ÑYlRÙɆ«»sË[Ñ}÷{‡ûUŽK¯ºP¡ðÞx�Áû ºý �—XL9<*^Ù�`<ŸZ�GO 51i„1¯Cs¥fo`Ná$…s78Ïð-a_´µêáQï}o¿û¦;…„ÇÕ£ª‡çÏUúø°E?xF´ô±DöÑë¿>0ñgxW¨ï_sSyû2=å#ãi+ÚVÛcº]ß W¸Õ99~{pÔû÷épÔLrƒæ-ÝíwK¸£z>_^XÇ E|/‡„H]œð¯…x>9vygñj¬=xà5#d¬±dQõ\ýÇjhä0åþ<˜|·Cå}1 ÒF•(%�,’Rߊ1׋—…tµIÚ°Ö¸+Ýf´ä&â£UÌýbí—!ïç®ÜªcùËêíð’¥­ îÅ*½I3à•ØoüНglH�~wó36–Ëü÷ðÊÑêô}këy[õE­"!;Œ…Ä[MÀ>B)Ù‘؇ì%24Ì¼Ž“�\0K«(ž§ÚÀÇ•§Xz¤êá€ðøaÃê�ÕúQøƒ²+–üÐOøos®€SOVÍlhOiviC$òÁäH|t–•)Ÿm-3žen™ŽŠroÿíG|Ù}DP?42Á‡ ;ÏCœMçã±�az$ d~ÉȰó~ä´°^©O\�wI&n™=r�ÄVâëy9m‡5K›LŽY4¹ªñ?´n6�t�lbh /c¤`ÁHDÐŽj…X� ¬«(ù͘[ µ¹æL¿Ë(|{ß[n÷ø(?<`©x³¶µúïà]§'Á˜ZÍ`4J¶ðÈ6ùOÑiç1õÅãº�ši¢ ¡Ûv»]x[í¾íîþ¤ÖßwöOº’Ec’ñ×zUþꢖFÀ[�‘)¼H®u+Æá²±ˆH‘EÞTá�]ðÝüÎßuÖòC*¶ª×¯¤U3Ÿ:çýÜ¥*×0ïL'ä›/é–æšÀ8ÇŽ‡S97‘ç%rG¶ÇCÜ{p ÍfÁãG3š)ȳða\Z‘p¼t~€L}nà9¼ÀÖ½óùÑç�lNðƒ¹Zįêy›“Ù†¢åÝ`-/³ÅÄ h“d³qE9(æ9CÜñÁCÝAÞ$¬Q}£1Ä »À§*ÌáEœÜ”œ5¦3ô¦Ïè;QÃÁz¯%"Ž�Ø}ÃY{X‘³¾\º;ÕnuÙ*n?§%·²‘†Ó4¢œSíêÕ\S®UI¥¾&�ëB6|…ðÍ$°1l+¸@z•Ø�†Òß‘ô ¿Øî+Y‘¼…1”œ[òqnx&g‰N;€¬T³@b¿p/65˱£[ßyŸ+-–}ø[[ßû‰ ª'0l†0šqpŽíxùa'¹BÐXkõºv®ÁŸZÉпüA†WHTsÊ€Ò3Ù¡àºûÉ<§yuYz ŸÙ´[ÌãYq*¤zúJÀ£"¯¨¤­{YŽ(-Ükæ ^¦çpÒrì¸ñÃ}k¤ilì�µÕ/ÞZ$³š@F ¾n͜ůÞ[38ksvÐu¯º¿saÖ�²7pó{oŽN5³ÞP[ µ \ÆšG.  …ãǬíÚ­ùüžãÍÌ%¢ÙËЕñ@…ŸÂpF²„Îv¸ ¬awŽh ÜÁ, ®³$˜ÂD2Ï€é,U6§Ð(úK8:q8b$üPäît�»­\+?ü8_76<£>Ó¥ð-5È3Se\LžÌì9r\…%,ÕÂv·óíò4KÚEãÙRÑ´ß¶;;�ýcäÜ�š~¯4§[D®r�’9'¾Æ�ð‚ÛBÑbÙ’}uÑâgãÈ—Ã’/‹'ÞD8ˆrO·kƒmöúÉè’ψõy+þÀmª°è*†[sŠ 8ë˘Iu\F°ò,û}¬oTꟓJUvT®ùFªþ×�ª¯9œû!‡Ýa3rµ“)«ËÙë·ýý†Kì,ëÂ|wž¢I»•.Oäv6>,²¬á'ô¤Ç¨¬ö9éËÂq’ж�i²{:z+1¶ÍfÓý tmGü·�—ÞI°Uû3s~¶ pÍSÈlªã8ƒ!$áu‚L||ÎÖÝ)7�±Z€}¿™á½NFKËõ8ÅA£�58ší‡lý §ÍÌ'³çÌUkŒ“Ùˆ}ÉLÀ;ó*‡¢¥8W!!¢<Ã`xi^ÜàÛ/ù$¬;IÚ†µ.!ÍR6ÒÀV³KyJ¸c¢2gaˆ•$D5")Â…2ž±ºnØd>¾ “$¢ä*âA�¥âì}€Ém®£/gH¾®ãùxÄͳú5 7l<õ‘IV"oTÃÏ0Òša³A“sE …á�üÃáœnWJúætå˜%s>™áüÇIfNÛ¡˜HAj§[ )Üõe„ eR1´•“ÓžÄÁ�ßfƒcFdû½äÆ ƒ$U”™e} ©ªôIMé�YÔ`$E¡5‰:ôrCâmè˜Ë c¹¡c*7ÜÊ 7¹!q’NX䆉„ÜpBûJmGs%ÐdÓ´(!Æ›·Î(‡bJ‚ä ô¤@ò%ðn=+RftÁ˜`T†FúUS@íÚ¡j½«ûŒµÊVýãÖÈ%7®•½B)gƒZ£6ªÕurܽnÿ¸wüÁWg1&Uõ?ˆŒJ¼…µŽzL¹4í<6Ìä5ÌlÕi8zÝn+r Rå¼g’j!

z'(L7reÛÑ_— ˆ^šÌ `> @Qh¼Ð%bË�$2�ºÜ±è)…¼þ"¡ÂYÊq�ÌI9L^ ðžTÁâN‰ÿÿ}μãüQEìJL¨îC¦X¡õ(ÕxÑr)ª¤pδ}¡õ"DTÍfÉÌ©Äg‰j¿Ä&ù·Bô•�ÄÚÄYÜãCR“Ì&:·—òˆ|ü�Ü(t Ð97âU1ª›Î-õÃ]á@Û �öö�Í{h;öË6öö¾ÔôQgÛaEd>t†¥’,µ'© ÛîÙÚ;™ð ªŠeDyBÌ2±k ¬"jÜÀX‹†XREÂ!–Ƙz65°Sö‡yäÁi_ã”-( ‹“ušxj“ê­¡œÔn å&sk(“¿ XÊ×–Fÿ…ß1ZÃæKløkïžà ÓK¬««ïØ;53ÅÚh'gÙÍÆ>¹›÷޳4¦žO«=˜towF¼|w�|’»†—Ú®‘Ëf×ðÒÓ5¼Är ?ŸEÆ9U¥ÐÛ6…)ù°»ß-‹óã¿Ö?PÞ›.÷‚9*Û¥”ý3v›;¿žÑµ;…—z}8–ßïivhnÞ 5Þõúd].º®a¦øzç—’×õ²»¯Ãž¢\µó…5µ‰Ô=I¶–îFi¡+Ê ¢~î¿ÕSéãT¡ÂQ—«Ô” [åPv;»o­JD^w�°P¡¦¼�T`¡È|C›çîs°X¦ô+²8ô¢‹Ü°Œ¯´¢#ËÜ �¤Øa)?–ÖyJ±ëY¾>4�¬™pœ1Þ²NáQ½[ÜœÜ+S5ŒbšÍëß×%Ö^\{·)^iÐÝ'šjªãÓ ÖHíè/ææ^8GätnW‡+_B^ZóŠ3)Ú1@#_×®¥Ò/û”6 ypf‹l�Í!*&™êöèˆ8œ×„æŽÑ/¥Ðê¼ÂÀýx¢Oyºå>)ÞÁn¨›åuÕùüÜ>ï°„ù˜vÅy¸Û:Ýmø_'L__i�|î.q‰°¥®Rµ¯Y�Õ>‹`JÜs4ÂDÍk$[j" Ü8�Öñœ”Á˜”›˜† ŠÖá P³G”Þ¾ÕIT:Ÿ3{!ï4•: dg7xT h`ˆ¡ÿ‰bs^TÓ®âŒßy�Äýáé%Æv‹²œ±Á1ÁçX<¹û�ß5€øX™Å®Ç¦ÔG» IØâ\¶.eÿØóè‚�:&34è ƒþ5ñY*Äâ÷[#Çê åÈsB—²‡uÊWévÕ�¸°u�éEΓx¢Î1B!Y< ¿®7Õ÷º7‰¨ÄÑνaÑÉš°xr5ëàe·pç£�KÂÀ² LÄŒ0_Å…IÊæ$ PcÎŒ"¯¾hÑ«&«ë-F¶d[@eée43 ÍÈ% ƒÓÜ lúû5F““˜ÜL"Ì5mÖ"aß�uuA»URûдO‚_ãä°Ë±Í¢ÝJþ¡Ííµe‰’6·õáŠF F°Ÿ„㾚É8; a>pçÒó�èXæ\ìæ£LwøM«bI(ÙÐ)‘nÆÛ†™Ì†“õÓºôm>Þlm<ÞÚxüœRxüeëum9_IMMG˜†ÃF�ò_À쑘Àæ&SÉFåfïsü•諌ùMyƒ'²Õ°ºsµg‡‘FÞ]…�Úaeèk¦�ˆ®èÍ�¯Zo 2gHj�Ú¬FEšXS`û*ð¨Ã†íXÞDL|ú„;Àü‰Pkžg)G00[ó(½Ú-t ©û^�²`: Í,¸[ê´lOñt»ïç÷•H_l=~Â.2��ªpl�€9Ìlš$„m^QToÙšÙu„Ò@¶ï"‰% ‹.$Qàd>΢ü:t�6öº¯{(0C,g;Î9]Tözƒc¸¬£ŠQ¢…bKd¤Ën¡w¦Æ]± UfMg·Á/»áä‡Ùsg¿žâb'åb]g�#¼ÈÁ‚‰¯BÕgC—ÆMwýÄPg� èÐ7>yH²ô‡®÷'úNòfâÚR\\ñòœ,m¦³!í1ÄñÈ¢æxP"�ˆÈ®¾¸4ŒA°ÄnlçÔbE˜$±8úžÁª9¾¿dÉõ—濲…£ëðë.÷úý,`ý�…ÿ4½%¾/÷|vÑþìùü±¿l4®ÜácƺÌoÒ?† STÊ+\=I¡öÂÈ¥o;>Š}>ôžze>˜h>ІÈâHj3Í cÆî¸ÉJbð±c�)ï\¿/àúÇhþíuu‘çv�±Hðð�µ=?w> ÊK(‡-ÜÎd{ò Z¯{z;“[Ýò˜åí:Ø•¥Ž›«‹¬YÍH¾‡7-Èì!ŸµpÍa̽Æ|7âµ¢üRÓ^Á»ÍæÓÀäp¤WÍgšÿ±)µ9±=¦Ï²æì&3àô#ðå›À£¥1«]˜=Ÿh±œ½wi­ÝrY&‹~䪉}Ìð,2À6è.Í-•Kæ%Ž€ŒQXêb.l¥Îv©¤í¥{uóÁšr›YÖíy•;5eàIÿÆÆ�}îìÞz¾-\ÔÏÜb-D­jäZ®…XwÔGŽyF½xÉpN’?ŠB¢V1Ý·?t6ËÏœòY.!!¿ûZÿI¯ê÷³Q?§Þçú-YÁEkx•Ë?ƒÆ¥XñËœ)9Èõ ¥:—¯p¶Üœ?Þ~Ó}Ó}Ó­*,¨�ü^₯'/Xí ƒ+£Ž#Œ†‚¡A&ašxU½Œ..ÙqŒ½|L9õ`€’v¾)¦…Ü$L.ð -îi1&:,öC>Åê>ùÁœí§ $ ˆr-½?–tëKú{$xøƒq&«1¯�ûî—–÷o”©³`øQ¢ý£X$e9†ããº�Y³Ge«½€Lã,†‹/á¹ßÈT�´ÂYü§›¦æS;U²è–‹—vÞQT©ƒ¾ª¸�÷ƒ!ÛVJ‡‚ËŸ1+çw"Å•ó@¿[&´ßÕ¤b�!„Em`‰�uÞÔ`|ÜP°;O#6K¢8Á䨌rõ~œãåõwáãÞ™^Ê¢¥ØÞi6™-Š ÄAÉ�È@Ê9ø)­¼BŸÐðSÖvB/úრ$^È* ßß~t‰w‰uˆÄP‰²â¬�1™¸DìP켎¬’Xæ93‡§œ;{Šaá`]ùDìVïÂǾó/KøjfUµC9òºC×T3cnCw û´py—€W\L^AE™g ?‡Nµ TJ·Z±ò¾¸<½†^ÓåÜ�»§Ý‡³—Y;MØ—aB§ “ •%m`>åQL©­R˜óx>µ?:ñb$˜høÑ«·YíÎÊ£ª·í´+öÁ‘ópGÇx.4œœÏ:¦‘íÛ{[­ÔÛ-Ül>ø…ëø­l döÛß«ÿ¿½oÿjãHöÿyù+fÉÍ‘”++–ð/{Ùac�7»œA`b¡Qf$0÷ìÿ­G¿§g4ã�ÝizúY]]U]U{[aÍÍÜ+ÙÄwµû*{(V! ÊÍÉ&�×ͼö‹Îí»9µ„ÞÿÀtD-#¨©õ–ÊGªàP Õ]þÜèJÃ:#åz£dóqßÈ^xM.’�«û‘êG‰ÊÈ  ÕIâ„ì-Î bPéRQ„l!+Øð eL¤œ7˜¶lG“”ØQÖ¶ßÖÐ\"¿Œh —é&úè4:O‘.±šˆ2ÁÁãè*N¦Ô¨ªe»HÂ6±7„ý “™S]¦i:ЉVð�*ÅC6rCɉ€9ÙÐYoèWú*vêÖ­æ­ãŸS•Ãf^‚ßÀãäíæñû£ZÓì*<\_7*ƒy#´~D‰^¼¤òãàÇÎAÏ ›rÚ2b£ëÝ<:ÚÜú18Ø<<Ú9Bp6»ÕÆaÎW¥ØDοJrN[ëìkJÑ*–)Å ¦>—ÇúJI3!}M§ÍÄ<‰ª¸‡Îuèsiº¼!øšcsRó‚¸�’<* ÆŒK0~À“1½q»rõÜÝ®ªòëÜh ô j®¹Q¸•ö«øÚg”Š»8£tÑ?l‘g>ë�¢*³¤ÅÔ~�ºå�r«Ü(ó>Ó%î'Ƴ ©"ùZm’4Ç›@œ·QuÃ^æéOZð3+?˜ˆTé:–ª³¾¥ñ��gk·Z·I�§:xZkÅ[ÃwMa_û°+Œ¯.�4^Ú�¹h¯Q1¦k‘Ê¥Z! |#óœ–ä–” ÓgáiJçÁÀbê¶^N' æÅEØ“›V®�Ïž®˜ÙÄ2õAžÔ¿k°�á‘\qÕÃ) ¥Š.‘c –HMõ<£<é>žE_²¯ÖïªãŠ Þèæn3›æUÙÙÀ%vÔ7aú Óów‘+ÛÒ2=†î9DÚeK´®ŸÚðyódI¨Ø+ÈVo#ÜÚŵ”J‘�ßÒ}¿óãüÆÆ,:§¦›ù6°�3cÒæìõéÝ÷ºÄM4VüËãC~C<Ê¢tšN?I"‚–O*î­q‰¡PQÖ‚Ey»óKNi}öÔ¸ž‚Ÿua÷Ö}VÎà¥$¶:ÿôû§/¾o¿ ”§ÜZ›oKduÿC÷ðp‡‰ÞÇÞQw7 ÌÊUZ8 Í®ŽPÐÐø JÄ»î^÷ŽÈ퀩K}G»H¶KÎwÀW:<ÇŽäN&i|Š ¡{å`_ÝùòâÃÏ|‡-¬ÇUl‹ç˜Q×k ³ „ê=E¿ ¾Ðâ÷ÖMûšcFÏÌxRÉ�VËs ˜åƒºñBIÓRýpüNxøâÎCf‘&×b牋Á|$œ ;c¿íõº‡GÂ;(D'Ê2ô´€˜ñ椨_Úí�ÎW~U¾dô^Þ?\TxB<謢ތYëQ.µÚDLŒË›o2ÄÝ�ñ‚*#ÚpYÇ«´�Ÿ—™x�¡å€øúYpš†£þ…¡©©fð‹�ÈE©ãy­7ŒLúü”…*U"¡ûYÎ`Ôe3èŸÊÍàdÚOÆ7šžäyCéæó…]ᬭ ø˜LƒKL:�U°!D«)Ië„€Ù‹k¼A¡«=ôÈ0É‘h¢­±¤ó4Xk"£MJ …# Ÿ!¯(i4å㵂ÇXU [7”í}ìv~fen}Ýí/~XØÁ`%ßÅ—¯ƒ ¡Û;[üÎ’á—1°Hˆ^ÙÚ?ø(+Í 8,kVG¨™¬A’ã÷“Ëñ÷òAkòyRc@äíîû�ÝYºi;A•žˆÁÈ*x¾jëºJ¨¯É.H532ÍZ:lÑ(IÚ1.°„Q ÙUj¢o�­ÀƒÓ·ñ0ÚBÒt81>ûd-œ©ÂÄèø‹Rà—ú“ £ÜZÿõg�ý÷îý ÜõàWš5‡ SUóÎ:Þä“I”ùŽ 9ß1RÅ-ß¼jdDíš×PèiÿOÇÃĉ 5¤²Nñ¤Ìí›Ã4RiVL¢ÁéPdƒ„“›š*KøEzûë~y»¸»ylõ>xú>ײz§»#ßïæ¯�dï‚ 'c>J¥31ý”áQc²h¼¶y9†¶;�ÉLÝyöýLÈÍ¢¶à7Êü´ôÍQ’Ö«Xè#Ï RÇÏb i“+~4­�¼Ã‰³Í3l]Æn â”Õ;¨“ï¤ä™ÖdTÉ4ÀqˆÂ\@C«¤ÆU îgÍá$›\Nf†ø-Ó»êM‚e²"ËëRYï0ãNV\d.çÎTãYƒøJãˆ/Ž�do(Ó ¨`FQ„�·¡¯±äÛøó7/ž“"~„ÉɆÑÙ„`¸µ{îù4Î.läôÆÌ8¤fª{õ‚ª³PÉA[çé6Ë¿n³ýCBqˆ€ˆ|ÁöÓçÌnCÃñÇŠnNtb5à«i Xj(nµØ×Z¨ © ´"ÒQ¤ ºÍ‹6 Ì-�•·wzG;{[”¦ÈÀT¦ìò™Ñm_Ül`ÆÍª>‹‹ q.æƒg±ï*¤U™0$! GõnQ‚n&&<}Ó{[|QÛûô·¤«4c-ŒõÞhXïMã|¶ö7ßw{$jYó(76|ñ�¸«Oo⣾©PXç*…÷¬6ƒ­Mä¢x&¸¨ð|og–%8ÉZÙ¸O›Ù£ 1Õ dµ…Í\õúì¹ÍÏd£ÂŠÜãü?üÈõXž¹ˆ™s9jœÜ¡âªpæ‡/²8Î9¨Æú�ý�=÷Lą󟈹)¢±J'¤÷U} ’{tŠê)™ºðûîÛ#î£G+“2D?aZKZŽN¦zæo\–}·Ï`·tÝ$6¯×‡C!†ç‡w@VйØ,<çš(–¿€BFeó"Êþz z€ÊÕ>´È ¨(Q ¨ø6½ Û@õ¶\¢ÔÉþHëúïÿÚÊ?Q•*™‰¡ÂË+¼I©ŠUþ½nî9Þ#ä£:«X°X ímÕ; rQ.B†ýyo“ ÐñÈ1;›²5ÉÓ®�Yö¤š[S.ÒD€Ì ;ÏE(½ùë™Ï¹‡œ,2Ž�ºaÊ9%þý-5$©=+(ÿÌOõÜ6u5Ó)�“- ®Žs7µ&Ì|ãxïýþ»w îÕ¼´ s6¦yW2Ÿýˆ�¶L ´Gð±?4øXáNúK•qÜI^Gjý/÷�RÊ_sbŸzW¹(Ù“?ÝÓƒ‚bªœ¨úÉ—/õ¥ó�È«VÁ§áÔ�3]¯ pê…7ýâঀKsa;Å7õž´?Ê]ÊbPÄ„RuVP„{üx|×€ÇÕÂW€x<ƒU#VîÆù#BVÙ›ìöûÊéá"»Ë‹u‡mR•§#ž£æ8ê8ê8ª\Ñû"ÊÜÝ«ræ jo^‘íaHi1Ûu„´¿ÎçKËho< ‹ŸÛ h¹�~5òÙè�ö×Gûkðå�æGûë×i½†Eûtú¡½h߼ꬋ›:Üé:6álŠá¿OÃ!çëä¨1¥éTEŸ,a_¤œ3ø† y#>#ú<ÁSÒ …=ˆ¤Q„GüZÎåØ8 ¹ˆŽð…\Ó­­_ì .º_쥮Æ1—‡&FãÐl¬û‘f`_~Á”É’k„/‡vù 0/¾8¸êlƒ|][äyƒš¬—–†5~yй�uß´?¤åûJ×åì½Ûo¾‘to>ã÷ŸÁö�ñnŽ §ç²€×Î}ÇÅ<ÝyPtûЭî�øG üõÀ—+û_HÝ¿G…¿h½!ý">¿àà$ÖQ†ñ§ˆâ<×ݪ˜-B—.£ôœslpjÄ~<¹�µ^åQ8gGFÿ¦x«ªÐv¡’.Oü\›e»˜¡½þ�¤Óx¥r_BJ59ö�h³W0Ò/î_GÜdsA_À‘•HÅQ>9…eÏA‘á§„ Œ’IÜ�ÊUì�á„p�ïP‰q—åŠ>ˆt�*¿(Hº…Wÿ·¿[€y‘$žä§�‘Â/Jèw�b/ä² ÛH°À�x7ÇáƒP®ïYSïä P† èI.T7ë]˜ÓT!)3жNˆJïÄÛ¾ù~M.’�«h‘žE¦HKW�j\Q!ÝŠʌӤ˜†’Ü¥:‚Ù³Bå5ÎyS¢¤ïì¹Û¦ÂZ2Ú;í™�C40':<¼�¿Š.Ui¹c¶ÌcPP9?tDú�¶›ËíªÎLÑ4²†tTÕ‚KTË�EÉ6Úvj­ ‘sK?£¬È_ ≬º˜»«“ ÐQ!˜`šB!D)¢’v›V@Á"›áƦѹ¸Aü]dŽ2 Ô¨ªe“@ÂæA©ÂDƒ™Ô‰¨.Ó€ôªØ¨uªÙÈô$'ædCçÿ`¨;ü*öÍÖ­æ­ãŸS•Íc^òÛ@.N¹kM³«ðp}ݨLæ™´~DAW¼d$žTÚ£Å.I%ÛŽóDmnýlbZß}‘+2ÔöOε£6m.X®Jr„mõÉ'ÆÉÓ¤�Ùõå3S±$L}.ÇóSøÞ !i~Ï.â1a{È[13«¤k l!�FFiìOQheIÎHôC§jÙ}Ãý�£ôÆ9aÊ•M4<Îá?°J ýÓŸD—’LAs3‘¤± Öi‚†â‹ê{iI71¯,£¡FAÙõ ›‹Sîz"¼jêêMØÖÍ [k}wÜÃ+Áf­†ŒÖ)­ªõ”V­(¾n×m¾Ke öë£}ÃÕŸ‘½]ù£NTEÖÉb0O.ËiÀ™°bÌ‹?ŽFƒŒ,åâHµþ4"îÅ&u‚o7ñU…Ïð…‘Í^ ä<ž®ªI�"# ³æµ£Íæê–¹5šk�± ¥%Sâ%AxŠóËðF¦LÇC`–‰˜q%By?g¸~ rfR:N¤K˜.d`tmÝ1ö:ÕÒbfg†\%f�øÊ<™+ø«Ÿ?^Uw#Ñçȧ†\ å#<}°Mͱ¸O”.ŽÄ³4)«÷)�‘èm�¡J¹•ÏÂá�TAhk:‚÷f–†´$Èc:èÝS(Ë@'@ÉA²F•À&pÊçú42fˆfXë±Jë,¼úá º¦¶Z-—ƒËäZDàÂj…ÅiZ”© #9S$7•à $?Œ’Ñ�ñ.Ù#Ž|œÆ—!ŒqßAHjQF·Zñ¥%;Ø&î n=ÓŒA˜ÝG§v—3»¥ƒ"¤fAº¹h+5ìWñµ?Î(wqFéLJ”‚Ð&|ÖEoÑ~¬RÚÁ¤ÅŸ%¾ Ô-¿€ [å"­HxS°Ÿ€Œ ]¤S« M’>p33o£sÃ^æéOZð³ +«’E*m‡²Ýß¶i¶Z ˜q›”H™(Çx’Lk ã¡xËbø®-êköìËÞ¦ý…ãsé^ÍK#2Aç5êÈdçɱéÔÇ�’‘Ûß,Éc˜‰lÝgáiJçÂÀbîdÜ;œNLÚ'äSZ«ð&˜3cˆÂ2¸¢«‡7ÊóàÔ+ñšê™EÙÒ}<‹¢d_õðîª×jý�PmÆUöÁ*ã'g'�C [¶<æú=¿$OFž¹qê;áÝãÔW‰ š³×§wßërØVÒWTï?Ùv&Úa°µ4ê'é@Ä3 `ê› GÏXk�!¼Ýù%§f)pÀ' bîµä¬<ªKKÈú•@vÏÆé~„ç~„ç~„çÎ/Â#<÷ÃÂ:~`€×�ðÜ÷F¾�ðÜ<ÂsuðÜ�ÚF¯î@»`š–›]5»³tÔlý‹ñçøÙ_ ¸õÞfup뙀֮™ñ¾z‰ðÕ1úk‚‹}ýƒÂC�j{DˆÝž×ëü!:zDˆ¾„hŸs�ÄcžåTò¤£™ ¨’UÁ˜¡”ïJY­‘tÄ ”OEY¢‘�Q)Üɉõ%¥ÐOxÉ€{e�ÒÏQàuåË{ ×­†¨F£1<¶è®“e¸ïwð&¥Õ¡Û9Õ€½vÑ«˜JĆO¼êÝ<<Üüˆs%;¿�#¨xÅrßÍyXéŠÌDÈÎÅÊ×0&Ó�™<¥7ï ò–ÿ“MEwPrÔhÎȉmL[ðg˜·2çîEfR;qÌäé[²ø[p›EUwY œ�sqÎ 'oYx°¹]—hžRð}Þİ ´&­,/peFÔ¬Æ|b„1˰¿Ǥ•E¬Áp égÌè€Ýdù•MØfç#ŽÈý}ŠxЇs¾ò6jqQ¡¡ ÝSeCù¾DÚ‡Ñu^ˆo(Õ_Ã3lCÄjäh�©þ�»{[°0cëðƒZè¬B~d`)»Ä¢ 59%ÂïÇãžå;Ûݽ£�£�Îð€…‘n’þS_°•ëuëE ÒrˆZ ƒÝu*®*¡­‡q+èj-ââÑ(;ËŠGÓËS†‚Pïȉº×®ÞÒ÷—;~#…}f䳋wý°ˆDïÅña°¸/‰lvÄ:ÏÌ o,ÙŽlfŠ# ?¼b/óùŠM=&/$BnÂå²È| Õ§ò;,©û“š]öeCM_#=!än ×�u&Â=š,‹¸µÔš²ƒËétBëÂ>Õ9•Ò)/ÍV¨/sÔågAÂ8!Â0cy{¹–umW—uú´†:×U6ƒ'¯_¿n°)Á{âzš`ßaëÅ¡#Ú�nöôÀîâmF›-]Ñ ñ]ëù% Þ¾©dÇ�ÉÐ3µŒŽX�Wn›$ûd›ÿQ0ßbóÙ¸A+æ1L«]‚ôŽnÿÁM4Ó�'”¾�¸‚as,ÊE(g€Öé_¾‚@åPyáV3"~j±¦úþ‡îááùêg7p"~n˜6_ôx�G¦(à zÿ‹¿¬Q/ì†Å»¶á(ûŽû~ -]ŸÙhæÁ;QÇv‚Æ:N3`­>Òâì=aaþð÷Æ›Zp¿‰½Š!c.0Í¢F>ŸãÏß¼X³~Í…ÿÜÙ„•Ù ý}»ïeüâIç �ô$hx¹NÁ²Ã1ì~âzRd‡œ &䕯 6ñÉ`ÆÊÍÕønA¨±”k‰5qÀW°¥’”ü«¹´HßtIì�ÑêÖ&."îQ9H�O4¨îou·�»µ\™3YæíñÞšºjtœ wM×ÌíØ‹0–ËÐ*[ˆ–½º.Ÿa¹H—ÊÓ'ù&³” § ™ßv+�QéËÊTÉ�Ñ“|ò/¿¼gÉu˜S0ÿ¶Áè^·q§8*‡LÄá±ã¦|€šna� ’w“[*?r¾¢ø6ø¶Þ0R,êLàs§ZT[¶G�Ó“é�ÌùÉ\B/xø½OÀ²‡Oêz�K°T]«>3³�0 ‚¥ÊóÓhZ÷4¡eoߎ¸wMî�¾å$kCs[3ÒÝ’NÚ·‡º9ïk>|;sÁ)š¹KܦòwÔI0¤W¾XÌ-ŸxÁ�O ŠÌÑ��ã,O>½Bïó̪¥…†KÈ£Ã2�ü¹D¯T๑¦0¿+’禺ì:¿x° ,úx?žÃ�çð%‘Çsøñ~<‡—;�h Ÿ®ÊW¬{ïøu�o¼_/ÁG`e¡˜JãNü›gˆ0Ìr›$ÐѼŠ5óvé+nªR8 Ý¡©àÌ9B3­ëìY.ÙeaV…á™t7mú$Èè$ó÷'O´Oª4·hWSŽ‚4]=ñýT-E¡•’"©�7²²ÄóÔ\Ì—k óž0D8 ³+gWçhÔWnÙê>“'Q·ÉмÂr+ˆŸ“ÓaS¢‰èç.šå9&pØÛùÐÞ(›F.[1UO#Ì ïV¨º_䊡dT'Ö‰•ÓCç(¹216.kX4ÎN.Ñ«&‡ñÿEåWðô:_ÁÒÓi£�µl¦| �žZ|lÞä‘F׈´Œ�S»J80Rq Z0©±ïu=öÜËWâå´]q9ÏûZ¥å*‰ ®¬,¬²˜"H=»”Gõ ·ê¥–Ð Rª0ƒõ0f�5-5½Ànù4:³_¡oÞÌ­Aˆù`/¦!= QVA®\<ÁšG5·eù�F2ÂáÆb8Ç{pD9`.¤9_ jàìñ;åþwÁíIžFÕb¨XÐ--º×ŒË¦7ÿf`z£5%BG¢£ÊåX7Î26Ž~ç!nUˆçÿo÷Ñ€•ÆÁÜ’è%­òQZ—V 8²Dš„o³àÃN÷çà[\ýo³š»Mð£9çœð”¥«0Á.4u¸³ù~çÿu·} >}„ã”ç1ˆ¾Þmhüˆ�‹EðQéÕ�­˜3â(Rœ'˜…ß° ©·³_w¸|€H&{+[Iß(8�Ëðs�8P£é¯pۇùla~ é”}ˆ�»Ihm…ÞY8 "4RÑjy~zGrtäD%ñ ˜]P&ÿp^Xâ±Õê�ªâ5eàÄCŠV‰3z¶ÜA«…âð7c9$JˆÈï›I(ŠÎQ�Ï {\Æýd(ä|§Ï‚R—Öe+_˜³ß11ý&e¶bÚVÞö”4^¤Çb)Ej$ýÝâŽ[úo½ãzG‡ dɨèg$x`ë‚ÀkE*€©`�ü/fC�™¨vO`1åÁÙ”�\ÆoŽ`MM¸BÁ¾!l´¤H¡Ÿ+‹�- „™Ý Q�|IJd21;[X›î¼Ý#CÎ1ÆçV£E ÝÜ(#IÉø.%ßôi¹#_§h˜+T_DmùY×U•œ"ª–ÊØˆÖH*½eœA¾öìÀ¿’&ì‚T«]Ú$9F<„è-ÿIà<�¬FEì7õóËš;fårÏY"�Bo ôT}L4@ÛÁÜ^‚2ô&ÇMϵnpzü�Ðcíy„mÁ[8oA'Ÿ¸¨�Y ×,ù ô0¦5F¢ bÅ iùÑ´°dœ$ÞüX�Á“Ýú„éþb]xmíïíìwsIÎ=]yÛªcgõÁïõéñø,õö¼ —E'‰“™€ªÈ�X›prÙðæ­Š½� çD [¨ù7¬Óf°MN|gá=ŸEK [ë£-ÌÖÏ˰mصK1ç^®­Ë`0,§ùMÊr÷y; †ÒGÃ,â@çF€-�øŒ‰¿+,@‹Î»Ìz=[‹‰çAä ã¿Í« _¦¢UϵÞK•™¢…ƒ…ÎmÝN¨ÕuSPsm¹»ˆ ÀKDXÀþ§…ÝV··J¶¶�í{Óï¡OëôÜ-SzyÁŸ\gÅcËSz¹Ûoi’ž?1] ì 5Ã{Üãż~좑ªïdQÃ{v3 ÛŠt8«×n¯ûRàMÒøüô@�\´.ÞSÍà7ĺÆŸxÓÅ6Þq½º4ù5„Ë÷î‡uiO>MXÝy°ß.ÏÝø{Û3t8JWhŽâIzþ:Ø‘0ºªŒÁªè'ÃßYº ž„Ÿ¶–�zT~+ËtêÖþù믣_Mÿõß”Qÿs^ ìð`ù¢Ka&#[®ÓX5#¶Ï„­ÍËÊ}¦ µd‡'ãæ|öU°°g®8<Ò¸YÊ­cîÞÑ®;Ï «^Rc.}œôJÒDn—$ÃXÇ ˜åÅ c£qOSÌO<剾ïÙ›GÖf쟵»¸Ãú¦ýôEð#_�`*ùë‹ØÝxÕ¨6asÉê8J/Aà^·º2O.ww{.åò…¦íÆvk´7g{�ë¶_¥ôëô.§wDÌh]Èk¿OÃ!AN‡C85uO®TC7%9¶ÞÅŽ—ºVdl/uiß;Ø¿³å�å. °û(¬*Ȳ²É†m,ÔkÉ¿ãуþ%ç&#–(üÄx­'“ä„�‹:ÙÕšŒ—[û®&Z8îáñÃ)›Œ³ZÂ{­°DnS»#œ0Ú)FI‚xlDJ²¹sëÇîÖOز™ˆ‡$�Vº6UUo³+s­o¸Úü9¡�A1hRnê¹ÎíÄ«—ß¶¿ýÁ ­*•éU“Õ$¹T· EA‹f*ϯ�=NwF U*¶ ‹êšyu_¨æ,<¶@T|S6ã •cÚ ž¾zsú•a”ñw¡¢ð³VA)\Øk'w¿Xº#Â^À,â™P¼d4ˆ1mô ”¶ä:l¢Q[V@ái’ µW] ô›NÜ�8}G~¯4™fh¥žº}Ë/—A2 ×Ñ\=ÃB oÆ‚¥jÂîdLÎ8îax™B�uÝ3ò)LàÇ2G5ã(0N»+Ì×»{ôÖáþÏÁûî‡î{˜Ë­ãÃ�£�¦}²¬© 'ÐÂíÎa'^¿bÎòCUÎR‘q<«dMZB–@T¨ «p¡U �êQ'¨*ªé³q©ÒÙ£¸ÿ(îßA=ŠõË£º;"¹ûº—%·¨|üG”„ç!…uú$È’–ˆi[¦KœþÜ‚wAç‹ {¬zÞ˜NÇÐN6m¡1�Ýé÷›2‹+À¨úÁf¯´¶‘|K’>f ¾Bë F4ÆÛÒK�jr$ÜÛMÍl'¹š}`õíÍÎë±Ò [ádâÉ›'7àŒ÷©[vöqÈXìùŠ,ð�ýÃîλ½  Mö2ä¢>'²|ñ+YœKÈ›:§T,KíìmwñVÔ“E$6°’_~ùE T•¥pÈïÕ¼ºÓ¬¥0 +²±¸¨z—†XPžíÛ+ò7ÓI£(ÿRå­G2рݧ¸t`äUÆ“( ‡uë݆t¾5^uN_úÍ“Ç7Dݬò¤Y¹ ÍþèÕ¾™tG.Y/ÁÄ¥ñéTæQT¯Ð”Bƒ*U�ÞrtÇö”Îjõƒh#›žÊVŠo©5!¡o-ì%™‡Fwß$-ªD¢ËÝrû6òø½Jpå%,½—Ãýas;XV î¦6câMì qÏNk öIOn““�m&â"g’PE¥B¯­¢ 2·öfU6ún» ÑU_;«s7ä,/:±1€!]Ób<<ìzH3êžðO8œD7–I…*9º¹„ƒÄ€øëE¼:úƒ�€¬“¿ˆ'pÂ’Ë[S6IÕ( v>]68=‚ª �ÏMqÁ|ŽÇ0FæIBue^þ~ç§®ªœö'Ü!<Ε´׊$Qô9{{Ú§81Hž¦IJ®y ´í½ÿÈÞy”Ë–HA Æ /<…‰hõ {OýºŒ&ɨäÇÓx’†é�1ÛfŸœ'f×hêñ*#él§ ´xÉMA�vöº|˜KÙ˜×,&\ÊL¦5¢n’s=ôÜH¿ÂÓù)ŠÆœy%Ì™]9ÎÈQõF“‹$:ôcLÑyŽ{¨b±ãQdΔCQˆLâÑDbÇ÷“4EG]ݬÙêAš\ÅZ´�RT-s¬a%çS,@œâõ..RÎÙϰqÙwö�6a³qVš�É”Æ7ŠyÑvô,“kŒË“Oxªy]í5EÒgŠ$¸Pf«@dΩ’:öåUðV �^+9%ò]#ªþÛƒ’’þ�Á  ¢Þ´›�W�YÎòÖ>©"ë¸ES…NÔü®¡øß'ÚR`#j/šVBeßõ:¹y«{¹ ’Ôlª&.¨äA$V¢Wo7æðGž­ót tžŽ¡ó<_–ÎÓQJ�U5¡Û*>…2ŠÐTL’îmýØÝÝôYLݦè�!ûAÈ¢¾I�¡S†,ÚÌ‹˜l#’Â%P‹ª(Î!°-µ­¼à¦3€æèD2h™eÎ�hÇ¢„,Ž>C�‘'ÀÌ ?a,YNïᏱlâ5L‰Õˆ›wÅK;$]2+t¬ÌM³Ø?sùãñ$\ ´”K92ž:z’T“&-xÍÎ�Å*A�ž�Jƒñ˜Á¦îš%�Ûök¾¾MM©FcTeCÞÀ¡*�zE÷—�°aÑB»p|ÑPÔ ÍJ2‚ꂟ᫠ϡ¹4.ûÐöÙYÓN�¿�¢— ¤<¨?i»Õb¼2Þ¤Éb&—�£!90S§zÜhÜÁ΢µ‚I6î–$¿¤hêöP§c‘Ó8Ý>@pi´Jå¡Å½ÅBYÌÀòÚÏ ™Á¯¢�ƒxCð­i)Tºy1_aÿÝ®�/-|y6´»k=GŸ€rßßËã¸�rI+ófACŠëó¤Ø`7·õmn‰¬\þ¶RÚ­"Ÿ”l[÷kE[«bUÒÓàC!3uk?f®.¼ŽÆ�fÉ]p^7–FkÝýë¥'~hÎ…aÂ.¹/žyT”\ Ù ÔÝÁïªA“î;-…ÕæŒ`fƱûèÄvÍöþîæNéÖP¢rɾ0k©@ü¹* åûÕ ë*d!ÝÂórŽá DUî¹ð9LZÖ�t—‚šÊ îlÉȃÚåÿjõÏ¥¨k”¶y‰c9êîüU|Ԁߩ¬s4I©­Oo*ã­²sâÇ6<ý*TF¹ÀÉeˆ¹xØ~EéД5O¼j[1×a°“æ)úW]ù,Ûª(�‚ÆOU ‚t �¾Ä+�Ó(¸ ÚOÑ®>ŒDbiƳù e_šܧUëÑìôhvšÓìdÝ1å?ÅF©/Þ�ÉÊßâ¢v¬e[¬îÖä„x°¡£o^½Ät@á`ÀÇ A‡R×1£J2EWFe›Qב³mN9Y�ÓØjs ýºúh[²mKÉŠ4—G@Eû .ä¯eØs Ì]X[¾ôùü€Œ!s>J—|錇²R_�=áXJIdmý¡Á×®Zßµí×y¾YäW¯\L:~“N‘cýŽL²{JÚ8zöí%ÁæÖ{‰ç PµÔywñ‘š¨à‰ï#bœ‘í!eëÁ6¯A6ûm•µÝÁ쾜µW6‚…Ò_Yªå¿*nŠ[2sóî|èéaŠÅdWœ9Ý.Ÿ‚à‹›¼ÌÞ§té€vCˆ!w ḺJ9‰W›&sï‰I��Š$ É7aËÒÎ$� ø¶�•h·‹ë+íð®\UÅu1&“ÀÉGªÔÄ�#U u‡e.è]ˆ…dçd¿à"HŽ¿XÒ¬qh¼Dq‡ ý*”5{xª`%'ØØ *[U^ã²UÇqÜrà5 ì®Üs Gì¼5ŸÇµÁ8ךHœ²Ûˆü¡ûnÇrÀu‘ÙÆ·A€k‡ÿªÐ_`ê7TØ €G¡¿yÁU€ \8ã;¼ªòßê¼”ü@§¥´+PÅ­¬±Æ>ód¦ô Íì¹è‰óÂi…Ÿ‚é˜Sõ�§!üc£?"ˆAã4¾ÊšÁåÍàôd:å*ÚH¯�¿§ïCFdPð$`gé¤ÎM5oÔšm�Ì!x3Ç«óÕ$�õªKí{Ý, e®7K[Íϸ(†”#†ˆR³´ ù]²êÅ(FE]tm¯˜’?Åï?Њ{^¡Ê}Žú©gA4J¦ç�\Ù 'à a3oZN=:WŸRÜî¾Ý<~à²ó¾û®Û#Î~¸/CÅh„¹­²C#xwˆžÐ›°Å� Iݵ‡ñ«ºæH1ÜTÁùñ ™¡c jO³hBéõQê¿FžG'irržŽÝ¬Àxy8sèNÆÀå1¼Ãî[`m{[Ý^38:Üy÷*Þa`4Ö½2�3¼¡fÄC¢ç¥0УEÉABšúß�¬½½nY¦Ä!û=E¿ûßÔ( Ñ´läiâM``¸£3Hήf5WÏjiEùì#9)ÍÊ‹ŸYѰ¢¥|fëòÈX£õ¥%¶ž™Úº8ÃnÑlåöкû’)úš«Ý–¥(Ä•Ì?L[Ž£iù˳Ö•^J¬…Ø$_â¡P _—?>v{—wOzl°LômGnI/æŠ1N)ƒ´ªrJñ”sËù˜Î"l§`ÓÏË;d«ÿå[½êf/ØîU6üò·|…M_¾í‹6þÌ­oU[²ýý”öe9_öMéŸ8ÄŒí 7AbmØù”2ã*La˜7\€Ì3¾ù.gb…;Ê®—¥Ó9VÓj5Ç ZU!‹š¸âΙ];>ØÞ<êzºV¸q¬÷M ¨fPVYÁH+Í@5ö›Yèm¾[Ú$×5çÜÿ•÷ªx\ýÑ(û×ÇãÊ×±yŽ«,bü®À/ û!/ŠF7ó|›ïœ*=«œ]•eè!Ç'M §ß80H N´fð[Ÿìâ¯^K@ÁÉfñ×°5ë㔄Õ’ÎЊ\¼Ú!ëßQ… |ÎӺ˜«�à Œ¹ðÀ�wÌÅ-:æÁ]�y–g)c.®hÑ1OîjÌÂî´„A—Ô´è¨�înÔÇ{[ˡﲪæ÷‘È´íqÙvÖ»5V.A${4w©ª™pNákÙVÅ;“Õf Xtùûhª¬nªDïêfÊ?¾¡rÆhõÀe›-ïI­«f].sΟ7ÅF¯…ì>¹Ù¶æç �\á‘+|�ËŒ‡-›U7GÃãg>pÖò†õuåò$Tí Uèø}\j¶C”ðÊùKúHå]cìQç<7ìÂ!m£-ÂßµK®|°¢§@ç¶Þ~+xzµÜÖ‚¸$áÂfÚÛ'ÏÛ€‡oýˆbrG²óZîØ ,EâMtN2¡g‰ÝÆ#6I£«8™f¾ƒ(c×Óà:F÷%B²U€mÖ{ž`Í _‡©™Dn.>&i -‡C‘Àñ"ĬŽWÉ'  3‘¥‡–µØC8@gOøEÁàÕ¶ôÀ5KÀ['¯`5ïæ•2çæ~:õ/nŒq `ñ0‡¢¨\ÃIØïsü®J]GÇb8 ��†èÙ—‰?„ÛlÓBî€ù×?•¿‡ççu$zJbÔ”nª´ „w*ðRÑ›ºJžžªÁ_7‚­ãÃÃîÞÑÉq„t6”N¼Ú·¶"(jãéé0]2cx]»Ùi®5Ÿ5Ÿ7_kyÞºÇÕ™þÏ/ïÛÿ¹f˜fÆ-$âõu }¹äÐu}VYÆRâTÚJ#ü�C1¸è‰ŒÈ˜BµÙ $ŒÇÖá~¯çó#&Ï0,™æKÖ?l¾?†m[w£�&üÉjk òì×q°åéžæ ­R¡©4úl µÓO@ãbìHšñ0:�ê)ç�ø^�Ÿ|fnšå´ÅM©ÑŠñwÖÖeêcLC€™òA½É@ L_<½ÈØœr 5L£pp‚ÛY-�Ÿ\§1L™ú­![T.Óª¦f19Ò;…þÔEÉs O0!H£Æ¹WAQøÆ2µ[@Q”�Óî!-6yõCzƽ„3†bd˜iJ7½¬ˆ©Êç_}uo|ÕHî·(ÛDÞ†U/„N%æ®ÆÊ£ßËù8GS“•Êë-y¸¸Ò$&nðsqëçãç2PÎ ¯\_O£sã[5–>q *Jy¯ Ä<÷�qúûæ»sóPÓ‚U– {0ÔÁ)‚@ò˜Œs3dQQUnì ¬X|›Í‡qãÿok9|˜º¿t^,ƒ@Šxñ[ùüûƒ4éGƒiê°å×wÀ–e?yœ/ÚÌ…A×B‹ÚI?�Ť›Ñåxr°¡”\ ¥è°œðj=¸ ?E¢``DHbÆw æˆTBdóØŽ©aN@·ÎÄDÕ‘RàíôFx•Òüœ…ÃŒS¥ÓÈJ÷ª{_¯]DK1üŸŠóÍ—¡íÍá ï›ó²nª�ÁŠ]¥"T‹S²½B�5¸`Öó¦ù´äí<í´Ÿ<]{òô9gyÇUŒiÖž¯#BEÝ�>÷#«µ=K€*šäæØã$J3ÒÚÎѱV1ä ‡›Àú0˜BÆÊxv‘L‡b¾ä‡{žL@©%€…–îž}(K~`ÄU>�%£ƒÉx†§Sc¹y䆯ý»^¯¯þóß«ÿúnµñŸ:þñÏ·þõ]£ñk«ñ?Dp&Ïz¥ƒ'Ò8ÙŸ<ñî”Äâ¬Ê‘.Ná3+×Åb§pÁ�+W>àž‰ô'b•j†¢—¤¨öåÎþJ%:~I Í-ÑM²&3,>ñåÀ˜<Óh2MG˜«ç<#lÍýã#i¤êéf·ö7ßw{[Ý:ÎÔt�¢6ª¨Ä,µÆ:_ÕžŸÃ04Cí�&l'΀…ó);D5å™ ã‘€R1®Âá426Êm¥át¬`ÈÄn¬Œ ù6 ‡‹‘å3+Ú³Lú˜|E�‹G€®2‹üÆ"ò¶34QS]&GÂhtEÔôF<¹9�uŸ„E�ó¾ÐK Â�Å?™o“=lgPw‚ƒTÑ�dÓI1x—«ß?ø ­V›¢Õæªèôªw–ˆï�ã€IÇå{)åü3” ÈM8SÁeÌ�°UŽo÷½ê‹fg%JÇÙ M!>�`ô±Û«¹er5¦ÎOE/ΟúözqíÊ(f•”9ì¼JV�pª Íw‡ûÇd�rÉ¿¸”î £Y�2ïÇ’%©·ªæ”/ÿðU§ÅS¡,&'/ž ÅUò�M.ÕØ†“B§ œêÓa˜*tSÌœ�Œá'ÆÇºDÕÏÔùôaý<¸L®$t×)èK¼é‡I2F4cNÃ|£-y‡—?ŠÑÆÒÿt‚HªÙ$¼×õ-Ó‚L`݇äz¢SƒÀot馸—ÚÞYñÂWêˆr£$8Þ>¢U7ÏY�4«0 庳r9•ø9߃[½ÚÙÞÙ|··ßƒ¦zêípÿç“­ýã½£7úuåµ£±éôPšA{MOœ�Ê´?U°ÿ¡{x¸³�dÖûØ;êîdUò.7 ²…`~+ Dù»6<>�ùÆÊ+5ßõŒI@?·›TºáN¢ë3±@ 6aˆÎ"û”°Ók‚yr¯·"•Ð –³¬6…íP�ÆIÿ‚Ù_ÝÇ|²Ý/�à4Oã~½ýª¹Ö° ½„&,îûÌ]›º}à–`sÇtОŠ@ßdL®rDîéÐ~Ê#,ÉfæN[Ö§†pôÆ7%kØ'¬m{ù:ˆÿ‰•$[ùx;Ñj˜8xH¯CËBÞD³ ¿˜Á¯Œ—Ì øáHY$´2ê,Ÿ uÖÿ$,Hþ‹ªñÑ&pLêSùS�ñZ�~Üé­…8RŽwÿ¶Û­k�/[{ï;V�¸„ñüͬªÆ ­Ãù6ƒ¹ÙÕó;o¬Álí|”ÏÃÁ �²Œôãï'—ãïiÏ 5 ¾×Ȇ‚T;»;èxºÙCùͯ9>}t¸³k°hqGfÊ^aQnÃã KÒ†5�5¦Ãd³6“Ú°`>Þ¢ÌÛÛ^ýbî´;º vߥ6ç¬å¹U‰ÅÙyrº¿luØ¡­9Húd_vR,ò¼Ú3f“«©zT¾~ì~ °¦ðâÖkŒ’ë™Lñòj!»ÓCåd0#hþÄáù(É&q?#"ÞýôÓ(D<2âž�þr_è$‚^ ×Iú©„Ãëð&S×Áí¥[ :ÏKdaR“ŠeUçЊ”Íš ³ar4¯hô¢d[ÞÉÆœckVð!Ž®›øÚ·bgÇOpoŠÏÿÌÜ¢37bÛÁ؉ú‹yWd¾S°/å-Ó<Ó•OW+<›õÓ” GȯÄLÔšý‘cÖWX1Ü#×@„á-¢¹ÈÌyFwH_ýˆx ¨7P„â)“ÞnÁ.EÈlpÆ'쮟ѶŠGÁöæÑ& 4µk,Ä“ <›DÖE ÖŽÕq7Ô×üEˆe²ß–]EòÈ`˽Z²%Øî±çŸÊ¯’§âÁ„i†*g�&O–ÅZUmž ßål·â`8;4 ÌæZ¡èpÑUbi0ª' j2ŠäÂõ4“–íZ�ç8)4su6ÛMd§…˜¥Ñ—E‘—eQyÎTÓÌåítV׌Õn-¯Ñvçéº^&éáÌQ‹©A1ûÞ™gz5çÙŠEve„U…¦JU›bªqI&'.²•}*T¤ˆEK¯¾Yˆ?+ÎBÛ<Ü\šY½†ÃpÏKq‡kð5feȬN£!-\%`æ‰ô ï|D­+ÊÎÒ®(¡↻‰ÊÀ„H/0DRùÍ3˧íjzx ,;�Ãaü‚٘ћ­`Ä1¤oÆ€„×õM‡]-R|Ö2Ѳ¨ÃP;¾�?CC¼0k/ ó‚~oiÑ$&E‰={ò´ý¤óÌè� ‘öK£¬ ûØ´üÃ&§ÿ0�—¯ ½ŠÑJT¨ æ-~…Å/¡¸QÞ“›7k<”ž7(æ”á™L4�³+ò±ƒmm8�-§ùœ'›Õ~Î�ÍîB‘ÿ7dºGŸ–¢å z_Á;�#lÌá.TšŸ˜à?’Ntûº|YoQD˜ü`ò�˜Ëéÿ\½÷;í)6cÛæ¶jÓSÓ€ªNhZÏ=U*å\¼zw�E‚¦óç©"Èg/Z‚æ(³�¬‡^<º Ö*I@(¢�Ö©¶Á^þ»Ü- �G"Óoá_g¹ì[ŽûÏ[ûBmÕ  ‚z‡„z¶Nö�(·>æ¡táCéW2ã¼° ÆäÎÔ;ÚÜú©»m¹5ÁºÄá9ºôïv{˜ìä¨ûËQSüNÎJïNTƒ'ÛÝ£Í�÷òñšûøG 5ù�ò¹óÝí“Þÿ¾§Ðùð9¿¹µ¿g6÷­O<7L�W'iDÔSÆ£hCì/~™Œ AMýòLþ¢ ™~¦‡2©®x‹ “H)B#'N2�Œ§ëE{YEݘ²GÌ›þsMÿùLÿù\ÿùbCì¬BM¼šb5�jêÔl™Ðœ&�Žíp%µìJÒŒ^¦Ú³Î˧Ï|0Æ=~Œ†c‘mƒÌ>‹¿”jÅÁ7º³3¡Tì!PçM2å\@a€yaŸlwßîuÀ9<œ^ŽhŸ—qvNú-8¥Ó› �ŽFÛfîv‡Æßî§ÃØ…„V –ÚtpÂ\h6¤ ò·¿û‡”wN·öë¨f#îàO<­L€bŠÉ*¼‡€Ü7ð�¼Jþ& R‹5¹ŠF{ù¡ªï+ž¿„›¤<Ä)Ô#HΜC.Ñ/øŽ‘ŒŽœÉåxÞƒD|r~ÉxÑ·íf_²Ã`‹{Ù; opÄõpÇŒ¢kþc[;{Òùru"vnSLNSwÕ=-­ï£èZÇzX”âÑ<é6'�“UMËm²û–Êr+=K8+¡ä9æ¢P’’U/ P™ûÀ 7>=Ó¾ôGÞðaÿýæp C8%ƒöS¼t�áñe€JL&d“3ÕYØçÿÒ\ƒìŸÿMôç=¼IØÆ@¶ hfo°ÊÃî‡ýŸº­c$F7ÓŸU¬ž·#õèÍÊÿ; ø!îdjango-pgschemas-1.2.0/django_pgschemas/contrib/000077500000000000000000000000001512245224300216605ustar00rootroot00000000000000django-pgschemas-1.2.0/django_pgschemas/contrib/__init__.py000066400000000000000000000000001512245224300237570ustar00rootroot00000000000000django-pgschemas-1.2.0/django_pgschemas/contrib/cache.py000066400000000000000000000010211512245224300232670ustar00rootroot00000000000000from typing import Any from django_pgschemas.schema import get_current_schema def make_key(key: str, key_prefix: str, version: Any) -> str: """ Tenant aware function to generate a cache key. """ current_schema = get_current_schema() return "%s:%s:%s:%s" % (current_schema.schema_name, key_prefix, version, key) def reverse_key(key: str) -> str: """ Tenant aware function to reverse a cache key. Required for django-redis REVERSE_KEY_FUNCTION setting. """ return key.split(":", 3)[3] django-pgschemas-1.2.0/django_pgschemas/contrib/channels.py000066400000000000000000000124661512245224300240360ustar00rootroot00000000000000from typing import Any, Awaitable, Callable, cast from channels.db import database_sync_to_async from channels.middleware import BaseMiddleware from channels.routing import URLRouter from django.conf import settings from django.db.models import Q from django.urls import URLResolver, path from django.utils.encoding import force_bytes, force_str from django.utils.module_loading import import_string from django_pgschemas.models import TenantModel as TenantModelBase from django_pgschemas.routing.info import DomainInfo, HeadersInfo from django_pgschemas.routing.urlresolvers import get_ws_urlconf_from_schema from django_pgschemas.schema import Schema from django_pgschemas.settings import get_tenant_header from django_pgschemas.utils import get_domain_model, get_tenant_model, remove_www def TenantURLRouter() -> Callable[[dict[str, Any], Any, Any], Awaitable[None]]: async def router(scope: dict[str, Any], receive: Any, send: Any) -> None: schema: Schema | None = scope.get("tenant") routes = [] if schema is not None: ws_urlconf = get_ws_urlconf_from_schema(schema) if schema else None if ws_urlconf: routes = import_string(ws_urlconf + ".urlpatterns") match (routes, schema.routing): case ([URLResolver()], DomainInfo(_, folder)) if folder: routes = [path(f"{folder}/", URLRouter(routes[0].url_patterns))] case _: pass _router = URLRouter(routes) return await _router(scope, receive, send) return router class BaseRoutingMiddleware(BaseMiddleware): @database_sync_to_async def get_scope_tenant(self, scope: dict[str, Any]) -> Schema | None: raise NotImplementedError async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> Any: scope = dict(scope) scope["tenant"] = await self.get_scope_tenant(scope) return await super().__call__(scope, receive, send) class DomainRoutingMiddleware(BaseRoutingMiddleware): @database_sync_to_async def get_scope_tenant(self, scope: dict[str, Any]) -> Schema | None: hostname = force_str(dict(scope["headers"]).get(b"host", b"")) hostname = remove_www(hostname.split(":")[0]) tenant: Schema | None = None # Checking for static tenants for schema, data in settings.TENANTS.items(): if schema in ["public", "default"]: continue if hostname in data.get("DOMAINS", []): tenant = Schema.create( schema_name=schema, routing=DomainInfo(domain=hostname), ) break # Checking for dynamic tenants else: DomainModel = get_domain_model() prefix = scope["path"].split("/")[1] domain = None if DomainModel is not None: try: domain = DomainModel.objects.select_related("tenant").get( domain=hostname, folder=prefix ) except DomainModel.DoesNotExist: try: domain = DomainModel.objects.select_related("tenant").get( domain=hostname, folder="" ) except DomainModel.DoesNotExist: pass if domain is not None: tenant = cast(TenantModelBase, domain.tenant) tenant.routing = DomainInfo(domain=hostname) if prefix and domain.folder == prefix: tenant.routing = DomainInfo(domain=hostname, folder=prefix) # Checking fallback domains if not tenant: for schema, data in settings.TENANTS.items(): if schema in ["public", "default"]: continue if hostname in data.get("FALLBACK_DOMAINS", []): tenant = Schema.create( schema_name=schema, routing=DomainInfo(domain=hostname), ) break return tenant class HeadersRoutingMiddleware(BaseRoutingMiddleware): @database_sync_to_async def get_scope_tenant(self, scope: dict[str, Any]) -> Schema | None: tenant_header = get_tenant_header() tenant_ref = force_str(dict(scope["headers"]).get(force_bytes(tenant_header), b"")) if not tenant_ref: return None tenant: Schema | None = None # Checking for static tenants for schema, data in settings.TENANTS.items(): if schema in ["public", "default"]: continue if tenant_ref == schema or tenant_ref == data.get("HEADER"): tenant = Schema.create( schema_name=schema, routing=HeadersInfo(reference=tenant_ref) ) break # Checking for dynamic tenants else: if (TenantModel := get_tenant_model()) is not None: tenant = TenantModel._default_manager.filter( Q(pk__iexact=tenant_ref) | Q(schema_name=tenant_ref) ).first() if tenant is not None: tenant.routing = HeadersInfo(reference=tenant_ref) return tenant django-pgschemas-1.2.0/django_pgschemas/contrib/storage.py000066400000000000000000000043251512245224300237020ustar00rootroot00000000000000import os from django.core.files.storage import FileSystemStorage from django_pgschemas.routing.info import DomainInfo from django_pgschemas.schema import get_current_schema from django_pgschemas.settings import get_pathname_function class TenantFileSystemStorage(FileSystemStorage): """ Tenant aware file system storage. Appends the tenant identifier to the base location and base URL. """ def get_schema_path_identifier(self) -> str: schema = get_current_schema() if schema is None: return "" path_identifier = schema.schema_name if hasattr(schema, "schema_pathname"): path_identifier = schema.schema_pathname() elif pathname_function := get_pathname_function(): path_identifier = pathname_function(schema) return path_identifier @property # To avoid caching of tenant def base_location(self) -> str: """ Appends base location with the schema path identifier. """ file_folder = self.get_schema_path_identifier() location = os.path.join(super().base_location, file_folder) if not location.endswith("/"): location += "/" return location @property # To avoid caching of tenant def location(self) -> str: return super().location @property # To avoid caching of tenant def base_url(self) -> str: """ Optionally appends base URL with the schema path identifier. If the current schema is already using a folder, no path identifier is appended. """ schema = get_current_schema() url_folder = self.get_schema_path_identifier() # Specific case of domain+folder routing if ( url_folder and schema and isinstance(schema.routing, DomainInfo) and schema.routing.folder ): # Since we're already prepending all URLs with schema, there is no # need to make the differentiation here url_folder = "" parent_base_url = super().base_url.strip("/") url = "/".join(["", parent_base_url, url_folder]) if not url.endswith("/"): url += "/" return url django-pgschemas-1.2.0/django_pgschemas/log.py000066400000000000000000000014711512245224300213560ustar00rootroot00000000000000import logging from typing import Any from django_pgschemas.routing.info import DomainInfo, HeadersInfo, SessionInfo from django_pgschemas.schema import get_current_schema class SchemaContextFilter(logging.Filter): """ Add the current routing info to log records. """ def filter(self, record: Any) -> bool: current_schema = get_current_schema() record.schema_name = current_schema.schema_name match current_schema.routing: case DomainInfo(domain, folder): record.domain = domain record.folder = folder case SessionInfo(reference): record.reference = reference case HeadersInfo(reference): record.reference = reference case _: pass return True django-pgschemas-1.2.0/django_pgschemas/management/000077500000000000000000000000001512245224300223345ustar00rootroot00000000000000django-pgschemas-1.2.0/django_pgschemas/management/__init__.py000066400000000000000000000000001512245224300244330ustar00rootroot00000000000000django-pgschemas-1.2.0/django_pgschemas/management/commands/000077500000000000000000000000001512245224300241355ustar00rootroot00000000000000django-pgschemas-1.2.0/django_pgschemas/management/commands/__init__.py000066400000000000000000000271621512245224300262560ustar00rootroot00000000000000import enum from typing import Any, Callable, Self from django.conf import settings from django.core.management.base import BaseCommand, CommandError, CommandParser from django.db.models import Case, CharField, Q, Value, When from django.db.models.functions import Concat from django.db.utils import ProgrammingError from django_pgschemas.management.commands._executors import parallel, sequential from django_pgschemas.schema import Schema, get_current_schema from django_pgschemas.utils import ( create_schema, dynamic_models_exist, get_clone_reference, get_domain_model, get_tenant_model, ) class CommandScope(enum.Enum): ALL = "all" DYNAMIC = "dynamic" STATIC = "static" @classmethod def allow_static(cls) -> list[Self]: return [cls.ALL, cls.STATIC] @classmethod def allow_dynamic(cls) -> list[Self]: return [cls.ALL, cls.DYNAMIC] EXECUTORS = { "sequential": sequential, "parallel": parallel, } class WrappedSchemaOption: scope = CommandScope.ALL specific_schemas = None allow_interactive = True allow_wildcards = True def add_arguments(self, parser: CommandParser) -> None: if self.allow_interactive: parser.add_argument( "--noinput", "--no-input", action="store_false", dest="interactive", help="Tells Django to NOT prompt the user for input of any kind.", ) parser.add_argument( "-s", "--schema", nargs="+", dest="schemas", help="Schema(s) to execute the current command", ) parser.add_argument( "-x", "--exclude-schema", nargs="+", dest="excluded_schemas", help="Schema(s) to exclude when executing the current command", ) if self.allow_wildcards: parser.add_argument( "-as", "--include-all-schemas", action="store_true", dest="all_schemas", help="Include all schemas when executing the current command", ) parser.add_argument( "-ss", "--include-static-schemas", action="store_true", dest="static_schemas", help="Include all static schemas when executing the current command", ) parser.add_argument( "-ds", "--include-dynamic-schemas", action="store_true", dest="dynamic_schemas", help="Include all dynamic schemas when executing the current command", ) parser.add_argument( "-ts", "--include-tenant-schemas", action="store_true", dest="tenant_schemas", help="Include all tenant-like schemas when executing the current command", ) parser.add_argument( "--parallel", dest="parallel", action="store_true", help="Run command in parallel mode", ) parser.add_argument( "--no-create-schemas", dest="skip_schema_creation", action="store_true", help="Skip automatic creation of non-existing schemas", ) def get_schemas_from_options(self, **options: Any) -> list[str]: skip_schema_creation = options.get("skip_schema_creation", False) try: schemas = self._get_schemas_from_options(**options) except ProgrammingError: # This happens with unmigrated database. # It can also happen when the tenant model contains unapplied migrations that break. raise CommandError( "Error while attempting to retrieve dynamic schemas. " "Perhaps you need to migrate the 'public' schema first?" ) if self.specific_schemas is not None: schemas = [x for x in schemas if x in self.specific_schemas] if not schemas: raise CommandError("This command can only run in %s" % self.specific_schemas) if not skip_schema_creation: for schema in schemas: create_schema(schema, check_if_exists=True, sync_schema=False, verbosity=0) return schemas def get_executor_from_options(self, **options: Any) -> Callable[..., list[str]]: return EXECUTORS["parallel"] if options.get("parallel") else EXECUTORS["sequential"] def get_scope_display(self) -> str: return "|".join(self.specific_schemas or []) or self.scope.value def _get_schemas_from_options(self, **options: Any) -> list[str]: schemas = options.get("schemas") or [] excluded_schemas = options.get("excluded_schemas") or [] include_all_schemas = options.get("all_schemas") or False include_static_schemas = options.get("static_schemas") or False include_dynamic_schemas = options.get("dynamic_schemas") or False include_tenant_schemas = options.get("tenant_schemas") or False dynamic_ready = dynamic_models_exist() allow_static = self.scope in CommandScope.allow_static() allow_dynamic = self.scope in CommandScope.allow_dynamic() clone_reference = get_clone_reference() if ( not schemas and not include_all_schemas and not include_static_schemas and not include_dynamic_schemas and not include_tenant_schemas ): if not self.allow_interactive: include_all_schemas = True elif options.get("interactive", True): schema = input( "Enter schema to run command (leave blank for running on '%s' schemas): " % self.get_scope_display() ).strip() if schema: schemas.append(schema) else: include_all_schemas = True else: raise CommandError("No schema provided") TenantModel = get_tenant_model() has_domains = get_domain_model() is not None static_schemas = ( [x for x in settings.TENANTS.keys() if x != "default"] if allow_static else [] ) dynamic_schemas = ( TenantModel.objects.values_list("schema_name", flat=True) if TenantModel is not None and dynamic_ready and allow_dynamic else [] ) if clone_reference and allow_static: static_schemas.append(clone_reference) schemas_to_return: set[str] = set() if include_all_schemas: if not allow_static and not allow_dynamic: raise CommandError("Including all schemas is NOT allowed") schemas_to_return = schemas_to_return.union(static_schemas + list(dynamic_schemas)) if include_static_schemas: if not allow_static: raise CommandError("Including static schemas is NOT allowed") schemas_to_return = schemas_to_return.union(static_schemas) if include_dynamic_schemas: if not allow_dynamic: raise CommandError("Including dynamic schemas is NOT allowed") schemas_to_return = schemas_to_return.union(dynamic_schemas) if include_tenant_schemas: if not allow_dynamic: raise CommandError("Including tenant-like schemas is NOT allowed") schemas_to_return = schemas_to_return.union(dynamic_schemas) if clone_reference: schemas_to_return.add(clone_reference) def find_schema_by_reference(reference: str, as_excluded: bool = False) -> str: if reference in settings.TENANTS and reference != "default" and allow_static: return reference elif reference == clone_reference: return reference elif ( TenantModel is not None and dynamic_ready and TenantModel.objects.filter(schema_name=reference).exists() and allow_dynamic ): return reference else: local = [] if allow_static: local += [ schema_name for schema_name, data in settings.TENANTS.items() if schema_name not in ["public", "default"] and any(x for x in data.get("DOMAINS", []) if x.startswith(reference)) ] if TenantModel is not None and dynamic_ready and allow_dynamic: local += ( TenantModel.objects.annotate( route=Case( When( domains__folder="", then="domains__domain", ), default=Concat( "domains__domain", Value("/"), "domains__folder", output_field=CharField(), ), output_field=CharField(), ) if has_domains else Value("") ) .filter(Q(schema_name=reference) | Q(route__startswith=reference)) .distinct() .values_list("schema_name", flat=True) ) if not local: message = ( "No schema found for '%s' (excluded)" if as_excluded else "No schema found for '%s'" ) raise CommandError(message % reference) if len(local) > 1: message = ( "More than one tenant found for schema '%s' by domain (excluded), " "please, narrow down the filter" if as_excluded else "More than one tenant found for schema '%s' by domain, please, narrow down the filter" ) raise CommandError(message % reference) return local[0] for schema in schemas: included = find_schema_by_reference(schema, as_excluded=False) schemas_to_return.add(included) for schema in excluded_schemas: excluded = find_schema_by_reference(schema, as_excluded=True) schemas_to_return -= {excluded} return ( list(schemas_to_return) if "public" not in schemas_to_return else ["public"] + list(schemas_to_return - {"public"}) ) class SchemaCommand(WrappedSchemaOption, BaseCommand): def handle(self, *args: Any, **options: Any) -> None: schemas = self.get_schemas_from_options(**options) executor = self.get_executor_from_options(**options) executor(schemas, self, "_raw_handle_schema", args, options, pass_schema_in_kwargs=True) def _raw_handle_schema(self, *args: Any, **kwargs: Any) -> None: kwargs.pop("schema_name") self.handle_schema(get_current_schema(), *args, **kwargs) def handle_schema(self, schema: Schema, *args: Any, **options: Any) -> None: raise NotImplementedError class StaticSchemaCommand(SchemaCommand): scope = CommandScope.STATIC class DynamicSchemaCommand(SchemaCommand): scope = CommandScope.DYNAMIC django-pgschemas-1.2.0/django_pgschemas/management/commands/_executors.py000066400000000000000000000117511512245224300266740ustar00rootroot00000000000000import functools from concurrent.futures import ThreadPoolExecutor, as_completed from typing import Any from django.conf import settings from django.core.management import call_command from django.core.management.base import BaseCommand, CommandError, OutputWrapper from django.db.utils import ProgrammingError from django_pgschemas.routing.info import DomainInfo from django_pgschemas.routing.models import get_primary_domain_for_tenant from django_pgschemas.schema import Schema, activate from django_pgschemas.utils import get_clone_reference, get_tenant_model def run_on_schema( schema_name: str, executor_codename: str, command: BaseCommand | type[BaseCommand], function_name: str | None = None, args: list[Any] | None = None, kwargs: dict[str, Any] | None = None, pass_schema_in_kwargs: bool = False, ) -> str: if args is None: args = [] if kwargs is None: kwargs = {} if not isinstance(command, BaseCommand): # Parallel executor needs to pass command 'type' instead of 'instance' # Therefore, no customizations for the command can be done, nor using custom stdout, stderr command = command() command.stdout = kwargs.pop("stdout", command.stdout) if not isinstance(command.stdout, OutputWrapper): command.stdout = OutputWrapper(command.stdout) command.stderr = kwargs.pop("stderr", command.stderr) if not isinstance(command.stderr, OutputWrapper): command.stderr = OutputWrapper(command.stderr) # Since we are prepending every output with the schema_name and executor, we need to determine # whether we need to do so based on the last ending used to write. If the last write didn't end # in '\n' then we don't do the prefixing in order to keep the output looking good. class StyleFunc: last_message: str | None = None def __call__(self, message: str) -> str: last_message = self.last_message self.last_message = message if last_message is None or last_message.endswith("\n"): return "[%s:%s] %s" % ( command.style.NOTICE(executor_codename), command.style.NOTICE(schema_name), message, ) return message command.stdout.style_func = StyleFunc() command.stderr.style_func = StyleFunc() if schema_name in settings.TENANTS: domains = settings.TENANTS[schema_name].get("DOMAINS", []) schema = Schema.create( schema_name=schema_name, routing=DomainInfo(domain=domains[0]) if domains else None, ) elif schema_name == get_clone_reference(): schema = Schema.create(schema_name=schema_name) elif (TenantModel := get_tenant_model()) is not None: try: schema = TenantModel.objects.get(schema_name=schema_name) if (domain := get_primary_domain_for_tenant(schema)) is not None: schema.routing = DomainInfo(domain=domain.domain, folder=domain.folder) except ProgrammingError: schema = Schema.create(schema_name=schema_name) else: raise CommandError(f"Unable to find schema {schema_name}!") if pass_schema_in_kwargs: kwargs.update({"schema_name": schema_name}) activate(schema) if function_name == "special:call_command": call_command(command, *args, **kwargs) elif function_name == "special:run_from_argv": command.run_from_argv(args) elif function_name is not None: getattr(command, function_name)(*args, **kwargs) else: raise CommandError("function_name must be provided") return schema_name def sequential( schemas: list[str], command: BaseCommand | type[BaseCommand], function_name: str, args: list[Any] | None = None, kwargs: dict[str, Any] | None = None, pass_schema_in_kwargs: bool = False, ) -> list[str]: runner = functools.partial( run_on_schema, executor_codename="sequential", command=command, function_name=function_name, args=args, kwargs=kwargs, pass_schema_in_kwargs=pass_schema_in_kwargs, ) for schema in schemas: runner(schema) return schemas def parallel( schemas: list[str], command: BaseCommand | type[BaseCommand], function_name: str, args: list[Any] | None = None, kwargs: dict[str, Any] | None = None, pass_schema_in_kwargs: bool = False, ) -> list[str]: processes = getattr(settings, "PGSCHEMAS_PARALLEL_MAX_PROCESSES", None) runner = functools.partial( run_on_schema, executor_codename="parallel", command=type(command), # Can't pass streams to children processes function_name=function_name, args=args, kwargs=kwargs, pass_schema_in_kwargs=pass_schema_in_kwargs, ) with ThreadPoolExecutor(max_workers=processes) as executor: results = {executor.submit(runner, schema) for schema in schemas} as_completed(results) return schemas django-pgschemas-1.2.0/django_pgschemas/management/commands/cloneschema.py000066400000000000000000000136151512245224300267760ustar00rootroot00000000000000from typing import Any from django.core.checks import Tags, run_checks from django.core.management.base import BaseCommand, CommandError, CommandParser from django.db.models import Model from django_pgschemas.utils import clone_schema, get_domain_model, get_tenant_model def strtobool(val: str) -> int: val = val.lower() if val in ("y", "yes", "t", "true", "on", "1"): return 1 elif val in ("n", "no", "f", "false", "off", "0"): return 0 else: raise ValueError(f"invalid truth value {val!r}") class Command(BaseCommand): help = "Clones a schema" def _run_checks(self, **kwargs: Any) -> list[Any]: # pragma: no cover issues = run_checks(tags=[Tags.database]) issues.extend(super()._run_checks(**kwargs)) return issues def add_arguments(self, parser: CommandParser) -> None: super().add_arguments(parser) parser.add_argument( "source", help="The name of the schema you want to clone", ) parser.add_argument( "destination", help="The name of the schema you want to create as clone", ) parser.add_argument( "--noinput", "--no-input", action="store_false", dest="interactive", help="Tells Django to NOT prompt the user for input of any kind.", ) parser.add_argument( "--dry-run", dest="dry_run", action="store_true", help="Just show what clone would do; without actually cloning.", ) def _ask(self, question: str) -> int: answer = None while answer is None: try: raw_answer = input(f"{question.strip()} [Y/n] ").strip() or "y" answer = strtobool(raw_answer) except ValueError: self.stderr.write(f"{raw_answer} is not a valid answer.") pass return answer def _check_required_field(self, field: Any, exclude: list[str] | None = None) -> bool: if exclude is None: exclude = [] return ( field.editable and not field.primary_key and not field.is_relation and not ( field.null or field.has_default() or (field.blank and field.empty_strings_allowed) or getattr(field, "auto_now", False) or getattr(field, "auto_now_add", False) ) and field.name not in exclude ) def _get_constructed_instance(self, model_class: type[Model], data: dict[str, Any]) -> Model: fields = [ field for field in model_class._meta.fields if self._check_required_field(field, list(data.keys())) ] instance = model_class(**data) if fields: self.stdout.write( self.style.WARNING(f"We need some data for model '{model_class._meta.model_name}':") ) for field in fields: while field.name not in data: raw_value = input(f"Value for field '{field.name}': ") try: data[field.name] = field.clean(raw_value, None) instance = model_class(**data) instance.clean() except Exception as e: if hasattr(e, "message"): self.stderr.write(e.message) # noqa elif hasattr(e, "messages"): self.stderr.write(" ".join(e.messages)) # noqa else: self.stderr.write(e) data.pop(field.name, None) return instance def get_dynamic_tenant(self, **options: Any) -> tuple[Model | None, Model | None]: tenant = None domain = None if self._ask( "You are cloning a schema for a dynamic tenant. Would you like to create a database entry for it?" ): TenantModel = get_tenant_model() DomainModel = get_domain_model() if TenantModel is not None: tenant = self._get_constructed_instance( TenantModel, {"schema_name": options["destination"]} ) if DomainModel is not None: domain = self._get_constructed_instance(DomainModel, {"is_primary": True}) if options["verbosity"] >= 1: self.stdout.write(self.style.WARNING("Looks good! Let's get to it!")) return tenant, domain def handle(self, *args: Any, **options: Any) -> None: tenant = None domain = None dry_run = options.get("dry_run") if options.get("interactive", True): TenantModel = get_tenant_model() if ( TenantModel is not None and TenantModel.objects.filter(schema_name=options["source"]).exists() ): tenant, domain = self.get_dynamic_tenant(**options) try: clone_schema(options["source"], options["destination"], dry_run) if tenant and domain: if options["verbosity"] >= 1: self.stdout.write("Schema cloned.") if not dry_run: tenant.save() domain.tenant = tenant if not dry_run: domain.save() if options["verbosity"] >= 1: self.stdout.write("Tenant and domain successfully saved.") if options["verbosity"] >= 1: self.stdout.write("All done!") except Exception as e: if hasattr(e, "message"): raise CommandError(e.message) # noqa elif hasattr(e, "messages"): raise CommandError(" ".join(e.messages)) # noqa else: raise CommandError(e) django-pgschemas-1.2.0/django_pgschemas/management/commands/createrefschema.py000066400000000000000000000035071512245224300276350ustar00rootroot00000000000000from typing import Any from django.core.checks import Tags, run_checks from django.core.management.base import BaseCommand, CommandError, CommandParser from django_pgschemas.utils import create_schema, drop_schema, get_clone_reference class Command(BaseCommand): help = "Creates the reference schema for faster dynamic tenant creation" def _run_checks(self, **kwargs: Any) -> list[Any]: # pragma: no cover issues = run_checks(tags=[Tags.database]) issues.extend(super()._run_checks(**kwargs)) return issues def add_arguments(self, parser: CommandParser) -> None: super().add_arguments(parser) parser.add_argument( "--recreate", action="store_true", dest="recreate", help="Recreate reference schema.", ) def handle(self, *args: Any, **options: Any) -> None: clone_reference = get_clone_reference() if not clone_reference: raise CommandError("There is no reference schema configured.") if options.get("recreate", False): drop_schema(clone_reference, check_if_exists=True, verbosity=options["verbosity"]) if options["verbosity"] >= 1: self.stdout.write("Destroyed existing reference schema.") created = create_schema( clone_reference, check_if_exists=True, verbosity=options["verbosity"] ) if options["verbosity"] >= 1: if created: self.stdout.write("Reference schema successfully created!") else: self.stdout.write("Reference schema already exists.") self.stdout.write( self.style.WARNING( "Run this command again with --recreate if you want to recreate the reference schema." ) ) django-pgschemas-1.2.0/django_pgschemas/management/commands/migrate.py000066400000000000000000000001201512245224300261300ustar00rootroot00000000000000from .migrateschema import MigrateSchemaCommand Command = MigrateSchemaCommand django-pgschemas-1.2.0/django_pgschemas/management/commands/migrateschema.py000066400000000000000000000024711512245224300273240ustar00rootroot00000000000000from typing import Any from django.core.checks import Tags, run_checks from django.core.management.base import BaseCommand, CommandParser from django.core.management.commands.migrate import Command as MigrateCommand from django.db.migrations.autodetector import MigrationAutodetector from . import WrappedSchemaOption from .runschema import Command as RunSchemaCommand class NonInteractiveRunSchemaCommand(RunSchemaCommand): allow_interactive = False class MigrateSchemaCommand(WrappedSchemaOption, BaseCommand): autodetector = MigrationAutodetector allow_interactive = False requires_system_checks: list[str] = [] def _run_checks(self, **kwargs: Any) -> list[Any]: # pragma: no cover issues = run_checks(tags=[Tags.database]) issues.extend(super()._run_checks(**kwargs)) return issues def add_arguments(self, parser: CommandParser) -> None: super().add_arguments(parser) MigrateCommand.add_arguments(self, parser) def handle(self, *args: Any, **options: Any) -> None: runschema = NonInteractiveRunSchemaCommand() options.pop("run_syncdb", False) if "skip_checks" not in options: options["skip_checks"] = True runschema.execute(command_name="django.core.migrate", *args, **options) Command = MigrateSchemaCommand django-pgschemas-1.2.0/django_pgschemas/management/commands/runschema.py000066400000000000000000000066561512245224300265110ustar00rootroot00000000000000import argparse import sys from typing import Any from django.core.management import get_commands, load_command_class from django.core.management.base import BaseCommand, CommandError, CommandParser, SystemCheckError from . import WrappedSchemaOption class Command(WrappedSchemaOption, BaseCommand): help = "Wrapper around Django commands for use with an individual schema" def add_arguments(self, parser: CommandParser) -> None: super().add_arguments(parser) parser.add_argument("command_name", help="The command name you want to run") def get_command_from_arg(self, arg: str) -> BaseCommand: *chunks, command = arg.split(".") path = ".".join(chunks) if not path: path = get_commands().get(command) try: cmd = load_command_class(path, command) except Exception: raise CommandError("Unknown command: %s" % arg) if isinstance(cmd, WrappedSchemaOption): raise CommandError("Command '%s' cannot be used in runschema" % arg) return cmd def run_from_argv(self, argv: list[str]) -> None: # pragma: no cover """ Changes the option_list to use the options from the wrapped command. Adds schema parameter to specify which schema will be used when executing the wrapped command. """ try: # load the command object. if len(argv) <= 2: raise CommandError("No command to run") target_class = self.get_command_from_arg(argv[2]) # Ugly, but works. Delete command_name from the argv, parse the schemas manually # and forward the rest of the arguments to the actual command being wrapped. del argv[1] schema_parser = argparse.ArgumentParser() super().add_arguments(schema_parser) schema_ns, args = schema_parser.parse_known_args(argv) schemas = self.get_schemas_from_options( schemas=schema_ns.schemas, all_schemas=schema_ns.all_schemas, static_schemas=schema_ns.static_schemas, dynamic_schemas=schema_ns.dynamic_schemas, tenant_schemas=schema_ns.tenant_schemas, ) executor = self.get_executor_from_options(parallel=schema_ns.parallel) except Exception as e: if not isinstance(e, CommandError): raise # SystemCheckError takes care of its own formatting. if isinstance(e, SystemCheckError): self.stderr.write(str(e), lambda x: x) else: self.stderr.write("%s: %s" % (e.__class__.__name__, e)) sys.exit(1) executor(schemas, target_class, "special:run_from_argv", args) def handle(self, *args: Any, **options: Any) -> None: target = self.get_command_from_arg(options.pop("command_name")) schemas = self.get_schemas_from_options(**options) executor = self.get_executor_from_options(**options) options.pop("schemas") options.pop("excluded_schemas") options.pop("all_schemas") options.pop("static_schemas") options.pop("dynamic_schemas") options.pop("tenant_schemas") options.pop("parallel") options.pop("skip_schema_creation") if self.allow_interactive: options.pop("interactive") executor(schemas, target, "special:call_command", args, options) django-pgschemas-1.2.0/django_pgschemas/management/commands/whowill.py000066400000000000000000000006541512245224300262010ustar00rootroot00000000000000from typing import Any from django_pgschemas.schema import Schema from . import SchemaCommand class Command(SchemaCommand): help = "Displays which schemas would be used based on the passed schema selectors" def handle_schema(self, schema: Schema, *args: Any, **options: Any) -> None: if options["verbosity"] >= 1: self.stdout.write(str(schema.routing) if schema.routing else schema.schema_name) django-pgschemas-1.2.0/django_pgschemas/models.py000066400000000000000000000073061512245224300220630ustar00rootroot00000000000000from django.db import models from django_pgschemas.postgresql.base import check_schema_name from django_pgschemas.schema import Schema from django_pgschemas.signals import ( dynamic_tenant_needs_sync, dynamic_tenant_post_sync, dynamic_tenant_pre_drop, ) from django_pgschemas.utils import ( create_or_clone_schema, drop_schema, schema_exists, ) class TenantModel(Schema, models.Model): """ All tenant models must inherit this class. """ auto_create_schema = True """ Set this flag to `False` on a parent class if you don't want the schema to be automatically created upon save. """ auto_drop_schema = False """ *USE THIS WITH CAUTION!* Set this flag to `True` on a parent class if you want the schema to be automatically deleted if the tenant row gets deleted. """ is_dynamic = True """ Leave this as `True`. Denotes it's a database controlled tenant. """ schema_name = models.CharField(max_length=63, unique=True, validators=[check_schema_name]) class Meta: abstract = True def save( self, force_insert: bool = False, force_update: bool = False, using: str | None = None, update_fields: list[str] | None = None, verbosity: int = 1, ) -> None: is_new = self.pk is None super().save( force_insert=force_insert, force_update=force_update, using=using, update_fields=update_fields, ) if is_new and self.auto_create_schema: try: self.create_schema(verbosity=verbosity) dynamic_tenant_post_sync.send(sender=TenantModel, tenant=self.serializable_fields()) except Exception: # We failed creating the tenant, delete what we created and re-raise the exception self.delete(force_drop=True) raise elif is_new: # Although we are not using the schema functions directly, the signal might be registered by a listener dynamic_tenant_needs_sync.send(sender=TenantModel, tenant=self.serializable_fields()) elif not is_new and self.auto_create_schema and not schema_exists(self.schema_name): # Create schemas for existing models, deleting only the schema on failure try: self.create_schema(verbosity=verbosity) dynamic_tenant_post_sync.send(sender=TenantModel, tenant=self.serializable_fields()) except Exception: # We failed creating the schema, delete what we created and re-raise the exception self.drop_schema() raise def delete( self, using: str | None = None, keep_parents: bool = False, force_drop: bool = False ) -> None: """ Deletes this row. Drops the tenant's schema if the attribute `auto_drop_schema` is `True`. """ if force_drop or self.auto_drop_schema: dynamic_tenant_pre_drop.send(sender=TenantModel, tenant=self.serializable_fields()) self.drop_schema() super().delete(using, keep_parents) def serializable_fields(self) -> "TenantModel": """ In certain cases the model isn't serializable so you may want to only send the id. """ return self def create_schema(self, sync_schema: bool = True, verbosity: int = 1) -> bool: """ Creates or clones the schema `schema_name` for this tenant. """ return create_or_clone_schema(self.schema_name, sync_schema, verbosity) def drop_schema(self) -> bool: """ Drops the schema. """ return drop_schema(self.schema_name) django-pgschemas-1.2.0/django_pgschemas/postgresql/000077500000000000000000000000001512245224300224235ustar00rootroot00000000000000django-pgschemas-1.2.0/django_pgschemas/postgresql/__init__.py000066400000000000000000000000001512245224300245220ustar00rootroot00000000000000django-pgschemas-1.2.0/django_pgschemas/postgresql/_constraints.py000066400000000000000000000106721512245224300255110ustar00rootroot00000000000000from typing import Any from django.db.models.indexes import Index from django_pgschemas.schema import get_current_schema def get_constraints(self: Any, cursor: Any, table_name: str) -> dict[str, dict[str, Any]]: """ Retrieve any constraints or keys (unique, pk, fk, check, index) across one or more columns. Also retrieve the definition of expression-based indexes. """ constraints = {} # Loop over the key table, collecting things as constraints. The column # array must return column names in the same order in which they were # created. # The subquery containing generate_series can be replaced with # "WITH ORDINALITY" when support for Postgres 9.3 is dropped. cursor.execute( """ SELECT c.conname, array( SELECT attname FROM ( SELECT unnest(c.conkey) AS colid, generate_series(1, array_length(c.conkey, 1)) AS arridx ) AS cols JOIN pg_attribute AS ca ON cols.colid = ca.attnum WHERE ca.attrelid = c.conrelid ORDER BY cols.arridx ), c.contype, (SELECT fkc.relname || '.' || fka.attname FROM pg_attribute AS fka JOIN pg_class AS fkc ON fka.attrelid = fkc.oid WHERE fka.attrelid = c.confrelid AND fka.attnum = c.confkey[1]), cl.reloptions FROM pg_constraint AS c JOIN pg_class AS cl ON c.conrelid = cl.oid JOIN pg_namespace AS ns ON cl.relnamespace = ns.oid WHERE ns.nspname = %s AND cl.relname = %s """, [get_current_schema().schema_name, table_name], ) for constraint, columns, kind, used_cols, options in cursor.fetchall(): constraints[constraint] = { "columns": columns, "primary_key": kind == "p", "unique": kind in ["p", "u"], "foreign_key": tuple(used_cols.split(".", 1)) if kind == "f" else None, "check": kind == "c", "index": False, "definition": None, "options": options, } # Now get indexes # The row_number() function for ordering the index fields can be # replaced by WITH ORDINALITY in the unnest() functions when support # for Postgres 9.3 is dropped. cursor.execute( """ SELECT indexname, array_agg(attname ORDER BY rnum), indisunique, indisprimary, array_agg(ordering ORDER BY rnum), amname, exprdef, s2.attoptions FROM ( SELECT row_number() OVER () as rnum, c2.relname as indexname, idx.*, attr.attname, am.amname, CASE WHEN idx.indexprs IS NOT NULL THEN pg_get_indexdef(idx.indexrelid) END AS exprdef, CASE am.amname WHEN 'btree' THEN CASE (option & 1) WHEN 1 THEN 'DESC' ELSE 'ASC' END END as ordering, c2.reloptions as attoptions FROM ( SELECT *, unnest(i.indkey) as key, unnest(i.indoption) as option FROM pg_index i ) idx LEFT JOIN pg_class c ON idx.indrelid = c.oid LEFT JOIN pg_namespace n ON n.oid = c.relnamespace LEFT JOIN pg_class c2 ON idx.indexrelid = c2.oid LEFT JOIN pg_am am ON c2.relam = am.oid LEFT JOIN pg_attribute attr ON attr.attrelid = c.oid AND attr.attnum = idx.key WHERE c.relname = %s and n.nspname = %s ) s2 GROUP BY indexname, indisunique, indisprimary, amname, exprdef, attoptions; """, [table_name, get_current_schema().schema_name], ) for index, columns, unique, primary, orders, type_, definition, options in cursor.fetchall(): if index not in constraints: constraints[index] = { "columns": columns if columns != [None] else [], "orders": orders if orders != [None] else [], "primary_key": primary, "unique": unique, "foreign_key": None, "check": False, "index": True, "type": Index.suffix if type_ == "btree" else type_, "definition": definition, "options": options, } return constraints django-pgschemas-1.2.0/django_pgschemas/postgresql/base.py000066400000000000000000000062171512245224300237150ustar00rootroot00000000000000from typing import Any from django.core.exceptions import ImproperlyConfigured from django.db.utils import DatabaseError from django.utils.asyncio import async_unsafe from django_pgschemas.schema import Schema, get_current_schema, get_default_schema from django_pgschemas.settings import ( get_base_backend_module, get_extra_search_paths, get_original_backend_module, ) from django_pgschemas.utils import check_schema_name, get_limit_set_calls from .introspection import DatabaseSchemaIntrospection try: try: import psycopg as _psycopg except ImportError: import psycopg2 as _psycopg # type: ignore[no-redef] except ImportError: raise ImproperlyConfigured("Error loading psycopg2 or psycopg module") try: module = get_original_backend_module("base") except AttributeError: module = get_base_backend_module("base") def get_search_path(schema: Schema | None = None) -> str: if schema is None: schema = get_default_schema() search_path = ["public"] if schema.schema_name == "public" else [schema.schema_name, "public"] search_path.extend(get_extra_search_paths()) for part in search_path: check_schema_name(part) return ", ".join(search_path) class DatabaseWrapper(module.DatabaseWrapper): # type: ignore[name-defined] def __init__(self, *args: Any, **kwargs: Any) -> None: self._search_path: str | None = None self._setting_search_path = False super().__init__(*args, **kwargs) # Patched version of DatabaseIntrospection that only returns the table list for the currently selected schema self.introspection = DatabaseSchemaIntrospection(self) @async_unsafe def close(self) -> None: self._search_path = None self._setting_search_path = False super().close() @async_unsafe def rollback(self) -> None: self._search_path = None self._setting_search_path = False super().rollback() def _handle_search_path(self, cursor: Any | None = None) -> None: search_path_for_current_schema = get_search_path(get_current_schema()) skip = self._setting_search_path or ( self._search_path == search_path_for_current_schema and get_limit_set_calls() ) if not skip: self._setting_search_path = True cursor_for_search_path = self.connection.cursor() if cursor is None else cursor try: cursor_for_search_path.execute( f"SET search_path = {search_path_for_current_schema}" ) except (DatabaseError, _psycopg.InternalError): self._search_path = None else: self._search_path = search_path_for_current_schema finally: self._setting_search_path = False if cursor is None: cursor_for_search_path.close() def _cursor(self, name: str | None = None) -> Any: cursor = super()._cursor(name=name) cursor_for_search_path = cursor if name is None else None # Named cursors cannot be reused self._handle_search_path(cursor_for_search_path) return cursor django-pgschemas-1.2.0/django_pgschemas/postgresql/introspection.py000066400000000000000000000142351512245224300257020ustar00rootroot00000000000000from typing import Any from django.db.backends.base.introspection import FieldInfo, TableInfo from django.utils.encoding import force_str from django_pgschemas.schema import get_current_schema from django_pgschemas.settings import get_base_backend_module, get_original_backend_module from . import _constraints try: module = get_original_backend_module("introspection") except AttributeError: module = get_base_backend_module("introspection") class DatabaseSchemaIntrospection(module.DatabaseIntrospection): # type: ignore[name-defined] # pragma: no cover """ database schema introspection class """ _get_indexes_query = """ SELECT attr.attname, idx.indkey, idx.indisunique, idx.indisprimary FROM pg_catalog.pg_class c, INNER JOIN pg_catalog.pg_index idx ON c.oid = idx.indrelid INNER JOIN pg_catalog.pg_class c2 ON idx.indexrelid = c2.oid INNER JOIN pg_catalog.pg_attribute attr ON attr.attrelid = c.oid and attr.attnum = idx.indkey[0] INNER JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace WHERE c.relname = %s AND n.nspname = %s """ def get_table_list(self, cursor: Any) -> list[TableInfo]: """ Returns a list of table names in the current database and schema. """ cursor.execute( """ SELECT c.relname, c.relkind FROM pg_catalog.pg_class c LEFT JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace WHERE c.relkind IN ('r', 'v', '') AND n.nspname = '%s' AND pg_catalog.pg_table_is_visible(c.oid)""" % get_current_schema().schema_name ) return [ TableInfo(row[0], {"r": "t", "v": "v"}.get(row[1])) for row in cursor.fetchall() if row[0] not in self.ignored_tables ] def get_table_description(self, cursor: Any, table_name: str) -> list[FieldInfo]: "Returns a description of the table, with the DB-API cursor.description interface." # As cursor.description does not return reliably the nullable property, # we have to query the information_schema (#7783) cursor.execute( """ SELECT column_name, is_nullable, column_default FROM information_schema.columns WHERE table_schema = %s and table_name = %s""", [get_current_schema().schema_name, table_name], ) field_map = {line[0]: line[1:] for line in cursor.fetchall()} cursor.execute("SELECT * FROM %s LIMIT 1" % self.connection.ops.quote_name(table_name)) return [ FieldInfo( *( (force_str(line[0]),) + line[1:6] + (field_map[force_str(line[0])][0] == "YES", field_map[force_str(line[0])][1]) ) ) for line in cursor.description ] def get_indexes(self, cursor: Any, table_name: str) -> dict[str, dict[str, bool]]: # This query retrieves each index on the given table, including the # first associated field name cursor.execute(self._get_indexes_query, [table_name, get_current_schema().schema_name]) indexes = {} for row in cursor.fetchall(): # row[1] (idx.indkey) is stored in the DB as an array. It comes out as # a string of space-separated integers. This designates the field # indexes (1-based) of the fields that have indexes on the table. # Here, we skip any indexes across multiple fields. if " " in row[1]: continue if row[0] not in indexes: indexes[row[0]] = {"primary_key": False, "unique": False} # It's possible to have the unique and PK constraints in separate indexes. if row[3]: indexes[row[0]]["primary_key"] = True if row[2]: indexes[row[0]]["unique"] = True return indexes def get_relations(self, cursor: Any, table_name: str) -> dict[str, tuple[str, str]]: """ Returns a dictionary of {field_name: (field_name_other_table, other_table)} representing all relationships to the given table. """ cursor.execute( """ SELECT c2.relname, a1.attname, a2.attname FROM pg_constraint con LEFT JOIN pg_class c1 ON con.conrelid = c1.oid LEFT JOIN pg_namespace n ON n.oid = c1.relnamespace LEFT JOIN pg_class c2 ON con.confrelid = c2.oid LEFT JOIN pg_attribute a1 ON c1.oid = a1.attrelid AND a1.attnum = con.conkey[1] LEFT JOIN pg_attribute a2 ON c2.oid = a2.attrelid AND a2.attnum = con.confkey[1] WHERE c1.relname = %s and n.nspname = %s AND con.contype = 'f'""", [table_name, get_current_schema().schema_name], ) relations = {} for row in cursor.fetchall(): relations[row[1]] = (row[2], row[0]) return relations get_constraints = _constraints.get_constraints def get_key_columns(self, cursor: Any, table_name: str) -> list[tuple[str, str, str]]: key_columns: list[tuple[str, str, str]] = [] cursor.execute( """ SELECT kcu.column_name, ccu.table_name AS referenced_table, ccu.column_name AS referenced_column FROM information_schema.constraint_column_usage ccu LEFT JOIN information_schema.key_column_usage kcu ON ccu.constraint_catalog = kcu.constraint_catalog AND ccu.constraint_schema = kcu.constraint_schema AND ccu.constraint_name = kcu.constraint_name LEFT JOIN information_schema.table_constraints tc ON ccu.constraint_catalog = tc.constraint_catalog AND ccu.constraint_schema = tc.constraint_schema AND ccu.constraint_name = tc.constraint_name WHERE kcu.table_name = %s AND tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = %s """, [table_name, get_current_schema().schema_name], ) key_columns.extend(cursor.fetchall()) return key_columns django-pgschemas-1.2.0/django_pgschemas/py.typed000066400000000000000000000000001512245224300217050ustar00rootroot00000000000000django-pgschemas-1.2.0/django_pgschemas/routers.py000066400000000000000000000025171512245224300223020ustar00rootroot00000000000000from typing import Iterable from django.apps import apps from django.conf import settings from django_pgschemas.schema import get_current_schema from django_pgschemas.utils import get_tenant_database_alias class TenantAppsRouter: """ A router to control which applications will be actually migrated depending on the schema. """ def app_in_list(self, app_label: str, app_list: Iterable) -> bool: app_config = apps.get_app_config(app_label) app_config_full_name = f"{app_config.__module__}.{app_config.__class__.__name__}" return (app_config.name in app_list) or (app_config_full_name in app_list) def allow_migrate( self, db: str, app_label: str, model_name: str | None = None, **hints: object ) -> bool | None: current_schema = get_current_schema() if db != get_tenant_database_alias() or current_schema is None: return False app_list = [] if current_schema.schema_name == "public": app_list = settings.TENANTS["public"]["APPS"] elif current_schema.schema_name in settings.TENANTS: app_list = settings.TENANTS[current_schema.schema_name]["APPS"] else: app_list = settings.TENANTS["default"]["APPS"] if not app_list: return None return self.app_in_list(app_label, app_list) django-pgschemas-1.2.0/django_pgschemas/routing/000077500000000000000000000000001512245224300217075ustar00rootroot00000000000000django-pgschemas-1.2.0/django_pgschemas/routing/__init__.py000066400000000000000000000000001512245224300240060ustar00rootroot00000000000000django-pgschemas-1.2.0/django_pgschemas/routing/info.py000066400000000000000000000011441512245224300232140ustar00rootroot00000000000000from dataclasses import dataclass from typing import TypeAlias @dataclass(frozen=True) class DomainInfo: domain: str folder: str | None = None def __str__(self) -> str: return f"{self.domain}/{self.folder}" if self.folder else self.domain @dataclass(frozen=True) class SessionInfo: reference: str def __str__(self) -> str: return f"Session: {self.reference}" @dataclass(frozen=True) class HeadersInfo: reference: str def __str__(self) -> str: return f"Header: {self.reference}" RoutingInfo: TypeAlias = DomainInfo | SessionInfo | HeadersInfo | None django-pgschemas-1.2.0/django_pgschemas/routing/middleware.py000066400000000000000000000154431512245224300244050ustar00rootroot00000000000000import re from typing import Callable, TypeAlias, cast from asgiref.sync import iscoroutinefunction, sync_to_async from django.conf import settings from django.db.models import Q from django.http import Http404, HttpRequest, HttpResponse from django.shortcuts import redirect from django.urls import clear_url_caches, set_urlconf from django.utils.decorators import sync_and_async_middleware from django_pgschemas.models import TenantModel as TenantModelBase from django_pgschemas.routing.info import DomainInfo, HeadersInfo, SessionInfo from django_pgschemas.routing.models import get_primary_domain_for_tenant from django_pgschemas.routing.urlresolvers import get_urlconf_from_schema from django_pgschemas.schema import Schema, activate, activate_public from django_pgschemas.settings import get_tenant_header, get_tenant_session_key from django_pgschemas.utils import get_domain_model, get_tenant_model, remove_www def strip_tenant_from_path_factory(prefix: str) -> Callable[[str], str]: def strip_tenant_from_path(path: str) -> str: return re.sub(r"^/{}/".format(prefix), "/", path) return strip_tenant_from_path ResponseHandler: TypeAlias = Callable[[HttpRequest], HttpResponse] def route_domain(request: HttpRequest) -> HttpResponse | None: hostname = remove_www(request.get_host().split(":")[0]) activate_public() tenant: Schema | None = None # Checking for static tenants for schema, data in settings.TENANTS.items(): if schema in ["public", "default"]: continue if hostname in data.get("DOMAINS", []): tenant = Schema.create( schema_name=schema, routing=DomainInfo(domain=hostname), ) break # Checking for dynamic tenants else: DomainModel = get_domain_model() prefix = request.path.split("/")[1] domain = None if DomainModel is not None: try: domain = DomainModel.objects.select_related("tenant").get( domain=hostname, folder=prefix ) except DomainModel.DoesNotExist: try: domain = DomainModel.objects.select_related("tenant").get( domain=hostname, folder="" ) except DomainModel.DoesNotExist: pass if domain is not None: tenant = cast(TenantModelBase, domain.tenant) tenant.routing = DomainInfo(domain=hostname) request.strip_tenant_from_path = lambda x: x if prefix and domain.folder == prefix: tenant.routing = DomainInfo(domain=hostname, folder=prefix) request.strip_tenant_from_path = strip_tenant_from_path_factory(prefix) clear_url_caches() # Required to remove previous tenant prefix from cache (#8) if domain.redirect_to_primary: primary_domain = get_primary_domain_for_tenant(tenant) if primary_domain: path = request.strip_tenant_from_path(request.path) return redirect(primary_domain.absolute_url(path), permanent=True) # Checking fallback domains if not tenant: for schema, data in settings.TENANTS.items(): if schema in ["public", "default"]: continue if hostname in data.get("FALLBACK_DOMAINS", []): tenant = Schema.create( schema_name=schema, routing=DomainInfo(domain=hostname), ) break # No tenant found from domain / folder if not tenant: raise Http404("No tenant for hostname '%s'" % hostname) urlconf = get_urlconf_from_schema(tenant) request.tenant = tenant request.urlconf = urlconf set_urlconf(urlconf) activate(tenant) return None def route_session(request: HttpRequest) -> HttpResponse | None: tenant_session_key = get_tenant_session_key() if not hasattr(request, "session") or not ( tenant_ref := request.session.get(tenant_session_key) ): return None tenant: Schema | None = None # Checking for static tenants for schema, data in settings.TENANTS.items(): if schema in ["public", "default"]: continue if tenant_ref == schema or tenant_ref == data.get("SESSION_KEY"): tenant = Schema.create(schema_name=schema) break # Checking for dynamic tenants else: if (TenantModel := get_tenant_model()) is not None: tenant = TenantModel._default_manager.filter( Q(pk__iexact=tenant_ref) | Q(schema_name=tenant_ref) ).first() if tenant is not None: tenant.routing = SessionInfo(reference=tenant_ref) request.tenant = tenant activate(tenant) return None def route_headers(request: HttpRequest) -> HttpResponse | None: tenant_header = get_tenant_header() if not (tenant_ref := request.headers.get(tenant_header)): return None tenant: Schema | None = None # Checking for static tenants for schema, data in settings.TENANTS.items(): if schema in ["public", "default"]: continue if tenant_ref == schema or tenant_ref == data.get("HEADER"): tenant = Schema.create(schema_name=schema) break # Checking for dynamic tenants else: if (TenantModel := get_tenant_model()) is not None: tenant = TenantModel._default_manager.filter( Q(pk__iexact=tenant_ref) | Q(schema_name=tenant_ref) ).first() if tenant is not None: tenant.routing = HeadersInfo(reference=tenant_ref) request.tenant = tenant activate(tenant) return None def middleware_factory( handler: Callable[[HttpRequest], HttpResponse | None], ) -> Callable[[ResponseHandler], ResponseHandler]: @sync_and_async_middleware def middleware(get_response: ResponseHandler) -> ResponseHandler: if iscoroutinefunction(get_response): async_base_middleware = sync_to_async(handler) async def sync_middleware(request: HttpRequest) -> HttpResponse | None: if response := await async_base_middleware(request): return response return await get_response(request) return sync_middleware else: def async_middleware(request: HttpRequest) -> HttpResponse | None: if response := handler(request): return response return get_response(request) return async_middleware return middleware DomainRoutingMiddleware = middleware_factory(route_domain) SessionRoutingMiddleware = middleware_factory(route_session) HeadersRoutingMiddleware = middleware_factory(route_headers) django-pgschemas-1.2.0/django_pgschemas/routing/models.py000066400000000000000000000043651512245224300235540ustar00rootroot00000000000000from django.conf import settings from django.db import models, transaction from django_pgschemas.models import TenantModel from django_pgschemas.utils import get_domain_model class DomainModel(models.Model): """ All models that store the domains must inherit this class. """ tenant = ( models.ForeignKey( settings.TENANTS["default"]["TENANT_MODEL"], db_index=True, related_name="domains", on_delete=models.CASCADE, ) if getattr(settings, "TENANTS", {}).get("default") else None ) domain = models.CharField(max_length=253, db_index=True) folder = models.SlugField(max_length=253, blank=True, db_index=True) is_primary = models.BooleanField(default=True) redirect_to_primary = models.BooleanField(default=False) class Meta: abstract = True unique_together = (("domain", "folder"),) def __str__(self) -> str: return "/".join([self.domain, self.folder]) if self.folder else self.domain @transaction.atomic def save(self, *args: object, **kwargs: object) -> None: using = kwargs.get("using") domain_list = self.__class__.objects if using: domain_list = domain_list.using(using) domain_list = domain_list.filter(tenant=self.tenant, is_primary=True).exclude(pk=self.pk) self.is_primary = self.is_primary or (not domain_list.exists()) if self.is_primary: domain_list.update(is_primary=False) if self.redirect_to_primary: self.redirect_to_primary = False super().save(*args, **kwargs) def absolute_url(self, path: str) -> str: """ Constructs an absolute url for this domain / folder and a given path """ parts = [self.domain] if self.folder: parts.append(self.folder) parts.append(path) final_path = "/".join(parts).replace("//", "/") return f"//{final_path}" def get_primary_domain_for_tenant(tenant: TenantModel) -> DomainModel | None: DomainModel = get_domain_model() if DomainModel is None: return None try: return tenant.domains.get(is_primary=True) except DomainModel.DoesNotExist: return None django-pgschemas-1.2.0/django_pgschemas/routing/urlresolvers.py000066400000000000000000000102301512245224300250240ustar00rootroot00000000000000import re import sys from types import ModuleType from typing import Any, Literal from django.conf import settings from django.urls import URLResolver from django_pgschemas.routing.info import DomainInfo, HeadersInfo, SessionInfo from django_pgschemas.schema import Schema, get_current_schema DYNAMIC_URLCONF_SUFFIX = "_dynamically_tenant_prefixed" class TenantPrefixPattern: converters: dict = {} @property def tenant_prefix(self) -> str: current_schema = get_current_schema() return ( f"{current_schema.routing.folder}/" if isinstance(current_schema.routing, DomainInfo) and current_schema.routing.folder else "" ) @property def regex(self) -> re.Pattern: # This is only used by reverse() and cached in _reverse_dict. return re.compile(self.tenant_prefix) def match(self, path: str) -> tuple | None: tenant_prefix = self.tenant_prefix if path.startswith(tenant_prefix): return path[len(tenant_prefix) :], (), {} return None def check(self) -> list: return [] def describe(self) -> str: return f"'{self}'" def __str__(self) -> str: return self.tenant_prefix def get_dynamic_tenant_prefixed_urlconf(urlconf: str, dynamic_path: str) -> ModuleType: """ Generates a new urlconf module with all patterns prefixed with tenant. """ class LazyURLConfModule(ModuleType): def __getattr__(self, attr: str) -> Any: if attr == "urlpatterns": return [URLResolver(TenantPrefixPattern(), urlconf)] return self.__getattribute__(attr) return LazyURLConfModule(dynamic_path) def _get_urlconf_from_schema( schema: Schema, config_key: Literal["URLCONF", "WS_URLCONF"] ) -> str | None: match schema.routing: case DomainInfo(domain, _): # Checking for static tenants if not schema.is_dynamic: for schema_name, data in settings.TENANTS.items(): if schema_name in ["public", "default"]: continue if domain in data.get("DOMAINS", []): return data.get(config_key) if domain in data.get("FALLBACK_DOMAINS", []): return data.get(config_key) return None # Checking for dynamic tenants urlconf = settings.TENANTS.get("default", {}).get(config_key) if urlconf is not None and schema.routing.folder: dynamic_path = urlconf + DYNAMIC_URLCONF_SUFFIX if not sys.modules.get(dynamic_path): sys.modules[dynamic_path] = get_dynamic_tenant_prefixed_urlconf( urlconf, dynamic_path ) urlconf = dynamic_path return urlconf case SessionInfo(reference): # Checking for static tenants if not schema.is_dynamic: for schema_name, data in settings.TENANTS.items(): if schema_name in ["public", "default"]: continue if reference == data.get("SESSION_KEY"): return data.get(config_key) return None # Checking for dynamic tenants return settings.TENANTS.get("default", {}).get(config_key) case HeadersInfo(reference): # Checking for static tenants if not schema.is_dynamic: for schema_name, data in settings.TENANTS.items(): if schema_name in ["public", "default"]: continue if reference == data.get("HEADER"): return data.get(config_key) return None # Checking for dynamic tenants return settings.TENANTS.get("default", {}).get(config_key) case _: return None def get_urlconf_from_schema(schema: Schema) -> str | None: return _get_urlconf_from_schema(schema, "URLCONF") def get_ws_urlconf_from_schema(schema: Schema) -> str | None: return _get_urlconf_from_schema(schema, "WS_URLCONF") django-pgschemas-1.2.0/django_pgschemas/schema.py000066400000000000000000000036541512245224300220420ustar00rootroot00000000000000from contextlib import contextmanager from contextvars import ContextVar, Token from functools import lru_cache from typing import Iterator from django_pgschemas.routing.info import RoutingInfo from django_pgschemas.signals import schema_activate class Schema: schema_name: str routing: RoutingInfo = None is_dynamic = False _context_tokens: list[Token["Schema"] | None] def __init__(self, *args: object, **kwargs: object) -> None: super().__init__(*args, **kwargs) self._context_tokens = [] @staticmethod def create(schema_name: str, routing: RoutingInfo | None = None) -> "Schema": schema = Schema() schema.schema_name = schema_name schema.routing = routing return schema def __enter__(self) -> None: self._context_tokens.append(push(self)) def __exit__(self, *args: object) -> None: if self._context_tokens: token = self._context_tokens.pop() if token is not None: active.reset(token) def shallow_equal(schema1: Schema, schema2: Schema) -> bool: return schema1.schema_name == schema2.schema_name and schema1.routing == schema2.routing @lru_cache def get_default_schema() -> Schema: return Schema.create("public") active: ContextVar["Schema"] = ContextVar("active_schema", default=get_default_schema()) def get_current_schema() -> Schema: return active.get() def push(schema: Schema) -> Token[Schema] | None: if shallow_equal(get_current_schema(), schema): return None token = active.set(schema) schema_activate.send(sender=Schema, schema=schema) return token def activate(schema: Schema) -> None: push(schema) def deactivate() -> None: push(get_default_schema()) activate_public = deactivate @contextmanager def override(schema: Schema) -> Iterator[None]: token = push(schema) yield if token is not None: active.reset(token) django-pgschemas-1.2.0/django_pgschemas/settings.py000066400000000000000000000025311512245224300224330ustar00rootroot00000000000000from importlib import import_module from types import ModuleType from typing import Callable from django.conf import settings from django.db import DEFAULT_DB_ALIAS DEFAULT_BACKEND = "django.db.backends.postgresql" def get_tenant_db_alias() -> str: return getattr(settings, "PGSCHEMAS_TENANT_DB_ALIAS", DEFAULT_DB_ALIAS) def get_limit_set_calls() -> bool: return getattr(settings, "PGSCHEMAS_LIMIT_SET_CALLS", False) def get_original_backend() -> str: return getattr(settings, "PGSCHEMAS_ORIGINAL_BACKEND", DEFAULT_BACKEND) def get_extra_search_paths() -> list[str]: return getattr(settings, "PGSCHEMAS_EXTRA_SEARCH_PATHS", []) def get_tenant_session_key() -> str: return getattr(settings, "PGSCHEMAS_TENANT_SESSION_KEY", "tenant") def get_tenant_header() -> str: return getattr(settings, "PGSCHEMAS_TENANT_HEADER", "tenant") def get_pathname_function() -> Callable | None: return getattr(settings, "PGSCHEMAS_PATHNAME_FUNCTION", None) def get_base_backend_module(submodule: str | None = None) -> ModuleType: module = DEFAULT_BACKEND if submodule: module += f".{submodule}" return import_module(module) def get_original_backend_module(submodule: str | None = None) -> ModuleType: module = get_original_backend() if submodule: module += f".{submodule}" return import_module(module) django-pgschemas-1.2.0/django_pgschemas/signals.py000066400000000000000000000022421512245224300222320ustar00rootroot00000000000000from typing import Any from django.db.models.signals import pre_delete from django.dispatch import Signal, receiver from django_pgschemas.utils import get_tenant_model, schema_exists schema_activate = Signal() schema_activate.__doc__ = "Sent after a schema has been activated" dynamic_tenant_needs_sync = Signal() dynamic_tenant_needs_sync.__doc__ = ( "Sent when a schema from a dynamic tenant needs to have migrations applied" ) dynamic_tenant_post_sync = Signal() dynamic_tenant_post_sync.__doc__ = ( "Sent after a tenant has been saved, its schema created and all migrations applied" ) dynamic_tenant_pre_drop = Signal() dynamic_tenant_pre_drop.__doc__ = "Sent when a schema from a dynamic tenant is about to be dropped" @receiver(pre_delete) def tenant_delete_callback(sender: Any, instance: Any, **kwargs: object) -> None: TenantModel = get_tenant_model() if TenantModel is None: return if not isinstance(instance, TenantModel): return if instance.auto_drop_schema and schema_exists(instance.schema_name): dynamic_tenant_pre_drop.send(sender=TenantModel, tenant=instance.serializable_fields()) instance.drop_schema() django-pgschemas-1.2.0/django_pgschemas/utils.py000066400000000000000000000166701512245224300217440ustar00rootroot00000000000000import gzip import os import re from typing import Any, Callable from django.apps import apps from django.conf import settings from django.core.exceptions import ValidationError from django.core.management import call_command from django.db import DEFAULT_DB_ALIAS, ProgrammingError, connection, transaction from django.db.models import Model from django.utils.encoding import force_str def get_tenant_model(require_ready: bool = True) -> Model | None: "Returns the tenant model." if "default" not in settings.TENANTS: return None return apps.get_model(settings.TENANTS["default"]["TENANT_MODEL"], require_ready=require_ready) def get_domain_model(require_ready: bool = True) -> Model | None: "Returns the domain model." if "default" not in settings.TENANTS or "DOMAIN_MODEL" not in settings.TENANTS["default"]: return None return apps.get_model(settings.TENANTS["default"]["DOMAIN_MODEL"], require_ready=require_ready) def get_tenant_database_alias() -> str: return getattr(settings, "PGSCHEMAS_TENANT_DB_ALIAS", DEFAULT_DB_ALIAS) def get_limit_set_calls() -> bool: return getattr(settings, "PGSCHEMAS_LIMIT_SET_CALLS", False) def get_clone_reference() -> str | None: if "default" not in settings.TENANTS: return None return settings.TENANTS["default"].get("CLONE_REFERENCE", None) def is_valid_identifier(identifier: str) -> bool: "Checks the validity of identifier." SQL_IDENTIFIER_RE = re.compile(r"^[_a-zA-Z][_a-zA-Z0-9]{,62}$") return bool(SQL_IDENTIFIER_RE.match(identifier)) def is_valid_schema_name(name: str) -> bool: "Checks the validity of a schema name." SQL_SCHEMA_NAME_RESERVED_RE = re.compile(r"^pg_", re.IGNORECASE) return is_valid_identifier(name) and not SQL_SCHEMA_NAME_RESERVED_RE.match(name) def check_schema_name(name: str) -> None: """ Checks schema name and raises `ValidationError` if `name` is not a valid identifier. """ if not is_valid_schema_name(name): raise ValidationError("Invalid string used for the schema name.") def remove_www(path: str) -> str: if path.startswith("www."): return path[4:] return path def django_is_in_test_mode() -> bool: """ I know this is very ugly! I'm looking for more elegant solutions. See: http://stackoverflow.com/questions/6957016/detect-django-testing-mode """ from django.core import mail return hasattr(mail, "outbox") def run_in_public_schema(func: Callable) -> Callable: "Decorator that makes decorated function to be run in the public schema." def wrapper(*args: object, **kwargs: object) -> Any: from django_pgschemas.schema import Schema with Schema.create(schema_name="public"): return func(*args, **kwargs) return wrapper def schema_exists(schema_name: str) -> bool: "Checks if a schema exists in database." sql = """ SELECT EXISTS( SELECT 1 FROM pg_catalog.pg_namespace WHERE LOWER(nspname) = LOWER(%s) ) """ with connection.cursor() as cursor: cursor.execute(sql, (schema_name,)) row = cursor.fetchone() if row: exists = row[0] else: # pragma: no cover exists = False return exists @run_in_public_schema def dynamic_models_exist() -> bool: "Checks if tenant model and domain model are ready to be used in the database." sql = """ SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public' AND table_name in (%s); """ TenantModel = get_tenant_model() DomainModel = get_domain_model() models_to_check = [] if TenantModel is not None: models_to_check.append(TenantModel) if DomainModel is not None: models_to_check.append(DomainModel) if not models_to_check: return False template = ", ".join(f"'{model._meta.db_table}'" for model in models_to_check) with connection.cursor() as cursor: cursor.execute(sql % template) value = cursor.fetchone() == (len(models_to_check),) return value @run_in_public_schema def create_schema( schema_name: str, check_if_exists: bool = False, sync_schema: bool = True, verbosity: int = 1, ) -> bool: """ Creates the schema `schema_name`. Optionally checks if the schema already exists before creating it. Returns `True` if the schema was created, `False` otherwise. """ check_schema_name(schema_name) if check_if_exists and schema_exists(schema_name): return False with connection.cursor() as cursor: cursor.execute("CREATE SCHEMA %s" % schema_name) if sync_schema: call_command("migrateschema", schemas=[schema_name], verbosity=verbosity) return True @run_in_public_schema def drop_schema(schema_name: str, check_if_exists: bool = True, verbosity: int = 1) -> bool: """ Drops the schema. Optionally checks if the schema already exists before dropping it. """ if check_if_exists and not schema_exists(schema_name): return False with connection.cursor() as cursor: cursor.execute("DROP SCHEMA %s CASCADE" % schema_name) return True class DryRunException(Exception): pass def _create_clone_schema_function() -> None: """ Creates a postgres function `clone_schema` that copies a schema and its contents. Will replace any existing `clone_schema` functions owned by the `postgres` superuser. """ with gzip.open( os.path.join(os.path.dirname(os.path.abspath(__file__)), "clone_schema.gz") ) as gzip_file: CLONE_SCHEMA_FUNCTION = ( force_str(gzip_file.read()) .replace("RAISE NOTICE ' source schema", "RAISE EXCEPTION ' source schema") .replace("RAISE NOTICE ' dest schema", "RAISE EXCEPTION ' dest schema") ) with connection.cursor() as cursor: cursor.execute(CLONE_SCHEMA_FUNCTION) @run_in_public_schema def clone_schema(base_schema_name: str, new_schema_name: str, dry_run: bool = False) -> None: """ Creates a new schema `new_schema_name` as a clone of an existing schema `base_schema_name`. """ check_schema_name(new_schema_name) cursor = connection.cursor() # check if the clone_schema function already exists in the db try: cursor.execute( "SELECT 'public.clone_schema(text, text, public.cloneparms[])'::regprocedure" ) except ProgrammingError: # pragma: no cover _create_clone_schema_function() transaction.commit() try: with transaction.atomic(): cursor.callproc("clone_schema", [base_schema_name, new_schema_name, "DATA"]) cursor.close() if dry_run: raise DryRunException except DryRunException: cursor.close() def create_or_clone_schema(schema_name: str, sync_schema: bool = True, verbosity: int = 1) -> bool: """ Creates the schema `schema_name`. Optionally checks if the schema already exists before creating it. Returns `True` if the schema was created, `False` otherwise. """ check_schema_name(schema_name) if schema_exists(schema_name): return False clone_reference = get_clone_reference() if ( clone_reference and schema_exists(clone_reference) and not django_is_in_test_mode() ): # pragma: no cover clone_schema(clone_reference, schema_name) return True return create_schema(schema_name, sync_schema=sync_schema, verbosity=verbosity) django-pgschemas-1.2.0/docs/000077500000000000000000000000001512245224300156545ustar00rootroot00000000000000django-pgschemas-1.2.0/docs/advanced.md000066400000000000000000000221651512245224300177510ustar00rootroot00000000000000## Fast dynamic tenant creation Every time a instance of the tenant model is created, by default, the corresponding schema is created and migrations are applied automatically. Depending on the number of migrations you already have in place, or the amount of time these could take, or whether you need to pre-populate the newly created schema with fixtures, this process could take a considerable amount of time. If you need a faster creation of dynamic schemas, you can do so by provisioning a "reference" schema that can cloned into new schemas. ```python title="settings.py" hl_lines="10" TENANTS |= { "default": { "TENANT_MODEL": "tenants.Tenant", "APPS": [ "django.contrib.auth", "django.contrib.sessions", "customers", ], "URLCONF": "customers.urls", "CLONE_REFERENCE": "sample", } } ``` Once you have this in your settings, you need to prepare your reference schema with everything a newly created dynamic schema will need. The first step consists of creating and applying migrations to the reference schema. After that, you can run any command on it or even edit its tables via `shell`. ```bash python manage.py createrefschema python runschema loaddata customers.products -s sample python runschema shell -s sample ``` The `runschema` command is explained in [running management commands](#running-management-commands). You don't need any extra step. As soon as a reference schema is configured, the next time you create an instance of the tenant model, it will clone the reference schema instead of actually creating the schema and applying all migrations. !!! Note The reference schema looks like a dynamic tenant, but it is actually static. It is also non-routable by design. !!! Warning This package relies on [denishpatel/pg-clone-schema](https://github.com/denishpatel/pg-clone-schema/) for the schema cloning functionality. ## Fallback domains If there is only one domain available, and no possibility to use subdomain routing, the URLs for accessing your different tenants might look like this: | URL | Tenant | | ---------------------- | --------- | | `mydomain.com` | Main site | | `mydomain.com/tenant1` | Tenant 1 | | `mydomain.com/tenant2` | Tenant 2 | In this case, due to the order in which domains are tested, it is not possible to put `mydomain.com` as domain for the main tenant without blocking all dynamic schemas from getting routed. When `django_pgschemas.routing.middleware.DomainRoutingMiddleware` is checking which tenant to route from the incoming domain, it checks for static tenants first, then for dynamic tenants. If `mydomain.com` is used for the main tenant (which is static), then URLs like `mydomain.com/tenant1/some/url/` will match the main tenant always. For a case like this, we provide a setting called `FALLBACK_DOMAINS`. If no tenant is found for an incoming combination of domain and subfolder, then, static tenants are checked again for the fallback domains. Something like this would be the proper configuration for the present case: ```python title="settings.py" hl_lines="16 17" TENANTS = { "public": { "APPS": [ "django.contrib.contenttypes", "django.contrib.staticfiles", "django_pgschemas", "tenants", ], }, "main": { "APPS": [ "django.contrib.auth", "django.contrib.sessions", "main", ], "DOMAINS": [], "FALLBACK_DOMAINS": ["mydomain.com"], "URLCONF": "main.urls", }, "default": { "TENANT_MODEL": "tenants.Tenant", "DOMAIN_MODEL": "tenants.Domain", "APPS": [ "django.contrib.auth", "django.contrib.sessions", "customers", ], "URLCONF": "customers.urls", } } ``` This example assumes that dynamic tenants will get their domains set to `mydomain.com` with a tenant specific subfolder, like `tenant1` or `tenant2`. Here, an incoming request for `mydomain.com/tenant1/some/url/` will fail for the main tenant, then match against an existing dynamic tenant. On the other hand, an incoming request for `mydomain.com/some/url/` will fail for all static tenants, then fail for all dynamic tenants, and will finally match against the fallback domains of the main tenant. ## Static tenants only It's also possible to have only static tenants and no dynamic tenants at all. For this, the default key must be omitted altogether: ```python title="settings.py" TENANTS = { "public": { "APPS": [ "django.contrib.contenttypes", "django.contrib.staticfiles", "django_pgschemas", "tenants", ], }, "www": { "APPS": [ "django.contrib.auth", "django.contrib.sessions", "main", ], "DOMAINS": ["mydomain.com"], "URLCONF": "main.urls", }, "blog": { "APPS": [ "django.contrib.auth", "django.contrib.sessions", "blog", ], "DOMAINS": ["blog.mydomain.com", "help.mydomain.com"], "URLCONF": "blog.urls", } } ``` In this case, no model is expected to inherit from `TenantModel` and `DomainModel`, and no clone reference schema can be created. ## Running management commands Since all management commands occur outside the request/response cycle, all commands from Django and any other third party apps are executed by default on the public schema. In order to work around this, we provide a `runschema` command that accepts any other command to be run on one or multiple schemas. A minimal synopsis of the `runschema` command is as follows: ```bash usage: manage.py runschema [-s SCHEMAS [SCHEMAS ...]] [-x EXCLUDED_SCHEMAS [EXCLUDED_SCHEMAS ...]] [-as] [-ss] [-ds] [-ts] [--parallel] [--no-create-schemas] [--noinput] command_name ``` The `-s --schema` argument accepts multiple inputs of different kinds: - The key of a static tenant or the `schema_name` of a dynamic tenant. - The prefix of any domain, as long as only one tenant is found. - The prefix of any `domain/folder` of a tenant, like `tenants.mydomain.com/tenant1`, as long as only one tenant is found. The arguments `-as`, `-ss`, `-ds` and `-ts` act as wildcard for selecting a class of schemas as follows: | Wildcard | Selected schemas | | -------- | ------------------------------------------------------------------------------- | | `-as` | All schemas | | `-ss` | Static schemas | | `-ds` | Dynamic schemas | | `-ts` | Tenant-like schemas: all dynamic schemas plus the reference schema if it exists | It's possible to exclude schemas via the `-x` argument. This argument accepts the same inputs as `--schema`. Excluded schemas will take precedence over included ones. At least one schema is mandatory. If it's not provided with the command, either explicitly or via wildcard params, it will be asked interactively, except when the option `--noinput` is passed, in which case the command will fail. If `--parallel` is passed, the command will be run asynchronously, spawning multiple threads controlled by the setting `PGSCHEMAS_PARALLEL_MAX_PROCESSES`. This setting defaults to `None`, in which case the number of CPUs will be used. By default, schemas that do not exist will be created (although migrations won't be applied). This can be bypassed by passing `--no-create-schemas`. !!! Tip When in doubt of which schemas will be selected from a combination of arguments, we provide the management command `whowill` that can be used to just display the selected schemas. ### Inheritable commands We also provide some base commands you can inherit, in order to mimic the behavior of `runschema`. By inheriting these you will get the arguments we discussed in the previous section. The base commands provide a `handle_schema` you must override in order to execute the actions you need on any given tenant. The base commands are: ```python title="django_pgschemas/management/commands/__init__.py" class SchemaCommand(WrappedSchemaOption, BaseCommand): def handle_schema(self, schema, *args, **options): """ Extensible method to perform some action in a schema. """ ... class StaticSchemaCommand(SchemaCommand): """ Management command that can only be run in static schemas. """ ... class DynamicSchemaCommand(SchemaCommand): """ Management command that can only be run in dynamic schemas. """ ... ``` !!! Warning Since these commands can work with the schemas of static and dynamic tenants, the parameter `schema` will be an instance of `django_pgschemas.schema.Schema`. Make sure to do the appropriate type checking before accessing the tenant members, as not always you will get an instance of the tenant model. django-pgschemas-1.2.0/docs/base.md000066400000000000000000000037361512245224300171210ustar00rootroot00000000000000## Schema Base representation of a Postgres schema. ```python class Schema: schema_name: str routing: RoutingInfo = None ``` Routing contains information on the routing method used (e.g. domain, session, header). It's filled automatically via middleware but may be missing in other contexts (e.g. management commands). ### Routing info Information on the routing method. ```python class DomainInfo: domain: str folder: str | None = None class SessionInfo: reference: str class HeadersInfo: reference: str RoutingInfo: TypeAlias = DomainInfo | SessionInfo | HeadersInfo | None ``` ## Tenant model Abstract base class for the tenant model. ```python class TenantModel(Schema, models.Model): auto_create_schema = True auto_drop_schema = False schema_name = models.CharField(max_length=63, unique=True) class Meta: abstract = True ``` `auto_create_schema` controls whether a schema is automatically created when a instance of the tenant model is created. `auto_drop_schema` controls whether the schema is automatically deleted when the instance is deleted. ## Domain model Abstract base class for the domain model. Optional when domain routing is not used. ```python class DomainModel(models.Model): tenant = models.ForeignKey( settings.TENANTS["default"]["TENANT_MODEL"], db_index=True, related_name="domains", on_delete=models.CASCADE, ) domain = models.CharField(max_length=253, db_index=True) folder = models.SlugField(max_length=253, blank=True, db_index=True) is_primary = models.BooleanField(default=True) redirect_to_primary = models.BooleanField(default=False) class Meta: abstract = True unique_together = ("domain", "folder") ``` There should only be one instance per tenant with `is_primary` set to `True`. If `redirect_to_primary` is `True` the routing middleware will perform a permanent redirect to whatever domain and folder is marked as primary. django-pgschemas-1.2.0/docs/basic.md000066400000000000000000000113721512245224300172630ustar00rootroot00000000000000This package requires: - Python (3.12+) - Django (5.2+) - Postgres (14+) - Any version of psycopg. ## Installation You can install `django-pgschemas` via `pip` or any other installer. ```bash pip install django-pgschemas ``` ## Database configuration Use `django_pgschemas.postgresql` as your database engine. This enables the API for setting Postgres search path: ```python title="settings.py" DATABASES = { "default": { "ENGINE": "django_pgschemas.postgresql", # more database configurations here } } ``` Add `django_pgschemas.routers.TenantAppsRouter` to your `DATABASE_ROUTERS`, so that the proper migrations can be applied, depending on the target schema. ```python title="settings.py" DATABASE_ROUTERS = ( "django_pgschemas.routers.TenantAppsRouter", # additional routers here if needed ) ``` Define your tenant model. ```python title="tenants/models.py" from django.db import models from django_pgschemas.models import TenantModel class Tenant(TenantModel): name = models.CharField(max_length=100) paid_until = models.DateField(blank=True, null=True) on_trial = models.BooleanField(default=True) created_on = models.DateField(auto_now_add=True) ``` Add the minimal tenant configuration. ```python title="settings.py" TENANTS = { "public": { "APPS": [ "django.contrib.contenttypes", "django.contrib.staticfiles", "django_pgschemas", "tenants", ], }, "default": { "TENANT_MODEL": "tenants.Tenant", "APPS": [ "django.contrib.auth", "django.contrib.sessions", "customers", ], "URLCONF": "customers.urls", } } ``` Each entry in the `TENANTS` dictionary represents a static tenant, except for `default`, which controls the settings for all dynamic tenants. Notice how each tenant has the relevant `APPS` whose migrations will be applied in the corresponding schema. For Django to function properly, `INSTALLED_APPS` and `ROOT_URLCONF` settings must be defined. Just make them get their information from the `TENANTS` dictionary, for the sake of consistency. ```python title="settings.py" INSTALLED_APPS = [] for schema in TENANTS: INSTALLED_APPS += [ app for app in TENANTS[schema]["APPS"] if app not in INSTALLED_APPS ] ROOT_URLCONF = TENANTS["default"]["URLCONF"] ``` ## Creating tenants More static tenants can be added to the `TENANTS` dict. ```python title="settings.py" TENANTS |= { "www": { "APPS": [ "django.contrib.auth", "django.contrib.sessions", "main", ], "URLCONF": "main.urls", }, "blog": { "APPS": [ "django.contrib.auth", "django.contrib.sessions", "blog", ], "URLCONF": "blog.urls", }, } ``` And dynamic tenants can be added as well, programatically. But first, you must always run migrations in the public schema in order to get the tenant model created. You can then migrate the rest of the schemas. ```bash python manage.py migrate -s public python manage.py migrate ``` Now you are ready to create your first dynamic tenant. In the example, the tenant is created through a `python manage.py shell` session. ```bash >>> from tenants.models import Tenant >>> Tenant.objects.create(schema_name="tenant_1") ``` This will automatically create a schema for the new dynamic tenant and apply migrations. ## Working with tenants Because static and dynamic tenants can have their own Django apps configured, only the models within those apps will be migrated into their respective schemas. Without activating any tenant, the `public` schema will be the only schema in the search path, and therefore only models from the apps in `TENANTS["public"]["APPS"]` will be accessible. For instance, after starting a new Django shell, querying the `Tenant` model will work, but querying models from other apps will raise a `ProgrammingError`: ```bash hl_lines="5 6" >>> from tenants.models import Tenant >>> from blog.models import BlogEntry >>> from customers.models import Product >>> Tenant.objects.all() >>> BlogEntry.objects.all() # ProgrammingError >>> Product.objects.all() # ProgrammingError ``` Before being able to operate in a tenant's schema, that tenant/schema must be activated first: ```bash hl_lines="1 5 8" >>> from django_pgschemas.schemas import Schema >>> from tenants.models import Tenant >>> from blog.models import BlogEntry >>> from customers.models import Product >>> with Schema.create("blog"): ... BlogEntry.objects.all() >>> tenant1 = Tenant.objects.first() >>> with tenant1: ... Product.objects.all() ``` Tenant activation happens automatically during the request/response cycle through [tenant routing](routing.md). django-pgschemas-1.2.0/docs/contrib.md000066400000000000000000000100641512245224300176370ustar00rootroot00000000000000All contributions and third party integrations live inside `django_pgschemas.contrib`. If you want to implement an integration with other Django packages, please submit a pull request containing: - The code for your integration. - The tests for your integration. - The docs for your integration in this section of the documentation. We're striving to maintain/increase our code coverage, but please, make sure your integration is properly tested. Proper tests will always beat meaningless 100% coverage. ## Caching In order to generate tenant aware cache keys, we provide `django_pgschemas.contrib.cache.make_key` which can be used as`KEY_FUNCTION`: ```python title="settings.py" CACHES = { "default": { "KEY_FUNCTION": "django_pgschemas.contrib.cache.make_key", } } ``` ## Tenant aware file system storage We provide a tenant aware file system storage at `django_pgschemas.contrib.storage.TenantFileSystemStorage`. It subclasses `django.core.files.storage.FileSystemStorage` and behaves like it in every aspect, except that it prepends a tenant identifier to the path and URL of all files. By default, the tenant identifier is the schema name of the current tenant. In order to override this behavior, it is possible to provide a different identifier. The storage will consider these options when looking for an identifier: - A method called `schema_pathname` in the current tenant. This method must accept no arguments and return an identifier. - A function specified in a setting called `PGSCHEMAS_PATHNAME_FUNCTION`. This function must accept a `Schema` and return an identifier. - Finally, the identifier will default to the schema name of the current tenant. In the case of the URL returned from the storage, if the storage detects that the current schema has been routed via subfolder, it won't prepend the schema identifier, because it considers that the path is properly disambiguated as is. This means that instead of something like: /tenant1/static/tenant1/path/to/file.txt It will generate: /tenant1/static/path/to/file.txt This storage class is a convenient way of storing media files in a folder structure organized at the top by tenants, as well as providing a tenant centric organization in the URLs that are generated. However, this storage class does NOT provide any form of security, such as controlling that from one tenant, files from another tenant are not accessible. Such security requirements have other implications that fall out of the scope of this package. !!! Tip In a project that requires airtight security you might want to use and customize [django-private-storage](https://github.com/edoburu/django-private-storage). ## Channels (websockets) We provide some tenant middleware and a tenant URL router for using with `channels`. You can use it as follows: ```python title="routing.py" hl_lines="12 14" from channels.routing import ProtocolTypeRouter from channels.auth import AuthMiddlewareStack from channels.security.websocket import AllowedHostsOriginValidator from django_pgschemas.contrib.channels import ( DomainRoutingMiddleware, TenantURLRouter ) application = ProtocolTypeRouter( { "websocket": AllowedHostsOriginValidator( DomainRoutingMiddleware( AuthMiddlewareStack( TenantURLRouter() ) ) ), } ) ``` ```python title="settings.py" ASGI_APPLICATION = "routing.application" ``` There is also the `HeadersRoutingMiddleware` for headers-based routing. The `TenantURLRouter` requires a urlconf for websockets: ```python title="settings.py" hl_lines="10" TENANTS |= { "default": { "TENANT_MODEL": "tenants.Tenant", "APPS": [ "django.contrib.auth", "django.contrib.sessions", "customers", ], "URLCONF": "customers.urls", "WS_URLCONF": "customers.ws_urls", } } ``` You still need to name your channel groups appropriately, taking the current tenant into account, if you want to keep your groups tenant-specific. The current tenant will be passed in `scope["tenant"]`. django-pgschemas-1.2.0/docs/credits.md000066400000000000000000000005331512245224300176340ustar00rootroot00000000000000# Credits This project stands on the shoulders of giants. Special thanks to: - Tom Turner for [django-tenants](https://github.com/tomturner/django-tenants). - Bernardo Pires for [django-tenant-schemas](https://github.com/bernardopires/django-tenant-schemas). - Denish Patel for [pg-clone-schema](https://github.com/denishpatel/pg-clone-schema) django-pgschemas-1.2.0/docs/index.md000066400000000000000000000041331512245224300173060ustar00rootroot00000000000000# django-pgschemas [![Build status](https://github.com/lorinkoz/django-pgschemas/workflows/code/badge.svg)](https://github.com/lorinkoz/django-pgschemas/actions) [![Documentation status](https://readthedocs.org/projects/django-pgschemas/badge/?version=latest)](https://django-pgschemas.readthedocs.io/) [![Code coverage](https://coveralls.io/repos/github/lorinkoz/django-pgschemas/badge.svg?branch=master)](https://coveralls.io/github/lorinkoz/django-pgschemas?branch=master) [![PyPi version](https://badge.fury.io/py/django-pgschemas.svg)](http://badge.fury.io/py/django-pgschemas) [![Downloads](https://pepy.tech/badge/django-pgschemas/month)](https://pepy.tech/project/django-pgschemas/) --- This package uses Postgres schemas to support data multi-tenancy in a single Django project. Schemas are a layer of separation between databases and tables, so that one database can have multiple schemas, which in turn can have multiple (and possibly identical) tables. For an accurate description on schemas, see [the official documentation on Postgres schemas](http://www.postgresql.org/docs/9.1/static/ddl-schemas.html). Postgres uses a "search path" to denote in which schemas it should look for the appropriate tables. If there are three schemas: `tenant1`, `common` and `public` and the search path is set to `["tenant1", "public"]`, Postgres will look for tables first on schema `tenant1`, and then, if not found, will look on schema `public`. The tables on schema `common` would never be searched. Also, if there is a table with the same name on both `tenant1` and `public` schemas (i.e. `django_migrations`), only the table in `tenant1` will be found by that search path. Table creation always takes place on the first schema in the search path. `django-pgschemas`, as well as its predecessors `django-tenants` and `django-tenant-schemas`, takes advantage of Postgres schemas to emulate multi-tenancy, by mapping an incoming HTTP request to a specific schema, and setting the search path accordingly. It also provides an API to change the search path outside the request/response cycle, in order to perform schema-specific tasks. django-pgschemas-1.2.0/docs/overview.md000066400000000000000000000071071512245224300200510ustar00rootroot00000000000000There are typically three solutions for solving the multi-tenancy problem. 1. Isolated approach: Separate databases. Each tenant has its own database. 2. Semi-isolated approach: Shared database, separate schemas. One database for all tenants, but one schema per tenant. 3. Shared approach: Shared database, shared schema. All tenants share the same database and schema. There is a main tenant-table, where all other tables have a foreign key pointing to. Each solution has its up and down sides, for a more in-depth discussion, see Microsoft's excellent article on [Multi-Tenant Data Architecture](https://docs.microsoft.com/en-us/azure/sql-database/saas-tenancy-app-design-patterns). This package implements the second approach, which in our opinion, represents a good compromise between simplicity and performance. !!! Tip If you are looking for an implementation of the third approach, you might be interested in [django-multitenant](https://github.com/citusdata/django-multitenant). For other solutions of the multi-tenancy problem, you could also look [here](https://djangopackages.org/grids/g/multi-tenancy/). The semi-isolated approach through Postgres schemas has some advantages and disadvantages: - Simplicity: barely make any changes to your current code to support multi-tenancy. Plus, you only manage one database. - Performance: make use of shared connections, buffers and memory. vs. - Scalability: for a large number of tenants (thousands) the schema approach might not be feasible, and as of now, there is no clear way for implementing tenant sharding. ## Schemas vs. Tenants The terms _schema_ and _tenant_ are used indistinctly all over the documentation. However, it is important to note some subtle differences between the two. We consider a _tenant_ to be a subset of isolated data, and we use database _schemas_ for that purpose. Still, there can be schemas that cannot be considered tenants according to our definition. One good example is the `public` schema, which contains data shared across all tenants. Therefore every tenant is a schema, but not every schema is a tenant. ## Static vs. Dynamic In a typical software-as-a-service (SaaS), there can be a group of static sites that are related to enterprise level operations. For instance, a site where customers can enter payment information and sign up for a tenant, or an enterprise content management system. These sites are generally well defined at the time of developing the web application. On the other hand, there are the sites of the customers that will sign up of the SaaS. The specific information for these sites is dynamic in nature, because it cannot be determined at the time of developing the application. This package allows you to manage both static and dynamic tenants. Static tenants are defined through Django settings, whereas dynamic tenants are stored in specific tables in the database. ## Users and tenants One of the most important architectural decisions that you must make before implementing a SaaS is to define the relationship between users and tenants. There are three possible approaches: - Users exist outside of tenants and can be granted access to specific tenants: this means that the user registers once and can be assigned to different tenants with different permissions on those tenants. - Users are confined within a tenant: this means that users must be created inside a tenant and cannot be part of more than one tenant. If the same person needs to be member of multiple tenants, they need different users. - Users are tenants: a tenant can only have one user. This is a special case of the previous approach. django-pgschemas-1.2.0/docs/requirements.txt000066400000000000000000000000371512245224300211400ustar00rootroot00000000000000django psycopg mkdocs-material django-pgschemas-1.2.0/docs/routing.md000066400000000000000000000152771512245224300177010ustar00rootroot00000000000000Routing is the process of deciding to which tenant an incoming request belongs, and activating it for the rest of the request/response cycle. This is typically done via middleware and this package provides three routing mechanisms: domain, header and session routing. The goal of these middleware is to augment the `request` object with tenant information. When these middleware are used the `request` will contain a `tenant` property with an instance of either the tenant model or the class `django_pgschemas.schema.Schema`. ## Domain routing Tenants will have one or many domains (or subdomains), but each domain will correspond to only one tenant. In this mechanism we use a database table to control domains per tenant. This domain model can be defined like this: ```python title="tenants/models.py" from django_pgschemas.models import DomainModel class Domain(DomainModel): pass ``` And added to the tenant settings like this: ```python title="settings.py" hl_lines="16 25 30" TENANTS = { "public": { "APPS": [ "django.contrib.contenttypes", "django.contrib.staticfiles", "django_pgschemas", "tenants", ], }, "www": { "APPS": [ "django.contrib.auth", "django.contrib.sessions", "main", ], "DOMAINS": ["mydomain.com"], "URLCONF": "main.urls", }, "blog": { "APPS": [ "django.contrib.auth", "django.contrib.sessions", "blog", ], "DOMAINS": ["blog.mydomain.com", "help.mydomain.com"], "URLCONF": "blog.urls", }, "default": { "TENANT_MODEL": "tenants.Tenant", "DOMAIN_MODEL": "tenants.Domain", "APPS": [ "django.contrib.auth", "django.contrib.sessions", "customers", ], "URLCONF": "customers.urls", } } ``` Then you can assign domains to tenants: ```bash >>> from tenants.models import Tenant, Domain >>> tenant = Tenant.objects.create(schema_name="tenant_1") >>> Domain.objects.create(tenant=tenant, domain="tenant1.mydomain.com") ``` !!! Note Notice that the `public` schema doesn't have `DOMAINS` configured. This is intentional. Attempting to add this key would result in an `ImproperlyConfigured` error. The public schema is non-routable by design. Finally add `DomainRoutingMiddleware` to the top of the middleware stack, so that all subsequent middleware can benefit from the added tenant. ```python title="settings.py" MIDDLEWARE = ( "django_pgschemas.routing.middleware.DomainRoutingMiddleware", # other middleware ) ``` ### Subfolder routing It is also possible to use subfolder routing, instead of using domains/subdomains. In this case all tenants would share the same domain, but with a different "folder" component at the beginning of the requested path. The domain model supports this by default and allows for multiple combinations: ```bash >>> from tenants.models import Tenant, Domain >>> tenant = Tenant.objects.create(schema_name="tenant_1") >>> Domain.objects.create( ... tenant=tenant, ... domain="tenant1.mydomain.com", ... is_primary=True, ... ) >>> Domain.objects.create( ... tenant=tenant, ... domain="tenants.mydomain.com", ... folder="tenant1", ... ) ``` !!! Warning Subfolder routing is currently not supported for static tenants. For a special case with subfolder routing please see [fallback domains](advanced.md#fallback-domains). ## Header routing In this mechanism a request header is defined to pass the tenant database ID or the schema name. ```python title="settings.py" PGSCHEMAS_TENANT_HEADER = "tenant" ``` Static tenants can be routed using the `HEADER` key in the `TENANTS` settings: ```python title="settings.py" hl_lines="16 25" TENANTS = { "public": { "APPS": [ "django.contrib.contenttypes", "django.contrib.staticfiles", "django_pgschemas", "tenants", ], }, "www": { "APPS": [ "django.contrib.auth", "django.contrib.sessions", "main", ], "HEADER": "main", "URLCONF": "main.urls", }, "blog": { "APPS": [ "django.contrib.auth", "django.contrib.sessions", "blog", ], "HEADER": "blog", "URLCONF": "blog.urls", }, "default": { "TENANT_MODEL": "tenants.Tenant", "APPS": [ "django.contrib.auth", "django.contrib.sessions", "customers", ], "URLCONF": "customers.urls", } } ``` !!! Note Notice that the `public` schema doesn't have `HEADER` configured. This is intentional. Attempting to add this key would result in an `ImproperlyConfigured` error. The public schema is non-routable by design. Then add `HeadersRoutingMiddleware` to the top of the middleware stack. ```python title="settings.py" MIDDLEWARE = ( "django_pgschemas.routing.middleware.HeadersRoutingMiddleware", # other middleware ) ``` ## Session routing In this mechanism a session key is defined to store the tenant database ID or the schema name. ```python title="settings.py" PGSCHEMAS_TENANT_SESSION_KEY = "tenant" ``` Static tenants can be routed using the `SESSION_KEY` key in the `TENANTS` settings: ```python title="settings.py" hl_lines="16 25" TENANTS = { "public": { "APPS": [ "django.contrib.contenttypes", "django.contrib.staticfiles", "django_pgschemas", "tenants", ], }, "www": { "APPS": [ "django.contrib.auth", "django.contrib.sessions", "main", ], "SESSION_KEY": "main", "URLCONF": "main.urls", }, "blog": { "APPS": [ "django.contrib.auth", "django.contrib.sessions", "blog", ], "SESSION_KEY": "blog", "URLCONF": "blog.urls", }, "default": { "TENANT_MODEL": "tenants.Tenant", "APPS": [ "django.contrib.auth", "django.contrib.sessions", "customers", ], "URLCONF": "customers.urls", } } ``` !!! Note Notice that the `public` schema doesn't have `SESSION_KEY` configured. This is intentional. Attempting to add this key would result in an `ImproperlyConfigured` error. The public schema is non-routable by design. Then add `SessionRoutingMiddleware` to the top of the middleware stack, but after the session middleware. ```python title="settings.py" MIDDLEWARE = ( "django.contrib.sessions.middleware.SessionMiddleware", "django_pgschemas.routing.middleware.SessionRoutingMiddleware", # other middleware ) ``` django-pgschemas-1.2.0/docs/settings.md000066400000000000000000000044541512245224300200450ustar00rootroot00000000000000## `TENANTS` Default: `None` The tenant configuration dictionary as explained in the [basic configuration](basic.md#database-configuration). A sample tenant configuration is: ```python TENANTS = { "public": { "APPS": [ "django.contrib.contenttypes", "django.contrib.staticfiles", "django_pgschemas", "tenants", ], }, "www": { "APPS": [ "django.contrib.auth", "django.contrib.sessions", "main", ], "URLCONF": "main.urls", }, "blog": { "APPS": [ "django.contrib.auth", "django.contrib.sessions", "blog", ], "URLCONF": "blog.urls", }, "default": { "TENANT_MODEL": "tenants.Tenant", "APPS": [ "django.contrib.auth", "django.contrib.sessions", "customers", ], "URLCONF": "customers.urls", "CLONE_REFERENCE": "sample", } } ``` ## `PGSCHEMAS_EXTRA_SEARCH_PATHS` Default: `[]` Other schemas to include in Postgres search path. You cannot include the schema for any static or dynamic tenant. The public schema is included by default, so, including it here will raise `ImproperlyConfigured`. ## `PGSCHEMAS_LIMIT_SET_CALLS` Default: `False` By default, the search path is set every time a database cursor is required. In some intense situations, this could ralentize the queries. Set to `True` to limit the number of calls for setting the search path. ## `PGSCHEMAS_ORIGINAL_BACKEND` Default: `"django.db.backends.postgresql"` The base backend to inherit from. If you have a customized backend of Postgres, you can specify it here. ## `PGSCHEMAS_PARALLEL_MAX_PROCESSES` Default: `None` When `--parallel` is passed in any tenant command, this setting will control the max number of processes the parallel executor can spawn. By default, `None` means that the number of CPUs will be used. ## `PGSCHEMAS_TENANT_DB_ALIAS` Default: `"default"` The database alias where the tenant configuration is going to take place. ## `PGSCHEMAS_PATHNAME_FUNCTION` Default: `None` Function that takes a schema descriptor and returns a string identifier for the schema. This identifier will be used in the `TenantFileSystemStorage` as the name of the tenant folder. django-pgschemas-1.2.0/docs/testing.md000066400000000000000000000032141512245224300176530ustar00rootroot00000000000000# Testing This is how you set up dynamic tenants for testing. ## Pytest You can define some tenants in the test configuration. ```python title="confest.py" import pytest from tenants.models import Tenant, Domain @pytest.fixture(scope="session", autouse=True) def setup(django_db_setup, django_db_blocker): with django_db_blocker.unblock(): tenant = Tenant.objects.get_or_create(schema_name="tenant1") Domain.objects.get_or_create( tenant=tenant, domain="tenant1.mydomain.com", is_primary=True, ) ``` And also provide them as fixtures: ```python title="confest.py" @pytest.fixture def tenant(db): return Tenant.objects.get(schema_name="tenant1") ``` If you want the tenant to be activated automatically in your test cases, you can so as follows. Using the tenants as context manager is useful in activating the tenant only in the scope of each test. ```python title="confest.py" @pytest.fixture def tenant(db): with (tenant := Tenant.objects.get(schema_name="tenant1")): yield tenant ``` You can also define a fixture for a client, including the necessary headers: ```python title="confest.py" from django.test import Client @pytest.fixture def domain_client(): return Client(headers={"host": "tenant1.mydomain.com"}) @pytest.fixture def header_client(): return Client(headers={"tenant": "tenant1"}) ``` ## Django test cases This package does not provide base clases to be used in place for Django's `TestCase`. If you need support in this regard, please visit the [dicussions section](https://github.com/lorinkoz/django-pgschemas/discussions) in the package's repository. django-pgschemas-1.2.0/docs/troubleshooting.md000066400000000000000000000065161512245224300214350ustar00rootroot00000000000000## Schema for tenant and domain models The application(s) that contain the tenant model and the domain model should be in the public schema only. Making those models available in other schemas will cause unpredictable problems. This package will raise an error check if the tenant / domain application is found missing in `settings.TENANTS["public"]["APPS"]` or present in other tenant configuration. !!! Tip You can silence this check through the code `pgschemas.W001`. ## Content types Installing `django.contrib.contenttypes` outside of the public schema can lead to problems when using other static or dynamic schemas. The recommended approach is to have this app in `settings.TENANTS["public"]["APPS"]`. This package will raise a warning check if the content types app is found somewhere else. !!! Tip You can silence this check through the code `pgschemas.W002`. ## Session leaking Configuring users in a multi-tenant application can be challenging, because the user model(s) can be installed on any schema. Depending on the scope of your desired authentication mechanism, you should decide whether the user app will leave in the public schema or in each of the other static or dynamic schemas. If you do the latter, consdier that the same user ID could be repeated in multiple schemas. User ID is what makes authentication possible via the sessions app. In order to prevent session leaking, the recommended approach is to always put the user app and the session app together. This package will raise a warning check if the user app and the session app are found to not be together in the same schemas. !!! Tip You can silence this check through the code `pgschemas.W003`. ## Moving apps between schemas Regardless of which apps you have included in each schema, migrations will be tracked as being run on all of them. If you move an app between schemas, the tables will not be created in the destination schema, because migrations are considered to be run there already. In order to overcome this, you must remove all migrations of said app via manage.py migrate app zero --fake -s and then run migrations again. In order to remove the tables from the source app, you will have to actually do a zero migrate before removing the app from the said schema apps. ## Name clash between static and dynamic schemas It is possible to define a static tenant whose name clashes with an existing dynamic tenant. This is especially true for the clone reference, which can be added as an afterthought in order to speed up dynamic tenant creation. It is also possible to create a dynamic tenant with a name already present in the static tenant configuration. We do not provide an out-of-the-box validation mechanism for dynamic tenants upon creation, as attempt to prevent name clashes with static tenants. However, we do provide a system check that fails with a critical error message if a name clash is found. Since this check must query the database in order to fetch the schema name for all dynamic tenants, it is tagged as a database check, which makes it run only in database related operations and management commands. This means that the check will not be run via `runserver`, but will be run in commands like `migrate`, `cloneschema` and `createrefschema`. If absolutely needed, !!! Tip you can silence this check through the code `pgschemas.W004`. django-pgschemas-1.2.0/mkdocs.yml000066400000000000000000000014361512245224300167330ustar00rootroot00000000000000site_name: django-pgschemas site_url: https://django-pgschemas.readthedocs.io/ repo_url: https://github.com/lorinkoz/django-pgschemas theme: name: material features: - content.code.copy - navigation.footer - toc.integrate markdown_extensions: - admonition - pymdownx.details - pymdownx.highlight: anchor_linenums: true line_spans: __span pygments_lang_class: true - pymdownx.inlinehilite - pymdownx.snippets - pymdownx.superfences - toc: permalink: true nav: - Intro: index.md - Multi-tenancy: overview.md - Usage: basic.md - Routing: routing.md - Advanced: advanced.md - Contrib: contrib.md - Troubleshooting: troubleshooting.md - Testing: testing.md - Settings: settings.md - Base classes: base.md - Credits: credits.md django-pgschemas-1.2.0/py.typed000066400000000000000000000000001512245224300164110ustar00rootroot00000000000000django-pgschemas-1.2.0/pyproject.toml000066400000000000000000000031301512245224300176350ustar00rootroot00000000000000[project] name = "django-pgschemas" version = "1.2.0" description = "Django multi-tenancy through Postgres schemas" readme = "README.md" license = { text = "MIT" } authors = [{ name = "Lorenzo Peña", email = "lorinkoz@gmail.com" }] keywords = ["django", "tenants", "schemas", "multi-tenancy", "postgres"] classifiers = [ "Development Status :: 5 - Production/Stable", "Framework :: Django", "Framework :: Django :: 5.2", "Framework :: Django :: 6.0", ] requires-python = ">=3.12" dependencies = ["django>=5.2"] [dependency-groups] dev = [ "mypy", "pytest", "pytest-cov", "pytest-django", "pytest-asyncio", "ruff", "channels[daphne]", "mkdocs-material", ] psycopg = ["psycopg"] [project.urls] repository = "https://github.com/lorinkoz/django-pgschemas" documentation = "https://django-pgschemas.readthedocs.io/" [tool.ruff] line-length = 100 [tool.ruff.lint] select = ["I", "E", "F"] ignore = [ "E501", # line-too-long "W191", # tab-indentation ] [tool.ruff.lint.isort] combine-as-imports = true known-first-party = ["django_pgschemas"] [tool.mypy] strict_optional = true disallow_untyped_defs = true check_untyped_defs = true warn_unused_ignores = true [[tool.mypy.overrides]] module = ["channels.*", "psycopg.*", "psycopg2.*", "django.*"] ignore_missing_imports = true [[tool.mypy.overrides]] module = ["docs.*", "sandbox.*"] ignore_errors = true [tool.pytest.ini_options] DJANGO_SETTINGS_MODULE = "sandbox.settings" django_find_project = false pythonpath = ["."] markers = ["bug"] [build-system] requires = ["hatchling"] build-backend = "hatchling.build" django-pgschemas-1.2.0/sandbox/000077500000000000000000000000001512245224300163625ustar00rootroot00000000000000django-pgschemas-1.2.0/sandbox/__init__.py000066400000000000000000000000001512245224300204610ustar00rootroot00000000000000django-pgschemas-1.2.0/sandbox/app_blog/000077500000000000000000000000001512245224300201455ustar00rootroot00000000000000django-pgschemas-1.2.0/sandbox/app_blog/__init__.py000066400000000000000000000000001512245224300222440ustar00rootroot00000000000000django-pgschemas-1.2.0/sandbox/app_blog/migrations/000077500000000000000000000000001512245224300223215ustar00rootroot00000000000000django-pgschemas-1.2.0/sandbox/app_blog/migrations/0001_initial.py000066400000000000000000000017011512245224300247630ustar00rootroot00000000000000# Generated by Django 2.1.4 on 2019-01-13 04:11 import django.db.models.deletion from django.conf import settings from django.db import migrations, models class Migration(migrations.Migration): initial = True dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( name="BlogEntry", fields=[ ( "id", models.AutoField( auto_created=True, primary_key=True, serialize=False, verbose_name="ID" ), ), ( "user", models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, related_name="blogs", to=settings.AUTH_USER_MODEL, ), ), ], ), ] django-pgschemas-1.2.0/sandbox/app_blog/migrations/__init__.py000066400000000000000000000000001512245224300244200ustar00rootroot00000000000000django-pgschemas-1.2.0/sandbox/app_blog/models.py000066400000000000000000000003141512245224300220000ustar00rootroot00000000000000from django.contrib.auth import get_user_model from django.db import models class BlogEntry(models.Model): user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE, related_name="blogs") django-pgschemas-1.2.0/sandbox/app_blog/urls.py000066400000000000000000000003611512245224300215040ustar00rootroot00000000000000from django.contrib import admin from django.urls import path from sandbox.views import generic urlpatterns = [ path("", generic, name="blog-home"), path("entries/", generic, name="entries"), path("admin/", admin.site.urls), ] django-pgschemas-1.2.0/sandbox/app_main/000077500000000000000000000000001512245224300201465ustar00rootroot00000000000000django-pgschemas-1.2.0/sandbox/app_main/__init__.py000066400000000000000000000000001512245224300222450ustar00rootroot00000000000000django-pgschemas-1.2.0/sandbox/app_main/migrations/000077500000000000000000000000001512245224300223225ustar00rootroot00000000000000django-pgschemas-1.2.0/sandbox/app_main/migrations/0001_initial.py000066400000000000000000000010171512245224300247640ustar00rootroot00000000000000# Generated by Django 2.1.4 on 2019-01-13 04:41 from django.db import migrations, models class Migration(migrations.Migration): initial = True dependencies = [] operations = [ migrations.CreateModel( name="MainData", fields=[ ( "id", models.AutoField( auto_created=True, primary_key=True, serialize=False, verbose_name="ID" ), ), ], ), ] django-pgschemas-1.2.0/sandbox/app_main/migrations/__init__.py000066400000000000000000000000001512245224300244210ustar00rootroot00000000000000django-pgschemas-1.2.0/sandbox/app_main/models.py000066400000000000000000000001061512245224300220000ustar00rootroot00000000000000from django.db import models class MainData(models.Model): pass django-pgschemas-1.2.0/sandbox/app_main/urls.py000066400000000000000000000003631512245224300215070ustar00rootroot00000000000000from django.contrib import admin from django.urls import path from sandbox.views import generic urlpatterns = [ path("", generic, name="main-home"), path("register/", generic, name="register"), path("admin/", admin.site.urls), ] django-pgschemas-1.2.0/sandbox/app_main/ws_urls.py000066400000000000000000000002321512245224300222130ustar00rootroot00000000000000from django.urls import path from sandbox.consumers import EchoConsumer urlpatterns = [ path("ws/main/", EchoConsumer.as_asgi(), name="main-ws"), ] django-pgschemas-1.2.0/sandbox/app_tenants/000077500000000000000000000000001512245224300206765ustar00rootroot00000000000000django-pgschemas-1.2.0/sandbox/app_tenants/__init__.py000066400000000000000000000000001512245224300227750ustar00rootroot00000000000000django-pgschemas-1.2.0/sandbox/app_tenants/migrations/000077500000000000000000000000001512245224300230525ustar00rootroot00000000000000django-pgschemas-1.2.0/sandbox/app_tenants/migrations/0001_initial.py000066400000000000000000000024471512245224300255240ustar00rootroot00000000000000# Generated by Django 2.1.4 on 2019-01-13 03:07 import django.db.models.deletion from django.conf import settings from django.db import migrations, models class Migration(migrations.Migration): initial = True dependencies = [ ("shared_public", "0001_initial"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( name="TenantData", fields=[ ( "id", models.AutoField( auto_created=True, primary_key=True, serialize=False, verbose_name="ID" ), ), ( "catalog", models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, related_name="tenant_objects", to="shared_public.Catalog", ), ), ( "user", models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, related_name="tenant_objects", to=settings.AUTH_USER_MODEL, ), ), ], ) ] django-pgschemas-1.2.0/sandbox/app_tenants/migrations/0002_tenantdata_active.py000066400000000000000000000005761512245224300275530ustar00rootroot00000000000000# Generated by Django 3.0.5 on 2021-02-22 21:07 from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ ("app_tenants", "0001_initial"), ] operations = [ migrations.AddField( model_name="tenantdata", name="active", field=models.BooleanField(default=True), ), ] django-pgschemas-1.2.0/sandbox/app_tenants/migrations/__init__.py000066400000000000000000000000001512245224300251510ustar00rootroot00000000000000django-pgschemas-1.2.0/sandbox/app_tenants/models.py000066400000000000000000000006241512245224300225350ustar00rootroot00000000000000from django.contrib.auth import get_user_model from django.db import models class TenantData(models.Model): catalog = models.ForeignKey( "shared_public.Catalog", on_delete=models.CASCADE, related_name="tenant_objects" ) user = models.ForeignKey( get_user_model(), on_delete=models.CASCADE, related_name="tenant_objects" ) active = models.BooleanField(default=True) django-pgschemas-1.2.0/sandbox/app_tenants/urls.py000066400000000000000000000006511512245224300222370ustar00rootroot00000000000000from django.contrib import admin from django.contrib.auth.decorators import login_required from django.urls import path from sandbox.views import generic urlpatterns = [ path("", generic, name="tenant-home"), path("profile/", generic, name="profile"), path("profile/advanced/", login_required(generic), name="advanced-profile"), path("login/", generic, name="login"), path("admin/", admin.site.urls), ] django-pgschemas-1.2.0/sandbox/app_tenants/ws_urls.py000066400000000000000000000002361512245224300227470ustar00rootroot00000000000000from django.urls import path from sandbox.consumers import EchoConsumer urlpatterns = [ path("ws/tenant/", EchoConsumer.as_asgi(), name="tenant-ws"), ] django-pgschemas-1.2.0/sandbox/asgi.py000066400000000000000000000002501512245224300176540ustar00rootroot00000000000000import os from django.core.asgi import get_asgi_application os.environ.setdefault("DJANGO_SETTINGS_MODULE", "sandbox.settings") application = get_asgi_application() django-pgschemas-1.2.0/sandbox/consumers.py000066400000000000000000000007541512245224300207600ustar00rootroot00000000000000import json from channels.generic.websocket import AsyncWebsocketConsumer class EchoConsumer(AsyncWebsocketConsumer): async def connect(self): await self.accept() async def disconnect(self, close_code): pass async def receive(self, text_data): data = json.loads(text_data) message = data["message"] schema_name = self.scope["tenant"].schema_name await self.send(text_data=json.dumps({"message": f"{schema_name}: {message}"})) django-pgschemas-1.2.0/sandbox/manage.py000077500000000000000000000010031512245224300201610ustar00rootroot00000000000000#!/usr/bin/env python import os import sys if __name__ == "__main__": os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") try: from django.core.management import execute_from_command_line except ImportError: raise ImportError( "Couldn't import Django. Are you sure it's installed and " "available on your PYTHONPATH environment variable? Did you " "forget to activate a virtual environment?" ) execute_from_command_line(sys.argv) django-pgschemas-1.2.0/sandbox/routing.py000066400000000000000000000012151512245224300204220ustar00rootroot00000000000000from channels.auth import AuthMiddlewareStack from channels.routing import ProtocolTypeRouter from django_pgschemas.contrib.channels import ( DomainRoutingMiddleware, HeadersRoutingMiddleware, TenantURLRouter, ) domain_application = ProtocolTypeRouter( { "websocket": DomainRoutingMiddleware( AuthMiddlewareStack( TenantURLRouter(), ), ), } ) headers_application = ProtocolTypeRouter( { "websocket": HeadersRoutingMiddleware( AuthMiddlewareStack( TenantURLRouter(), ), ), } ) application = domain_application django-pgschemas-1.2.0/sandbox/settings.py000066400000000000000000000116241512245224300206000ustar00rootroot00000000000000""" Django settings for sandbox project. Generated by 'django-admin startproject' using Django 2.1.4. For more information on this file, see https://docs.djangoproject.com/en/2.1/topics/settings/ For the full list of settings and their values, see https://docs.djangoproject.com/en/2.1/ref/settings/ """ import os # Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/2.1/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = "asd#$#ae)^gegm6m9omvic^ct@*@bkf!0afe*+4h$5-zmf^h&$u4(1vr" # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True ALLOWED_HOSTS = [".localhost"] TENANTS = { "public": { "APPS": [ "sandbox.shared_public", "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.staticfiles", "django.contrib.messages", ], }, "www": { "APPS": [ "sandbox.shared_common", "sandbox.app_main", "django.contrib.sessions", "django.contrib.admin", ], "URLCONF": "sandbox.app_main.urls", "WS_URLCONF": "sandbox.app_main.ws_urls", "DOMAINS": ["localhost"], "SESSION_KEY": "main", "HEADER": "main", "FALLBACK_DOMAINS": ["tenants.localhost"], }, "blog": { "APPS": [ "sandbox.shared_common", "sandbox.app_blog", "django.contrib.sessions", "django.contrib.admin", ], "URLCONF": "sandbox.app_blog.urls", "DOMAINS": ["blog.localhost"], }, "default": { "TENANT_MODEL": "shared_public.Tenant", "DOMAIN_MODEL": "shared_public.Domain", "APPS": [ "sandbox.shared_common", "sandbox.app_tenants", "django.contrib.sessions", "django.contrib.admin", ], "URLCONF": "sandbox.app_tenants.urls", "WS_URLCONF": "sandbox.app_tenants.ws_urls", "CLONE_REFERENCE": "sample", }, } # Application definition INSTALLED_APPS = ["django_pgschemas"] for schema in TENANTS: INSTALLED_APPS += [app for app in TENANTS[schema]["APPS"] if app not in INSTALLED_APPS] ROOT_URLCONF = TENANTS["default"]["URLCONF"] ASGI_APPLICATION = "routing.application" AUTH_USER_MODEL = "shared_common.User" LOGIN_URL = "login" MIDDLEWARE = [ "django_pgschemas.routing.middleware.DomainRoutingMiddleware", "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", ] TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", "DIRS": [ f"{BASE_DIR}/sandbox/templates", ], "APP_DIRS": True, "OPTIONS": { "context_processors": [ "django.template.context_processors.debug", "django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", ] }, } ] # Database # https://docs.djangoproject.com/en/2.1/ref/settings/#databases DATABASES = { "default": { "ENGINE": "django_pgschemas.postgresql", "NAME": "sandbox", "USER": "postgres", "PASSWORD": os.environ.get("DATABASE_PASSWORD", "postgres"), "HOST": os.environ.get("DATABASE_HOST", "localhost"), "PORT": "", } } DATABASE_ROUTERS = ("django_pgschemas.routers.TenantAppsRouter",) CACHES = { "default": { "BACKEND": "django.core.cache.backends.locmem.LocMemCache", "KEY_FUNCTION": "django_pgschemas.contrib.cache.make_key", } } # Password validation # https://docs.djangoproject.com/en/2.1/ref/settings/#auth-password-validators AUTH_PASSWORD_VALIDATORS = [ {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, ] # Internationalization # https://docs.djangoproject.com/en/2.1/topics/i18n/ LANGUAGE_CODE = "en-us" TIME_ZONE = "UTC" USE_I18N = True USE_TZ = True # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/2.1/howto/static-files/ STATIC_URL = "/static/" DEFAULT_AUTO_FIELD = "django.db.models.AutoField" django-pgschemas-1.2.0/sandbox/shared_common/000077500000000000000000000000001512245224300212005ustar00rootroot00000000000000django-pgschemas-1.2.0/sandbox/shared_common/__init__.py000066400000000000000000000000001512245224300232770ustar00rootroot00000000000000django-pgschemas-1.2.0/sandbox/shared_common/management/000077500000000000000000000000001512245224300233145ustar00rootroot00000000000000django-pgschemas-1.2.0/sandbox/shared_common/management/__init__.py000066400000000000000000000000001512245224300254130ustar00rootroot00000000000000django-pgschemas-1.2.0/sandbox/shared_common/management/commands/000077500000000000000000000000001512245224300251155ustar00rootroot00000000000000django-pgschemas-1.2.0/sandbox/shared_common/management/commands/__init__.py000066400000000000000000000000001512245224300272140ustar00rootroot00000000000000django-pgschemas-1.2.0/sandbox/shared_common/management/commands/reverse_url.py000066400000000000000000000020731512245224300300260ustar00rootroot00000000000000from django.conf import settings from django.urls import reverse from django_pgschemas.management.commands import SchemaCommand from django_pgschemas.routing.info import DomainInfo from django_pgschemas.routing.models import get_primary_domain_for_tenant from django_pgschemas.routing.urlresolvers import get_urlconf_from_schema from django_pgschemas.schema import Schema class Command(SchemaCommand): def add_arguments(self, parser): super().add_arguments(parser) parser.add_argument( dest="url_name", help="Url name to resolve in the specified schema", ) def handle_schema(self, schema: Schema, *args, **options): if schema.is_dynamic: primary_domain = get_primary_domain_for_tenant(schema) schema.routing = DomainInfo(domain=primary_domain.domain, folder=primary_domain.folder) else: schema.routing = DomainInfo(domain=settings.TENANTS[schema.schema_name]["DOMAINS"][0]) self.stdout.write(reverse(options["url_name"], urlconf=get_urlconf_from_schema(schema))) django-pgschemas-1.2.0/sandbox/shared_common/migrations/000077500000000000000000000000001512245224300233545ustar00rootroot00000000000000django-pgschemas-1.2.0/sandbox/shared_common/migrations/0001_initial.py000066400000000000000000000017351512245224300260250ustar00rootroot00000000000000# Generated by Django 2.1.4 on 2019-01-13 03:07 from django.db import migrations, models class Migration(migrations.Migration): initial = True dependencies = [] operations = [ migrations.CreateModel( name="User", fields=[ ( "id", models.AutoField( auto_created=True, primary_key=True, serialize=False, verbose_name="ID" ), ), ("password", models.CharField(max_length=128, verbose_name="password")), ( "last_login", models.DateTimeField(blank=True, null=True, verbose_name="last login"), ), ("email", models.EmailField(max_length=254, unique=True)), ("display_name", models.CharField(max_length=50)), ], options={ "abstract": False, }, ), ] django-pgschemas-1.2.0/sandbox/shared_common/migrations/__init__.py000066400000000000000000000000001512245224300254530ustar00rootroot00000000000000django-pgschemas-1.2.0/sandbox/shared_common/models.py000066400000000000000000000005661512245224300230440ustar00rootroot00000000000000from django.contrib.auth.models import AbstractBaseUser, BaseUserManager from django.db import models class UserManager(BaseUserManager): pass class User(AbstractBaseUser): email = models.EmailField(unique=True) display_name = models.CharField(max_length=50) USERNAME_FIELD = "email" REQUIRED_FIELDS = ("display_name",) objects = UserManager() django-pgschemas-1.2.0/sandbox/shared_public/000077500000000000000000000000001512245224300211665ustar00rootroot00000000000000django-pgschemas-1.2.0/sandbox/shared_public/__init__.py000066400000000000000000000000001512245224300232650ustar00rootroot00000000000000django-pgschemas-1.2.0/sandbox/shared_public/migrations/000077500000000000000000000000001512245224300233425ustar00rootroot00000000000000django-pgschemas-1.2.0/sandbox/shared_public/migrations/0001_initial.py000066400000000000000000000044151512245224300260110ustar00rootroot00000000000000# Generated by Django 2.1.4 on 2019-01-13 03:07 import django.db.models.deletion from django.db import migrations, models import django_pgschemas.schema import django_pgschemas.utils class Migration(migrations.Migration): initial = True dependencies = [] operations = [ migrations.CreateModel( name="Domain", fields=[ ( "id", models.AutoField( auto_created=True, primary_key=True, serialize=False, verbose_name="ID" ), ), ("domain", models.CharField(db_index=True, max_length=253)), ("folder", models.SlugField(blank=True, max_length=253)), ("is_primary", models.BooleanField(default=True)), ], options={"abstract": False}, ), migrations.CreateModel( name="Catalog", fields=[ ( "id", models.AutoField( auto_created=True, primary_key=True, serialize=False, verbose_name="ID" ), ) ], ), migrations.CreateModel( name="Tenant", fields=[ ( "id", models.AutoField( auto_created=True, primary_key=True, serialize=False, verbose_name="ID" ), ), ( "schema_name", models.CharField( max_length=63, unique=True, validators=[django_pgschemas.utils.check_schema_name], ), ), ], options={"abstract": False}, bases=(django_pgschemas.schema.Schema, models.Model), ), migrations.AddField( model_name="domain", name="tenant", field=models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, related_name="domains", to="shared_public.Tenant", ), ), migrations.AlterUniqueTogether(name="domain", unique_together={("domain", "folder")}), ] django-pgschemas-1.2.0/sandbox/shared_public/migrations/0002_domain_redirect_to_primary.py000066400000000000000000000006121512245224300317510ustar00rootroot00000000000000# Generated by Django 3.2.4 on 2021-10-27 19:53 from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ ("shared_public", "0001_initial"), ] operations = [ migrations.AddField( model_name="domain", name="redirect_to_primary", field=models.BooleanField(default=False), ), ] django-pgschemas-1.2.0/sandbox/shared_public/migrations/__init__.py000066400000000000000000000000001512245224300254410ustar00rootroot00000000000000django-pgschemas-1.2.0/sandbox/shared_public/models.py000066400000000000000000000005621512245224300230260ustar00rootroot00000000000000from django.conf import settings from django.db import models from django_pgschemas.models import TenantModel from django_pgschemas.routing.models import DomainModel class Tenant(TenantModel): pass if settings.TENANTS.get("default", {}).get("DOMAIN_MODEL", None) is not None: class Domain(DomainModel): pass class Catalog(models.Model): pass django-pgschemas-1.2.0/sandbox/templates/000077500000000000000000000000001512245224300203605ustar00rootroot00000000000000django-pgschemas-1.2.0/sandbox/templates/index.html000066400000000000000000000003661512245224300223620ustar00rootroot00000000000000

Path:
{{ path }}
User:
{{ user }}
Schema:
{{ schema }}
Routing:
{{ routing }}
Admin URL:
{{ admin_url }}
django-pgschemas-1.2.0/sandbox/tests/000077500000000000000000000000001512245224300175245ustar00rootroot00000000000000django-pgschemas-1.2.0/sandbox/tests/__init__.py000066400000000000000000000000001512245224300216230ustar00rootroot00000000000000django-pgschemas-1.2.0/sandbox/tests/bugs/000077500000000000000000000000001512245224300204645ustar00rootroot00000000000000django-pgschemas-1.2.0/sandbox/tests/bugs/__init__.py000066400000000000000000000000001512245224300225630ustar00rootroot00000000000000django-pgschemas-1.2.0/sandbox/tests/bugs/test_migrations_in_base_models.py000066400000000000000000000056301512245224300273000ustar00rootroot00000000000000import warnings from unittest.mock import patch import pytest from django.apps import apps from django.core import management from django.core.management.base import CommandError from django.db import models from django.db.utils import ProgrammingError from django_pgschemas.checks import check_schema_names from django_pgschemas.models import TenantModel as BaseTenantModel from django_pgschemas.utils import get_tenant_model def patched_get_tenant_model(*args, **kwargs): if RealTenantModel := get_tenant_model(): class TenantModel(BaseTenantModel): dummy = models.TextField() class Meta: app_label = RealTenantModel._meta.app_label return TenantModel return None @pytest.mark.bug def test_database_checks_with_zero_migrations(transactional_db): """ Provoke a handled ProgrammingError by migrating models from empty database. """ management.call_command("migrate", "shared_public", "zero", verbosity=0) # The goal is that the next line doesn't raise ProgrammingError check_schema_names(apps.get_app_config("django_pgschemas")) management.call_command("migrate", verbosity=0) @pytest.mark.bug @patch("django_pgschemas.management.commands.get_tenant_model", patched_get_tenant_model) def test_whowill_with_pending_migrations(TenantModel, db): """ Provoke a handled ProgrammingError by running tenant command with pending model changes. """ if TenantModel is None: pytest.skip("Dynamic tenants are not in use") with warnings.catch_warnings(): warnings.simplefilter("ignore") # Avoid warnings about model being registered twice with pytest.raises(CommandError) as ctx: management.call_command("whowill", all_schemas=True, verbosity=0) assert str(ctx.value) == ( "Error while attempting to retrieve dynamic schemas. " "Perhaps you need to migrate the 'public' schema first?" ) @pytest.mark.bug def test_migrate_with_exclusions(TenantModel, db): if TenantModel is None: pytest.skip("Dynamic tenants are not in use") # We first unapply a migration with fake so we can reapply it without fake # This should work without errors management.call_command( "migrate", "app_tenants", "0001_initial", fake=True, schemas=["tenant1"], verbosity=0 ) # We then migrate on all schemas except for tenant1, THIS IS THE CASE WE WANT TO TEST # This should work without errors management.call_command("migrate", all_schemas=True, excluded_schemas=["tenant1"], verbosity=0) # If we try to global migrate now, we should get a ProgrammingError with pytest.raises(ProgrammingError): management.call_command("migrate", all_schemas=True, verbosity=0) # We finally apply the migration again with fake # This should work without errors management.call_command("migrate", fake=True, all_schemas=True, verbosity=0) django-pgschemas-1.2.0/sandbox/tests/bugs/test_multithread_with_schema.py000066400000000000000000000016511512245224300267750ustar00rootroot00000000000000import time from concurrent.futures import ThreadPoolExecutor import pytest from django_pgschemas import Schema, get_current_schema FIRST_SCHEMA = "first_schema" SECOND_SCHEMA = "second_schema" @pytest.mark.bug def test_multithread_with_schema(): def handle_schema(): with Schema.create(schema_name=FIRST_SCHEMA): assert get_current_schema().schema_name == FIRST_SCHEMA with Schema.create(schema_name=SECOND_SCHEMA): assert get_current_schema().schema_name == SECOND_SCHEMA time.sleep(0.001) assert get_current_schema().schema_name == FIRST_SCHEMA return get_current_schema().schema_name with ThreadPoolExecutor(max_workers=15) as executor: futures = [executor.submit(handle_schema) for _ in range(15)] results = [future.result() for future in futures] assert all(value == FIRST_SCHEMA for value in results) django-pgschemas-1.2.0/sandbox/tests/bugs/test_url_cache.py000066400000000000000000000031251512245224300240230ustar00rootroot00000000000000import pytest from django.apps import apps from django.test import Client @pytest.fixture def UserModel(): return apps.get_model("shared_common.User") @pytest.fixture(autouse=True) def _setup(tenant1, tenant2, DomainModel, UserModel): if DomainModel is None: pytest.skip("Domain model is not in use") DomainModel.objects.create( tenant=tenant1, domain="everyone.localhost", folder="tenant1", is_primary=True ) DomainModel.objects.create( tenant=tenant2, domain="everyone.localhost", folder="tenant2", is_primary=True ) with tenant1: UserModel.objects.create(email="user1@localhost", display_name="Admin") with tenant2: UserModel.objects.create(email="user2@localhost", display_name="Admin") @pytest.fixture def client(): return Client(headers={"host": "everyone.localhost"}) @pytest.mark.bug def test_bug_in_cached_urls_1(client): # Provoke redirect to login on tenant2 client.get("/tenant2/profile/advanced/") # Provoke redirect to login on tenant1 buggy_response = client.get("/tenant1/profile/advanced/") assert buggy_response.status_code == 302 assert buggy_response.url == "/tenant1/login/?next=/tenant1/profile/advanced/" @pytest.mark.bug def test_bug_in_cached_urls_2(client): # Provoke redirect to login on tenant1 client.get("/tenant1/profile/advanced/") # Provoke redirect to login on tenant2 buggy_response = client.get("/tenant2/profile/advanced/") assert buggy_response.status_code == 302 assert buggy_response.url == "/tenant2/login/?next=/tenant2/profile/advanced/" django-pgschemas-1.2.0/sandbox/tests/commands/000077500000000000000000000000001512245224300213255ustar00rootroot00000000000000django-pgschemas-1.2.0/sandbox/tests/commands/__init__.py000066400000000000000000000000001512245224300234240ustar00rootroot00000000000000django-pgschemas-1.2.0/sandbox/tests/commands/test_executors.py000066400000000000000000000017071512245224300247640ustar00rootroot00000000000000import pytest from django.core import management @pytest.fixture(autouse=True) def _setup(TenantModel, DomainModel, db): if TenantModel is None: pytest.skip("Dynamic tenants are not in use") tenants = [] for i in range(10, 20): tenant = TenantModel(schema_name=f"tenant{i + 1}") tenant.save(verbosity=0) if DomainModel: DomainModel.objects.create( tenant=tenant, domain=f"tenant{i + 1}.localhost", is_primary=True ) tenants.append(tenant) yield for tenant in tenants: tenant.delete(force_drop=True) def test_all_schemas_in_sequential(): # If there are no errors, then this test passed management.call_command("migrate", all_schemas=True, parallel=False, verbosity=0) def test_all_schemas_in_parallel(): # If there are no errors, then this test passed management.call_command("migrate", all_schemas=True, parallel=True, verbosity=0) django-pgschemas-1.2.0/sandbox/tests/commands/test_reverse_url.py000066400000000000000000000014441512245224300252760ustar00rootroot00000000000000import pytest from django.core import management from django.urls.exceptions import NoReverseMatch def test_urls_for_main_error(db): with pytest.raises(NoReverseMatch): management.call_command("reverse_url", "entries", schemas=["www"]) def test_urls_for_main_success(stdout, db): management.call_command("reverse_url", "register", schemas=["www"], stdout=stdout) stdout.seek(0) assert stdout.read().strip() == "/register/" def test_urls_for_blog_error(db): with pytest.raises(NoReverseMatch): management.call_command("reverse_url", "register", schemas=["blog"]) def test_urls_for_blog_success(stdout, db): management.call_command("reverse_url", "entries", schemas=["blog"], stdout=stdout) stdout.seek(0) assert stdout.read().strip() == "/entries/" django-pgschemas-1.2.0/sandbox/tests/commands/test_schema_creation.py000066400000000000000000000037451512245224300260730ustar00rootroot00000000000000from unittest.mock import patch import pytest from django.core.management import call_command from django.core.management.base import CommandError from django_pgschemas import utils @pytest.fixture(autouse=True) def _setup(TenantModel): if TenantModel is None: pytest.skip("Dynamic tenants are not in use") def test_cloneschema(transactional_db): utils._create_clone_schema_function() assert not utils.schema_exists("cloned") call_command("cloneschema", "sample", "cloned", verbosity=0) # All good assert utils.schema_exists("cloned") with pytest.raises(CommandError): # Existing destination call_command("cloneschema", "sample", "cloned", verbosity=0) with pytest.raises(CommandError): # Not existing source call_command("cloneschema", "nonexisting", "newschema", verbosity=0) utils.drop_schema("cloned") def test_createrefschema(transactional_db): utils.drop_schema("cloned") call_command("createrefschema", verbosity=0) # All good assert utils.schema_exists("sample") utils.drop_schema("cloned") call_command("createrefschema", recreate=True, verbosity=0) # All good too assert utils.schema_exists("sample") utils.drop_schema("cloned") call_command("createrefschema", recreate=True, verbosity=0) # All good too assert utils.schema_exists("sample") def test_interactive_cloneschema(transactional_db): answer_provider = ( n for n in [ "y", # Would you like to create a database entry? "", # Domain name, simulated wrong answer "tenant1copy.localhost", # Domain name, good answer ] ) def patched_input(*args, **kwargs): return next(answer_provider) with patch("builtins.input", patched_input): call_command( "cloneschema", "tenant1", "tenant1copy", verbosity=0, ) assert utils.schema_exists("tenant1copy") utils.drop_schema("tenant1copy") django-pgschemas-1.2.0/sandbox/tests/commands/test_tenant_related.py000066400000000000000000000147121512245224300257340ustar00rootroot00000000000000from unittest.mock import patch import pytest from django.core import management from django.core.management.base import CommandError from django_pgschemas.management.commands import CommandScope from django_pgschemas.management.commands.whowill import Command as WhoWillCommand @pytest.fixture(autouse=True) def _setup(tenant1, tenant2, TenantModel, DomainModel): if TenantModel is None: pytest.skip("Dynamic tenants are not in use") if DomainModel: DomainModel.objects.create( tenant=tenant1, domain="tenant1.localhost", is_primary=True, ) DomainModel.objects.create( tenant=tenant1, domain="everyone.localhost", folder="tenant1", is_primary=False, ) DomainModel.objects.create( tenant=tenant2, domain="tenant2.localhost", is_primary=True, ) DomainModel.objects.create( tenant=tenant2, domain="everyone.localhost", folder="tenant2", is_primary=False, ) def test_no_schema_provided(): command = WhoWillCommand() with pytest.raises(CommandError) as ctx: management.call_command(command, interactive=False, verbosity=0) assert str(ctx.value) == "No schema provided" def test_no_all_schemas_allowed(): command = WhoWillCommand() command.allow_wildcards = False with pytest.raises(TypeError): management.call_command(command, all_schemas=True, verbosity=0) def test_no_static_schemas_allowed(): command = WhoWillCommand() command.scope = CommandScope.DYNAMIC with pytest.raises(CommandError) as ctx: management.call_command(command, static_schemas=True, verbosity=0) assert str(ctx.value) == "Including static schemas is NOT allowed" command = WhoWillCommand() command.allow_wildcards = False with pytest.raises(TypeError): management.call_command(command, static_schemas=True, verbosity=0) def test_no_dynamic_schemas_allowed(): command = WhoWillCommand() command.scope = CommandScope.STATIC with pytest.raises(CommandError) as ctx: management.call_command(command, dynamic_schemas=True, verbosity=0) assert str(ctx.value) == "Including dynamic schemas is NOT allowed" command = WhoWillCommand() command.allow_wildcards = False with pytest.raises(TypeError): management.call_command(command, dynamic_schemas=True, verbosity=0) def test_no_tenant_like_schemas_allowed(): command = WhoWillCommand() command.scope = CommandScope.STATIC with pytest.raises(CommandError) as ctx: management.call_command(command, tenant_schemas=True, verbosity=0) assert str(ctx.value) == "Including tenant-like schemas is NOT allowed" command = WhoWillCommand() command.allow_wildcards = False with pytest.raises(TypeError): management.call_command(command, tenant_schemas=True, verbosity=0) def test_nonexisting_schema(): with pytest.raises(CommandError) as ctx: management.call_command("whowill", schemas=["unknown"], verbosity=0) assert str(ctx.value) == "No schema found for 'unknown'" def test_ambiguous_schema(DomainModel): if DomainModel is None: pytest.skip("Domain model is not in use") with pytest.raises(CommandError) as ctx: management.call_command("whowill", schemas=["tenant"], verbosity=0) assert ( str(ctx.value) == "More than one tenant found for schema 'tenant' by domain, please, narrow down the filter" ) def test_specific_schemas(): command = WhoWillCommand() command.specific_schemas = ["blog"] with pytest.raises(CommandError) as ctx: management.call_command(command, schemas=["www"], verbosity=0) assert str(ctx.value) == "This command can only run in ['blog']" def test_nonexisting_schema_excluded(): with pytest.raises(CommandError) as ctx: management.call_command( "whowill", all_schemas=True, excluded_schemas=["unknown"], verbosity=0 ) assert str(ctx.value) == "No schema found for 'unknown' (excluded)" def test_ambiguous_schema_excluded(DomainModel): if DomainModel is None: pytest.skip("Domain model is not in use") with pytest.raises(CommandError) as ctx: management.call_command( "whowill", all_schemas=True, excluded_schemas=["tenant"], verbosity=0 ) assert ( str(ctx.value) == "More than one tenant found for schema 'tenant' by domain (excluded), please, narrow down the filter" ) def test_existing_schema_excluded_ok(): management.call_command("whowill", all_schemas=True, excluded_schemas=["tenant1"], verbosity=0) def test_interactive_ok(): def patched_input(*args, **kwargs): return "blog" with patch("builtins.input", patched_input): management.call_command("whowill", schemas=[], verbosity=0) def test_interactive_nonexisting(): def patched_input(*args, **kwargs): return "unknown" with patch("builtins.input", patched_input): with pytest.raises(CommandError) as ctx: management.call_command("whowill", schemas=[], verbosity=0) assert str(ctx.value) == "No schema found for 'unknown'" def test_mixed_ok(DomainModel): if DomainModel is None: pytest.skip("Domain model is not in use") management.call_command("whowill", all_schemas=True, verbosity=0) management.call_command("whowill", static_schemas=True, verbosity=0) management.call_command("whowill", dynamic_schemas=True, verbosity=0) management.call_command("whowill", tenant_schemas=True, verbosity=0) management.call_command("whowill", schemas=["public", "sample"], verbosity=0) management.call_command( "whowill", all_schemas=True, static_schemas=True, dynamic_schemas=True, tenant_schemas=True, schemas=["public", "sample"], verbosity=0, ) management.call_command( "whowill", all_schemas=True, excluded_schemas=["public", "sample"], verbosity=0 ) management.call_command("whowill", schemas=["everyone.localhost/tenant1"], verbosity=0) management.call_command("whowill", schemas=["tenant1"], verbosity=0) management.call_command( "whowill", all_schemas=True, excluded_schemas=["everyone.localhost/tenant1"], verbosity=0, ) management.call_command("whowill", all_schemas=True, excluded_schemas=["tenant1"], verbosity=0) django-pgschemas-1.2.0/sandbox/tests/commands/test_whowill.py000066400000000000000000000101241512245224300244210ustar00rootroot00000000000000import pytest from django.core import management @pytest.fixture(autouse=True) def _setup(tenant1, tenant2, tenant3, TenantModel, DomainModel): if TenantModel is None: pytest.skip("Dynamic tenants are not in use") if DomainModel: for tenant in [tenant1, tenant2, tenant3]: DomainModel.objects.create( tenant=tenant, domain=f"{tenant.schema_name}.localhost", is_primary=True ) def split_output(buffer): buffer.seek(0) return set(buffer.read().strip().splitlines()) def test_all_schemas(DomainModel, stdout): management.call_command("whowill", all_schemas=True, stdout=stdout) expected_dynamic = ( {"tenant1.localhost", "tenant2.localhost", "tenant3.localhost"} if DomainModel else {"tenant1", "tenant2", "tenant3"} ) assert ( split_output(stdout) == {"public", "sample", "localhost", "blog.localhost"} | expected_dynamic ) def test_static_schemas(stdout): management.call_command("whowill", static_schemas=True, stdout=stdout) assert split_output(stdout) == {"public", "sample", "localhost", "blog.localhost"} def test_tenant_like_schemas(DomainModel, stdout): management.call_command("whowill", tenant_schemas=True, stdout=stdout) expected_dynamic = ( {"tenant1.localhost", "tenant2.localhost", "tenant3.localhost"} if DomainModel else {"tenant1", "tenant2", "tenant3"} ) assert split_output(stdout) == {"sample"} | expected_dynamic def test_dynamic_schemas(DomainModel, stdout): management.call_command("whowill", dynamic_schemas=True, stdout=stdout) expected_dynamic = ( {"tenant1.localhost", "tenant2.localhost", "tenant3.localhost"} if DomainModel else {"tenant1", "tenant2", "tenant3"} ) assert split_output(stdout) == expected_dynamic def test_specific_schemas(DomainModel, stdout): management.call_command("whowill", schemas=["www", "blog", "tenant1"], stdout=stdout) expected_dynamic = {"tenant1.localhost"} if DomainModel else {"tenant1"} assert split_output(stdout) == {"localhost", "blog.localhost"} | expected_dynamic # Same test cases as before, but excluding one def test_all_schemas_minus_one(DomainModel, stdout): management.call_command("whowill", all_schemas=True, excluded_schemas=["blog"], stdout=stdout) expected_dynamic = ( { "tenant1.localhost", "tenant2.localhost", "tenant3.localhost", } if DomainModel else {"tenant1", "tenant2", "tenant3"} ) assert split_output(stdout) == {"public", "sample", "localhost"} | expected_dynamic def test_static_schemas_minus_one(stdout): management.call_command( "whowill", static_schemas=True, excluded_schemas=["sample"], stdout=stdout ) assert split_output(stdout) == {"public", "localhost", "blog.localhost"} def test_tenant_like_schemas_minus_one(DomainModel, stdout): management.call_command( "whowill", tenant_schemas=True, excluded_schemas=["tenant1"], stdout=stdout ) expected_dynamic = ( {"tenant2.localhost", "tenant3.localhost"} if DomainModel else {"tenant2", "tenant3"} ) assert split_output(stdout) == {"sample"} | expected_dynamic def test_dynamic_schemas_minus_one(DomainModel, stdout): management.call_command( "whowill", dynamic_schemas=True, excluded_schemas=["public"], stdout=stdout ) expected_dynamic = ( { "tenant1.localhost", "tenant2.localhost", "tenant3.localhost", } if DomainModel else { "tenant1", "tenant2", "tenant3", } ) assert split_output(stdout) == expected_dynamic def test_specific_schemas_minus_one(DomainModel, stdout): management.call_command( "whowill", schemas=["www", "blog", "tenant1"], excluded_schemas=["www"], stdout=stdout, ) expected_dynamic = {"tenant1.localhost"} if DomainModel else {"tenant1"} assert split_output(stdout) == {"blog.localhost"} | expected_dynamic django-pgschemas-1.2.0/sandbox/tests/conftest.py000066400000000000000000000031651512245224300217300ustar00rootroot00000000000000from io import StringIO import pytest @pytest.fixture(scope="session", autouse=True) def setup(django_db_setup, django_db_blocker): from sandbox.shared_public.models import Tenant with django_db_blocker.unblock(): Tenant.objects.get_or_create(schema_name="tenant1") Tenant.objects.get_or_create(schema_name="tenant2") Tenant.objects.get_or_create(schema_name="tenant3") @pytest.fixture(autouse=True, params=["static-only", "tenants-no-domains", "tenants-and-domains"]) def tenants_settings(request, settings): from copy import deepcopy current = deepcopy(settings.TENANTS) if request.param == "static-only": del settings.TENANTS["default"] if request.param == "tenants-no-domains": del settings.TENANTS["default"]["DOMAIN_MODEL"] yield settings.TENANTS settings.TENANTS.clear() settings.TENANTS.update(current) @pytest.fixture def TenantModel(): from django_pgschemas.utils import get_tenant_model return get_tenant_model() @pytest.fixture def DomainModel(): from django_pgschemas.utils import get_domain_model return get_domain_model() @pytest.fixture def tenant1(db): from sandbox.shared_public.models import Tenant return Tenant.objects.get(schema_name="tenant1") @pytest.fixture def tenant2(db): from sandbox.shared_public.models import Tenant return Tenant.objects.get(schema_name="tenant2") @pytest.fixture def tenant3(db): from sandbox.shared_public.models import Tenant return Tenant.objects.get(schema_name="tenant3") @pytest.fixture def stdout(): with StringIO() as buffer: yield buffer django-pgschemas-1.2.0/sandbox/tests/contrib/000077500000000000000000000000001512245224300211645ustar00rootroot00000000000000django-pgschemas-1.2.0/sandbox/tests/contrib/__init__.py000066400000000000000000000000001512245224300232630ustar00rootroot00000000000000django-pgschemas-1.2.0/sandbox/tests/contrib/test_cache.py000066400000000000000000000013301512245224300236350ustar00rootroot00000000000000from django_pgschemas.contrib.cache import make_key, reverse_key from django_pgschemas.schema import Schema def test_make_key_with_dynamic_tenant(tenant1): with tenant1: key = make_key(key="foo", key_prefix="", version=1) chunks = key.split(":") assert len(chunks) == 4 assert str(tenant1.schema_name) == chunks[0] def test_make_key_with_static_tenant(): with Schema.create(schema_name="www"): key = make_key(key="foo", key_prefix="", version=1) chunks = key.split(":") assert len(chunks) == 4 assert "www" == chunks[0] def test_reverse_key(tenant1): key = "some-key" with tenant1: assert key == reverse_key(make_key(key=key, key_prefix="", version=1)) django-pgschemas-1.2.0/sandbox/tests/contrib/test_channels.py000066400000000000000000000065511512245224300243770ustar00rootroot00000000000000import pytest from channels.testing import WebsocketCommunicator from sandbox.routing import domain_application, headers_application @pytest.mark.asyncio async def test_on_main_domain(): communicator = WebsocketCommunicator( domain_application, "/ws/main/", headers=[(b"host", b"localhost")], ) connected, subprotocol = await communicator.connect() assert connected # Send a message await communicator.send_json_to({"message": "hello"}) # Receive the message response = await communicator.receive_json_from() assert response["message"] == "www: hello" # Close the connection await communicator.disconnect() @pytest.mark.asyncio async def test_on_tenant_subdomain(tenant3, DomainModel): if DomainModel is None: pytest.skip("Domain model is not in use") await DomainModel.objects.acreate(tenant=tenant3, domain="ws_tenant3.localhost") communicator = WebsocketCommunicator( domain_application, "/ws/tenant/", headers=[(b"host", b"ws_tenant3.localhost")], ) connected, subprotocol = await communicator.connect() assert connected # Send a message await communicator.send_json_to({"message": "hello"}) # Receive the message response = await communicator.receive_json_from() assert response["message"] == "tenant3: hello" # Close the connection await communicator.disconnect() @pytest.mark.asyncio async def test_on_tenant_subfolder(tenant3, DomainModel): if DomainModel is None: pytest.skip("Domain model is not in use") await DomainModel.objects.acreate( tenant=tenant3, domain="ws_tenants.localhost", folder="tenant3" ) communicator = WebsocketCommunicator( domain_application, "/tenant3/ws/tenant/", headers=[(b"host", b"ws_tenants.localhost")], ) connected, subprotocol = await communicator.connect() assert connected # Send a message await communicator.send_json_to({"message": "hello"}) # Receive the message response = await communicator.receive_json_from() assert response["message"] == "tenant3: hello" # Close the connection await communicator.disconnect() @pytest.mark.asyncio async def test_on_main_header(): communicator = WebsocketCommunicator( headers_application, "/ws/main/", headers=[(b"tenant", b"main")], ) connected, subprotocol = await communicator.connect() assert connected # Send a message await communicator.send_json_to({"message": "hello"}) # Receive the message response = await communicator.receive_json_from() assert response["message"] == "www: hello" # Close the connection await communicator.disconnect() @pytest.mark.asyncio async def test_on_tenant_header(TenantModel, db): if TenantModel is None: pytest.skip("Dynamic tenants are not in use") communicator = WebsocketCommunicator( headers_application, "/ws/tenant/", headers=[(b"tenant", b"tenant3")], ) connected, subprotocol = await communicator.connect() assert connected # Send a message await communicator.send_json_to({"message": "hello"}) # Receive the message response = await communicator.receive_json_from() assert response["message"] == "tenant3: hello" # Close the connection await communicator.disconnect() django-pgschemas-1.2.0/sandbox/tests/contrib/test_storage.py000066400000000000000000000067741512245224300242570ustar00rootroot00000000000000import os import shutil import tempfile import pytest from django.core.files.base import ContentFile from django_pgschemas.contrib.storage import TenantFileSystemStorage from django_pgschemas.routing.info import DomainInfo from django_pgschemas.schema import Schema STORAGE_BASE_URL = "/base-url/" @pytest.fixture def temp_dir(): value = tempfile.mkdtemp() yield value shutil.rmtree(value) @pytest.fixture def storage(temp_dir): return TenantFileSystemStorage(location=temp_dir, base_url=STORAGE_BASE_URL) @pytest.fixture def settings_pathname(settings): settings.PGSCHEMAS_PATHNAME_FUNCTION = lambda tenant: f"custom-pathname-{tenant.schema_name}" class TestPathIdentifier: def test_basic_dynamic(self, storage, tenant1): with tenant1: assert storage.get_schema_path_identifier() == tenant1.schema_name def test_basic_static(self, storage): with Schema.create(schema_name="www"): assert storage.get_schema_path_identifier() == "www" def test_method_in_tenant(self, storage, tenant1): tenant1.schema_pathname = lambda: "custom-pathname" with tenant1: assert storage.get_schema_path_identifier() == "custom-pathname" del tenant1.schema_pathname def test_function_in_settings(self, tenant1, storage, settings_pathname): with tenant1: assert storage.get_schema_path_identifier() == f"custom-pathname-{tenant1.schema_name}" def test_base_location(storage, temp_dir, tenant1, settings_pathname): with tenant1: assert storage.base_location == f"{temp_dir}/custom-pathname-{tenant1.schema_name}/" def test_base_url(storage, tenant1): tenant1.routing = DomainInfo(domain="irrelevant", folder="tenant1") with tenant1: assert storage.base_url == STORAGE_BASE_URL tenant1.routing = None def test_file_path(storage, temp_dir, tenant1): assert not storage.exists("test.file") with tenant1: f = ContentFile("random content") f_name = storage.save("test.file", f) assert os.path.join(temp_dir, tenant1.schema_name, f_name) == storage.path(f_name) storage.delete(f_name) assert not storage.exists("test.file") def test_file_save_with_path(storage, temp_dir, tenant1): assert not storage.exists("path/to") with tenant1: storage.save("path/to/test.file", ContentFile("file saved with path")) assert storage.exists("path/to") with storage.open("path/to/test.file") as f: assert f.read() == b"file saved with path" assert os.path.exists( os.path.join(temp_dir, tenant1.schema_name, "path", "to", "test.file") ) storage.delete("path/to/test.file") assert not storage.exists("test.file") def test_file_url_simple(storage, tenant1): tenant1.routing = DomainInfo(domain="irrelevant", folder="tenant1") with tenant1: assert storage.url("test.file") == "/base-url/test.file" tenant1.routing = None def test_file_url_complex(storage, tenant1): with tenant1: assert ( storage.url(r"~!*()'@#$%^&*abc`+ =.file") == f"/base-url/{tenant1.schema_name}/~!*()'%40%23%24%25%5E%26*abc%60%2B%20%3D.file" ) assert storage.url("ab\0c") == f"/base-url/{tenant1.schema_name}/ab%00c" assert storage.url("a/b\\c.file") == f"/base-url/{tenant1.schema_name}/a/b/c.file" assert storage.url("") == f"/base-url/{tenant1.schema_name}/" assert storage.url(None) == f"/base-url/{tenant1.schema_name}/" django-pgschemas-1.2.0/sandbox/tests/routing/000077500000000000000000000000001512245224300212135ustar00rootroot00000000000000django-pgschemas-1.2.0/sandbox/tests/routing/__init__.py000066400000000000000000000000001512245224300233120ustar00rootroot00000000000000django-pgschemas-1.2.0/sandbox/tests/routing/test_middleware.py000066400000000000000000000212431512245224300247430ustar00rootroot00000000000000from itertools import permutations from unittest.mock import MagicMock import pytest from django.http import Http404 from django_pgschemas.routing.info import DomainInfo, HeadersInfo, SessionInfo from django_pgschemas.routing.middleware import ( DomainRoutingMiddleware, HeadersRoutingMiddleware, SessionRoutingMiddleware, strip_tenant_from_path_factory, ) @pytest.mark.parametrize( "path, prefix, expected", [ ("/some/path/", "", "/some/path/"), ("/some/path/", "path", "/some/path/"), ("/some/path/", "some", "/path/"), ], ) def test_strip_tenant_from_path_factory(path, prefix, expected): actual = strip_tenant_from_path_factory(prefix)(path) assert actual == expected class FakeRequest: def __init__( self, *, domain: str = "", path: str = "", session_tenant_ref: str | None = None, headers_tenant_ref: str | None = None, ) -> None: self.domain = domain self.path = path self.session_tenant_ref = session_tenant_ref self.headers_tenant_ref = headers_tenant_ref def get_host(self) -> str: return self.domain @property def session(self) -> dict: return { "tenant": self.session_tenant_ref, } @property def headers(self) -> dict: return { "tenant": self.headers_tenant_ref, } class TestDomainRoutingMiddleware: @pytest.fixture(autouse=True) def _setup(self, tenant1, tenant2, DomainModel): if DomainModel is None: pytest.skip("Domain model is not in use") DomainModel.objects.create( tenant=tenant1, domain="tenant1.localhost", is_primary=True, ) DomainModel.objects.create( tenant=tenant1, domain="tenants.localhost", folder="tenant1", is_primary=False, ) DomainModel.objects.create( tenant=tenant2, domain="tenant2.localhost", is_primary=True, ) DomainModel.objects.create( tenant=tenant2, domain="tenants.localhost", folder="tenant2", is_primary=False, ) @pytest.mark.parametrize( "domain, path, schema_name", [ ("tenant1.localhost", "", "tenant1"), ("tenants.localhost", "tenant1", "tenant1"), ("tenant2.localhost", "", "tenant2"), ("tenants.localhost", "tenant2", "tenant2"), ("tenant3.localhost", "", None), ("localhost", "", "www"), ("blog.localhost", "", "blog"), ("tenants.localhost", "", "www"), # fallback domains ], ) def test_tenant_matching(self, domain, path, schema_name, db): request = FakeRequest(domain=domain, path=f"/{path}/some/path/") get_response = MagicMock() handler = DomainRoutingMiddleware(get_response) if schema_name is None: with pytest.raises(Http404): handler(request) else: handler(request) assert request.tenant is not None assert request.tenant.schema_name == schema_name assert isinstance(request.tenant.routing, DomainInfo) assert request.tenant.routing.domain == domain assert request.tenant.routing.folder == (path if path else None) class TestDomainRoutingMiddlewareRedirection: @pytest.fixture(autouse=True) def _setup(self, tenant1, tenant2, DomainModel): if DomainModel is None: pytest.skip("Domain model is not in use") DomainModel(domain="tenant1.localhost", tenant=tenant1).save() DomainModel( domain="tenant1redirect.localhost", tenant=tenant1, is_primary=False, redirect_to_primary=True, ).save() DomainModel( domain="everyone.localhost", folder="tenant1redirect", tenant=tenant1, is_primary=False, redirect_to_primary=True, ).save() DomainModel(domain="everyone.localhost", folder="tenant2", tenant=tenant2).save() DomainModel( domain="tenant2redirect.localhost", tenant=tenant2, is_primary=False, redirect_to_primary=True, ).save() DomainModel( domain="everyone.localhost", folder="tenant2redirect", tenant=tenant2, is_primary=False, redirect_to_primary=True, ).save() @pytest.mark.parametrize( "domain, path, expected_redirection", [ ( "tenant1redirect.localhost", "/some/random/url/", "//tenant1.localhost/some/random/url/", ), ( "everyone.localhost", "/tenant1redirect/some/random/url/", "//tenant1.localhost/some/random/url/", ), ( "tenant2redirect.localhost", "/some/random/url/", "//everyone.localhost/tenant2/some/random/url/", ), ( "everyone.localhost", "/tenant2redirect/some/random/url/", "//everyone.localhost/tenant2/some/random/url/", ), ], ) def test_redirection(self, domain, path, expected_redirection): request = FakeRequest(domain=domain, path=path) get_response = MagicMock() response = DomainRoutingMiddleware(get_response)(request) assert response.status_code == 301 assert response.url == expected_redirection assert response["Location"] == expected_redirection class TestSessionRoutingMiddleware: @pytest.mark.parametrize( "session_key, schema_name", [ ("www", "www"), ("main", "www"), ("blog", "blog"), ("tenant1", "tenant1"), ], ) def test_tenant_matching(self, DomainModel, session_key, schema_name, db): if DomainModel is None and "tenant" in schema_name: pytest.skip("Domain model is not in use") request = FakeRequest(session_tenant_ref=session_key) get_response = MagicMock() handler = SessionRoutingMiddleware(get_response) handler(request) assert request.tenant is not None assert request.tenant.schema_name == schema_name assert isinstance(request.tenant.routing, SessionInfo) assert request.tenant.routing.reference == session_key class TestHeadersRoutingMiddleware: @pytest.mark.parametrize( "header, schema_name", [ ("www", "www"), ("main", "www"), ("blog", "blog"), ("tenant1", "tenant1"), ], ) def test_tenant_matching(self, DomainModel, header, schema_name, db): if DomainModel is None and "tenant" in schema_name: pytest.skip("Domain model is not in use") request = FakeRequest(headers_tenant_ref=header) get_response = MagicMock() handler = HeadersRoutingMiddleware(get_response) handler(request) assert request.tenant is not None assert request.tenant.schema_name == schema_name assert isinstance(request.tenant.routing, HeadersInfo) assert request.tenant.routing.reference == header @pytest.mark.parametrize( "first_middleware, second_middleware, last_middleware", permutations([DomainRoutingMiddleware, SessionRoutingMiddleware, HeadersRoutingMiddleware]), ) def test_last_middleware_prevails( first_middleware, second_middleware, last_middleware, tenant1, tenant2, tenant3, DomainModel ): if DomainModel is None: pytest.skip("Domain model is not in use") DomainModel.objects.create( domain="tenants.localhost", tenant=tenant1, folder="tenant1", is_primary=True, ) request = FakeRequest( domain="tenants.localhost", path="/tenant1/some/path/", session_tenant_ref=tenant2.schema_name, headers_tenant_ref=tenant3.schema_name, ) get_response = MagicMock() handler = first_middleware(second_middleware(last_middleware(get_response))) handler(request) if last_middleware is DomainRoutingMiddleware: assert request.tenant == tenant1 assert isinstance(request.tenant.routing, DomainInfo) if last_middleware is SessionRoutingMiddleware: assert request.tenant == tenant2 assert isinstance(request.tenant.routing, SessionInfo) if last_middleware is HeadersRoutingMiddleware: assert request.tenant == tenant3 assert isinstance(request.tenant.routing, HeadersInfo) django-pgschemas-1.2.0/sandbox/tests/routing/test_models.py000066400000000000000000000053611512245224300241140ustar00rootroot00000000000000import pytest from django_pgschemas.routing.models import get_primary_domain_for_tenant @pytest.fixture(autouse=True) def _setup(DomainModel): if DomainModel is None: pytest.skip("Domain model is not in use") @pytest.mark.parametrize( "domain, folder, expected", [ ("tenants.localhost", "", "tenants.localhost"), ("tenants.localhost", "tenant1", "tenants.localhost/tenant1"), ], ) def test_str(tenant1, domain, folder, expected, DomainModel): item = DomainModel.objects.create( tenant=tenant1, domain=domain, folder=folder, ) assert str(item) == expected def test_only_one_primary(tenant1, DomainModel): domain1 = DomainModel.objects.create( tenant=tenant1, domain="tenant1.localhost", folder="", is_primary=True, ) assert domain1.is_primary domain2 = DomainModel.objects.create( tenant=tenant1, domain="tenants.localhost", folder="tenant1", is_primary=True, ) domain1.refresh_from_db() assert not domain1.is_primary assert domain2.is_primary @pytest.mark.parametrize("is_primary", [True, False]) def test_redirect_to_primary_if_primary(tenant1, is_primary, DomainModel): domain1 = DomainModel.objects.create( domain="tenant1.localhost", folder="", is_primary=is_primary, redirect_to_primary=True, tenant=tenant1, ) assert domain1.redirect_to_primary is not domain1.is_primary @pytest.mark.parametrize( "domain, folder, path, expected", [ ("tenants.localhost", "", "", "//tenants.localhost/"), ("tenants.localhost", "tenant1", "", "//tenants.localhost/tenant1/"), ("tenants.localhost", "tenant1", "some/path", "//tenants.localhost/tenant1/some/path"), ("tenants.localhost", "tenant1", "/some/path", "//tenants.localhost/tenant1/some/path"), ("tenants.localhost", "tenant1", "/some/path/", "//tenants.localhost/tenant1/some/path/"), ], ) def test_absolute_url(tenant1, domain, folder, path, expected, DomainModel): item = DomainModel.objects.create( tenant=tenant1, domain=domain, folder=folder, ) assert item.absolute_url(path) == expected @pytest.mark.parametrize("is_primary", [True, False, None]) def test_get_primary_domain_for_tenant(tenant1, is_primary, DomainModel): if is_primary is not None: item = DomainModel.objects.create( tenant=tenant1, domain="tenant1.localhost", ) DomainModel.objects.update(is_primary=is_primary) item.refresh_from_db() if is_primary: assert get_primary_domain_for_tenant(tenant1) == item else: assert get_primary_domain_for_tenant(tenant1) is None django-pgschemas-1.2.0/sandbox/tests/routing/test_urlresolvers.py000066400000000000000000000014701512245224300253750ustar00rootroot00000000000000from django.conf import settings from django.urls import reverse from django_pgschemas.routing.info import DomainInfo from django_pgschemas.routing.urlresolvers import get_dynamic_tenant_prefixed_urlconf def test_no_tenant(): url = reverse("profile") assert url == "/profile/" def test_tenant_with_no_folder(tenant1): with tenant1: url = reverse("profile") assert url == "/profile/" def test_tenant_with_folder(tenant1): tenant1.routing = DomainInfo(domain="irrelevant", folder="tenant1") dynamic_path = settings.ROOT_URLCONF + "_dynamically_tenant_prefixed" urlconf = get_dynamic_tenant_prefixed_urlconf(settings.ROOT_URLCONF, dynamic_path) with tenant1: url = reverse("profile", urlconf=urlconf) tenant1.routing = None assert url == "/tenant1/profile/" django-pgschemas-1.2.0/sandbox/tests/test_apps.py000066400000000000000000000010121512245224300220720ustar00rootroot00000000000000from unittest.mock import patch from django.apps import apps @patch("django_pgschemas.checks.ensure_tenant_dict") @patch("django_pgschemas.checks.ensure_public_schema") @patch("django_pgschemas.checks.ensure_default_schemas") @patch("django_pgschemas.checks.ensure_overall_schemas") @patch("django_pgschemas.checks.ensure_extra_search_paths") def test_all_checkers_called(*checkers): config = apps.get_app_config("django_pgschemas") config.ready() for checker in checkers: checker.assert_called() django-pgschemas-1.2.0/sandbox/tests/test_checks.py000066400000000000000000000242241512245224300224010ustar00rootroot00000000000000import pytest from django.apps import apps from django.core.checks import Critical, Error, Warning from django.core.exceptions import ImproperlyConfigured from django_pgschemas import checks @pytest.fixture def app_config(): return apps.get_app_config("django_pgschemas") @pytest.fixture def tenant_manager(TenantModel, db): if TenantModel is None: yield None else: backup = TenantModel.auto_create_schema TenantModel.auto_create_schema = False yield TenantModel._default_manager TenantModel.auto_create_schema = backup def test_get_tenant_app(tenants_settings, TenantModel): if TenantModel: assert checks.get_tenant_app() == "sandbox.shared_public" del tenants_settings["default"] assert checks.get_tenant_app() is None def test_get_domain_app(tenants_settings, DomainModel): if DomainModel: assert checks.get_domain_app() == "sandbox.shared_public" del tenants_settings["default"]["DOMAIN_MODEL"] assert checks.get_domain_app() is None def test_get_user_app(settings): assert checks.get_user_app() == "sandbox.shared_common" del settings.AUTH_USER_MODEL assert checks.get_user_app() is None def test_get_session_app(settings): assert checks.get_session_app() == "django.contrib.sessions" def test_ensure_tenant_dict(settings): backup = settings.TENANTS del settings.TENANTS with pytest.raises(ImproperlyConfigured) as ctx: checks.ensure_tenant_dict() assert str(ctx.value) == "TENANTS dict setting not set." settings.TENANTS = backup class TestEnsurePublicSchema: @pytest.mark.parametrize("value", [None, "", 1]) def test_no_dict(self, tenants_settings, value): tenants_settings["public"] = value with pytest.raises(ImproperlyConfigured) as ctx: checks.ensure_public_schema() assert str(ctx.value) == "TENANTS must contain a 'public' dict." @pytest.mark.parametrize( "member", [ "URLCONF", "WS_URLCONF", "DOMAINS", "FALLBACK_DOMAINS", "SESSION_KEY", "HEADER", ], ) def test_invalid_members(self, tenants_settings, member): tenants_settings["public"][member] = None with pytest.raises(ImproperlyConfigured) as ctx: checks.ensure_public_schema() assert str(ctx.value) == f"TENANTS['public'] cannot contain a '{member}' key." class TestEnsureDefaultSchema: def test_static_only(self, tenants_settings): if "default" in tenants_settings: del tenants_settings["default"] checks.ensure_default_schemas() @pytest.mark.parametrize("value", [None, "", 1]) def test_no_dict(self, tenants_settings, value): tenants_settings["default"] = value with pytest.raises(ImproperlyConfigured) as ctx: checks.ensure_default_schemas() assert str(ctx.value) == "TENANTS must contain a 'default' dict." @pytest.mark.parametrize( "member", [ "DOMAINS", "FALLBACK_DOMAINS", "SESSION_KEY", "HEADER", ], ) def test_invalid_members(self, tenants_settings, member): if "default" not in tenants_settings: pytest.skip("default not in tenant settings") tenants_settings["default"][member] = None with pytest.raises(ImproperlyConfigured) as ctx: checks.ensure_default_schemas() assert str(ctx.value) == f"TENANTS['default'] cannot contain a '{member}' key." @pytest.mark.parametrize( "member", [ "TENANT_MODEL", "URLCONF", ], ) def test_required_members(self, tenants_settings, member): if "default" not in tenants_settings: pytest.skip("default not in tenant settings") del tenants_settings["default"][member] with pytest.raises(ImproperlyConfigured) as ctx: checks.ensure_default_schemas() assert str(ctx.value) == f"TENANTS['default'] must contain a '{member}' key." @pytest.mark.parametrize( "name", [ "public", "default", "blog", "www", ], ) def test_clone_reference_invalid_name(self, tenants_settings, name): if "default" not in tenants_settings: pytest.skip("default not in tenant settings") tenants_settings["default"]["CLONE_REFERENCE"] = name with pytest.raises(ImproperlyConfigured) as ctx: checks.ensure_default_schemas() assert ( str(ctx.value) == "TENANTS['default']['CLONE_REFERENCE'] must be a unique schema name." ) class TestEnsureOverallSchema: @pytest.mark.parametrize( "name", [ "pg_something", "1something", ".something", "-something", "tomanycharacters0123456789abcdef0123456789abcef0123456789abcdef0", ], ) def test_invalid_names(self, tenants_settings, name): tenants_settings[name] = {} with pytest.raises(ImproperlyConfigured) as ctx: checks.ensure_overall_schemas() assert str(ctx.value) == f"'{name}' is not a valid schema name." @pytest.mark.parametrize( "extra", [ "public", "blog", "www", # "default", ], ) def test_ensure_extra_search_paths(settings, extra, db): settings.PGSCHEMAS_EXTRA_SEARCH_PATHS = [extra] with pytest.raises(ImproperlyConfigured) as ctx: checks.ensure_extra_search_paths() invalid = ", ".join([extra]) assert str(ctx.value) == f"Do not include '{invalid}' on PGSCHEMAS_EXTRA_SEARCH_PATHS." class TestCheckPrincipalApps: BASE_DEFAULT = {"TENANT_MODEL": "shared_public.Tenant", "DOMAIN_MODEL": "shared_public.DOMAIN"} def test_location_wrong(self, tenants_settings, app_config): tenants_settings.update( { "public": {"APPS": []}, "default": self.BASE_DEFAULT, } ) expected_errors = [ Error( "Your tenant app 'sandbox.shared_public' must be on the 'public' schema.", id="pgschemas.W001", ), Error( "Your domain app 'sandbox.shared_public' must be on the 'public' schema.", id="pgschemas.W001", ), ] errors = checks.check_principal_apps(app_config) assert errors == expected_errors def test_location_twice(self, tenants_settings, app_config): tenants_settings.update( { "public": {"APPS": ["sandbox.shared_public"]}, "default": {**self.BASE_DEFAULT, "APPS": ["sandbox.shared_public"]}, } ) expected_errors = [ Error( "Your tenant app 'sandbox.shared_public' in TENANTS['default']['APPS'] " "must be on the 'public' schema only.", id="pgschemas.W001", ), Error( "Your domain app 'sandbox.shared_public' in TENANTS['default']['APPS'] " "must be on the 'public' schema only.", id="pgschemas.W001", ), ] errors = checks.check_principal_apps(app_config) assert errors == expected_errors class TestCheckOtherApps: def test_contenttypes_location_wrong(self, tenants_settings, app_config): tenants_settings.update( { "default": {"APPS": ["django.contrib.contenttypes"]}, } ) expected_errors = [ Warning( "'django.contrib.contenttypes' in TENANTS['default']['APPS'] " "must be on 'public' schema only.", id="pgschemas.W002", ) ] errors = checks.check_other_apps(app_config) assert errors == expected_errors def test_contenttypes_location_twice(self, tenants_settings, app_config): tenants_settings.update( { "default": {}, "www": {"APPS": ["django.contrib.contenttypes"]}, } ) expected_errors = [ Warning( "'django.contrib.contenttypes' in TENANTS['www']['APPS'] " "must be on 'public' schema only.", id="pgschemas.W002", ) ] errors = checks.check_other_apps(app_config) assert errors == expected_errors def test_user_location_wrong(self, tenants_settings, app_config): user_app = checks.get_user_app() tenants_settings.update( { "default": {"APPS": ["django.contrib.sessions"]}, } ) expected_errors = [ Warning( f"'{user_app}' must be together with 'django.contrib.sessions' " "in TENANTS['default']['APPS'].", id="pgschemas.W003", ) ] errors = checks.check_other_apps(app_config) assert errors == expected_errors def test_session_location_wrong(self, tenants_settings, app_config): user_app = checks.get_user_app() tenants_settings.update( { "www": {"APPS": ["shared_common", user_app]}, "default": {"APPS": ["shared_common"]}, } ) expected_errors = [ Warning( f"'django.contrib.sessions' must be together with '{user_app}' " "in TENANTS['www']['APPS'].", id="pgschemas.W003", ) ] errors = checks.check_other_apps(app_config) assert errors == expected_errors @pytest.mark.parametrize("schema", ["public", "www", "blog", "sample"]) def test_check_schema_names(schema, app_config, tenant_manager): if tenant_manager is None: pytest.skip("Dynamic tenants are not in use") tenant_manager.create(schema_name=schema) expected_errors = [ Critical( f"Name clash found between static and dynamic tenants: {{'{schema}'}}", id="pgschemas.W004", ), ] errors = checks.check_schema_names(app_config) assert errors == expected_errors django-pgschemas-1.2.0/sandbox/tests/test_log.py000066400000000000000000000027001512245224300217150ustar00rootroot00000000000000from django_pgschemas.log import SchemaContextFilter from django_pgschemas.routing.info import DomainInfo, HeadersInfo, SessionInfo from django_pgschemas.schema import Schema class FakeRecord: pass class TestSchemaContextFilter: def test_filter_with_domain(self): record = FakeRecord() scf = SchemaContextFilter() with Schema.create( schema_name="some-tenant", routing=DomainInfo( domain="some-tenant.some-url.com", folder="folder1", ), ): scf.filter(record) assert record.schema_name == "some-tenant" assert record.domain == "some-tenant.some-url.com" assert record.folder == "folder1" def test_filter_with_session(self): record = FakeRecord() scf = SchemaContextFilter() with Schema.create( schema_name="some-tenant", routing=SessionInfo(reference="tenant1"), ): scf.filter(record) assert record.schema_name == "some-tenant" assert record.reference == "tenant1" def test_filter_with_header(self): record = FakeRecord() scf = SchemaContextFilter() with Schema.create( schema_name="some-tenant", routing=HeadersInfo(reference="tenant1"), ): scf.filter(record) assert record.schema_name == "some-tenant" assert record.reference == "tenant1" django-pgschemas-1.2.0/sandbox/tests/test_schema.py000066400000000000000000000065221512245224300224020ustar00rootroot00000000000000import pytest from django.template import Context, Template from django_pgschemas.routing.info import DomainInfo, HeadersInfo, SessionInfo from django_pgschemas.schema import ( Schema, deactivate, get_current_schema, get_default_schema, override, shallow_equal, ) @pytest.mark.parametrize( "schema1, schema2, equals", [ ( get_default_schema(), get_default_schema(), True, ), ( Schema.create("test1"), Schema.create("test1"), True, ), ( Schema.create("test1"), Schema.create("test2"), False, ), ( Schema.create("test1"), Schema.create("test1", SessionInfo("ref1")), False, ), ( Schema.create("test1", HeadersInfo("ref1")), Schema.create("test1", SessionInfo("ref1")), False, ), ( Schema.create("test1", HeadersInfo("ref1")), Schema.create("test1", HeadersInfo("ref1")), True, ), ( Schema.create("test1", DomainInfo("domain1")), Schema.create("test1", DomainInfo("domain1", "folder")), False, ), ( Schema.create("test1", DomainInfo("domain1", "folder")), Schema.create("test1", DomainInfo("domain1", "folder")), True, ), ], ) def test_shallow_equal(schema1, schema2, equals): assert shallow_equal(schema1, schema2) == equals def test_nested_override(): deactivate() schema1 = Schema.create(schema_name="schema_1") schema2 = Schema.create(schema_name="schema_2") assert get_current_schema().schema_name == get_default_schema().schema_name with override(schema1): assert get_current_schema().schema_name == schema1.schema_name with override(schema2): assert get_current_schema().schema_name == schema2.schema_name with override(schema1): assert get_current_schema().schema_name == schema1.schema_name assert get_current_schema().schema_name == schema2.schema_name assert get_current_schema().schema_name == schema1.schema_name assert get_current_schema().schema_name == get_default_schema().schema_name def test_nested_class_override(): deactivate() schema1 = Schema.create(schema_name="schema_1") schema2 = Schema.create(schema_name="schema_2") assert get_current_schema().schema_name == get_default_schema().schema_name with schema1: assert get_current_schema().schema_name == schema1.schema_name with schema2: assert get_current_schema().schema_name == schema2.schema_name with schema1: assert get_current_schema().schema_name == schema1.schema_name assert get_current_schema().schema_name == schema2.schema_name assert get_current_schema().schema_name == schema1.schema_name assert get_current_schema().schema_name == get_default_schema().schema_name def test_schema_is_template_renderable(): schema = Schema.create(schema_name="template_schema") context = Context({"schema": schema}) template = Template("{{ schema.schema_name }}") rendered = template.render(context) assert rendered == "template_schema" django-pgschemas-1.2.0/sandbox/tests/test_signals.py000066400000000000000000000042641512245224300226030ustar00rootroot00000000000000from unittest.mock import MagicMock import pytest from django_pgschemas.schema import Schema, activate, deactivate, get_default_schema from django_pgschemas.signals import schema_activate from django_pgschemas.utils import schema_exists def test_schema_activate(): deactivate() schema = Schema.create(schema_name="test") receiver = MagicMock() schema_activate.connect(receiver) activate(schema) schema_activate.disconnect(receiver) receiver.assert_called_once_with(signal=schema_activate, sender=Schema, schema=schema) def test_schema_double_activate(): deactivate() schema = Schema.create(schema_name="test") receiver = MagicMock() schema_activate.connect(receiver) activate(schema) activate(schema) schema_activate.disconnect(receiver) receiver.assert_called_once_with(signal=schema_activate, sender=Schema, schema=schema) def test_schema_deactivate(): schema = Schema.create(schema_name="test") activate(schema) receiver = MagicMock() schema_activate.connect(receiver) deactivate() schema_activate.disconnect(receiver) receiver.assert_called_once_with( signal=schema_activate, sender=Schema, schema=get_default_schema() ) def test_schema_override(): deactivate() schema = Schema.create(schema_name="test") receiver = MagicMock() schema_activate.connect(receiver) with schema: pass schema_activate.disconnect(receiver) receiver.assert_called_once_with(signal=schema_activate, sender=Schema, schema=schema) def test_tenant_delete_callback(TenantModel, db): if TenantModel is None: pytest.skip("Dynamic tenants are not in use") backup_create, backup_drop = TenantModel.auto_create_schema, TenantModel.auto_drop_schema TenantModel.auto_create_schema = False TenantModel.auto_drop_schema = True tenant = TenantModel(schema_name="tenant_signal") tenant.save() tenant.create_schema(sync_schema=False) assert schema_exists("tenant_signal") TenantModel.objects.all().delete() assert not schema_exists("tenant_signal") TenantModel.auto_create_schema, TenantModel.auto_drop_schema = backup_create, backup_drop django-pgschemas-1.2.0/sandbox/tests/test_tenant_integration.py000066400000000000000000000223771512245224300250440ustar00rootroot00000000000000from contextlib import contextmanager import pytest from django.apps import apps from django.contrib.auth import authenticate from django.db import ProgrammingError, transaction from django_pgschemas.schema import Schema, deactivate from django_pgschemas.signals import dynamic_tenant_post_sync from django_pgschemas.utils import schema_exists @pytest.fixture def BlogEntryModel(): return apps.get_model("app_blog.BlogEntry") @pytest.fixture def CatalogModel(): return apps.get_model("shared_public.Catalog") @pytest.fixture def MainDataModel(): return apps.get_model("app_main.MainData") @pytest.fixture def UserModel(): return apps.get_model("shared_common.User") @pytest.fixture def TenantDataModel(TenantModel): return apps.get_model("app_tenants.TenantData") if TenantModel is not None else None class ControlledException(Exception): pass @contextmanager def controlled_raises(exception): """ Since we are expecting database errors, we must use savepoints in order to make sure multiple errors can be caught in the same test case. """ sid = transaction.savepoint() with pytest.raises(exception): yield transaction.savepoint_rollback(sid) class TestTenantAutomaticOperations: @pytest.fixture(autouse=True) def _setup(self, TenantModel): if TenantModel is None: pytest.skip("Dynamic tenants are not in use") def test_new_creation_deletion(self, TenantModel, db): assert not schema_exists("new_tenant1") tenant = TenantModel(schema_name="new_tenant1") tenant.save(verbosity=0) assert schema_exists("new_tenant1") tenant.delete(force_drop=True) assert not schema_exists("new_tenant1") def test_existing_creation(self, TenantModel, db): assert not schema_exists("new_tenant1") tenant = TenantModel(schema_name="new_tenant1") tenant.auto_create_schema = False tenant.save(verbosity=0) assert not schema_exists("new_tenant1") tenant.auto_create_schema = True tenant.save(verbosity=0) assert schema_exists("new_tenant1") tenant.delete(force_drop=True) assert not schema_exists("new_tenant1") def test_new_aborted_creation(self, TenantModel, db): def signal_receiver(*args, **kwargs): raise ControlledException assert not schema_exists("new_tenant1") tenant = TenantModel(schema_name="new_tenant1") dynamic_tenant_post_sync.connect(signal_receiver) with pytest.raises(ControlledException): tenant.save(verbosity=0) assert not schema_exists("new_tenant1") assert not TenantModel.objects.filter(schema_name="new_tenant1").exists() dynamic_tenant_post_sync.disconnect(signal_receiver) def test_existing_aborted_creation(self, TenantModel, db): def signal_receiver(*args, **kwargs): raise ControlledException assert not schema_exists("new_tenant1") tenant = TenantModel(schema_name="new_tenant1") tenant.auto_create_schema = False tenant.save(verbosity=0) tenant.auto_create_schema = True dynamic_tenant_post_sync.connect(signal_receiver) with pytest.raises(ControlledException): tenant.save(verbosity=0) assert not schema_exists("new_tenant1") assert TenantModel.objects.filter(schema_name="new_tenant1").exists() dynamic_tenant_post_sync.disconnect(signal_receiver) tenant.delete(force_drop=True) assert not TenantModel.objects.filter(schema_name="new_tenant1").exists() class TestTenantIntegration: @pytest.fixture(autouse=True) def _setup( self, tenant1, CatalogModel, UserModel, MainDataModel, BlogEntryModel, TenantDataModel ): catalog = CatalogModel.objects.create() CatalogModel.objects.create() with Schema.create(schema_name="www"): user = UserModel.objects.create(email="main@localhost", display_name="Main User") user.set_password("weakpassword") user.save() MainDataModel.objects.create() with Schema.create(schema_name="blog"): user = UserModel.objects.create(email="blog@localhost", display_name="Blog User") user.set_password("weakpassword") user.save() BlogEntryModel.objects.create(user=user) if TenantDataModel is not None: with tenant1: user = UserModel.objects.create( email="tenant@localhost", display_name="Tenant User" ) user.set_password("weakpassword") user.save() TenantDataModel.objects.create(user=user, catalog=catalog) def test_migrated_public_apps( self, CatalogModel, UserModel, MainDataModel, BlogEntryModel, TenantDataModel ): deactivate() # Apps expected to be migrated assert CatalogModel.objects.count() == 2 # Apps expected to NOT be migrated with controlled_raises(ProgrammingError): list(UserModel.objects.all()) with controlled_raises(ProgrammingError): list(MainDataModel.objects.all()) with controlled_raises(ProgrammingError): list(BlogEntryModel.objects.all()) if TenantDataModel is not None: with controlled_raises(ProgrammingError): list(TenantDataModel.objects.all()) def test_migrated_main_apps( self, CatalogModel, UserModel, MainDataModel, BlogEntryModel, TenantDataModel ): with Schema.create(schema_name="www"): # Apps expected to be migrated assert CatalogModel.objects.count() == 2 assert UserModel.objects.count() == 1 assert MainDataModel.objects.count() == 1 # Apps expected to NOT be migrated with controlled_raises(ProgrammingError): list(BlogEntryModel.objects.all()) if TenantDataModel is not None: with controlled_raises(ProgrammingError): list(TenantDataModel.objects.all()) def test_migrated_blog_apps( self, CatalogModel, UserModel, MainDataModel, BlogEntryModel, TenantDataModel ): with Schema.create(schema_name="blog"): # Apps expected to be migrated assert CatalogModel.objects.count() == 2 assert UserModel.objects.count() == 1 assert BlogEntryModel.objects.count() == 1 # Direct and reverse relations assert UserModel.objects.first() == BlogEntryModel.objects.first().user assert UserModel.objects.first().blogs.first() == BlogEntryModel.objects.first() # Apps expected to NOT be migrated with controlled_raises(ProgrammingError): list(MainDataModel.objects.all()) if TenantDataModel is not None: with controlled_raises(ProgrammingError): list(TenantDataModel.objects.all()) def test_migrated_tenant_apps( self, tenant1, CatalogModel, UserModel, MainDataModel, BlogEntryModel, TenantDataModel ): if not TenantDataModel: pytest.skip("Dynamic tenants are not in use") with tenant1: # Apps expected to be migrated assert CatalogModel.objects.count() == 2 assert UserModel.objects.count() == 1 assert TenantDataModel.objects.count() == 1 # Direct and reverse relations assert UserModel.objects.first() == TenantDataModel.objects.first().user assert ( UserModel.objects.first().tenant_objects.first() == TenantDataModel.objects.first() ) assert CatalogModel.objects.first() == TenantDataModel.objects.first().catalog assert ( CatalogModel.objects.first().tenant_objects.first() == TenantDataModel.objects.first() ) # Apps expected to NOT be migrated with controlled_raises(ProgrammingError): list(MainDataModel.objects.all()) with controlled_raises(ProgrammingError): list(BlogEntryModel.objects.all()) def test_cross_authentication(self, tenant1, TenantModel): with Schema.create(schema_name="www"): assert authenticate(email="main@localhost", password="weakpassword") assert not authenticate(email="blog@localhost", password="weakpassword") assert not authenticate(email="tenant@localhost", password="weakpassword") with Schema.create(schema_name="blog"): assert not authenticate(email="main@localhost", password="weakpassword") assert authenticate(email="blog@localhost", password="weakpassword") assert not authenticate(email="tenant@localhost", password="weakpassword") if TenantModel is not None: with tenant1: assert not authenticate(email="main@localhost", password="weakpassword") assert not authenticate(email="blog@localhost", password="weakpassword") assert authenticate(email="tenant@localhost", password="weakpassword") with controlled_raises(ProgrammingError): authenticate(email="irrelevant@localhost", password="irrelevant") django-pgschemas-1.2.0/sandbox/tests/test_tenant_views.py000066400000000000000000000062771512245224300236570ustar00rootroot00000000000000import pytest from django.apps import apps from django.test import Client from django_pgschemas.routing.info import DomainInfo from django_pgschemas.schema import Schema @pytest.fixture def UserModel(): return apps.get_model("shared_common.User") @pytest.fixture(autouse=True) def _setup(UserModel, db): with Schema.create("www"): UserModel.objects.create(email="user_www@localhost", display_name="Admin") with Schema.create("blog"): UserModel.objects.create(email="user_blog@localhost", display_name="Admin") @pytest.fixture def _setup_dynamic(tenant1, UserModel, DomainModel): if DomainModel is None: pytest.skip("Domain model is not in use") DomainModel.objects.create(tenant=tenant1, domain="tenant1.localhost", is_primary=True) DomainModel.objects.create(tenant=tenant1, domain="everyone.localhost", folder="tenant1") with tenant1: yield UserModel.objects.create(email="user1@localhost", display_name="Admin") @pytest.mark.parametrize( "url, expected_status", [ ("/", 200), ("/register/", 200), ("/admin/", 302), ("/non-existing/", 404), ], ) def test_views_www(url, expected_status): client = Client(headers={"host": "localhost"}) response = client.get(url) assert response.status_code == expected_status @pytest.mark.parametrize( "url, expected_status", [ ("/", 200), ("/entries/", 200), ("/admin/", 302), ("/non-existing/", 404), ], ) def test_views_blog(url, expected_status): client = Client(headers={"host": "blog.localhost"}) response = client.get(url) assert response.status_code == expected_status @pytest.mark.parametrize( "url, expected_status", [ ("/", 200), ("/profile/", 200), ("/profile/advanced/", 302), ("/login/", 200), ("/admin/", 302), ("/non-existing/", 404), ], ) def test_tenants_domain(url, expected_status, _setup_dynamic): user = _setup_dynamic client = Client(headers={"host": "tenant1.localhost"}) response = client.get(url) assert response.status_code == expected_status if expected_status == "200": assert response.context == { "path": url, "user": user, "schema": "tenant1", "routing": DomainInfo(domain="tenant1.localhost", folder=""), "admin_url": "/admin/", } @pytest.mark.parametrize( "url, expected_status", [ ("/tenant1/", 200), ("/tenant1/profile/", 200), ("/tenant1/profile/advanced/", 302), ("/tenant1/login/", 200), ("/tenant1/admin/", 302), ("/tenant1/non-existing/", 404), ], ) def test_tenants_folder(url, expected_status, _setup_dynamic): user = _setup_dynamic client = Client(headers={"host": "everyone.localhost"}) response = client.get(url) assert response.status_code == expected_status if expected_status == "200": assert response.context == { "path": url, "user": user, "schema": "tenant1", "routing": DomainInfo(domain="everyone.localhost", folder="tenant1"), "admin_url": "/tenant1/admin/", } django-pgschemas-1.2.0/sandbox/tests/test_utils.py000066400000000000000000000123231512245224300222760ustar00rootroot00000000000000import pytest from django.core.exceptions import ValidationError from django.db import connection from django.db.utils import DatabaseError from django_pgschemas import schema, utils VALID_IDENTIFIERS = ["___", "a_a0", "_a0_", "a" * 63] INVALID_IDENTIFIERS = ["", " ", "^", ".", "&", "{", "(", "@", "!", "a" * 64] VALID_SCHEMA_NAMES = ["a_pg", "w_pg_a", "_pg_awa", "pgwa"] + VALID_IDENTIFIERS INVALID_SCHEMA_NAMES = ["pg_a", "pg_"] + INVALID_IDENTIFIERS def test_get_tenant_model(tenants_settings): TenantModel = utils.get_tenant_model() if "default" in tenants_settings: assert TenantModel is not None assert TenantModel._meta.model_name == "tenant" else: assert TenantModel is None def test_get_domain_model(tenants_settings): DomainModel = utils.get_domain_model() if "default" in tenants_settings and "DOMAIN_MODEL" in tenants_settings["default"]: assert DomainModel is not None assert DomainModel._meta.model_name == "domain" else: assert DomainModel is None @pytest.mark.parametrize("has_value", [True, False]) def test_get_tenant_database_alias(settings, has_value): if has_value: settings.PGSCHEMAS_TENANT_DB_ALIAS = "something" assert utils.get_tenant_database_alias() == "something" else: assert utils.get_tenant_database_alias() == "default" @pytest.mark.parametrize("has_value", [True, False]) def test_get_limit_set_calls(settings, has_value): if has_value: settings.PGSCHEMAS_LIMIT_SET_CALLS = True assert utils.get_limit_set_calls() else: assert not utils.get_limit_set_calls() def test_get_clone_reference(tenants_settings): clone_reference = utils.get_clone_reference() if "default" in tenants_settings: assert clone_reference == "sample" else: assert clone_reference is None @pytest.mark.parametrize( "identifier, is_valid", [(identifier, True) for identifier in VALID_IDENTIFIERS] + [(identifier, False) for identifier in INVALID_IDENTIFIERS], ) def test_is_valid_identifier(identifier, is_valid): assert utils.is_valid_identifier(identifier) == is_valid @pytest.mark.parametrize( "name, is_valid", [(name, True) for name in VALID_SCHEMA_NAMES] + [(name, False) for name in INVALID_SCHEMA_NAMES], ) def test_is_valid_schema_name(name, is_valid): assert utils.is_valid_schema_name(name) == is_valid @pytest.mark.parametrize( "name, is_valid", [(name, True) for name in VALID_SCHEMA_NAMES] + [(name, False) for name in INVALID_SCHEMA_NAMES], ) def test_check_schema_name(name, is_valid): if is_valid: utils.check_schema_name(name) else: with pytest.raises(ValidationError): utils.check_schema_name(name) def test_run_in_public_schema(db): @utils.run_in_public_schema def inner(): with connection.cursor() as cursor: cursor.execute("SHOW search_path") assert cursor.fetchone() == ("public",) with schema.Schema.create(schema_name="test"): inner() with connection.cursor() as cursor: cursor.execute("SHOW search_path") cursor.fetchone() == ("test, public",) def test_schema_exists(db): assert utils.schema_exists("public") assert utils.schema_exists("www") assert utils.schema_exists("blog") assert not utils.schema_exists("default") def test_dynamic_models_exist(tenants_settings, db): if "default" in tenants_settings: assert utils.dynamic_models_exist() else: assert not utils.dynamic_models_exist() utils.drop_schema("public") assert not utils.dynamic_models_exist() def test_create_drop_schema(db): assert not utils.create_schema("public", check_if_exists=True) # Schema existed already assert utils.schema_exists("public") # Schema exists assert utils.drop_schema("public") # Schema was dropped assert not utils.drop_schema("public") # Schema no longer exists assert not utils.schema_exists("public") # Schema doesn't exist assert utils.create_schema("public", sync_schema=False) # Schema was created assert utils.schema_exists("public") # Schema exists def test_clone_schema(db): utils._create_clone_schema_function() assert not utils.schema_exists("sample2") # Schema doesn't exist previously utils.clone_schema("sample", "sample2", dry_run=True) # Dry run assert not utils.schema_exists("sample2") # Schema won't exist, dry run utils.clone_schema("sample", "sample2") # Real run, schema was cloned assert utils.schema_exists("sample2") # Schema exists with pytest.raises(DatabaseError): utils.clone_schema("sample", "sample2") # Schema already exists, error assert utils.schema_exists("sample2") # Schema still exists def test_create_or_clone_schema(db): assert not utils.create_or_clone_schema("sample") # Schema existed @pytest.mark.parametrize( "path, expected", [ ("", ""), ("www", "www"), ("www.", ""), ("www.test.com", "test.com"), ("www.test.com/complex/path", "test.com/complex/path"), ("1www.test.com", "1www.test.com"), ], ) def test_remove_www(path, expected): actual = utils.remove_www(path) assert actual == expected django-pgschemas-1.2.0/sandbox/views.py000066400000000000000000000005611512245224300200730ustar00rootroot00000000000000from django.shortcuts import render from django.urls import reverse def generic(request): context = { "path": request.get_full_path(), "user": request.user, "schema": request.tenant.schema_name, "routing": request.tenant.routing, "admin_url": reverse("admin:index"), } return render(request, "index.html", context) django-pgschemas-1.2.0/sandbox/wsgi.py000066400000000000000000000002501512245224300177020ustar00rootroot00000000000000import os from django.core.wsgi import get_wsgi_application os.environ.setdefault("DJANGO_SETTINGS_MODULE", "sandbox.settings") application = get_wsgi_application() django-pgschemas-1.2.0/uv.lock000066400000000000000000006557451512245224300162560ustar00rootroot00000000000000version = 1 revision = 3 requires-python = ">=3.12" [[package]] name = "asgiref" version = "3.11.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/76/b9/4db2509eabd14b4a8c71d1b24c8d5734c52b8560a7b1e1a8b56c8d25568b/asgiref-3.11.0.tar.gz", hash = "sha256:13acff32519542a1736223fb79a715acdebe24286d98e8b164a73085f40da2c4", size = 37969, upload-time = "2025-11-19T15:32:20.106Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/91/be/317c2c55b8bbec407257d45f5c8d1b6867abc76d12043f2d3d58c538a4ea/asgiref-3.11.0-py3-none-any.whl", hash = "sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d", size = 24096, upload-time = "2025-11-19T15:32:19.004Z" }, ] [[package]] name = "attrs" version = "25.4.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, ] [[package]] name = "autobahn" version = "25.12.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cbor2" }, { name = "cffi" }, { name = "cryptography" }, { name = "hyperlink" }, { name = "msgpack", marker = "platform_python_implementation == 'CPython'" }, { name = "py-ubjson" }, { name = "txaio" }, { name = "u-msgpack-python", marker = "platform_python_implementation != 'CPython'" }, { name = "ujson" }, ] sdist = { url = "https://files.pythonhosted.org/packages/54/d5/9adf0f5b9eb244e58e898e9f3db4b00c09835ef4b6c37d491886e0376b4f/autobahn-25.12.2.tar.gz", hash = "sha256:754c06a54753aeb7e8d10c5cbf03249ad9e2a1a32bca8be02865c6f00628a98c", size = 13893652, upload-time = "2025-12-15T11:13:19.086Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/64/8d/36452c06cbcad6d04587aeb87dfa987ef94be4a427b9f2155783d166bd97/autobahn-25.12.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:220748f21e91bd4a538d2d3de640cc17ee30b79f1c04a6c3dcdef321d531ee1c", size = 2225453, upload-time = "2025-12-15T11:13:02.865Z" }, { url = "https://files.pythonhosted.org/packages/e4/cb/47647ff140f2b8ef80aa689451f3f1076404c115d79310f49477143410dc/autobahn-25.12.2-cp312-cp312-win_amd64.whl", hash = "sha256:ba1867aafdbe585d3d4a5abd35238a78ab54ab3de5bd12a21bca20379c9f512b", size = 2157007, upload-time = "2025-12-15T11:13:03.95Z" }, { url = "https://files.pythonhosted.org/packages/83/30/ef9c47038e4e9257319d6e1b87668b3df360a0c488d66ccff9d11aaff6ba/autobahn-25.12.2-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:bc17f6cab9438156d2701c293c76fd02a144f9be0a992c065dfee1935ce4845b", size = 1960447, upload-time = "2025-12-15T11:13:05.007Z" }, { url = "https://files.pythonhosted.org/packages/e2/e4/f3d5cb70bc0b9b5523d940734b2e0a251510d051a50d2e723f321e890859/autobahn-25.12.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5297a782fc7d0a26842438ef1342549ceee29496cda52672ac44635c79eeb94", size = 2053955, upload-time = "2025-12-15T11:13:06.052Z" }, { url = "https://files.pythonhosted.org/packages/ea/49/4e592a19ae58fd9c796821a882b22598fac295ede50f899cc9d14a0282b6/autobahn-25.12.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0c3f1d5dafda52f8dc962ab583b6f3473b7b7186cab082d05372ed43a8261a5", size = 2225441, upload-time = "2025-12-15T11:13:07.527Z" }, { url = "https://files.pythonhosted.org/packages/ed/f7/430074a5ea3f6187335a4ddc26f16dd75d5125e346a84cf132ddbd41a3e8/autobahn-25.12.2-cp313-cp313-win_amd64.whl", hash = "sha256:e9e2a962f2de0bc4c53b452916458417a15f5137c956245ac6d0a783a83fa1f7", size = 2151873, upload-time = "2025-12-15T11:13:08.89Z" }, { url = "https://files.pythonhosted.org/packages/54/b7/0a0e3ecb2af7e452f5f359d19bdc647cbc8658f3f498bfa3bf8545cf4768/autobahn-25.12.2-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c840ee136bfaf6560467160129b0b25a0e33c9a51e2b251e98c5474f27583915", size = 1960463, upload-time = "2025-12-15T11:13:10.183Z" }, { url = "https://files.pythonhosted.org/packages/19/8b/4215ac49d6b793b592fb08698f3a0e21a59eb3520be7f7ed288fcb52d919/autobahn-25.12.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9abda5cf817c0f8a19a55a67a031adf2fc70ed351719b5bd9e6fa0f5f4bc8f89", size = 2225590, upload-time = "2025-12-15T11:13:11.367Z" }, { url = "https://files.pythonhosted.org/packages/f6/58/e498821606db57305c8f3c26d9b28fd73e4e0583a1f48330df500721c418/autobahn-25.12.2-cp314-cp314-win_amd64.whl", hash = "sha256:18b12e8af7fc115487715afa10b3f5b5a4b5989bebbe05b71722cf9fce7b1bfb", size = 2184111, upload-time = "2025-12-15T11:13:12.461Z" }, ] [[package]] name = "automat" version = "25.4.16" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e3/0f/d40bbe294bbf004d436a8bcbcfaadca8b5140d39ad0ad3d73d1a8ba15f14/automat-25.4.16.tar.gz", hash = "sha256:0017591a5477066e90d26b0e696ddc143baafd87b588cfac8100bc6be9634de0", size = 129977, upload-time = "2025-04-16T20:12:16.002Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/02/ff/1175b0b7371e46244032d43a56862d0af455823b5280a50c63d99cc50f18/automat-25.4.16-py3-none-any.whl", hash = "sha256:04e9bce696a8d5671ee698005af6e5a9fa15354140a87f4870744604dcdd3ba1", size = 42842, upload-time = "2025-04-16T20:12:14.447Z" }, ] [[package]] name = "babel" version = "2.17.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852, upload-time = "2025-02-01T15:17:41.026Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" }, ] [[package]] name = "backrefs" version = "6.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/86/e3/bb3a439d5cb255c4774724810ad8073830fac9c9dee123555820c1bcc806/backrefs-6.1.tar.gz", hash = "sha256:3bba1749aafe1db9b915f00e0dd166cba613b6f788ffd63060ac3485dc9be231", size = 7011962, upload-time = "2025-11-15T14:52:08.323Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/3b/ee/c216d52f58ea75b5e1841022bbae24438b19834a29b163cb32aa3a2a7c6e/backrefs-6.1-py310-none-any.whl", hash = "sha256:2a2ccb96302337ce61ee4717ceacfbf26ba4efb1d55af86564b8bbaeda39cac1", size = 381059, upload-time = "2025-11-15T14:51:59.758Z" }, { url = "https://files.pythonhosted.org/packages/e6/9a/8da246d988ded941da96c7ed945d63e94a445637eaad985a0ed88787cb89/backrefs-6.1-py311-none-any.whl", hash = "sha256:e82bba3875ee4430f4de4b6db19429a27275d95a5f3773c57e9e18abc23fd2b7", size = 392854, upload-time = "2025-11-15T14:52:01.194Z" }, { url = "https://files.pythonhosted.org/packages/37/c9/fd117a6f9300c62bbc33bc337fd2b3c6bfe28b6e9701de336b52d7a797ad/backrefs-6.1-py312-none-any.whl", hash = "sha256:c64698c8d2269343d88947c0735cb4b78745bd3ba590e10313fbf3f78c34da5a", size = 398770, upload-time = "2025-11-15T14:52:02.584Z" }, { url = "https://files.pythonhosted.org/packages/eb/95/7118e935b0b0bd3f94dfec2d852fd4e4f4f9757bdb49850519acd245cd3a/backrefs-6.1-py313-none-any.whl", hash = "sha256:4c9d3dc1e2e558965202c012304f33d4e0e477e1c103663fd2c3cc9bb18b0d05", size = 400726, upload-time = "2025-11-15T14:52:04.093Z" }, { url = "https://files.pythonhosted.org/packages/1d/72/6296bad135bfafd3254ae3648cd152980a424bd6fed64a101af00cc7ba31/backrefs-6.1-py314-none-any.whl", hash = "sha256:13eafbc9ccd5222e9c1f0bec563e6d2a6d21514962f11e7fc79872fd56cbc853", size = 412584, upload-time = "2025-11-15T14:52:05.233Z" }, { url = "https://files.pythonhosted.org/packages/02/e3/a4fa1946722c4c7b063cc25043a12d9ce9b4323777f89643be74cef2993c/backrefs-6.1-py39-none-any.whl", hash = "sha256:a9e99b8a4867852cad177a6430e31b0f6e495d65f8c6c134b68c14c3c95bf4b0", size = 381058, upload-time = "2025-11-15T14:52:06.698Z" }, ] [[package]] name = "cbor2" version = "5.7.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a2/b8/c0f6a7d46f816cb18b1fda61a2fe648abe16039f1ff93ea720a6e9fb3cee/cbor2-5.7.1.tar.gz", hash = "sha256:7a405a1d7c8230ee9acf240aad48ae947ef584e8af05f169f3c1bde8f01f8b71", size = 102467, upload-time = "2025-10-24T09:23:06.569Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/56/54/48426472f0c051982c647331441aed09b271a0500356ae0b7054c813d174/cbor2-5.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bd5ca44891c06f6b85d440836c967187dc1d30b15f86f315d55c675d3a841078", size = 69031, upload-time = "2025-10-24T09:22:25.438Z" }, { url = "https://files.pythonhosted.org/packages/d3/68/1dd58c7706e9752188358223db58c83f3c48e07f728aa84221ffd244652f/cbor2-5.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:537d73ef930ccc1a7b6a2e8d2cbf81407d270deb18e40cda5eb511bd70f71078", size = 68825, upload-time = "2025-10-24T09:22:26.497Z" }, { url = "https://files.pythonhosted.org/packages/09/4e/380562fe9f9995a1875fb5ec26fd041e19d61f4630cb690a98c5195945fc/cbor2-5.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:edbf814dd7763b6eda27a5770199f6ccd55bd78be8f4367092460261bfbf19d0", size = 286222, upload-time = "2025-10-24T09:22:27.546Z" }, { url = "https://files.pythonhosted.org/packages/7c/bb/9eccdc1ea3c4d5c7cdb2e49b9de49534039616be5455ce69bd64c0b2efe2/cbor2-5.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9fc81da8c0e09beb42923e455e477b36ff14a03b9ca18a8a2e9b462de9a953e8", size = 285688, upload-time = "2025-10-24T09:22:28.651Z" }, { url = "https://files.pythonhosted.org/packages/59/8c/4696d82f5bd04b3d45d9a64ec037fa242630c134e3218d6c252b4f59b909/cbor2-5.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e4a7d660d428911a3aadb7105e94438d7671ab977356fdf647a91aab751033bd", size = 277063, upload-time = "2025-10-24T09:22:29.775Z" }, { url = "https://files.pythonhosted.org/packages/95/50/6538e44ca970caaad2fa376b81701d073d84bf597aac07a59d0a253b1a7f/cbor2-5.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:228e0af9c0a9ddf6375b6ae010eaa1942a1901d403f134ac9ee6a76a322483f9", size = 278334, upload-time = "2025-10-24T09:22:30.904Z" }, { url = "https://files.pythonhosted.org/packages/64/a9/156ccd2207fb26b5b61d23728b4dbdc595d1600125aa79683a4a8ddc9313/cbor2-5.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:2d08a6c0d9ed778448e185508d870f4160ba74f59bb17a966abd0d14d0ff4dd3", size = 68404, upload-time = "2025-10-24T09:22:32.108Z" }, { url = "https://files.pythonhosted.org/packages/4f/49/adc53615e9dd32c4421f6935dfa2235013532c6e6b28ee515bbdd92618be/cbor2-5.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:752506cfe72da0f4014b468b30191470ee8919a64a0772bd3b36a4fccf5fcefc", size = 64047, upload-time = "2025-10-24T09:22:33.147Z" }, { url = "https://files.pythonhosted.org/packages/16/b1/51fb868fe38d893c570bb90b38d365ff0f00421402c1ae8f63b31b25d665/cbor2-5.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:59d5da59fffe89692d5bd1530eef4d26e4eb7aa794aaa1f4e192614786409009", size = 69068, upload-time = "2025-10-24T09:22:34.464Z" }, { url = "https://files.pythonhosted.org/packages/b9/db/5abc62ec456f552f617aac3359a5d7114b23be9c4d886169592cd5f074b9/cbor2-5.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:533117918d518e01348f8cd0331271c207e7224b9a1ed492a0ff00847f28edc8", size = 68927, upload-time = "2025-10-24T09:22:35.458Z" }, { url = "https://files.pythonhosted.org/packages/9a/c2/58d787395c99874d2a2395b3a22c9d48a3cfc5a7dcd5817bf74764998b75/cbor2-5.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d6d9436ff3c3323ea5863ecf7ae1139590991685b44b9eb6b7bb1734a594af6", size = 285185, upload-time = "2025-10-24T09:22:36.867Z" }, { url = "https://files.pythonhosted.org/packages/d0/9c/b680b264a8f4b9aa59c95e166c816275a13138cbee92dd2917f58bca47b9/cbor2-5.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:661b871ca754a619fcd98c13a38b4696b2b57dab8b24235c00b0ba322c040d24", size = 284440, upload-time = "2025-10-24T09:22:38.08Z" }, { url = "https://files.pythonhosted.org/packages/1f/59/68183c655d6226d0eee10027f52516882837802a8d5746317a88362ed686/cbor2-5.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d8065aa90d715fd9bb28727b2d774ee16e695a0e1627ae76e54bf19f9d99d63f", size = 276876, upload-time = "2025-10-24T09:22:39.561Z" }, { url = "https://files.pythonhosted.org/packages/ee/a2/1964e0a569d2b81e8f4862753fee7701ae5773c22e45492a26f92f62e75a/cbor2-5.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb1b7047d73590cfe8e373e2c804fa99be47e55b1b6186602d0f86f384cecec1", size = 278216, upload-time = "2025-10-24T09:22:41.132Z" }, { url = "https://files.pythonhosted.org/packages/00/78/9b566d68cb88bb1ecebe354765625161c9d6060a16e55008006d6359f776/cbor2-5.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:31d511df7ebd6624fdb4cecdafb4ffb9a205f9ff8c8d98edd1bef0d27f944d74", size = 68451, upload-time = "2025-10-24T09:22:42.227Z" }, { url = "https://files.pythonhosted.org/packages/db/85/7a6a922d147d027fd5d8fd5224b39e8eaf152a42e8cf16351458096d3d62/cbor2-5.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:f5d37f7b0f84394d2995bd8722cb01c86a885c4821a864a34b7b4d9950c5e26e", size = 64111, upload-time = "2025-10-24T09:22:43.213Z" }, { url = "https://files.pythonhosted.org/packages/5f/f0/f220222a57371e33434ba7bdc25de31d611cbc0ade2a868e03c3553305e7/cbor2-5.7.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e5826e4fa4c33661960073f99cf67c82783895524fb66f3ebdd635c19b5a7d68", size = 69002, upload-time = "2025-10-24T09:22:44.316Z" }, { url = "https://files.pythonhosted.org/packages/c7/3c/34b62ba5173541659f248f005d13373530f02fb997b78fde00bf01ede4f4/cbor2-5.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f19a00d6ac9a77cb611073250b06bf4494b41ba78a1716704f7008e0927d9366", size = 69177, upload-time = "2025-10-24T09:22:45.711Z" }, { url = "https://files.pythonhosted.org/packages/77/fd/2400d820d9733df00a5c18aa74201e51d710fb91588687eb594f4a7688ea/cbor2-5.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2113aea044cd172f199da3520bc4401af69eae96c5180ca7eb660941928cb89", size = 284259, upload-time = "2025-10-24T09:22:46.749Z" }, { url = "https://files.pythonhosted.org/packages/42/65/280488ef196c1d71ba123cd406ea47727bb3a0e057767a733d9793fcc428/cbor2-5.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f17eacea2d28fecf28ac413c1d7927cde0a11957487d2630655d6b5c9c46a0b", size = 281958, upload-time = "2025-10-24T09:22:48.876Z" }, { url = "https://files.pythonhosted.org/packages/42/82/bcdd3fdc73bd5f4194fdb08c808112010add9530bae1dcfdb1e2b2ceae19/cbor2-5.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d65deea39cae533a629561e7da672402c46731122b6129ed7c8eaa1efe04efce", size = 276025, upload-time = "2025-10-24T09:22:50.147Z" }, { url = "https://files.pythonhosted.org/packages/ae/a8/a6065dd6a157b877d7d8f3fe96f410fb191a2db1e6588f4d20b5f9a507c2/cbor2-5.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:57d8cc29ec1fd20500748e0e767ff88c13afcee839081ba4478c41fcda6ee18b", size = 275978, upload-time = "2025-10-24T09:22:51.873Z" }, { url = "https://files.pythonhosted.org/packages/62/f4/37934045174af9e4253a340b43f07197af54002070cb80fae82d878f1f14/cbor2-5.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:94fb939d0946f80c49ba45105ca3a3e13e598fc9abd63efc6661b02d4b4d2c50", size = 70269, upload-time = "2025-10-24T09:22:53.275Z" }, { url = "https://files.pythonhosted.org/packages/0b/fd/933416643e7f5540ae818691fb23fa4189010c6efa39a12c4f59d825da28/cbor2-5.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4fd7225ac820bbb9f03bd16bc1a7efb6c4d1c451f22c0a153ff4ec46495c59c5", size = 66182, upload-time = "2025-10-24T09:22:54.697Z" }, { url = "https://files.pythonhosted.org/packages/d5/7d/383bafeabb54c17fe5b6d5aca4e863e6b7df10bcc833b34aa169e9dfce1a/cbor2-5.7.1-py3-none-any.whl", hash = "sha256:68834e4eff2f56629ce6422b0634bc3f74c5a4269de5363f5265fe452c706ba7", size = 23829, upload-time = "2025-10-24T09:23:05.54Z" }, ] [[package]] name = "certifi" version = "2025.11.12" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, ] [[package]] name = "cffi" version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pycparser", marker = "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/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" }, ] [[package]] name = "channels" version = "4.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asgiref" }, { name = "django" }, ] sdist = { url = "https://files.pythonhosted.org/packages/74/92/b18d4bb54d14986a8b35215a1c9e6a7f9f4d57ca63ac9aee8290ebb4957d/channels-4.3.2.tar.gz", hash = "sha256:f2bb6bfb73ad7fb4705041d07613c7b4e69528f01ef8cb9fb6c21d9295f15667", size = 27023, upload-time = "2025-11-20T15:13:05.102Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/16/34/c32915288b7ef482377b6adc401192f98c6a99b3a145423d3b8aed807898/channels-4.3.2-py3-none-any.whl", hash = "sha256:fef47e9055a603900cf16cef85f050d522d9ac4b3daccf24835bd9580705c176", size = 31313, upload-time = "2025-11-20T15:13:02.357Z" }, ] [package.optional-dependencies] daphne = [ { name = "daphne" }, ] [[package]] name = "charset-normalizer" version = "3.4.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, ] [[package]] name = "click" version = "8.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, ] [[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 = "constantly" version = "23.10.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/4d/6f/cb2a94494ff74aa9528a36c5b1422756330a75a8367bf20bd63171fc324d/constantly-23.10.4.tar.gz", hash = "sha256:aa92b70a33e2ac0bb33cd745eb61776594dc48764b06c35e0efd050b7f1c7cbd", size = 13300, upload-time = "2023-10-28T23:18:24.316Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b8/40/c199d095151addf69efdb4b9ca3a4f20f70e20508d6222bffb9b76f58573/constantly-23.10.4-py3-none-any.whl", hash = "sha256:3fd9b4d1c3dc1ec9757f3c52aef7e53ad9323dbe39f51dfd4c43853b68dfa3f9", size = 13547, upload-time = "2023-10-28T23:18:23.038Z" }, ] [[package]] name = "coverage" version = "7.13.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/b6/45/2c665ca77ec32ad67e25c77daf1cee28ee4558f3bc571cdbaf88a00b9f23/coverage-7.13.0.tar.gz", hash = "sha256:a394aa27f2d7ff9bc04cf703817773a59ad6dfbd577032e690f961d2460ee936", size = 820905, upload-time = "2025-12-08T13:14:38.055Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/9b/f1/2619559f17f31ba00fc40908efd1fbf1d0a5536eb75dc8341e7d660a08de/coverage-7.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0b3d67d31383c4c68e19a88e28fc4c2e29517580f1b0ebec4a069d502ce1e0bf", size = 218274, upload-time = "2025-12-08T13:12:52.095Z" }, { url = "https://files.pythonhosted.org/packages/2b/11/30d71ae5d6e949ff93b2a79a2c1b4822e00423116c5c6edfaeef37301396/coverage-7.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:581f086833d24a22c89ae0fe2142cfaa1c92c930adf637ddf122d55083fb5a0f", size = 218638, upload-time = "2025-12-08T13:12:53.418Z" }, { url = "https://files.pythonhosted.org/packages/79/c2/fce80fc6ded8d77e53207489d6065d0fed75db8951457f9213776615e0f5/coverage-7.13.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0a3a30f0e257df382f5f9534d4ce3d4cf06eafaf5192beb1a7bd066cb10e78fb", size = 250129, upload-time = "2025-12-08T13:12:54.744Z" }, { url = "https://files.pythonhosted.org/packages/5b/b6/51b5d1eb6fcbb9a1d5d6984e26cbe09018475c2922d554fd724dd0f056ee/coverage-7.13.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:583221913fbc8f53b88c42e8dbb8fca1d0f2e597cb190ce45916662b8b9d9621", size = 252885, upload-time = "2025-12-08T13:12:56.401Z" }, { url = "https://files.pythonhosted.org/packages/0d/f8/972a5affea41de798691ab15d023d3530f9f56a72e12e243f35031846ff7/coverage-7.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f5d9bd30756fff3e7216491a0d6d520c448d5124d3d8e8f56446d6412499e74", size = 253974, upload-time = "2025-12-08T13:12:57.718Z" }, { url = "https://files.pythonhosted.org/packages/8a/56/116513aee860b2c7968aa3506b0f59b22a959261d1dbf3aea7b4450a7520/coverage-7.13.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a23e5a1f8b982d56fa64f8e442e037f6ce29322f1f9e6c2344cd9e9f4407ee57", size = 250538, upload-time = "2025-12-08T13:12:59.254Z" }, { url = "https://files.pythonhosted.org/packages/d6/75/074476d64248fbadf16dfafbf93fdcede389ec821f74ca858d7c87d2a98c/coverage-7.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b01c22bc74a7fb44066aaf765224c0d933ddf1f5047d6cdfe4795504a4493f8", size = 251912, upload-time = "2025-12-08T13:13:00.604Z" }, { url = "https://files.pythonhosted.org/packages/f2/d2/aa4f8acd1f7c06024705c12609d8698c51b27e4d635d717cd1934c9668e2/coverage-7.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:898cce66d0836973f48dda4e3514d863d70142bdf6dfab932b9b6a90ea5b222d", size = 250054, upload-time = "2025-12-08T13:13:01.892Z" }, { url = "https://files.pythonhosted.org/packages/19/98/8df9e1af6a493b03694a1e8070e024e7d2cdc77adedc225a35e616d505de/coverage-7.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3ab483ea0e251b5790c2aac03acde31bff0c736bf8a86829b89382b407cd1c3b", size = 249619, upload-time = "2025-12-08T13:13:03.236Z" }, { url = "https://files.pythonhosted.org/packages/d8/71/f8679231f3353018ca66ef647fa6fe7b77e6bff7845be54ab84f86233363/coverage-7.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d84e91521c5e4cb6602fe11ece3e1de03b2760e14ae4fcf1a4b56fa3c801fcd", size = 251496, upload-time = "2025-12-08T13:13:04.511Z" }, { url = "https://files.pythonhosted.org/packages/04/86/9cb406388034eaf3c606c22094edbbb82eea1fa9d20c0e9efadff20d0733/coverage-7.13.0-cp312-cp312-win32.whl", hash = "sha256:193c3887285eec1dbdb3f2bd7fbc351d570ca9c02ca756c3afbc71b3c98af6ef", size = 220808, upload-time = "2025-12-08T13:13:06.422Z" }, { url = "https://files.pythonhosted.org/packages/1c/59/af483673df6455795daf5f447c2f81a3d2fcfc893a22b8ace983791f6f34/coverage-7.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:4f3e223b2b2db5e0db0c2b97286aba0036ca000f06aca9b12112eaa9af3d92ae", size = 221616, upload-time = "2025-12-08T13:13:07.95Z" }, { url = "https://files.pythonhosted.org/packages/64/b0/959d582572b30a6830398c60dd419c1965ca4b5fb38ac6b7093a0d50ca8d/coverage-7.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:086cede306d96202e15a4b77ace8472e39d9f4e5f9fd92dd4fecdfb2313b2080", size = 220261, upload-time = "2025-12-08T13:13:09.581Z" }, { url = "https://files.pythonhosted.org/packages/7c/cc/bce226595eb3bf7d13ccffe154c3c487a22222d87ff018525ab4dd2e9542/coverage-7.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:28ee1c96109974af104028a8ef57cec21447d42d0e937c0275329272e370ebcf", size = 218297, upload-time = "2025-12-08T13:13:10.977Z" }, { url = "https://files.pythonhosted.org/packages/3b/9f/73c4d34600aae03447dff3d7ad1d0ac649856bfb87d1ca7d681cfc913f9e/coverage-7.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e97353dcc5587b85986cda4ff3ec98081d7e84dd95e8b2a6d59820f0545f8a", size = 218673, upload-time = "2025-12-08T13:13:12.562Z" }, { url = "https://files.pythonhosted.org/packages/63/ab/8fa097db361a1e8586535ae5073559e6229596b3489ec3ef2f5b38df8cb2/coverage-7.13.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:99acd4dfdfeb58e1937629eb1ab6ab0899b131f183ee5f23e0b5da5cba2fec74", size = 249652, upload-time = "2025-12-08T13:13:13.909Z" }, { url = "https://files.pythonhosted.org/packages/90/3a/9bfd4de2ff191feb37ef9465855ca56a6f2f30a3bca172e474130731ac3d/coverage-7.13.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff45e0cd8451e293b63ced93161e189780baf444119391b3e7d25315060368a6", size = 252251, upload-time = "2025-12-08T13:13:15.553Z" }, { url = "https://files.pythonhosted.org/packages/df/61/b5d8105f016e1b5874af0d7c67542da780ccd4a5f2244a433d3e20ceb1ad/coverage-7.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4f72a85316d8e13234cafe0a9f81b40418ad7a082792fa4165bd7d45d96066b", size = 253492, upload-time = "2025-12-08T13:13:16.849Z" }, { url = "https://files.pythonhosted.org/packages/f3/b8/0fad449981803cc47a4694768b99823fb23632150743f9c83af329bb6090/coverage-7.13.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11c21557d0e0a5a38632cbbaca5f008723b26a89d70db6315523df6df77d6232", size = 249850, upload-time = "2025-12-08T13:13:18.142Z" }, { url = "https://files.pythonhosted.org/packages/9a/e9/8d68337c3125014d918cf4327d5257553a710a2995a6a6de2ac77e5aa429/coverage-7.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76541dc8d53715fb4f7a3a06b34b0dc6846e3c69bc6204c55653a85dd6220971", size = 251633, upload-time = "2025-12-08T13:13:19.56Z" }, { url = "https://files.pythonhosted.org/packages/55/14/d4112ab26b3a1bc4b3c1295d8452dcf399ed25be4cf649002fb3e64b2d93/coverage-7.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6e9e451dee940a86789134b6b0ffbe31c454ade3b849bb8a9d2cca2541a8e91d", size = 249586, upload-time = "2025-12-08T13:13:20.883Z" }, { url = "https://files.pythonhosted.org/packages/2c/a9/22b0000186db663b0d82f86c2f1028099ae9ac202491685051e2a11a5218/coverage-7.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5c67dace46f361125e6b9cace8fe0b729ed8479f47e70c89b838d319375c8137", size = 249412, upload-time = "2025-12-08T13:13:22.22Z" }, { url = "https://files.pythonhosted.org/packages/a1/2e/42d8e0d9e7527fba439acdc6ed24a2b97613b1dc85849b1dd935c2cffef0/coverage-7.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f59883c643cb19630500f57016f76cfdcd6845ca8c5b5ea1f6e17f74c8e5f511", size = 251191, upload-time = "2025-12-08T13:13:23.899Z" }, { url = "https://files.pythonhosted.org/packages/a4/af/8c7af92b1377fd8860536aadd58745119252aaaa71a5213e5a8e8007a9f5/coverage-7.13.0-cp313-cp313-win32.whl", hash = "sha256:58632b187be6f0be500f553be41e277712baa278147ecb7559983c6d9faf7ae1", size = 220829, upload-time = "2025-12-08T13:13:25.182Z" }, { url = "https://files.pythonhosted.org/packages/58/f9/725e8bf16f343d33cbe076c75dc8370262e194ff10072c0608b8e5cf33a3/coverage-7.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:73419b89f812f498aca53f757dd834919b48ce4799f9d5cad33ca0ae442bdb1a", size = 221640, upload-time = "2025-12-08T13:13:26.836Z" }, { url = "https://files.pythonhosted.org/packages/8a/ff/e98311000aa6933cc79274e2b6b94a2fe0fe3434fca778eba82003675496/coverage-7.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:eb76670874fdd6091eedcc856128ee48c41a9bbbb9c3f1c7c3cf169290e3ffd6", size = 220269, upload-time = "2025-12-08T13:13:28.116Z" }, { url = "https://files.pythonhosted.org/packages/cf/cf/bbaa2e1275b300343ea865f7d424cc0a2e2a1df6925a070b2b2d5d765330/coverage-7.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6e63ccc6e0ad8986386461c3c4b737540f20426e7ec932f42e030320896c311a", size = 218990, upload-time = "2025-12-08T13:13:29.463Z" }, { url = "https://files.pythonhosted.org/packages/21/1d/82f0b3323b3d149d7672e7744c116e9c170f4957e0c42572f0366dbb4477/coverage-7.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:494f5459ffa1bd45e18558cd98710c36c0b8fbfa82a5eabcbe671d80ecffbfe8", size = 219340, upload-time = "2025-12-08T13:13:31.524Z" }, { url = "https://files.pythonhosted.org/packages/fb/e3/fe3fd4702a3832a255f4d43013eacb0ef5fc155a5960ea9269d8696db28b/coverage-7.13.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:06cac81bf10f74034e055e903f5f946e3e26fc51c09fc9f584e4a1605d977053", size = 260638, upload-time = "2025-12-08T13:13:32.965Z" }, { url = "https://files.pythonhosted.org/packages/ad/01/63186cb000307f2b4da463f72af9b85d380236965574c78e7e27680a2593/coverage-7.13.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f2ffc92b46ed6e6760f1d47a71e56b5664781bc68986dbd1836b2b70c0ce2071", size = 262705, upload-time = "2025-12-08T13:13:34.378Z" }, { url = "https://files.pythonhosted.org/packages/7c/a1/c0dacef0cc865f2455d59eed3548573ce47ed603205ffd0735d1d78b5906/coverage-7.13.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0602f701057c6823e5db1b74530ce85f17c3c5be5c85fc042ac939cbd909426e", size = 265125, upload-time = "2025-12-08T13:13:35.73Z" }, { url = "https://files.pythonhosted.org/packages/ef/92/82b99223628b61300bd382c205795533bed021505eab6dd86e11fb5d7925/coverage-7.13.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:25dc33618d45456ccb1d37bce44bc78cf269909aa14c4db2e03d63146a8a1493", size = 259844, upload-time = "2025-12-08T13:13:37.69Z" }, { url = "https://files.pythonhosted.org/packages/cf/2c/89b0291ae4e6cd59ef042708e1c438e2290f8c31959a20055d8768349ee2/coverage-7.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:71936a8b3b977ddd0b694c28c6a34f4fff2e9dd201969a4ff5d5fc7742d614b0", size = 262700, upload-time = "2025-12-08T13:13:39.525Z" }, { url = "https://files.pythonhosted.org/packages/bf/f9/a5f992efae1996245e796bae34ceb942b05db275e4b34222a9a40b9fbd3b/coverage-7.13.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:936bc20503ce24770c71938d1369461f0c5320830800933bc3956e2a4ded930e", size = 260321, upload-time = "2025-12-08T13:13:41.172Z" }, { url = "https://files.pythonhosted.org/packages/4c/89/a29f5d98c64fedbe32e2ac3c227fbf78edc01cc7572eee17d61024d89889/coverage-7.13.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:af0a583efaacc52ae2521f8d7910aff65cdb093091d76291ac5820d5e947fc1c", size = 259222, upload-time = "2025-12-08T13:13:43.282Z" }, { url = "https://files.pythonhosted.org/packages/b3/c3/940fe447aae302a6701ee51e53af7e08b86ff6eed7631e5740c157ee22b9/coverage-7.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f1c23e24a7000da892a312fb17e33c5f94f8b001de44b7cf8ba2e36fbd15859e", size = 261411, upload-time = "2025-12-08T13:13:44.72Z" }, { url = "https://files.pythonhosted.org/packages/eb/31/12a4aec689cb942a89129587860ed4d0fd522d5fda81237147fde554b8ae/coverage-7.13.0-cp313-cp313t-win32.whl", hash = "sha256:5f8a0297355e652001015e93be345ee54393e45dc3050af4a0475c5a2b767d46", size = 221505, upload-time = "2025-12-08T13:13:46.332Z" }, { url = "https://files.pythonhosted.org/packages/65/8c/3b5fe3259d863572d2b0827642c50c3855d26b3aefe80bdc9eba1f0af3b0/coverage-7.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6abb3a4c52f05e08460bd9acf04fec027f8718ecaa0d09c40ffbc3fbd70ecc39", size = 222569, upload-time = "2025-12-08T13:13:47.79Z" }, { url = "https://files.pythonhosted.org/packages/b0/39/f71fa8316a96ac72fc3908839df651e8eccee650001a17f2c78cdb355624/coverage-7.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:3ad968d1e3aa6ce5be295ab5fe3ae1bf5bb4769d0f98a80a0252d543a2ef2e9e", size = 220841, upload-time = "2025-12-08T13:13:49.243Z" }, { url = "https://files.pythonhosted.org/packages/f8/4b/9b54bedda55421449811dcd5263a2798a63f48896c24dfb92b0f1b0845bd/coverage-7.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:453b7ec753cf5e4356e14fe858064e5520c460d3bbbcb9c35e55c0d21155c256", size = 218343, upload-time = "2025-12-08T13:13:50.811Z" }, { url = "https://files.pythonhosted.org/packages/59/df/c3a1f34d4bba2e592c8979f924da4d3d4598b0df2392fbddb7761258e3dc/coverage-7.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af827b7cbb303e1befa6c4f94fd2bf72f108089cfa0f8abab8f4ca553cf5ca5a", size = 218672, upload-time = "2025-12-08T13:13:52.284Z" }, { url = "https://files.pythonhosted.org/packages/07/62/eec0659e47857698645ff4e6ad02e30186eb8afd65214fd43f02a76537cb/coverage-7.13.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9987a9e4f8197a1000280f7cc089e3ea2c8b3c0a64d750537809879a7b4ceaf9", size = 249715, upload-time = "2025-12-08T13:13:53.791Z" }, { url = "https://files.pythonhosted.org/packages/23/2d/3c7ff8b2e0e634c1f58d095f071f52ed3c23ff25be524b0ccae8b71f99f8/coverage-7.13.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3188936845cd0cb114fa6a51842a304cdbac2958145d03be2377ec41eb285d19", size = 252225, upload-time = "2025-12-08T13:13:55.274Z" }, { url = "https://files.pythonhosted.org/packages/aa/ac/fb03b469d20e9c9a81093575003f959cf91a4a517b783aab090e4538764b/coverage-7.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2bdb3babb74079f021696cb46b8bb5f5661165c385d3a238712b031a12355be", size = 253559, upload-time = "2025-12-08T13:13:57.161Z" }, { url = "https://files.pythonhosted.org/packages/29/62/14afa9e792383c66cc0a3b872a06ded6e4ed1079c7d35de274f11d27064e/coverage-7.13.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7464663eaca6adba4175f6c19354feea61ebbdd735563a03d1e472c7072d27bb", size = 249724, upload-time = "2025-12-08T13:13:58.692Z" }, { url = "https://files.pythonhosted.org/packages/31/b7/333f3dab2939070613696ab3ee91738950f0467778c6e5a5052e840646b7/coverage-7.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8069e831f205d2ff1f3d355e82f511eb7c5522d7d413f5db5756b772ec8697f8", size = 251582, upload-time = "2025-12-08T13:14:00.642Z" }, { url = "https://files.pythonhosted.org/packages/81/cb/69162bda9381f39b2287265d7e29ee770f7c27c19f470164350a38318764/coverage-7.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6fb2d5d272341565f08e962cce14cdf843a08ac43bd621783527adb06b089c4b", size = 249538, upload-time = "2025-12-08T13:14:02.556Z" }, { url = "https://files.pythonhosted.org/packages/e0/76/350387b56a30f4970abe32b90b2a434f87d29f8b7d4ae40d2e8a85aacfb3/coverage-7.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5e70f92ef89bac1ac8a99b3324923b4749f008fdbd7aa9cb35e01d7a284a04f9", size = 249349, upload-time = "2025-12-08T13:14:04.015Z" }, { url = "https://files.pythonhosted.org/packages/86/0d/7f6c42b8d59f4c7e43ea3059f573c0dcfed98ba46eb43c68c69e52ae095c/coverage-7.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4b5de7d4583e60d5fd246dd57fcd3a8aa23c6e118a8c72b38adf666ba8e7e927", size = 251011, upload-time = "2025-12-08T13:14:05.505Z" }, { url = "https://files.pythonhosted.org/packages/d7/f1/4bb2dff379721bb0b5c649d5c5eaf438462cad824acf32eb1b7ca0c7078e/coverage-7.13.0-cp314-cp314-win32.whl", hash = "sha256:a6c6e16b663be828a8f0b6c5027d36471d4a9f90d28444aa4ced4d48d7d6ae8f", size = 221091, upload-time = "2025-12-08T13:14:07.127Z" }, { url = "https://files.pythonhosted.org/packages/ba/44/c239da52f373ce379c194b0ee3bcc121020e397242b85f99e0afc8615066/coverage-7.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:0900872f2fdb3ee5646b557918d02279dc3af3dfb39029ac4e945458b13f73bc", size = 221904, upload-time = "2025-12-08T13:14:08.542Z" }, { url = "https://files.pythonhosted.org/packages/89/1f/b9f04016d2a29c2e4a0307baefefad1a4ec5724946a2b3e482690486cade/coverage-7.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:3a10260e6a152e5f03f26db4a407c4c62d3830b9af9b7c0450b183615f05d43b", size = 220480, upload-time = "2025-12-08T13:14:10.958Z" }, { url = "https://files.pythonhosted.org/packages/16/d4/364a1439766c8e8647860584171c36010ca3226e6e45b1753b1b249c5161/coverage-7.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9097818b6cc1cfb5f174e3263eba4a62a17683bcfe5c4b5d07f4c97fa51fbf28", size = 219074, upload-time = "2025-12-08T13:14:13.345Z" }, { url = "https://files.pythonhosted.org/packages/ce/f4/71ba8be63351e099911051b2089662c03d5671437a0ec2171823c8e03bec/coverage-7.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0018f73dfb4301a89292c73be6ba5f58722ff79f51593352759c1790ded1cabe", size = 219342, upload-time = "2025-12-08T13:14:15.02Z" }, { url = "https://files.pythonhosted.org/packages/5e/25/127d8ed03d7711a387d96f132589057213e3aef7475afdaa303412463f22/coverage-7.13.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:166ad2a22ee770f5656e1257703139d3533b4a0b6909af67c6b4a3adc1c98657", size = 260713, upload-time = "2025-12-08T13:14:16.907Z" }, { url = "https://files.pythonhosted.org/packages/fd/db/559fbb6def07d25b2243663b46ba9eb5a3c6586c0c6f4e62980a68f0ee1c/coverage-7.13.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f6aaef16d65d1787280943f1c8718dc32e9cf141014e4634d64446702d26e0ff", size = 262825, upload-time = "2025-12-08T13:14:18.68Z" }, { url = "https://files.pythonhosted.org/packages/37/99/6ee5bf7eff884766edb43bd8736b5e1c5144d0fe47498c3779326fe75a35/coverage-7.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e999e2dcc094002d6e2c7bbc1fb85b58ba4f465a760a8014d97619330cdbbbf3", size = 265233, upload-time = "2025-12-08T13:14:20.55Z" }, { url = "https://files.pythonhosted.org/packages/d8/90/92f18fe0356ea69e1f98f688ed80cec39f44e9f09a1f26a1bbf017cc67f2/coverage-7.13.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:00c3d22cf6fb1cf3bf662aaaa4e563be8243a5ed2630339069799835a9cc7f9b", size = 259779, upload-time = "2025-12-08T13:14:22.367Z" }, { url = "https://files.pythonhosted.org/packages/90/5d/b312a8b45b37a42ea7d27d7d3ff98ade3a6c892dd48d1d503e773503373f/coverage-7.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22ccfe8d9bb0d6134892cbe1262493a8c70d736b9df930f3f3afae0fe3ac924d", size = 262700, upload-time = "2025-12-08T13:14:24.309Z" }, { url = "https://files.pythonhosted.org/packages/63/f8/b1d0de5c39351eb71c366f872376d09386640840a2e09b0d03973d791e20/coverage-7.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9372dff5ea15930fea0445eaf37bbbafbc771a49e70c0aeed8b4e2c2614cc00e", size = 260302, upload-time = "2025-12-08T13:14:26.068Z" }, { url = "https://files.pythonhosted.org/packages/aa/7c/d42f4435bc40c55558b3109a39e2d456cddcec37434f62a1f1230991667a/coverage-7.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:69ac2c492918c2461bc6ace42d0479638e60719f2a4ef3f0815fa2df88e9f940", size = 259136, upload-time = "2025-12-08T13:14:27.604Z" }, { url = "https://files.pythonhosted.org/packages/b8/d3/23413241dc04d47cfe19b9a65b32a2edd67ecd0b817400c2843ebc58c847/coverage-7.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:739c6c051a7540608d097b8e13c76cfa85263ced467168dc6b477bae3df7d0e2", size = 261467, upload-time = "2025-12-08T13:14:29.09Z" }, { url = "https://files.pythonhosted.org/packages/13/e6/6e063174500eee216b96272c0d1847bf215926786f85c2bd024cf4d02d2f/coverage-7.13.0-cp314-cp314t-win32.whl", hash = "sha256:fe81055d8c6c9de76d60c94ddea73c290b416e061d40d542b24a5871bad498b7", size = 221875, upload-time = "2025-12-08T13:14:31.106Z" }, { url = "https://files.pythonhosted.org/packages/3b/46/f4fb293e4cbe3620e3ac2a3e8fd566ed33affb5861a9b20e3dd6c1896cbc/coverage-7.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:445badb539005283825959ac9fa4a28f712c214b65af3a2c464f1adc90f5fcbc", size = 222982, upload-time = "2025-12-08T13:14:33.1Z" }, { url = "https://files.pythonhosted.org/packages/68/62/5b3b9018215ed9733fbd1ae3b2ed75c5de62c3b55377a52cae732e1b7805/coverage-7.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:de7f6748b890708578fc4b7bb967d810aeb6fcc9bff4bb77dbca77dab2f9df6a", size = 221016, upload-time = "2025-12-08T13:14:34.601Z" }, { url = "https://files.pythonhosted.org/packages/8d/4c/1968f32fb9a2604645827e11ff84a31e59d532e01995f904723b4f5328b3/coverage-7.13.0-py3-none-any.whl", hash = "sha256:850d2998f380b1e266459ca5b47bc9e7daf9af1d070f66317972f382d46f1904", size = 210068, upload-time = "2025-12-08T13:14:36.236Z" }, ] [[package]] name = "cryptography" version = "46.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" }, { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" }, { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" }, { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" }, { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" }, { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" }, { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" }, { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" }, { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" }, { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" }, { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" }, { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" }, { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" }, { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" }, { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" }, { url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" }, { url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" }, { url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" }, { url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" }, { url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" }, { url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" }, { url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" }, { url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" }, { url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" }, { url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" }, { url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" }, { url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" }, { url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" }, { url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" }, { url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" }, { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" }, { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" }, { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" }, { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" }, { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" }, { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" }, { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" }, { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" }, { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" }, { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" }, { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" }, { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" }, { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" }, { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" }, { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" }, ] [[package]] name = "daphne" version = "4.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asgiref" }, { name = "autobahn" }, { name = "twisted", extra = ["tls"] }, ] sdist = { url = "https://files.pythonhosted.org/packages/cd/9d/322b605fdc03b963cf2d33943321c8f4405e8d82e698bf49d1eed1ca40c4/daphne-4.2.1.tar.gz", hash = "sha256:5f898e700a1fda7addf1541d7c328606415e96a7bd768405f0463c312fcb31b3", size = 45600, upload-time = "2025-07-02T12:57:04.935Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/01/34/6171ab34715ed210bcd6c2b38839cc792993cff4fe2493f50bc92b0086a0/daphne-4.2.1-py3-none-any.whl", hash = "sha256:881e96b387b95b35ad85acd855f229d7f5b79073d6649089c8a33f661885e055", size = 29015, upload-time = "2025-07-02T12:57:03.793Z" }, ] [[package]] name = "django" version = "6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asgiref" }, { name = "sqlparse" }, { name = "tzdata", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/15/75/19762bfc4ea556c303d9af8e36f0cd910ab17dff6c8774644314427a2120/django-6.0.tar.gz", hash = "sha256:7b0c1f50c0759bbe6331c6a39c89ae022a84672674aeda908784617ef47d8e26", size = 10932418, upload-time = "2025-12-03T16:26:21.878Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d7/ae/f19e24789a5ad852670d6885f5480f5e5895576945fcc01817dfd9bc002a/django-6.0-py3-none-any.whl", hash = "sha256:1cc2c7344303bbfb7ba5070487c17f7fc0b7174bbb0a38cebf03c675f5f19b6d", size = 8339181, upload-time = "2025-12-03T16:26:16.231Z" }, ] [[package]] name = "django-pgschemas" version = "1.2.0" source = { editable = "." } dependencies = [ { name = "django" }, ] [package.dev-dependencies] dev = [ { name = "channels", extra = ["daphne"] }, { name = "mkdocs-material" }, { name = "mypy" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-django" }, { name = "ruff" }, ] psycopg = [ { name = "psycopg" }, ] [package.metadata] requires-dist = [{ name = "django", specifier = ">=5.2" }] [package.metadata.requires-dev] dev = [ { name = "channels", extras = ["daphne"] }, { name = "mkdocs-material" }, { name = "mypy" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-django" }, { name = "ruff" }, ] psycopg = [{ name = "psycopg" }] [[package]] name = "ghp-import" version = "2.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "python-dateutil" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, ] [[package]] name = "hyperlink" version = "21.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3a/51/1947bd81d75af87e3bb9e34593a4cf118115a8feb451ce7a69044ef1412e/hyperlink-21.0.0.tar.gz", hash = "sha256:427af957daa58bc909471c6c40f74c5450fa123dd093fc53efd2e91d2705a56b", size = 140743, upload-time = "2021-01-08T05:51:20.972Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/6e/aa/8caf6a0a3e62863cbb9dab27135660acba46903b703e224f14f447e57934/hyperlink-21.0.0-py2.py3-none-any.whl", hash = "sha256:e6b14c37ecb73e89c77d78cdb4c2cc8f3fb59a885c5b3f819ff4ed80f25af1b4", size = 74638, upload-time = "2021-01-08T05:51:22.906Z" }, ] [[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 = "incremental" version = "24.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ef/3c/82e84109e02c492f382c711c58a3dd91badda6d746def81a1465f74dc9f5/incremental-24.11.0.tar.gz", hash = "sha256:87d3480dbb083c1d736222511a8cf380012a8176c2456d01ef483242abbbcf8c", size = 24000, upload-time = "2025-11-28T02:30:17.861Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/1d/55/0f4df2a44053867ea9cbea73fc588b03c55605cd695cee0a3d86f0029cb2/incremental-24.11.0-py3-none-any.whl", hash = "sha256:a34450716b1c4341fe6676a0598e88a39e04189f4dce5dc96f656e040baa10b3", size = 21109, upload-time = "2025-11-28T02:30:16.442Z" }, ] [[package]] name = "iniconfig" version = "2.3.0" source = { registry = "https://pypi.org/simple" } 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 = "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 = "librt" version = "0.7.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/93/e4/b59bdf1197fdf9888452ea4d2048cdad61aef85eb83e99dc52551d7fdc04/librt-0.7.4.tar.gz", hash = "sha256:3871af56c59864d5fd21d1ac001eb2fb3b140d52ba0454720f2e4a19812404ba", size = 145862, upload-time = "2025-12-15T16:52:43.862Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f3/e7/b805d868d21f425b7e76a0ea71a2700290f2266a4f3c8357fcf73efc36aa/librt-0.7.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7dd3b5c37e0fb6666c27cf4e2c88ae43da904f2155c4cfc1e5a2fdce3b9fcf92", size = 55688, upload-time = "2025-12-15T16:51:31.571Z" }, { url = "https://files.pythonhosted.org/packages/59/5e/69a2b02e62a14cfd5bfd9f1e9adea294d5bcfeea219c7555730e5d068ee4/librt-0.7.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9c5de1928c486201b23ed0cc4ac92e6e07be5cd7f3abc57c88a9cf4f0f32108", size = 57141, upload-time = "2025-12-15T16:51:32.714Z" }, { url = "https://files.pythonhosted.org/packages/6e/6b/05dba608aae1272b8ea5ff8ef12c47a4a099a04d1e00e28a94687261d403/librt-0.7.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:078ae52ffb3f036396cc4aed558e5b61faedd504a3c1f62b8ae34bf95ae39d94", size = 165322, upload-time = "2025-12-15T16:51:33.986Z" }, { url = "https://files.pythonhosted.org/packages/8f/bc/199533d3fc04a4cda8d7776ee0d79955ab0c64c79ca079366fbc2617e680/librt-0.7.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce58420e25097b2fc201aef9b9f6d65df1eb8438e51154e1a7feb8847e4a55ab", size = 174216, upload-time = "2025-12-15T16:51:35.384Z" }, { url = "https://files.pythonhosted.org/packages/62/ec/09239b912a45a8ed117cb4a6616d9ff508f5d3131bd84329bf2f8d6564f1/librt-0.7.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b719c8730c02a606dc0e8413287e8e94ac2d32a51153b300baf1f62347858fba", size = 189005, upload-time = "2025-12-15T16:51:36.687Z" }, { url = "https://files.pythonhosted.org/packages/46/2e/e188313d54c02f5b0580dd31476bb4b0177514ff8d2be9f58d4a6dc3a7ba/librt-0.7.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3749ef74c170809e6dee68addec9d2458700a8de703de081c888e92a8b015cf9", size = 183960, upload-time = "2025-12-15T16:51:37.977Z" }, { url = "https://files.pythonhosted.org/packages/eb/84/f1d568d254518463d879161d3737b784137d236075215e56c7c9be191cee/librt-0.7.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b35c63f557653c05b5b1b6559a074dbabe0afee28ee2a05b6c9ba21ad0d16a74", size = 177609, upload-time = "2025-12-15T16:51:40.584Z" }, { url = "https://files.pythonhosted.org/packages/5d/43/060bbc1c002f0d757c33a1afe6bf6a565f947a04841139508fc7cef6c08b/librt-0.7.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1ef704e01cb6ad39ad7af668d51677557ca7e5d377663286f0ee1b6b27c28e5f", size = 199269, upload-time = "2025-12-15T16:51:41.879Z" }, { url = "https://files.pythonhosted.org/packages/ff/7f/708f8f02d8012ee9f366c07ea6a92882f48bd06cc1ff16a35e13d0fbfb08/librt-0.7.4-cp312-cp312-win32.whl", hash = "sha256:c66c2b245926ec15188aead25d395091cb5c9df008d3b3207268cd65557d6286", size = 43186, upload-time = "2025-12-15T16:51:43.149Z" }, { url = "https://files.pythonhosted.org/packages/f1/a5/4e051b061c8b2509be31b2c7ad4682090502c0a8b6406edcf8c6b4fe1ef7/librt-0.7.4-cp312-cp312-win_amd64.whl", hash = "sha256:71a56f4671f7ff723451f26a6131754d7c1809e04e22ebfbac1db8c9e6767a20", size = 49455, upload-time = "2025-12-15T16:51:44.336Z" }, { url = "https://files.pythonhosted.org/packages/d0/d2/90d84e9f919224a3c1f393af1636d8638f54925fdc6cd5ee47f1548461e5/librt-0.7.4-cp312-cp312-win_arm64.whl", hash = "sha256:419eea245e7ec0fe664eb7e85e7ff97dcdb2513ca4f6b45a8ec4a3346904f95a", size = 42828, upload-time = "2025-12-15T16:51:45.498Z" }, { url = "https://files.pythonhosted.org/packages/fe/4d/46a53ccfbb39fd0b493fd4496eb76f3ebc15bb3e45d8c2e695a27587edf5/librt-0.7.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d44a1b1ba44cbd2fc3cb77992bef6d6fdb1028849824e1dd5e4d746e1f7f7f0b", size = 55745, upload-time = "2025-12-15T16:51:46.636Z" }, { url = "https://files.pythonhosted.org/packages/7f/2b/3ac7f5212b1828bf4f979cf87f547db948d3e28421d7a430d4db23346ce4/librt-0.7.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c9cab4b3de1f55e6c30a84c8cee20e4d3b2476f4d547256694a1b0163da4fe32", size = 57166, upload-time = "2025-12-15T16:51:48.219Z" }, { url = "https://files.pythonhosted.org/packages/e8/99/6523509097cbe25f363795f0c0d1c6a3746e30c2994e25b5aefdab119b21/librt-0.7.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2857c875f1edd1feef3c371fbf830a61b632fb4d1e57160bb1e6a3206e6abe67", size = 165833, upload-time = "2025-12-15T16:51:49.443Z" }, { url = "https://files.pythonhosted.org/packages/fe/35/323611e59f8fe032649b4fb7e77f746f96eb7588fcbb31af26bae9630571/librt-0.7.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b370a77be0a16e1ad0270822c12c21462dc40496e891d3b0caf1617c8cc57e20", size = 174818, upload-time = "2025-12-15T16:51:51.015Z" }, { url = "https://files.pythonhosted.org/packages/41/e6/40fb2bb21616c6e06b6a64022802228066e9a31618f493e03f6b9661548a/librt-0.7.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d05acd46b9a52087bfc50c59dfdf96a2c480a601e8898a44821c7fd676598f74", size = 189607, upload-time = "2025-12-15T16:51:52.671Z" }, { url = "https://files.pythonhosted.org/packages/32/48/1b47c7d5d28b775941e739ed2bfe564b091c49201b9503514d69e4ed96d7/librt-0.7.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:70969229cb23d9c1a80e14225838d56e464dc71fa34c8342c954fc50e7516dee", size = 184585, upload-time = "2025-12-15T16:51:54.027Z" }, { url = "https://files.pythonhosted.org/packages/75/a6/ee135dfb5d3b54d5d9001dbe483806229c6beac3ee2ba1092582b7efeb1b/librt-0.7.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4450c354b89dbb266730893862dbff06006c9ed5b06b6016d529b2bf644fc681", size = 178249, upload-time = "2025-12-15T16:51:55.248Z" }, { url = "https://files.pythonhosted.org/packages/04/87/d5b84ec997338be26af982bcd6679be0c1db9a32faadab1cf4bb24f9e992/librt-0.7.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:adefe0d48ad35b90b6f361f6ff5a1bd95af80c17d18619c093c60a20e7a5b60c", size = 199851, upload-time = "2025-12-15T16:51:56.933Z" }, { url = "https://files.pythonhosted.org/packages/86/63/ba1333bf48306fe398e3392a7427ce527f81b0b79d0d91618c4610ce9d15/librt-0.7.4-cp313-cp313-win32.whl", hash = "sha256:21ea710e96c1e050635700695095962a22ea420d4b3755a25e4909f2172b4ff2", size = 43249, upload-time = "2025-12-15T16:51:58.498Z" }, { url = "https://files.pythonhosted.org/packages/f9/8a/de2c6df06cdfa9308c080e6b060fe192790b6a48a47320b215e860f0e98c/librt-0.7.4-cp313-cp313-win_amd64.whl", hash = "sha256:772e18696cf5a64afee908662fbcb1f907460ddc851336ee3a848ef7684c8e1e", size = 49417, upload-time = "2025-12-15T16:51:59.618Z" }, { url = "https://files.pythonhosted.org/packages/31/66/8ee0949efc389691381ed686185e43536c20e7ad880c122dd1f31e65c658/librt-0.7.4-cp313-cp313-win_arm64.whl", hash = "sha256:52e34c6af84e12921748c8354aa6acf1912ca98ba60cdaa6920e34793f1a0788", size = 42824, upload-time = "2025-12-15T16:52:00.784Z" }, { url = "https://files.pythonhosted.org/packages/74/81/6921e65c8708eb6636bbf383aa77e6c7dad33a598ed3b50c313306a2da9d/librt-0.7.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4f1ee004942eaaed6e06c087d93ebc1c67e9a293e5f6b9b5da558df6bf23dc5d", size = 55191, upload-time = "2025-12-15T16:52:01.97Z" }, { url = "https://files.pythonhosted.org/packages/0d/d6/3eb864af8a8de8b39cc8dd2e9ded1823979a27795d72c4eea0afa8c26c9f/librt-0.7.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d854c6dc0f689bad7ed452d2a3ecff58029d80612d336a45b62c35e917f42d23", size = 56898, upload-time = "2025-12-15T16:52:03.356Z" }, { url = "https://files.pythonhosted.org/packages/49/bc/b1d4c0711fdf79646225d576faee8747b8528a6ec1ceb6accfd89ade7102/librt-0.7.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a4f7339d9e445280f23d63dea842c0c77379c4a47471c538fc8feedab9d8d063", size = 163725, upload-time = "2025-12-15T16:52:04.572Z" }, { url = "https://files.pythonhosted.org/packages/2c/08/61c41cd8f0a6a41fc99ea78a2205b88187e45ba9800792410ed62f033584/librt-0.7.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39003fc73f925e684f8521b2dbf34f61a5deb8a20a15dcf53e0d823190ce8848", size = 172469, upload-time = "2025-12-15T16:52:05.863Z" }, { url = "https://files.pythonhosted.org/packages/8b/c7/4ee18b4d57f01444230bc18cf59103aeab8f8c0f45e84e0e540094df1df1/librt-0.7.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bb15ee29d95875ad697d449fe6071b67f730f15a6961913a2b0205015ca0843", size = 186804, upload-time = "2025-12-15T16:52:07.192Z" }, { url = "https://files.pythonhosted.org/packages/a1/af/009e8ba3fbf830c936842da048eda1b34b99329f402e49d88fafff6525d1/librt-0.7.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:02a69369862099e37d00765583052a99d6a68af7e19b887e1b78fee0146b755a", size = 181807, upload-time = "2025-12-15T16:52:08.554Z" }, { url = "https://files.pythonhosted.org/packages/85/26/51ae25f813656a8b117c27a974f25e8c1e90abcd5a791ac685bf5b489a1b/librt-0.7.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ec72342cc4d62f38b25a94e28b9efefce41839aecdecf5e9627473ed04b7be16", size = 175595, upload-time = "2025-12-15T16:52:10.186Z" }, { url = "https://files.pythonhosted.org/packages/48/93/36d6c71f830305f88996b15c8e017aa8d1e03e2e947b40b55bbf1a34cf24/librt-0.7.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:776dbb9bfa0fc5ce64234b446995d8d9f04badf64f544ca036bd6cff6f0732ce", size = 196504, upload-time = "2025-12-15T16:52:11.472Z" }, { url = "https://files.pythonhosted.org/packages/08/11/8299e70862bb9d704735bf132c6be09c17b00fbc7cda0429a9df222fdc1b/librt-0.7.4-cp314-cp314-win32.whl", hash = "sha256:0f8cac84196d0ffcadf8469d9ded4d4e3a8b1c666095c2a291e22bf58e1e8a9f", size = 39738, upload-time = "2025-12-15T16:52:12.962Z" }, { url = "https://files.pythonhosted.org/packages/54/d5/656b0126e4e0f8e2725cd2d2a1ec40f71f37f6f03f135a26b663c0e1a737/librt-0.7.4-cp314-cp314-win_amd64.whl", hash = "sha256:037f5cb6fe5abe23f1dc058054d50e9699fcc90d0677eee4e4f74a8677636a1a", size = 45976, upload-time = "2025-12-15T16:52:14.441Z" }, { url = "https://files.pythonhosted.org/packages/60/86/465ff07b75c1067da8fa7f02913c4ead096ef106cfac97a977f763783bfb/librt-0.7.4-cp314-cp314-win_arm64.whl", hash = "sha256:a5deebb53d7a4d7e2e758a96befcd8edaaca0633ae71857995a0f16033289e44", size = 39073, upload-time = "2025-12-15T16:52:15.621Z" }, { url = "https://files.pythonhosted.org/packages/b3/a0/24941f85960774a80d4b3c2aec651d7d980466da8101cae89e8b032a3e21/librt-0.7.4-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b4c25312c7f4e6ab35ab16211bdf819e6e4eddcba3b2ea632fb51c9a2a97e105", size = 57369, upload-time = "2025-12-15T16:52:16.782Z" }, { url = "https://files.pythonhosted.org/packages/77/a0/ddb259cae86ab415786c1547d0fe1b40f04a7b089f564fd5c0242a3fafb2/librt-0.7.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:618b7459bb392bdf373f2327e477597fff8f9e6a1878fffc1b711c013d1b0da4", size = 59230, upload-time = "2025-12-15T16:52:18.259Z" }, { url = "https://files.pythonhosted.org/packages/31/11/77823cb530ab8a0c6fac848ac65b745be446f6f301753b8990e8809080c9/librt-0.7.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1437c3f72a30c7047f16fd3e972ea58b90172c3c6ca309645c1c68984f05526a", size = 183869, upload-time = "2025-12-15T16:52:19.457Z" }, { url = "https://files.pythonhosted.org/packages/a4/ce/157db3614cf3034b3f702ae5ba4fefda4686f11eea4b7b96542324a7a0e7/librt-0.7.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c96cb76f055b33308f6858b9b594618f1b46e147a4d03a4d7f0c449e304b9b95", size = 194606, upload-time = "2025-12-15T16:52:20.795Z" }, { url = "https://files.pythonhosted.org/packages/30/ef/6ec4c7e3d6490f69a4fd2803516fa5334a848a4173eac26d8ee6507bff6e/librt-0.7.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28f990e6821204f516d09dc39966ef8b84556ffd648d5926c9a3f681e8de8906", size = 206776, upload-time = "2025-12-15T16:52:22.229Z" }, { url = "https://files.pythonhosted.org/packages/ad/22/750b37bf549f60a4782ab80e9d1e9c44981374ab79a7ea68670159905918/librt-0.7.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc4aebecc79781a1b77d7d4e7d9fe080385a439e198d993b557b60f9117addaf", size = 203205, upload-time = "2025-12-15T16:52:23.603Z" }, { url = "https://files.pythonhosted.org/packages/7a/87/2e8a0f584412a93df5faad46c5fa0a6825fdb5eba2ce482074b114877f44/librt-0.7.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:022cc673e69283a42621dd453e2407cf1647e77f8bd857d7ad7499901e62376f", size = 196696, upload-time = "2025-12-15T16:52:24.951Z" }, { url = "https://files.pythonhosted.org/packages/e5/ca/7bf78fa950e43b564b7de52ceeb477fb211a11f5733227efa1591d05a307/librt-0.7.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2b3ca211ae8ea540569e9c513da052699b7b06928dcda61247cb4f318122bdb5", size = 217191, upload-time = "2025-12-15T16:52:26.194Z" }, { url = "https://files.pythonhosted.org/packages/d6/49/3732b0e8424ae35ad5c3166d9dd5bcdae43ce98775e0867a716ff5868064/librt-0.7.4-cp314-cp314t-win32.whl", hash = "sha256:8a461f6456981d8c8e971ff5a55f2e34f4e60871e665d2f5fde23ee74dea4eeb", size = 40276, upload-time = "2025-12-15T16:52:27.54Z" }, { url = "https://files.pythonhosted.org/packages/35/d6/d8823e01bd069934525fddb343189c008b39828a429b473fb20d67d5cd36/librt-0.7.4-cp314-cp314t-win_amd64.whl", hash = "sha256:721a7b125a817d60bf4924e1eec2a7867bfcf64cfc333045de1df7a0629e4481", size = 46772, upload-time = "2025-12-15T16:52:28.653Z" }, { url = "https://files.pythonhosted.org/packages/36/e9/a0aa60f5322814dd084a89614e9e31139702e342f8459ad8af1984a18168/librt-0.7.4-cp314-cp314t-win_arm64.whl", hash = "sha256:76b2ba71265c0102d11458879b4d53ccd0b32b0164d14deb8d2b598a018e502f", size = 39724, upload-time = "2025-12-15T16:52:29.836Z" }, ] [[package]] name = "markdown" version = "3.10" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/7dd27d9d863b3376fcf23a5a13cb5d024aed1db46f963f1b5735ae43b3be/markdown-3.10.tar.gz", hash = "sha256:37062d4f2aa4b2b6b32aefb80faa300f82cc790cb949a35b8caede34f2b68c0e", size = 364931, upload-time = "2025-11-03T19:51:15.007Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/70/81/54e3ce63502cd085a0c556652a4e1b919c45a446bd1e5300e10c44c8c521/markdown-3.10-py3-none-any.whl", hash = "sha256:b5b99d6951e2e4948d939255596523444c0e677c669700b1d17aa4a8a464cb7c", size = 107678, upload-time = "2025-11-03T19:51:13.887Z" }, ] [[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/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" }, ] [[package]] name = "mergedeep" version = "1.3.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, ] [[package]] name = "mkdocs" version = "1.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "ghp-import" }, { name = "jinja2" }, { name = "markdown" }, { name = "markupsafe" }, { name = "mergedeep" }, { name = "mkdocs-get-deps" }, { name = "packaging" }, { name = "pathspec" }, { name = "pyyaml" }, { name = "pyyaml-env-tag" }, { name = "watchdog" }, ] sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, ] [[package]] name = "mkdocs-get-deps" version = "0.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mergedeep" }, { name = "platformdirs" }, { name = "pyyaml" }, ] sdist = { url = "https://files.pythonhosted.org/packages/98/f5/ed29cd50067784976f25ed0ed6fcd3c2ce9eb90650aa3b2796ddf7b6870b/mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c", size = 10239, upload-time = "2023-11-20T17:51:09.981Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/9f/d4/029f984e8d3f3b6b726bd33cafc473b75e9e44c0f7e80a5b29abc466bdea/mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134", size = 9521, upload-time = "2023-11-20T17:51:08.587Z" }, ] [[package]] name = "mkdocs-material" version = "9.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "babel" }, { name = "backrefs" }, { name = "colorama" }, { name = "jinja2" }, { name = "markdown" }, { name = "mkdocs" }, { name = "mkdocs-material-extensions" }, { name = "paginate" }, { name = "pygments" }, { name = "pymdown-extensions" }, { name = "requests" }, ] sdist = { url = "https://files.pythonhosted.org/packages/27/e2/2ffc356cd72f1473d07c7719d82a8f2cbd261666828614ecb95b12169f41/mkdocs_material-9.7.1.tar.gz", hash = "sha256:89601b8f2c3e6c6ee0a918cc3566cb201d40bf37c3cd3c2067e26fadb8cce2b8", size = 4094392, upload-time = "2025-12-18T09:49:00.308Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/3e/32/ed071cb721aca8c227718cffcf7bd539620e9799bbf2619e90c757bfd030/mkdocs_material-9.7.1-py3-none-any.whl", hash = "sha256:3f6100937d7d731f87f1e3e3b021c97f7239666b9ba1151ab476cabb96c60d5c", size = 9297166, upload-time = "2025-12-18T09:48:56.664Z" }, ] [[package]] name = "mkdocs-material-extensions" version = "1.3.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, ] [[package]] name = "msgpack" version = "1.1.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ad/bd/8b0d01c756203fbab65d265859749860682ccd2a59594609aeec3a144efa/msgpack-1.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:70a0dff9d1f8da25179ffcf880e10cf1aad55fdb63cd59c9a49a1b82290062aa", size = 81939, upload-time = "2025-10-08T09:15:01.472Z" }, { url = "https://files.pythonhosted.org/packages/34/68/ba4f155f793a74c1483d4bdef136e1023f7bcba557f0db4ef3db3c665cf1/msgpack-1.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:446abdd8b94b55c800ac34b102dffd2f6aa0ce643c55dfc017ad89347db3dbdb", size = 85064, upload-time = "2025-10-08T09:15:03.764Z" }, { url = "https://files.pythonhosted.org/packages/f2/60/a064b0345fc36c4c3d2c743c82d9100c40388d77f0b48b2f04d6041dbec1/msgpack-1.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63eea553c69ab05b6747901b97d620bb2a690633c77f23feb0c6a947a8a7b8f", size = 417131, upload-time = "2025-10-08T09:15:05.136Z" }, { url = "https://files.pythonhosted.org/packages/65/92/a5100f7185a800a5d29f8d14041f61475b9de465ffcc0f3b9fba606e4505/msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:372839311ccf6bdaf39b00b61288e0557916c3729529b301c52c2d88842add42", size = 427556, upload-time = "2025-10-08T09:15:06.837Z" }, { url = "https://files.pythonhosted.org/packages/f5/87/ffe21d1bf7d9991354ad93949286f643b2bb6ddbeab66373922b44c3b8cc/msgpack-1.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2929af52106ca73fcb28576218476ffbb531a036c2adbcf54a3664de124303e9", size = 404920, upload-time = "2025-10-08T09:15:08.179Z" }, { url = "https://files.pythonhosted.org/packages/ff/41/8543ed2b8604f7c0d89ce066f42007faac1eaa7d79a81555f206a5cdb889/msgpack-1.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be52a8fc79e45b0364210eef5234a7cf8d330836d0a64dfbb878efa903d84620", size = 415013, upload-time = "2025-10-08T09:15:09.83Z" }, { url = "https://files.pythonhosted.org/packages/41/0d/2ddfaa8b7e1cee6c490d46cb0a39742b19e2481600a7a0e96537e9c22f43/msgpack-1.1.2-cp312-cp312-win32.whl", hash = "sha256:1fff3d825d7859ac888b0fbda39a42d59193543920eda9d9bea44d958a878029", size = 65096, upload-time = "2025-10-08T09:15:11.11Z" }, { url = "https://files.pythonhosted.org/packages/8c/ec/d431eb7941fb55a31dd6ca3404d41fbb52d99172df2e7707754488390910/msgpack-1.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1de460f0403172cff81169a30b9a92b260cb809c4cb7e2fc79ae8d0510c78b6b", size = 72708, upload-time = "2025-10-08T09:15:12.554Z" }, { url = "https://files.pythonhosted.org/packages/c5/31/5b1a1f70eb0e87d1678e9624908f86317787b536060641d6798e3cf70ace/msgpack-1.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:be5980f3ee0e6bd44f3a9e9dea01054f175b50c3e6cdb692bc9424c0bbb8bf69", size = 64119, upload-time = "2025-10-08T09:15:13.589Z" }, { url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" }, { url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" }, { url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" }, { url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" }, { url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" }, { url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" }, { url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" }, { url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" }, { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" }, { url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" }, { url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" }, { url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" }, { url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" }, { url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" }, { url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" }, { url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" }, { url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" }, { url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" }, { url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" }, { url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" }, { url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" }, { url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" }, { url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" }, { url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" }, { url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" }, { url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" }, { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" }, ] [[package]] name = "mypy" version = "1.19.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, { name = "mypy-extensions" }, { name = "pathspec" }, { name = "typing-extensions" }, ] 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/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/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-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 = "packaging" version = "25.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] [[package]] name = "paginate" version = "0.5.7" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, ] [[package]] name = "pathspec" version = "0.12.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, ] [[package]] name = "platformdirs" version = "4.5.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, ] [[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 = "psycopg" version = "3.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.13'" }, { name = "tzdata", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e0/1a/7d9ef4fdc13ef7f15b934c393edc97a35c281bb7d3c3329fbfcbe915a7c2/psycopg-3.3.2.tar.gz", hash = "sha256:707a67975ee214d200511177a6a80e56e654754c9afca06a7194ea6bbfde9ca7", size = 165630, upload-time = "2025-12-06T17:34:53.899Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/8c/51/2779ccdf9305981a06b21a6b27e8547c948d85c41c76ff434192784a4c93/psycopg-3.3.2-py3-none-any.whl", hash = "sha256:3e94bc5f4690247d734599af56e51bae8e0db8e4311ea413f801fef82b14a99b", size = 212774, upload-time = "2025-12-06T17:31:41.414Z" }, ] [[package]] name = "py-ubjson" version = "0.16.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/1d/c7/28220d37e041fe1df03e857fe48f768dcd30cd151480bf6f00da8713214a/py-ubjson-0.16.1.tar.gz", hash = "sha256:b9bfb8695a1c7e3632e800fb83c943bf67ed45ddd87cd0344851610c69a5a482", size = 50316, upload-time = "2020-04-18T15:05:57.698Z" } [[package]] name = "pyasn1" version = "0.6.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322, upload-time = "2024-09-10T22:41:42.55Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" }, ] [[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" } 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 = "pygments" version = "2.19.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] [[package]] name = "pymdown-extensions" version = "10.19.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown" }, { name = "pyyaml" }, ] sdist = { url = "https://files.pythonhosted.org/packages/72/2d/9f30cee56d4d6d222430d401e85b0a6a1ae229819362f5786943d1a8c03b/pymdown_extensions-10.19.1.tar.gz", hash = "sha256:4969c691009a389fb1f9712dd8e7bd70dcc418d15a0faf70acb5117d022f7de8", size = 847839, upload-time = "2025-12-14T17:25:24.42Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/fb/35/b763e8fbcd51968329b9adc52d188fc97859f85f2ee15fe9f379987d99c5/pymdown_extensions-10.19.1-py3-none-any.whl", hash = "sha256:e8698a66055b1dc0dca2a7f2c9d0ea6f5faa7834a9c432e3535ab96c0c4e509b", size = 266693, upload-time = "2025-12-14T17:25:22.999Z" }, ] [[package]] name = "pyopenssl" version = "25.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/80/be/97b83a464498a79103036bc74d1038df4a7ef0e402cfaf4d5e113fb14759/pyopenssl-25.3.0.tar.gz", hash = "sha256:c981cb0a3fd84e8602d7afc209522773b94c1c2446a3c710a75b06fe1beae329", size = 184073, upload-time = "2025-09-17T00:32:21.037Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d1/81/ef2b1dfd1862567d573a4fdbc9f969067621764fbb74338496840a1d2977/pyopenssl-25.3.0-py3-none-any.whl", hash = "sha256:1fda6fc034d5e3d179d39e59c1895c9faeaf40a79de5fc4cbbfbe0d36f4a77b6", size = 57268, upload-time = "2025-09-17T00:32:19.474Z" }, ] [[package]] name = "pytest" version = "9.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, { name = "pygments" }, ] 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.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, { name = "typing-extensions", marker = "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-cov" version = "7.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "coverage" }, { name = "pluggy" }, { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, ] [[package]] name = "pytest-django" version = "4.11.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b1/fb/55d580352db26eb3d59ad50c64321ddfe228d3d8ac107db05387a2fadf3a/pytest_django-4.11.1.tar.gz", hash = "sha256:a949141a1ee103cb0e7a20f1451d355f83f5e4a5d07bdd4dcfdd1fd0ff227991", size = 86202, upload-time = "2025-04-03T18:56:09.338Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/be/ac/bd0608d229ec808e51a21044f3f2f27b9a37e7a0ebaca7247882e67876af/pytest_django-4.11.1-py3-none-any.whl", hash = "sha256:1b63773f648aa3d8541000c26929c1ea63934be1cfa674c76436966d73fe6a10", size = 25281, upload-time = "2025-04-03T18:56:07.678Z" }, ] [[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 = "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/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" }, ] [[package]] name = "pyyaml-env-tag" version = "1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyyaml" }, ] sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, ] [[package]] name = "requests" version = "2.32.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "charset-normalizer" }, { name = "idna" }, { name = "urllib3" }, ] 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 = "ruff" version = "0.14.10" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/57/08/52232a877978dd8f9cf2aeddce3e611b40a63287dfca29b6b8da791f5e8d/ruff-0.14.10.tar.gz", hash = "sha256:9a2e830f075d1a42cd28420d7809ace390832a490ed0966fe373ba288e77aaf4", size = 5859763, upload-time = "2025-12-18T19:28:57.98Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/60/01/933704d69f3f05ee16ef11406b78881733c186fe14b6a46b05cfcaf6d3b2/ruff-0.14.10-py3-none-linux_armv6l.whl", hash = "sha256:7a3ce585f2ade3e1f29ec1b92df13e3da262178df8c8bdf876f48fa0e8316c49", size = 13527080, upload-time = "2025-12-18T19:29:25.642Z" }, { url = "https://files.pythonhosted.org/packages/df/58/a0349197a7dfa603ffb7f5b0470391efa79ddc327c1e29c4851e85b09cc5/ruff-0.14.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:674f9be9372907f7257c51f1d4fc902cb7cf014b9980152b802794317941f08f", size = 13797320, upload-time = "2025-12-18T19:29:02.571Z" }, { url = "https://files.pythonhosted.org/packages/7b/82/36be59f00a6082e38c23536df4e71cdbc6af8d7c707eade97fcad5c98235/ruff-0.14.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d85713d522348837ef9df8efca33ccb8bd6fcfc86a2cde3ccb4bc9d28a18003d", size = 12918434, upload-time = "2025-12-18T19:28:51.202Z" }, { url = "https://files.pythonhosted.org/packages/a6/00/45c62a7f7e34da92a25804f813ebe05c88aa9e0c25e5cb5a7d23dd7450e3/ruff-0.14.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6987ebe0501ae4f4308d7d24e2d0fe3d7a98430f5adfd0f1fead050a740a3a77", size = 13371961, upload-time = "2025-12-18T19:29:04.991Z" }, { url = "https://files.pythonhosted.org/packages/40/31/a5906d60f0405f7e57045a70f2d57084a93ca7425f22e1d66904769d1628/ruff-0.14.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:16a01dfb7b9e4eee556fbfd5392806b1b8550c9b4a9f6acd3dbe6812b193c70a", size = 13275629, upload-time = "2025-12-18T19:29:21.381Z" }, { url = "https://files.pythonhosted.org/packages/3e/60/61c0087df21894cf9d928dc04bcd4fb10e8b2e8dca7b1a276ba2155b2002/ruff-0.14.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7165d31a925b7a294465fa81be8c12a0e9b60fb02bf177e79067c867e71f8b1f", size = 14029234, upload-time = "2025-12-18T19:29:00.132Z" }, { url = "https://files.pythonhosted.org/packages/44/84/77d911bee3b92348b6e5dab5a0c898d87084ea03ac5dc708f46d88407def/ruff-0.14.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c561695675b972effb0c0a45db233f2c816ff3da8dcfbe7dfc7eed625f218935", size = 15449890, upload-time = "2025-12-18T19:28:53.573Z" }, { url = "https://files.pythonhosted.org/packages/e9/36/480206eaefa24a7ec321582dda580443a8f0671fdbf6b1c80e9c3e93a16a/ruff-0.14.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bb98fcbbc61725968893682fd4df8966a34611239c9fd07a1f6a07e7103d08e", size = 15123172, upload-time = "2025-12-18T19:29:23.453Z" }, { url = "https://files.pythonhosted.org/packages/5c/38/68e414156015ba80cef5473d57919d27dfb62ec804b96180bafdeaf0e090/ruff-0.14.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f24b47993a9d8cb858429e97bdf8544c78029f09b520af615c1d261bf827001d", size = 14460260, upload-time = "2025-12-18T19:29:27.808Z" }, { url = "https://files.pythonhosted.org/packages/b3/19/9e050c0dca8aba824d67cc0db69fb459c28d8cd3f6855b1405b3f29cc91d/ruff-0.14.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59aabd2e2c4fd614d2862e7939c34a532c04f1084476d6833dddef4afab87e9f", size = 14229978, upload-time = "2025-12-18T19:29:11.32Z" }, { url = "https://files.pythonhosted.org/packages/51/eb/e8dd1dd6e05b9e695aa9dd420f4577debdd0f87a5ff2fedda33c09e9be8c/ruff-0.14.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:213db2b2e44be8625002dbea33bb9c60c66ea2c07c084a00d55732689d697a7f", size = 14338036, upload-time = "2025-12-18T19:29:09.184Z" }, { url = "https://files.pythonhosted.org/packages/6a/12/f3e3a505db7c19303b70af370d137795fcfec136d670d5de5391e295c134/ruff-0.14.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b914c40ab64865a17a9a5b67911d14df72346a634527240039eb3bd650e5979d", size = 13264051, upload-time = "2025-12-18T19:29:13.431Z" }, { url = "https://files.pythonhosted.org/packages/08/64/8c3a47eaccfef8ac20e0484e68e0772013eb85802f8a9f7603ca751eb166/ruff-0.14.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1484983559f026788e3a5c07c81ef7d1e97c1c78ed03041a18f75df104c45405", size = 13283998, upload-time = "2025-12-18T19:29:06.994Z" }, { url = "https://files.pythonhosted.org/packages/12/84/534a5506f4074e5cc0529e5cd96cfc01bb480e460c7edf5af70d2bcae55e/ruff-0.14.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c70427132db492d25f982fffc8d6c7535cc2fd2c83fc8888f05caaa248521e60", size = 13601891, upload-time = "2025-12-18T19:28:55.811Z" }, { url = "https://files.pythonhosted.org/packages/0d/1e/14c916087d8598917dbad9b2921d340f7884824ad6e9c55de948a93b106d/ruff-0.14.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5bcf45b681e9f1ee6445d317ce1fa9d6cba9a6049542d1c3d5b5958986be8830", size = 14336660, upload-time = "2025-12-18T19:29:16.531Z" }, { url = "https://files.pythonhosted.org/packages/f2/1c/d7b67ab43f30013b47c12b42d1acd354c195351a3f7a1d67f59e54227ede/ruff-0.14.10-py3-none-win32.whl", hash = "sha256:104c49fc7ab73f3f3a758039adea978869a918f31b73280db175b43a2d9b51d6", size = 13196187, upload-time = "2025-12-18T19:29:19.006Z" }, { url = "https://files.pythonhosted.org/packages/fb/9c/896c862e13886fae2af961bef3e6312db9ebc6adc2b156fe95e615dee8c1/ruff-0.14.10-py3-none-win_amd64.whl", hash = "sha256:466297bd73638c6bdf06485683e812db1c00c7ac96d4ddd0294a338c62fdc154", size = 14661283, upload-time = "2025-12-18T19:29:30.16Z" }, { url = "https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size = 13729839, upload-time = "2025-12-18T19:28:48.636Z" }, ] [[package]] name = "service-identity" version = "24.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, { name = "cryptography" }, { name = "pyasn1" }, { name = "pyasn1-modules" }, ] sdist = { url = "https://files.pythonhosted.org/packages/07/a5/dfc752b979067947261dbbf2543470c58efe735c3c1301dd870ef27830ee/service_identity-24.2.0.tar.gz", hash = "sha256:b8683ba13f0d39c6cd5d625d2c5f65421d6d707b013b375c355751557cbe8e09", size = 39245, upload-time = "2024-10-26T07:21:57.736Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/08/2c/ca6dd598b384bc1ce581e24aaae0f2bed4ccac57749d5c3befbb5e742081/service_identity-24.2.0-py3-none-any.whl", hash = "sha256:6b047fbd8a84fd0bb0d55ebce4031e400562b9196e1e0d3e0fe2b8a59f6d4a85", size = 11364, upload-time = "2024-10-26T07:21:56.302Z" }, ] [[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 = "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 = "twisted" version = "25.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, { name = "automat" }, { name = "constantly" }, { name = "hyperlink" }, { name = "incremental" }, { name = "typing-extensions" }, { name = "zope-interface" }, ] sdist = { url = "https://files.pythonhosted.org/packages/13/0f/82716ed849bf7ea4984c21385597c949944f0f9b428b5710f79d0afc084d/twisted-25.5.0.tar.gz", hash = "sha256:1deb272358cb6be1e3e8fc6f9c8b36f78eb0fa7c2233d2dbe11ec6fee04ea316", size = 3545725, upload-time = "2025-06-07T09:52:24.858Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/eb/66/ab7efd8941f0bc7b2bd555b0f0471bff77df4c88e0cc31120c82737fec77/twisted-25.5.0-py3-none-any.whl", hash = "sha256:8559f654d01a54a8c3efe66d533d43f383531ebf8d81d9f9ab4769d91ca15df7", size = 3204767, upload-time = "2025-06-07T09:52:21.428Z" }, ] [package.optional-dependencies] tls = [ { name = "idna" }, { name = "pyopenssl" }, { name = "service-identity" }, ] [[package]] name = "txaio" version = "25.12.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7f/67/ea9c9ddbaa3e0b4d53c91f8778a33e42045be352dc7200ed2b9aaa7dc229/txaio-25.12.2.tar.gz", hash = "sha256:9f232c21e12aa1ff52690e365b5a0ecfd42cc27a6ec86e1b92ece88f763f4b78", size = 117393, upload-time = "2025-12-09T15:03:26.527Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/50/05/bdb6318120cac9bf97779674f49035e0595d894b42d4c43b60637bafdb1f/txaio-25.12.2-py3-none-any.whl", hash = "sha256:5f6cd6c6b397fc3305790d15efd46a2d5b91cdbefa96543b4f8666aeb56ba026", size = 31208, upload-time = "2025-12-09T04:30:27.811Z" }, ] [[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 = "tzdata" version = "2025.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, ] [[package]] name = "u-msgpack-python" version = "2.8.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/36/9d/a40411a475e7d4838994b7f6bcc6bfca9acc5b119ce3a7503608c4428b49/u-msgpack-python-2.8.0.tar.gz", hash = "sha256:b801a83d6ed75e6df41e44518b4f2a9c221dc2da4bcd5380e3a0feda520bc61a", size = 18167, upload-time = "2023-05-18T09:28:12.187Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b1/5e/512aeb40fd819f4660d00f96f5c7371ee36fc8c6b605128c5ee59e0b28c6/u_msgpack_python-2.8.0-py2.py3-none-any.whl", hash = "sha256:1d853d33e78b72c4228a2025b4db28cda81214076e5b0422ed0ae1b1b2bb586a", size = 10590, upload-time = "2023-05-18T09:28:10.323Z" }, ] [[package]] name = "ujson" version = "5.11.0" source = { registry = "https://pypi.org/simple" } 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/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" }, ] [[package]] name = "urllib3" version = "2.6.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/1e/24/a2a2ed9addd907787d7aa0355ba36a6cadf1768b934c652ea78acbd59dcd/urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797", size = 432930, upload-time = "2025-12-11T15:56:40.252Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd", size = 131182, upload-time = "2025-12-11T15:56:38.584Z" }, ] [[package]] name = "watchdog" version = "6.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, ] [[package]] name = "zope-interface" version = "8.1.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/71/c9/5ec8679a04d37c797d343f650c51ad67d178f0001c363e44b6ac5f97a9da/zope_interface-8.1.1.tar.gz", hash = "sha256:51b10e6e8e238d719636a401f44f1e366146912407b58453936b781a19be19ec", size = 254748, upload-time = "2025-11-15T08:32:52.404Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/08/3d/f5b8dd2512f33bfab4faba71f66f6873603d625212206dd36f12403ae4ca/zope_interface-8.1.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:a16715808408db7252b8c1597ed9008bdad7bf378ed48eb9b0595fad4170e49d", size = 208660, upload-time = "2025-11-15T08:36:53.579Z" }, { url = "https://files.pythonhosted.org/packages/e5/41/c331adea9b11e05ff9ac4eb7d3032b24c36a3654ae9f2bf4ef2997048211/zope_interface-8.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce6b58752acc3352c4aa0b55bbeae2a941d61537e6afdad2467a624219025aae", size = 208851, upload-time = "2025-11-15T08:36:54.854Z" }, { url = "https://files.pythonhosted.org/packages/25/00/7a8019c3bb8b119c5f50f0a4869183a4b699ca004a7f87ce98382e6b364c/zope_interface-8.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:807778883d07177713136479de7fd566f9056a13aef63b686f0ab4807c6be259", size = 259292, upload-time = "2025-11-15T08:36:56.409Z" }, { url = "https://files.pythonhosted.org/packages/1a/fc/b70e963bf89345edffdd5d16b61e789fdc09365972b603e13785360fea6f/zope_interface-8.1.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50e5eb3b504a7d63dc25211b9298071d5b10a3eb754d6bf2f8ef06cb49f807ab", size = 264741, upload-time = "2025-11-15T08:36:57.675Z" }, { url = "https://files.pythonhosted.org/packages/96/fe/7d0b5c0692b283901b34847f2b2f50d805bfff4b31de4021ac9dfb516d2a/zope_interface-8.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eee6f93b2512ec9466cf30c37548fd3ed7bc4436ab29cd5943d7a0b561f14f0f", size = 264281, upload-time = "2025-11-15T08:36:58.968Z" }, { url = "https://files.pythonhosted.org/packages/2b/2c/a7cebede1cf2757be158bcb151fe533fa951038cfc5007c7597f9f86804b/zope_interface-8.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:80edee6116d569883c58ff8efcecac3b737733d646802036dc337aa839a5f06b", size = 212327, upload-time = "2025-11-15T08:37:00.4Z" }, { url = "https://files.pythonhosted.org/packages/85/81/3c3b5386ce4fba4612fd82ffb8a90d76bcfea33ca2b6399f21e94d38484f/zope_interface-8.1.1-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:84f9be6d959640de9da5d14ac1f6a89148b16da766e88db37ed17e936160b0b1", size = 209046, upload-time = "2025-11-15T08:37:01.473Z" }, { url = "https://files.pythonhosted.org/packages/4a/e3/32b7cb950c4c4326b3760a8e28e5d6f70ad15f852bfd8f9364b58634f74b/zope_interface-8.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:531fba91dcb97538f70cf4642a19d6574269460274e3f6004bba6fe684449c51", size = 209104, upload-time = "2025-11-15T08:37:02.887Z" }, { url = "https://files.pythonhosted.org/packages/a3/3d/c4c68e1752a5f5effa2c1f5eaa4fea4399433c9b058fb7000a34bfb1c447/zope_interface-8.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:fc65f5633d5a9583ee8d88d1f5de6b46cd42c62e47757cfe86be36fb7c8c4c9b", size = 259277, upload-time = "2025-11-15T08:37:04.389Z" }, { url = "https://files.pythonhosted.org/packages/fd/5b/cf4437b174af7591ee29bbad728f620cab5f47bd6e9c02f87d59f31a0dda/zope_interface-8.1.1-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:efef80ddec4d7d99618ef71bc93b88859248075ca2e1ae1c78636654d3d55533", size = 264742, upload-time = "2025-11-15T08:37:05.613Z" }, { url = "https://files.pythonhosted.org/packages/0b/0e/0cf77356862852d3d3e62db9aadae5419a1a7d89bf963b219745283ab5ca/zope_interface-8.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49aad83525eca3b4747ef51117d302e891f0042b06f32aa1c7023c62642f962b", size = 264252, upload-time = "2025-11-15T08:37:07.035Z" }, { url = "https://files.pythonhosted.org/packages/8a/10/2af54aa88b2fa172d12364116cc40d325fedbb1877c3bb031b0da6052855/zope_interface-8.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:71cf329a21f98cb2bd9077340a589e316ac8a415cac900575a32544b3dffcb98", size = 212330, upload-time = "2025-11-15T08:37:08.14Z" }, { url = "https://files.pythonhosted.org/packages/b9/f5/44efbd98ba06cb937fce7a69fcd7a78c4ac7aa4e1ad2125536801376d2d0/zope_interface-8.1.1-cp314-cp314-macosx_10_9_x86_64.whl", hash = "sha256:da311e9d253991ca327601f47c4644d72359bac6950fbb22f971b24cd7850f8c", size = 209099, upload-time = "2025-11-15T08:37:09.395Z" }, { url = "https://files.pythonhosted.org/packages/fd/36/a19866c09c8485c36a4c6908e1dd3f8820b41c1ee333c291157cf4cf09e7/zope_interface-8.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3fb25fca0442c7fb93c4ee40b42e3e033fef2f648730c4b7ae6d43222a3e8946", size = 209240, upload-time = "2025-11-15T08:37:10.687Z" }, { url = "https://files.pythonhosted.org/packages/c1/28/0dbf40db772d779a4ac8d006a57ad60936d42ad4769a3d5410dcfb98f6f9/zope_interface-8.1.1-cp314-cp314-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:bac588d0742b4e35efb7c7df1dacc0397b51ed37a17d4169a38019a1cebacf0a", size = 260919, upload-time = "2025-11-15T08:37:11.838Z" }, { url = "https://files.pythonhosted.org/packages/72/ae/650cd4c01dd1b32c26c800b2c4d852f044552c34a56fbb74d41f569cee31/zope_interface-8.1.1-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d1f053d2d5e2b393e619bce1e55954885c2e63969159aa521839e719442db49", size = 264102, upload-time = "2025-11-15T08:37:13.241Z" }, { url = "https://files.pythonhosted.org/packages/46/f0/f534a2c34c006aa090c593cd70eaf94e259fd0786f934698d81f0534d907/zope_interface-8.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:64a1ad7f4cb17d948c6bdc525a1d60c0e567b2526feb4fa38b38f249961306b8", size = 264276, upload-time = "2025-11-15T08:37:14.369Z" }, { url = "https://files.pythonhosted.org/packages/5b/a8/d7e9cf03067b767e23908dbab5f6be7735d70cb4818311a248a8c4bb23cc/zope_interface-8.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:169214da1b82b7695d1a36f92d70b11166d66b6b09d03df35d150cc62ac52276", size = 212492, upload-time = "2025-11-15T08:37:15.538Z" }, ]