diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index d3a512c..2157275 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -1,10 +1,11 @@
---
version: 2
updates:
- - package-ecosystem: pip
+ - package-ecosystem: uv
directory: "/"
schedule:
interval: daily
- time: '09:00'
+ time: "09:00"
timezone: Asia/Tokyo
open-pull-requests-limit: 10
+ target-branch: main
diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
deleted file mode 100644
index 4566b9f..0000000
--- a/.github/workflows/build.yaml
+++ /dev/null
@@ -1,45 +0,0 @@
-name: build
-
-on: [push, pull_request]
-
-jobs:
- build:
- name: Python ${{ matrix.python-version }}
- runs-on: ubuntu-latest
- strategy:
- matrix:
- python-version: ['3.7', '3.8', '3.9', '3.10']
- env:
- COVERAGE_OPTIONS: "-a"
-
- steps:
- - uses: actions/checkout@v2
- - name: Set up Python ${{ matrix.python-version }}
- uses: actions/setup-python@v2
- with:
- python-version: ${{ matrix.python-version }}
- - name: Install Tox and any other packages
- run: pip install tox tox-gh-actions
- - name: Test with tox
- run: tox
-
- code_quality:
- name: Code Quality
- runs-on: ubuntu-latest
-
- steps:
- - uses: actions/checkout@v2
- - name: Set up Python 3.10
- uses: actions/setup-python@v2
- with:
- python-version: '3.10'
- - name: Install Tox
- run: pip install tox
- - name: isort
- run: tox -e isort
- - name: readme
- run: tox -e readme
- - name: flake8
- run: tox -e flake8
- - name: check-manifest
- run: tox -e check-manifest
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
new file mode 100644
index 0000000..920f9b4
--- /dev/null
+++ b/.github/workflows/publish.yml
@@ -0,0 +1,32 @@
+---
+name: "Publish"
+
+"on":
+ release:
+ types:
+ - published
+
+jobs:
+ publish:
+ name: Publish to PyPi
+ runs-on: ubuntu-latest
+ environment:
+ name: pypi
+ permissions:
+ id-token: write
+ contents: read
+ steps:
+ - name: Set up Python
+ uses: actions/setup-python@v6
+ with:
+ python-version-file: ".python-version"
+ - name: Set up uv
+ uses: astral-sh/setup-uv@v7
+ - name: Build
+ run: uv build
+ - name: Smoke test (wheel)
+ run: uv run --isolated --no-project --with $(ls dist/*.whl) tests/smoke_test.py
+ - name: Smoke test (source distribution)
+ run: uv run --isolated --no-project --with $(ls dist/*.tar.gz) tests/smoke_test.py
+ - name: Publish
+ run: uv publish
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..a9a08d0
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,60 @@
+---
+name: Test
+
+"on":
+ push:
+ branches:
+ - main
+ pull_request:
+ branches:
+ - main
+
+jobs:
+ lint:
+ name: Lint Typechecking
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v5
+ - name: Set up Python
+ uses: actions/setup-python@v6
+ with:
+ python-version-file: ".python-version"
+ - name: Set up uv
+ uses: astral-sh/setup-uv@v7
+ with:
+ enable-cache: true
+ - name: Install dependencies
+ run: uv sync --extra dev
+ - name: Lint (ruff format --check)
+ run: uv run ruff format --check
+ - name: Lint (ruff check)
+ run: uv run ruff check
+ - name: Type check (mypy)
+ run: uv run mypy
+
+ test:
+ name: Python ${{ matrix.python-version }} / Django ${{ matrix.django-version }}
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
+ django-version: ["4.2", "5.1", "5.2"]
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v5
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v6
+ with:
+ python-version: ${{ matrix.python-version }}
+ - name: Set up uv
+ uses: astral-sh/setup-uv@v7
+ with:
+ enable-cache: true
+ - name: Install dependencies
+ run: |
+ uv sync --extra dev
+ uv pip install "django~=${{ matrix.django-version }}"
+ - name: Test (pytest)
+ run: uv run pytest -v
diff --git a/.gitignore b/.gitignore
index 9adce58..5fea392 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,8 +1,8 @@
-### https://raw.github.com/github/gitignore/f57304e9762876ae4c9b02867ed0cb887316387e/python.gitignore
+### https://raw.github.com/github/gitignore/77b8cdb81610386ec48504c204b10c3acd322ecd/Python.gitignore
# Byte-compiled / optimized / DLL files
__pycache__/
-*.py[cod]
+*.py[codz]
*$py.class
# C extensions
@@ -10,7 +10,6 @@ __pycache__/
# Distribution / packaging
.Python
-env/
build/
develop-eggs/
dist/
@@ -23,9 +22,11 @@ parts/
sdist/
var/
wheels/
+share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
+MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
@@ -40,13 +41,17 @@ pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
+.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
-*,cover
+*.cover
+*.py.cover
.hypothesis/
+.pytest_cache/
+cover/
# Translations
*.mo
@@ -55,6 +60,8 @@ coverage.xml
# Django stuff:
*.log
local_settings.py
+db.sqlite3
+db.sqlite3-journal
# Flask stuff:
instance/
@@ -67,30 +74,75 @@ instance/
docs/_build/
# PyBuilder
+.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
-# pyenv
-.python-version
+# IPython
+profile_default/
+ipython_config.py
-# celery beat schedule file
+# pyenv
+# For a library or package, you might want to ignore these files since the code is
+# intended to run in multiple environments; otherwise, check them in:
+# .python-version
+
+# pipenv
+# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+# However, in case of collaboration, if having platform-specific dependencies or dependencies
+# having no cross-platform support, pipenv may install dependencies that don't work, or not
+# install all needed dependencies.
+#Pipfile.lock
+
+# UV
+# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
+# This is especially recommended for binary packages to ensure reproducibility, and is more
+# commonly ignored for libraries.
+#uv.lock
+
+# poetry
+# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
+# This is especially recommended for binary packages to ensure reproducibility, and is more
+# commonly ignored for libraries.
+# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
+#poetry.lock
+#poetry.toml
+
+# pdm
+# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
+#pdm.lock
+# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
+# in version control.
+# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
+.pdm.toml
+.pdm-python
+.pdm-build/
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
+__pypackages__/
+
+# Celery stuff
celerybeat-schedule
+celerybeat.pid
# SageMath parsed files
*.sage.py
-# dotenv
+# Environments
.env
-
-# virtualenv
+.envrc
.venv
+env/
venv/
ENV/
+env.bak/
+venv.bak/
# Spyder project settings
.spyderproject
+.spyproject
# Rope project settings
.ropeproject
@@ -98,4 +150,54 @@ ENV/
# mkdocs documentation
/site
-
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# Cython debug symbols
+cython_debug/
+
+# PyCharm
+# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
+# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
+# and can be added to the global gitignore or merged into this file. For a more nuclear
+# option (not recommended) you can uncomment the following to ignore the entire idea folder.
+#.idea/
+
+# Abstra
+# Abstra is an AI-powered process automation framework.
+# Ignore directories containing user credentials, local state, and settings.
+# Learn more at https://abstra.io/docs
+.abstra/
+
+# Visual Studio Code
+# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
+# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
+# and can be added to the global gitignore or merged into this file. However, if you prefer,
+# you could uncomment the following to ignore the entire vscode folder
+# .vscode/
+
+# Ruff stuff:
+.ruff_cache/
+
+# PyPI configuration file
+.pypirc
+
+# Cursor
+# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
+# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
+# refer to https://docs.cursor.com/context/ignore-files
+.cursorignore
+.cursorindexingignore
+
+# Marimo
+marimo/_static/
+marimo/_lsp/
+__marimo__/
diff --git a/.python-version b/.python-version
new file mode 100644
index 0000000..c8cfe39
--- /dev/null
+++ b/.python-version
@@ -0,0 +1 @@
+3.10
diff --git a/MANIFEST.in b/MANIFEST.in
index 089a35f..765da76 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,5 +1,5 @@
include LICENSE
-include README.rst
+include README.md
exclude requirements.txt
exclude tox.ini
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..f51b2c3
--- /dev/null
+++ b/README.md
@@ -0,0 +1,132 @@
+# django-elastipymemcache
+
+[](https://codecov.io/gh/harikitech/django-elastipymemcache)
+
+## Overview
+
+**django-elastipymemcache** is a Django cache backend for **Amazon ElastiCache (memcached)** clusters.
+It is built on top of [pymemcache](https://github.com/pinterest/pymemcache) and connects to all cluster nodes via
+[ElastiCache Auto Discovery](https://docs.aws.amazon.com/AmazonElastiCache/latest/UserGuide/AutoDiscovery.html).
+
+Originally forked from [django-elasticache](https://github.com/gusdan/django-elasticache), this implementation adds:
+
+- Thread-safe topology updates (atomic swaps)
+- Auto discovery for scaling events
+- Connection pooling (data nodes & config endpoint)
+- Optional TLS connectivity
+- Compatibility with Django’s cache interface
+
+## Requirements
+
+- Python >= 3.10
+- Django >= 4.2
+- pymemcache >= 4.0.0
+
+## Installation
+
+Get it from PyPI:
+
+```bash
+python3 -m pip install django-elastipymemcache
+```
+
+## Usage
+
+### Basic
+
+```python
+CACHES = {
+ "default": {
+ "BACKEND": "django_elastipymemcache.backend.ElastiPymemcache",
+ "LOCATION": "[configuration-endpoint]:11211",
+ "OPTIONS": {
+ "ignore_exc": True,
+ },
+ }
+}
+```
+
+### Connection Pooling
+
+```python
+CACHES = {
+ "default": {
+ "BACKEND": "django_elastipymemcache.backend.ElastiPymemcache",
+ "LOCATION": "[configuration-endpoint]:11211",
+ "OPTIONS": {
+ # Enable pooling for both config endpoint and data nodes
+ "use_pooling": True,
+ "max_pool_size": 50,
+ "pool_idle_timeout": 30,
+ "connect_timeout": 0.3,
+ "timeout": 0.5,
+ "ignore_exc": True,
+ },
+ }
+}
+```
+
+### Auto Discovery (with pooling)
+
+```python
+CACHES = {
+ "default": {
+ "BACKEND": "django_elastipymemcache.backend.ElastiPymemcache",
+ "LOCATION": "[configuration-endpoint]:11211",
+ "OPTIONS": {
+ "use_pooling": True,
+ "discovery_interval": 60.0,
+ "discovery_retry_delay": 2.0,
+ "ignore_exc": True,
+ },
+ }
+}
+```
+
+## Options
+
+The backend accepts a combination of **ElastiPymemcache-specific options** and
+**pymemcache client options**. For the complete list of pymemcache options, see:
+
+
+### ElastiPymemcache-specific options
+
+| Option | Type | Default | Description |
+| ----------------------- | ----- | ------- | ------------------------------------------------------------------ |
+| `discovery_interval` | float | `0.0` | Periodic auto-discovery interval in seconds. Set `0.0` to disable. |
+| `discovery_retry_delay` | float | `0.0` | Delay (seconds) before retrying discovery after failure. |
+| `use_vpc_ip_address` | bool | `True` | Prefer VPC private IPs over DNS hostnames (recommended on AWS). |
+
+### Notes
+
+- According to the official Amazon ElastiCache documentation, **auto-discovery must be enabled to support vertical scaling**.
+
+- Auto-discovery also runs **on demand** when the ring is empty, even if `discovery_interval` is `0.0`.
+ This helps recover after scale events.
+- If you use TLS, pass the appropriate `tls_context` through `OPTIONS` (this is a pymemcache option)
+ and ensure your ElastiCache cluster supports TLS.
+
+## Notice
+
+### Datadog `ddtrace` & `pymemcache` instrumentation (temporary workaround)
+
+When using `ddtrace` with Django or other frameworks, enabling the `pymemcache` integration may trigger runtime errors such as:
+
+```text
+ValueError: wrapper has not been initialized
+```
+
+This issue occurs due to `wrapt` interfering with class initialization order inside `ddtrace`’s `pymemcache` integration.
+Until Datadog releases a fix, disable the `pymemcache` tracer.
+
+#### Environment variable
+
+```sh
+DD_TRACE_PYMEMCACHE_ENABLED=false
+```
+
+#### Code-level patch
+
+```python
+patch_all(pymemcache=False)
+```
diff --git a/README.rst b/README.rst
deleted file mode 100644
index 587d806..0000000
--- a/README.rst
+++ /dev/null
@@ -1,55 +0,0 @@
-=======================
-django-elastipymemcache
-=======================
-
-.. index: README
-.. image:: https://travis-ci.org/harikitech/django-elastipymemcache.svg?branch=master
- :target: https://travis-ci.org/harikitech/django-elastipymemcache
-.. image:: https://codecov.io/gh/harikitech/django-elastipymemcache/branch/master/graph/badge.svg
- :target: https://codecov.io/gh/harikitech/django-elastipymemcache
-
-Purpose
--------
-
-Simple Django cache backend for Amazon ElastiCache (memcached based). It uses
-`pymemcache `_ and sets up a connection to each
-node in the cluster using
-`auto discovery `_.
-Originally forked from `django-elasticache `_.
-
-Requirements
-------------
-
-* pymemcache
-* Django>=2.2
-* django-pymemcache>=1.0
-
-Installation
-------------
-
-Get it from `pypi `_::
-
- pip install django-elastipymemcache
-
-Usage
------
-
-Your cache backend should look something like this::
-
- CACHES = {
- 'default': {
- 'BACKEND': 'django_elastipymemcache.backend.ElastiPymemcache',
- 'LOCATION': '[configuration endpoint]:11211',
- 'OPTIONS': {
- 'ignore_exc': True, # pymemcache Client params
- 'ignore_cluster_errors': True, # ignore get cluster info error
- }
- }
- }
-
-Testing
--------
-
-Run the tests like this::
-
- nosetests
diff --git a/django_elastipymemcache/__init__.py b/django_elastipymemcache/__init__.py
index 25e6d8b..528787c 100644
--- a/django_elastipymemcache/__init__.py
+++ b/django_elastipymemcache/__init__.py
@@ -1,2 +1 @@
-VERSION = (2, 0, 5)
-__version__ = '.'.join(map(str, VERSION))
+__version__ = "3.0.0"
diff --git a/django_elastipymemcache/backend.py b/django_elastipymemcache/backend.py
index 22b38fb..5dbac17 100644
--- a/django_elastipymemcache/backend.py
+++ b/django_elastipymemcache/backend.py
@@ -1,139 +1,47 @@
-"""
-Backend for django cache
-"""
import logging
-import socket
-from functools import wraps
+from typing import Any, Sequence
from django.core.cache import InvalidCacheBackendError
-from django.core.cache.backends.memcached import BaseMemcachedCache
-from djpymemcache import client as djpymemcache_client
+from django.core.cache.backends.memcached import PyMemcacheCache
+from django.utils.functional import cached_property
-from .client import ConfigurationEndpointClient
+from .client import _AWS_CONFIGURATION_ENDPOINT_PATTERN, AWSElastiCacheClient
logger = logging.getLogger(__name__)
-def invalidate_cache_after_error(f):
- """
- Catch any exception and invalidate internal cache with list of nodes
- """
- @wraps(f)
- def wrapper(self, *args, **kwds):
- try:
- return f(self, *args, **kwds)
- except Exception:
- self.clear_cluster_nodes_cache()
- raise
- return wrapper
-
-
-class ElastiPymemcache(BaseMemcachedCache):
- """
- Backend for Amazon ElastiCache (memcached) with auto discovery mode
- it used pymemcache
- """
- def __init__(self, server, params):
- params['OPTIONS'] = params.get('OPTIONS', {})
- params['OPTIONS'].setdefault('ignore_exc', True)
-
- self._cluster_timeout = params['OPTIONS'].pop(
- 'cluster_timeout',
- socket._GLOBAL_DEFAULT_TIMEOUT,
- )
- self._ignore_cluster_errors = params['OPTIONS'].pop(
- 'ignore_cluster_errors',
- False,
- )
-
- super().__init__(
- server,
- params,
- library=djpymemcache_client,
- value_not_found_exception=ValueError,
- )
-
- if len(self._servers) > 1:
- raise InvalidCacheBackendError(
- 'ElastiCache should be configured with only one server '
- '(Configuration Endpoint)',
- )
- try:
- host, port = self._servers[0].split(':')
- port = int(port)
- except ValueError:
- raise InvalidCacheBackendError(
- 'Server configuration should be in format IP:Port',
- )
-
- self.configuration_endpoint_client = ConfigurationEndpointClient(
- (host, port),
- ignore_cluster_errors=self._ignore_cluster_errors,
- **self._options,
+class ElastiPymemcache(PyMemcacheCache):
+ def __init__(
+ self,
+ server: str | Sequence[str],
+ params: dict[str, Any],
+ ) -> None:
+ super().__init__(server, params)
+ self._class = AWSElastiCacheClient
+ self._endpoint = self._validate_endpoint()
+
+ def _validate_endpoint(self) -> str:
+ if not self._servers or len(self._servers) != 1: # type: ignore[attr-defined]
+ raise InvalidCacheBackendError("ElastiCache requires exactly one Configuration Endpoint (host:port).")
+
+ endpoint = self._servers[0] # type: ignore[attr-defined]
+ if not isinstance(endpoint, str) or not _AWS_CONFIGURATION_ENDPOINT_PATTERN.fullmatch(endpoint):
+ raise InvalidCacheBackendError(f"Invalid Configuration Endpoint '{endpoint}'. Expected 'host:port'.")
+ return endpoint
+
+ @cached_property
+ def _cache(self) -> AWSElastiCacheClient:
+ return self._class(
+ configuration_endpoint=self._endpoint,
+ **self._options, # type: ignore[attr-defined]
)
- def clear_cluster_nodes_cache(self):
- """Clear internal cache with list of nodes in cluster"""
- if hasattr(self, '_client'):
- del self._client
+ def _safe_close(self, **kwargs: Any) -> None:
+ client = self.__dict__.pop("_cache", None)
+ if not client:
+ return
- def get_cluster_nodes(self):
try:
- return self.configuration_endpoint_client \
- .get_cluster_info()['nodes']
- except (
- OSError,
- socket.gaierror,
- socket.timeout,
- ) as e:
- logger.warning(
- 'Cannot connect to cluster %s, err: %s',
- self.configuration_endpoint_client.server,
- e,
- )
- return []
-
- @property
- def _cache(self):
- if getattr(self, '_client', None) is None:
- self._client = self._lib.Client(
- self.get_cluster_nodes(),
- **self._options,
- )
- return self._client
-
- @invalidate_cache_after_error
- def add(self, *args, **kwargs):
- return super().add(*args, **kwargs)
-
- @invalidate_cache_after_error
- def get(self, *args, **kwargs):
- return super().get(*args, **kwargs)
-
- @invalidate_cache_after_error
- def set(self, *args, **kwargs):
- return super().set(*args, **kwargs)
-
- @invalidate_cache_after_error
- def delete(self, *args, **kwargs):
- return super().delete(*args, **kwargs)
-
- @invalidate_cache_after_error
- def get_many(self, *args, **kwargs):
- return super().get_many(*args, **kwargs)
-
- @invalidate_cache_after_error
- def set_many(self, *args, **kwargs):
- return super().set_many(*args, **kwargs)
-
- @invalidate_cache_after_error
- def delete_many(self, *args, **kwargs):
- return super().delete_many(*args, **kwargs)
-
- @invalidate_cache_after_error
- def incr(self, *args, **kwargs):
- return super().incr(*args, **kwargs)
-
- @invalidate_cache_after_error
- def decr(self, *args, **kwargs):
- return super().decr(*args, **kwargs)
+ client.close()
+ except Exception as e:
+ logger.warning("Exception occurred while closing ElastiCache client: %s", e)
diff --git a/django_elastipymemcache/client.py b/django_elastipymemcache/client.py
index 4f6a2f5..12caf16 100644
--- a/django_elastipymemcache/client.py
+++ b/django_elastipymemcache/client.py
@@ -1,79 +1,308 @@
+"""
+Derived from pymemcache's AWS ElastiCache client
+
+Copy from: https://github.com/pinterest/pymemcache/blob/master/pymemcache/client/ext/aws_ec_client.py
+"""
+
import logging
+import random
+import re
+import threading
+import time
+from typing import Any, Callable, Concatenate, ParamSpec, TypeVar
-from django.utils.encoding import smart_str
-from packaging.version import parse
-from pymemcache.client.base import Client, _readline
-from pymemcache.exceptions import MemcacheUnknownError
+from django.utils.encoding import force_str
+from pymemcache import MemcacheUnknownCommandError
+from pymemcache.client import Client, PooledClient, RetryingClient
+from pymemcache.client.hash import HashClient
+from pymemcache.exceptions import MemcacheError
logger = logging.getLogger(__name__)
+# Accept either host:port or [IPv4]:port
+_AWS_CONFIGURATION_ENDPOINT_PATTERN = re.compile(
+ r"^(?:(?:[\w\d-]{0,61}[\w\d]\.)+[\w]{1,6}|\[(?:[\d]{1,3}\.){3}[\d]{1,3}\]):\d{1,5}$"
+)
+
+
+class _ConfigurationEndpointClient:
+ """ElastiCache's configuration endpoint client."""
+
+ client_class = Client
+
+ #: default: prefer VPC IPs (index 1). FQDN==0, IP==1
+ DEFAULT_VPC_ADDRESS_INDEX = 1
+
+ def __init__(
+ self,
+ configuration_endpoint: str,
+ default_kwargs: dict[str, Any] | None = None,
+ use_pooling: bool = False,
+ use_vpc_ip_address: bool = True,
+ ) -> None:
+ self.configuration_endpoint = configuration_endpoint
+ host, port = self.configuration_endpoint.rsplit(":", 1)
+ self._server = (host, int(port))
+
+ self._default_kwargs = default_kwargs or {}
+ self._use_pooling = bool(use_pooling)
+ self._use_vpc_ip_address = use_vpc_ip_address
-class ConfigurationEndpointClient(Client):
- # https://docs.aws.amazon.com/AmazonElastiCache/latest/mem-ug/AutoDiscovery.AddingToYourClientLibrary.html
+ self._lock = threading.Lock()
+ self._client: PooledClient | None = None
- def __init__(self, *args, ignore_cluster_errors=False, **kwargs):
- client = super().__init__(*args, **kwargs)
- self.ignore_cluster_errors = ignore_cluster_errors
+ def _new_client(self) -> Client:
+ client_class = PooledClient if self._use_pooling else self.client_class
+ client = client_class(self._server, **self._default_kwargs)
+ if self._use_pooling and isinstance(client, PooledClient):
+ client.client_class = self.client_class
return client
- def _get_cluster_info_cmd(self):
- if parse(smart_str(self.version())) < parse('1.4.14'):
- return b'get AmazonElastiCache:cluster\r\n'
- return b'config get cluster\r\n'
-
- def _extract_cluster_info(self, line):
- raw_version, raw_nodes, _ = line.split(b'\n')
- nodes = []
- for raw_node in raw_nodes.split(b' '):
- host, ip, port = raw_node.split(b'|')
- nodes.append('{host}:{port}'.format(
- host=smart_str(ip or host),
- port=int(port)
- ))
- return {
- 'version': int(raw_version),
- 'nodes': nodes,
- }
-
- def _fetch_cluster_info_cmd(self, cmd, name):
- if self.sock is None:
- self._connect()
- self.sock.sendall(cmd)
-
- buf = b''
- result = {}
- number_of_line = 0
-
- while True:
- buf, line = _readline(self.sock, buf)
- self._raise_errors(line, name)
- if line == b'END':
- if number_of_line != 2:
- raise MemcacheUnknownError('Wrong response')
- return result
- if number_of_line == 1:
+ def _get_client(self) -> Client | PooledClient:
+ if not self._use_pooling:
+ return self._new_client()
+
+ with self._lock:
+ if self._client is None:
+ self._client = self._new_client()
+ return self._client
+
+ def _close_client(self, force: bool = False) -> None:
+ if not force and self._use_pooling:
+ return
+
+ with self._lock:
+ if self._client is not None:
try:
- result = self._extract_cluster_info(line)
- except ValueError:
- raise MemcacheUnknownError('Wrong format: {line}'.format(
- line=line,
- ))
- number_of_line += 1
-
- def get_cluster_info(self):
- cmd = self._get_cluster_info_cmd()
+ self._client.close()
+ except Exception:
+ logger.warning("ElastiCache discovery: failed to close client", exc_info=True)
+ finally:
+ self._client = None
+
+ close = _close_client
+
+ def _raw_config_get_cluster(self, client: Client | PooledClient) -> bytes:
+ return bytes(
+ client.raw_command(
+ b"config get cluster",
+ end_tokens=b"\n\r\nEND\r\n",
+ )
+ )
+
+ def _parse_config_get_cluster_response(self, response: bytes) -> list[tuple[str, int]]:
+ lines = [force_str(line).strip() for line in response.splitlines() if force_str(line).strip()]
+ if not lines:
+ raise MemcacheError("ElastiCache discovery: empty response")
+ elif len(lines) < 2:
+ raise MemcacheError(f"ElastiCache discovery: unexpected response: {lines}")
+
+ header, version, *body = lines
+ if not header.lower().startswith("config cluster"):
+ raise MemcacheError(f"ElastiCache discovery: invalid header: {header}")
+ elif not version.isdigit():
+ raise MemcacheError(f"ElastiCache discovery: invalid version line: {version}")
+ elif not body:
+ raise MemcacheError("ElastiCache discovery: empty body")
+
+ membership_line = " ".join(body)
+ nodes: list[tuple[str, int]] = []
+ for token in membership_line.split(" "):
+ try:
+ host, ip, port = token.split("|")
+ except ValueError:
+ logger.warning("ElastiCache discovery: bad node format in token: %r", token)
+ continue
+
+ addr = self._use_vpc_ip_address and ip or host
+ nodes.append((addr, int(port)))
+
+ if not nodes:
+ logger.warning(
+ f"ElastiCache discovery: no nodes parsed from response: {body!r}",
+ )
+ raise MemcacheError("ElastiCache discovery: no nodes parsed")
+
+ return nodes
+
+ def config_get_cluster(self) -> list[tuple[str, int]]:
+ client = self._get_client()
try:
- return self._fetch_cluster_info_cmd(cmd, 'config cluster')
- except Exception as e:
- if self.ignore_cluster_errors:
- logger.warning('Failed to get cluster: %s', e)
- return {
- 'version': None,
- 'nodes': [
- '{host}:{port:d}'.format(
- host=self.server[0],
- port=int(self.server[1]),
- ),
- ]
- }
+ response = self._raw_config_get_cluster(client)
+ except Exception:
+ logger.warning("ElastiCache discovery: config get cluster failed", exc_info=True)
+ self._close_client(force=True)
raise
+ else:
+ self._close_client()
+
+ return self._parse_config_get_cluster_response(response)
+
+
+P = ParamSpec("P")
+R = TypeVar("R")
+
+
+def _retry_refresh_clients(
+ method: Callable[Concatenate["AWSElastiCacheClient", P], R],
+) -> Callable[Concatenate["AWSElastiCacheClient", P], R]:
+ def wrapped(
+ self: "AWSElastiCacheClient",
+ /,
+ *args: P.args,
+ **kwargs: P.kwargs,
+ ) -> R:
+ last_exception: Exception | None = None
+
+ for _ in range(self.retry_attempts + 1):
+ try:
+ return method(self, *args, **kwargs)
+ except (MemcacheError, OSError) as exc:
+ last_exception = exc
+ if getattr(self, "_discovery_retry_delay", 0.0) > 0.0:
+ time.sleep(self._discovery_retry_delay)
+ try:
+ self._refresh_clients(force=True)
+ except Exception as e:
+ logger.debug("Discovery refresh failed during retry: %r", e)
+
+ assert last_exception is not None
+ raise last_exception
+
+ return wrapped
+
+
+class AWSElastiCacheClient(HashClient): # type: ignore[misc]
+ """ElastiCache-aware HashClient with"""
+
+ def __init__(
+ self,
+ configuration_endpoint: str,
+ # pymemcache.HashClient params
+ use_pooling: bool = False,
+ retry_attempts: int = 2,
+ # Discovery & topology management
+ use_vpc_ip_address: bool = True,
+ discovery_interval: float | int = 0.0,
+ discovery_retry_delay: float | int = 0.0,
+ **kwargs: Any,
+ ) -> None:
+ if not _AWS_CONFIGURATION_ENDPOINT_PATTERN.fullmatch(configuration_endpoint):
+ raise ValueError(
+ f"Invalid configuration endpoint '{configuration_endpoint}' (expected 'host:port' or '[ip]:port')."
+ )
+
+ super().__init__(
+ servers=[], # Discovery after initialization
+ use_pooling=use_pooling,
+ retry_attempts=retry_attempts,
+ **kwargs,
+ )
+
+ self.configuration_endpoint: str = configuration_endpoint
+ configuration_endpoint_client = _ConfigurationEndpointClient(
+ configuration_endpoint,
+ default_kwargs=self.default_kwargs,
+ use_pooling=use_pooling,
+ use_vpc_ip_address=use_vpc_ip_address,
+ )
+
+ self._configuration_endpoint_client = RetryingClient(
+ configuration_endpoint_client,
+ attempts=retry_attempts,
+ retry_delay=discovery_retry_delay,
+ do_not_retry_for=(MemcacheUnknownCommandError,),
+ )
+
+ self._use_auto_discovery = bool(discovery_interval)
+ self._discovery_interval = (
+ self._use_auto_discovery
+ # Jitter discovery interval
+ and float(discovery_interval) * random.uniform(0.8, 1.2)
+ or float(discovery_interval)
+ )
+ self._discovery_retry_delay = float(discovery_retry_delay)
+ self._last_discovery_time: float = 0.0
+ self._topology_lock = threading.Lock()
+ try:
+ self._refresh_clients(force=True)
+ except Exception as e:
+ logger.exception(f"Initial discovery failed: {e}")
+
+ def _discover_client_keys(self) -> set[str]:
+ try:
+ node = self._configuration_endpoint_client.config_get_cluster()
+ return set(map(self._make_client_key, node))
+ except MemcacheError:
+ logger.warning("ElastiCache discovery: cluster discovery failed.")
+ return set()
+
+ def _refresh_clients(self, force: bool = False) -> None:
+ if not force and not self._use_auto_discovery:
+ return
+
+ now = time.monotonic()
+ if not force and (now - self._last_discovery_time) < self._discovery_interval:
+ return
+
+ old_clients: list[Client | PooledClient] = []
+
+ with self._topology_lock:
+ current_keys = set(self.clients.keys())
+ new_keys = self._discover_client_keys()
+
+ # remove
+ for client_key in current_keys - new_keys:
+ old_client = self.clients.pop(client_key, None)
+ if old_client:
+ old_clients.append(old_client)
+
+ self.hasher.remove_node(client_key)
+
+ host, port = client_key.split(":", 1)
+ server = (host, int(port))
+ self._failed_clients.pop(server, None)
+ self._dead_clients.pop(server, None)
+
+ # add
+ for client_key in new_keys - current_keys:
+ host, port = client_key.split(":", 1)
+ super().add_server((host, int(port)))
+
+ self._last_discovery_time = now
+
+ for old_client in old_clients:
+ try:
+ old_client.close()
+ except Exception:
+ logger.exception("Failed to close during topology refresh")
+
+ @_retry_refresh_clients
+ def _get_client(self, key: str) -> Client | PooledClient:
+ self._refresh_clients()
+ return super()._get_client(key)
+
+ def _close_clients(self) -> None:
+ if self.use_pooling:
+ return
+
+ try:
+ super().close()
+ except Exception:
+ logger.warning("Exception occurred while closing ElastiCache client", exc_info=True)
+
+ def _close_configuration_endpoint_client(self) -> None:
+ if not self._configuration_endpoint_client:
+ return
+
+ try:
+ self._configuration_endpoint_client.close()
+ except Exception:
+ logger.warning(
+ "Exception occurred while closing configuration endpoint client",
+ exc_info=True,
+ )
+
+ def close(self) -> None:
+ self._close_clients()
+ self._close_configuration_endpoint_client()
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..b1b4786
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,132 @@
+[build-system]
+requires = [
+ "hatchling>=1.25",
+]
+build-backend = "hatchling.build"
+
+[project]
+name = "django-elastipymemcache"
+description = "pymemcache-based Django cache backend for Amazon ElastiCache with auto discovery"
+readme = "README.md"
+requires-python = ">=3.10"
+license = { text = "MIT"}
+authors = [
+ { name = "Contributors" },
+]
+keywords = [
+ "django",
+ "cache",
+ "memcached",
+ "elasticache",
+ "pymemcache",
+]
+classifiers = [
+ "Framework :: Django",
+ "Framework :: Django :: 4.2",
+ "Framework :: Django :: 5.1",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3 :: Only",
+ "License :: OSI Approved :: MIT License",
+ "Operating System :: OS Independent",
+]
+dependencies = [
+ "pymemcache>=4.0",
+ "Django>=4.2",
+]
+dynamic = [
+ "version",
+]
+
+[project.optional-dependencies]
+dev = [
+ "pytest>=8.0",
+ "pytest-django>=4.11",
+ "coverage[toml]>=7.11",
+ "ruff>=0.14.1",
+ "mypy>=1.18",
+ "django-stubs[compatible-mypy]>=4.2",
+]
+
+[tool.pytest.ini_options]
+minversion = "8.0"
+addopts = "-ra -q --strict-markers --disable-warnings --maxfail=1"
+testpaths = [
+ "tests",
+]
+python_files = [
+ "test_*.py",
+]
+DJANGO_SETTINGS_MODULE = "tests.settings"
+
+[tool.coverage.run]
+branch = true
+source = [
+ "django_elastipymemcache",
+]
+
+[tool.coverage.report]
+show_missing = true
+skip_covered = true
+
+[tool.ruff]
+line-length = 120
+target-version = "py310"
+src = [
+ "django_elastipymemcache",
+ "tests",
+]
+extend-exclude = [
+ "dist",
+ "build",
+ ".venv",
+]
+
+[tool.ruff.lint]
+extend-select = [
+ "I", # isort
+]
+ignore = [
+ "E501", # rely on formatter for wrapping
+]
+[tool.ruff.lint.isort]
+known-first-party = [
+ "django_elastipymemcache",
+ "tests",
+]
+
+[tool.ruff.format]
+quote-style = "double"
+indent-style = "space"
+
+[tool.mypy]
+python_version = "3.10"
+strict = true
+warn_unused_ignores = true
+warn_redundant_casts = true
+ignore_missing_imports = true
+files = [
+ "django_elastipymemcache",
+ "tests",
+]
+plugins = [
+ "mypy_django_plugin.main",
+]
+
+[tool.django-stubs]
+django_settings_module = "tests.settings"
+
+[tool.hatch.version]
+path = "django_elastipymemcache/__init__.py"
+
+[tool.hatch.build.targets.sdist]
+include = [
+ "django_elastipymemcache",
+ "tests",
+ "README.md",
+ "LICENSE",
+]
+
+[tool.hatch.build.targets.wheel]
+packages = [
+ "django_elastipymemcache",
+]
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index 82e1394..0000000
--- a/requirements.txt
+++ /dev/null
@@ -1,8 +0,0 @@
-check-manifest==0.48
-coverage==6.4.4
-flake8==5.0.4
-isort==5.10.1
-mock==4.0.3
-pymemcache==3.5.2
-pytest==7.1.2
-readme-renderer==37.0
diff --git a/setup.cfg b/setup.cfg
deleted file mode 100644
index 363fcea..0000000
--- a/setup.cfg
+++ /dev/null
@@ -1,27 +0,0 @@
-[wheel]
-universal = 1
-
-[isort]
-include_trailing_comma=True
-line_length=80
-multi_line_output=3
-not_skip=__init__.py
-known_first_party=django_elastipymemcache
-
-[check-manifest]
-ignore =
- *.swp
-
-[coverage:run]
-branch = True
-omit = tests/*
-
-[flake8]
-exclude =
- .git,
- .tox,
- .venv,
- .eggs,
- migrations,
- venv,
- __pycache__
diff --git a/setup.py b/setup.py
deleted file mode 100644
index a914629..0000000
--- a/setup.py
+++ /dev/null
@@ -1,42 +0,0 @@
-#!/usr/bin/env python
-# -*- encoding: utf-8 -*-
-
-import io
-
-from setuptools import find_packages, setup
-
-import django_elastipymemcache
-
-setup(
- name='django-elastipymemcache',
- version=django_elastipymemcache.__version__,
- description='Django cache backend for Amazon ElastiCache (memcached)',
- keywords='elasticache amazon cache pymemcache memcached aws',
- author='HarikiTech',
- author_email='harikitech+noreply@googlegroups.com',
- url='http://github.com/harikitech/django-elastipymemcache',
- license='MIT',
- long_description=io.open('README.rst').read(),
- platforms='any',
- zip_safe=False,
- classifiers=[
- 'Development Status :: 4 - Beta',
- 'Environment :: Web Environment',
- 'Framework :: Django :: 2.2',
- 'Framework :: Django :: 3.0',
- 'Framework :: Django :: 3.1',
- 'Intended Audience :: Developers',
- 'License :: OSI Approved :: MIT License',
- 'Operating System :: OS Independent',
- 'Programming Language :: Python :: 3.6',
- 'Programming Language :: Python :: 3.7',
- 'Programming Language :: Python :: 3.8',
- 'Topic :: Software Development :: Libraries :: Python Modules',
- ],
- packages=find_packages(exclude=('tests',)),
- include_package_data=True,
- install_requires=[
- 'django-pymemcache>=1.0',
- 'Django>=2.2',
- ],
-)
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..d2dae89
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,86 @@
+import collections
+import socket
+
+
+class FakeSocket:
+ def __init__(
+ self,
+ responses: list[bytes],
+ ) -> None:
+ self.recv_bufs = collections.deque(responses)
+ self.sent: list[bytes] = []
+ self.closed = False
+ self.connections: list[tuple[str, int]] = []
+
+ def sendall(
+ self,
+ value: bytes,
+ ) -> None:
+ self.sent.append(value)
+
+ def recv(
+ self,
+ size: int,
+ ) -> bytes:
+ if not self.recv_bufs:
+ return b""
+ value = self.recv_bufs.popleft()
+ if isinstance(value, Exception):
+ raise value
+ return value
+
+ def settimeout(
+ self,
+ timeout: float | int,
+ ) -> None:
+ pass
+
+ def connect(
+ self,
+ server: tuple[str, int],
+ ) -> None:
+ self.connections.append(server)
+
+ def close(self) -> None:
+ self.closed = True
+
+
+class FakeSocketModule:
+ AF_UNSPEC = socket.AF_UNSPEC
+ AF_INET = socket.AF_INET
+ AF_INET6 = socket.AF_INET6
+ SOCK_STREAM = socket.SOCK_STREAM
+ IPPROTO_TCP = socket.IPPROTO_TCP
+
+ def __init__(
+ self,
+ responses: list[bytes],
+ ) -> None:
+ self._responses = responses
+ self.sockets: list[FakeSocket] = []
+
+ def socket(
+ self,
+ family: int,
+ type: int,
+ proto: int = 0,
+ fileno: int | None = None,
+ ) -> FakeSocket:
+ s = FakeSocket(list(self._responses))
+ self.sockets.append(s)
+ return s
+
+ def getaddrinfo(
+ self,
+ host: str,
+ port: int,
+ family: int = 0,
+ type: int = 0,
+ proto: int = 0,
+ flags: int = 0,
+ ) -> list[tuple[int, int, int, str, tuple[str, int]]]:
+ family = family or socket.AF_INET
+ type = type or socket.SOCK_STREAM
+ proto = proto or socket.IPPROTO_TCP
+ sockaddr = ("127.0.0.1", port)
+ return [(family, type, proto, "", sockaddr)]
diff --git a/tests/settings.py b/tests/settings.py
new file mode 100644
index 0000000..77c8c03
--- /dev/null
+++ b/tests/settings.py
@@ -0,0 +1,8 @@
+SECRET_KEY = "test"
+INSTALLED_APPS: list[str] = []
+CACHES = {
+ "default": {
+ "BACKEND": "django_elastipymemcache.backend.ElastiPymemcache",
+ "LOCATION": "localhost:11211",
+ }
+}
diff --git a/tests/smoke_test.py b/tests/smoke_test.py
new file mode 100644
index 0000000..c1c5202
--- /dev/null
+++ b/tests/smoke_test.py
@@ -0,0 +1,27 @@
+def test_import_and_version() -> None:
+ import django_elastipymemcache
+
+ assert hasattr(django_elastipymemcache, "__version__")
+
+
+def test_backend_importable() -> None:
+ from django_elastipymemcache.backend import ElastiPymemcache
+
+ assert ElastiPymemcache is not None
+
+
+def test_client_importable() -> None:
+ from django_elastipymemcache.client import (
+ AWSElastiCacheClient,
+ _ConfigurationEndpointClient,
+ )
+
+ assert AWSElastiCacheClient is not None
+ assert _ConfigurationEndpointClient is not None
+
+
+def test_client_basic() -> None:
+ from django_elastipymemcache.client import _ConfigurationEndpointClient
+
+ client = _ConfigurationEndpointClient("localhost:11211")
+ assert client.configuration_endpoint == "localhost:11211"
diff --git a/tests/test_aws_elasticache_client.py b/tests/test_aws_elasticache_client.py
new file mode 100644
index 0000000..e0d89a6
--- /dev/null
+++ b/tests/test_aws_elasticache_client.py
@@ -0,0 +1,108 @@
+import time
+from typing import Any, Callable
+from unittest.mock import Mock
+
+import pytest
+from pymemcache.client import Client, PooledClient
+from pytest import MonkeyPatch
+
+from django_elastipymemcache.client import AWSElastiCacheClient
+
+
+@pytest.fixture
+def mock_discovery(monkeypatch: MonkeyPatch) -> Callable[[list[tuple[str, int]]], None]:
+ def _set(nodes: list[tuple[str, int]]) -> None:
+ monkeypatch.setattr(
+ "django_elastipymemcache.client._ConfigurationEndpointClient.config_get_cluster",
+ lambda self: list(nodes),
+ )
+
+ return _set
+
+
+def make_client(**options: Any) -> AWSElastiCacheClient:
+ return AWSElastiCacheClient(
+ "test.0000.use1.cache.amazonaws.com:11211",
+ **options,
+ )
+
+
+def test_initial_refresh_builds_clients(
+ mock_discovery: Callable[[list[tuple[str, int]]], None],
+) -> None:
+ mock_discovery([("10.0.0.1", 11211), ("10.0.0.2", 11211)])
+ client = make_client(discovery_interval=0.0)
+ assert len(client.clients) == 2
+ assert set(client.clients.keys()) == {"10.0.0.1:11211", "10.0.0.2:11211"}
+
+
+def test_add_and_remove_nodes(
+ mock_discovery: Callable[[list[tuple[str, int]]], None],
+) -> None:
+ mock_discovery([("10.0.0.1", 11211), ("10.0.0.2", 11211)])
+ client = make_client(discovery_interval=0.0)
+ client._refresh_clients(force=True)
+ assert set(client.clients.keys()) == {"10.0.0.1:11211", "10.0.0.2:11211"}
+
+ mock_discovery([("10.0.0.2", 11211), ("10.0.0.3", 11211)])
+ client._refresh_clients(force=True)
+
+ assert set(client.clients.keys()) == {"10.0.0.2:11211", "10.0.0.3:11211"}
+
+
+def test_periodic_refresh_respects_interval(
+ monkeypatch: MonkeyPatch,
+ mock_discovery: Callable[[list[tuple[str, int]]], None],
+) -> None:
+ mock_discovery([("10.0.0.1", 11211)])
+ now = time.monotonic()
+ mock_monotonic = Mock(return_value=now)
+ monkeypatch.setattr(time, "monotonic", mock_monotonic)
+
+ client = make_client(discovery_interval=10.0)
+ client._refresh_clients(force=True)
+ assert set(client.clients.keys()) == {"10.0.0.1:11211"}
+ mock_monotonic.return_value = now + 5.0
+ mock_discovery([("10.0.0.2", 11211)])
+ client._refresh_clients()
+ assert set(client.clients.keys()) == {"10.0.0.1:11211"}
+
+ mock_monotonic.return_value = now + 20.0
+ client._refresh_clients()
+ assert set(client.clients.keys()) == {"10.0.0.2:11211"}
+
+
+def test_use_pooling_creates_pooled_clients(
+ mock_discovery: Callable[[list[tuple[str, int]]], None],
+) -> None:
+ mock_discovery([("10.0.0.1", 11211)])
+ client = make_client(
+ use_pooling=True,
+ discovery_interval=0.0,
+ max_pool_size=8,
+ )
+ client._refresh_clients(force=True)
+ assert all(isinstance(c, PooledClient) for c in client.clients.values())
+
+
+def test_get_client_triggers_retry_refresh_when_ring_empty(
+ monkeypatch: MonkeyPatch,
+ mock_discovery: Callable[[list[tuple[str, int]]], None],
+) -> None:
+ mock_discovery([])
+ client = make_client(discovery_interval=0.0)
+
+ mock_config_get = Mock(
+ side_effect=[
+ [], # 1st call
+ [("10.0.0.9", 11211)], # 2nd call
+ ]
+ )
+ monkeypatch.setattr(
+ "django_elastipymemcache.client._ConfigurationEndpointClient.config_get_cluster",
+ mock_config_get,
+ )
+
+ data_node = client._get_client("test")
+
+ assert isinstance(data_node, (Client, PooledClient))
diff --git a/tests/test_backend.py b/tests/test_backend.py
index a61237e..623ef71 100644
--- a/tests/test_backend.py
+++ b/tests/test_backend.py
@@ -1,226 +1,92 @@
-from unittest import TestCase
+from typing import Callable
from unittest.mock import Mock, patch
-import django
+import pytest
from django.core.cache import InvalidCacheBackendError
+from pytest import MonkeyPatch
-from django_elastipymemcache.client import ConfigurationEndpointClient
+from django_elastipymemcache.backend import ElastiPymemcache
-class ErrorTestCase(TestCase):
- def test_multiple_servers(self):
- with self.assertRaises(InvalidCacheBackendError):
- from django_elastipymemcache.backend import ElastiPymemcache
- ElastiPymemcache('h1:0,h2:0', {})
+@pytest.fixture
+def mock_discovery(monkeypatch: MonkeyPatch) -> Callable[[list[tuple[str, int]]], None]:
+ def _set(nodes: list[tuple[str, int]]) -> None:
+ monkeypatch.setattr(
+ "django_elastipymemcache.client._ConfigurationEndpointClient.config_get_cluster",
+ lambda self: list(nodes),
+ )
- def test_wrong_server_format(self):
- with self.assertRaises(InvalidCacheBackendError):
- from django_elastipymemcache.backend import ElastiPymemcache
- ElastiPymemcache('h', {})
+ return _set
-class BackendTestCase(TestCase):
- @patch.object(ConfigurationEndpointClient, 'get_cluster_info')
- def test_split_servers(self, get_cluster_info):
- from django_elastipymemcache.backend import ElastiPymemcache
- backend = ElastiPymemcache('h:0', {})
- servers = [('h1', 0), ('h2', 0)]
- get_cluster_info.return_value = {
- 'nodes': servers
- }
- backend._lib.Client = Mock()
- assert backend._cache
- get_cluster_info.assert_called()
- backend._lib.Client.assert_called_once_with(
- servers,
- ignore_exc=True,
+def test_multiple_servers() -> None:
+ with pytest.raises(InvalidCacheBackendError):
+ ElastiPymemcache(
+ "test.0001.use1.cache.amazonaws.com:11211,test.0002.use1.cache.amazonaws.com:11211",
+ {},
)
- @patch.object(ConfigurationEndpointClient, 'get_cluster_info')
- def test_node_info_cache(self, get_cluster_info):
- from django_elastipymemcache.backend import ElastiPymemcache
- servers = ['h1:0', 'h2:0']
- get_cluster_info.return_value = {
- 'nodes': servers
- }
-
- backend = ElastiPymemcache('h:0', {})
- backend._lib.Client = Mock()
- backend.set('key1', 'val')
- backend.get('key1')
- backend.set('key2', 'val')
- backend.get('key2')
- backend._lib.Client.assert_called_once_with(
- servers,
- ignore_exc=True,
+
+def test_wrong_server_format() -> None:
+ with pytest.raises(InvalidCacheBackendError):
+ ElastiPymemcache(
+ "test.0000.use1.cache.amazonaws.com",
+ {},
)
- assert backend._cache.get.call_count == 2
- assert backend._cache.set.call_count == 2
-
- get_cluster_info.assert_called_once()
-
- @patch.object(ConfigurationEndpointClient, 'get_cluster_info')
- def test_failed_to_connect_servers(self, get_cluster_info):
- from django_elastipymemcache.backend import ElastiPymemcache
- backend = ElastiPymemcache('h:0', {})
- get_cluster_info.side_effect = OSError()
- assert backend.get_cluster_nodes() == []
-
- @patch.object(ConfigurationEndpointClient, 'get_cluster_info')
- def test_invalidate_cache(self, get_cluster_info):
- from django_elastipymemcache.backend import ElastiPymemcache
- servers = ['h1:0', 'h2:0']
- get_cluster_info.return_value = {
- 'nodes': servers
- }
-
- backend = ElastiPymemcache('h:0', {})
- backend._lib.Client = Mock()
+
+
+def test_split_servers(
+ mock_discovery: Callable[[list[tuple[str, int]]], None],
+) -> None:
+ servers = [("10.0.0.1", 11211), ("10.0.0.2", 11211)]
+ mock_discovery(servers)
+
+ with patch("django_elastipymemcache.backend.AWSElastiCacheClient") as MockClient:
+ backend = ElastiPymemcache("test.0000.use1.cache.amazonaws.com:11211", {})
+
assert backend._cache
- backend._cache.get = Mock()
- backend._cache.get.side_effect = Exception()
- try:
- backend.get('key1', 'val')
- except Exception:
- pass
- # invalidate cached client
- container = getattr(backend, '_local', backend)
- container._client = None
- try:
- backend.get('key1', 'val')
- except Exception:
- pass
- assert backend._cache.get.call_count == 2
- assert get_cluster_info.call_count == 3
-
- @patch.object(ConfigurationEndpointClient, 'get_cluster_info')
- def test_client_add(self, get_cluster_info):
- from django_elastipymemcache.backend import ElastiPymemcache
-
- servers = ['h1:0', 'h2:0']
- get_cluster_info.return_value = {
- 'nodes': servers
- }
-
- backend = ElastiPymemcache('h:0', {})
- ret = backend.add('key1', 'value1')
- assert ret is False
-
- @patch.object(ConfigurationEndpointClient, 'get_cluster_info')
- def test_client_delete(self, get_cluster_info):
- from django_elastipymemcache.backend import ElastiPymemcache
-
- servers = ['h1:0', 'h2:0']
- get_cluster_info.return_value = {
- 'nodes': servers
- }
-
- backend = ElastiPymemcache('h:0', {})
- ret = backend.delete('key1')
- if django.get_version() >= '3.1':
- assert ret is False
- else:
- assert ret is None
-
- @patch.object(ConfigurationEndpointClient, 'get_cluster_info')
- def test_client_get_many(self, get_cluster_info):
- from django_elastipymemcache.backend import ElastiPymemcache
-
- servers = ['h1:0', 'h2:0']
- get_cluster_info.return_value = {
- 'nodes': servers
- }
-
- backend = ElastiPymemcache('h:0', {})
- ret = backend.get_many(['key1'])
- assert ret == {}
-
- # When server does not found...
- with patch('pymemcache.client.hash.HashClient._get_client') as p:
- p.return_value = None
- ret = backend.get_many(['key2'])
- assert ret == {}
-
- with patch('pymemcache.client.hash.HashClient._safely_run_func') as p2:
- p2.return_value = {
- ':1:key3': 1509111630.048594
- }
-
- ret = backend.get_many(['key3'])
- assert ret == {'key3': 1509111630.048594}
-
- # If False value is included, ignore it.
- with patch('pymemcache.client.hash.HashClient.get_many') as p:
- p.return_value = {
- ':1:key1': 1509111630.048594,
- ':1:key2': False,
- ':1:key3': 1509111630.058594,
- }
- ret = backend.get_many(['key1', 'key2', 'key3'])
- assert ret == {
- 'key1': 1509111630.048594,
- 'key3': 1509111630.058594
- }
-
- with patch('pymemcache.client.hash.HashClient.get_many') as p:
- p.return_value = {
- ':1:key1': None,
- ':1:key2': 1509111630.048594,
- ':1:key3': False,
- }
- ret = backend.get_many(['key1', 'key2', 'key3'])
- assert ret == {'key2': 1509111630.048594}
-
- @patch('pymemcache.client.base.Client.set_many')
- @patch.object(ConfigurationEndpointClient, 'get_cluster_info')
- def test_client_set_many(self, get_cluster_info, set_many):
- from django_elastipymemcache.backend import ElastiPymemcache
-
- servers = ['h1:0', 'h2:0']
- get_cluster_info.return_value = {
- 'nodes': servers
- }
- set_many.side_effect = [[':1:key1'], [':1:key2']]
-
- backend = ElastiPymemcache('h:0', {})
- ret = backend.set_many({'key1': 'value1', 'key2': 'value2'})
- assert ret == ['key1', 'key2']
-
- @patch.object(ConfigurationEndpointClient, 'get_cluster_info')
- def test_client_delete_many(self, get_cluster_info):
- from django_elastipymemcache.backend import ElastiPymemcache
-
- servers = ['h1:0', 'h2:0']
- get_cluster_info.return_value = {
- 'nodes': servers
- }
-
- backend = ElastiPymemcache('h:0', {})
- ret = backend.delete_many(['key1', 'key2'])
- assert ret is None
-
- @patch.object(ConfigurationEndpointClient, 'get_cluster_info')
- def test_client_incr(self, get_cluster_info):
- from django_elastipymemcache.backend import ElastiPymemcache
-
- servers = ['h1:0', 'h2:0']
- get_cluster_info.return_value = {
- 'nodes': servers
- }
-
- backend = ElastiPymemcache('h:0', {})
- ret = backend.incr('key1', 1)
- assert ret is False
-
- @patch.object(ConfigurationEndpointClient, 'get_cluster_info')
- def test_client_decr(self, get_cluster_info):
- from django_elastipymemcache.backend import ElastiPymemcache
-
- servers = ['h1:0', 'h2:0']
- get_cluster_info.return_value = {
- 'nodes': servers
- }
-
- backend = ElastiPymemcache('h:0', {})
- ret = backend.decr('key1', 1)
- assert ret is False
+ MockClient.assert_called_once()
+ _, kwargs = MockClient.call_args
+
+ assert kwargs["configuration_endpoint"] == "test.0000.use1.cache.amazonaws.com:11211"
+
+
+def test_node_info_cache(
+ mock_discovery: Callable[[list[tuple[str, int]]], None],
+) -> None:
+ servers = [("10.0.0.1", 11211), ("10.0.0.2", 11211)]
+ mock_discovery(servers)
+
+ with patch("django_elastipymemcache.backend.AWSElastiCacheClient") as MockClient:
+ mock_client = Mock()
+ MockClient.return_value = mock_client
+
+ backend = ElastiPymemcache("test.0000.use1.cache.amazonaws.com:11211", {})
+
+ backend.set("key1", "val")
+ backend.get("key1")
+ backend.set("key2", "val")
+ backend.get("key2")
+
+ assert mock_client.set.call_count == 2
+ assert mock_client.get.call_count == 2
+ MockClient.assert_called_once()
+
+
+def test_failed_to_connect_servers(monkeypatch: MonkeyPatch) -> None:
+ mock_config_get = Mock(
+ side_effect=[
+ OSError("boom"), # 1st call raises
+ [("10.0.0.9", 11211)], # 2nd call returns
+ ]
+ )
+
+ monkeypatch.setattr(
+ "django_elastipymemcache.client._ConfigurationEndpointClient.config_get_cluster",
+ mock_config_get,
+ )
+
+ backend = ElastiPymemcache("test.0000.use1.cache.amazonaws.com:11211", {})
+
+ client = backend._cache._get_client("test")
+ assert client is not None
diff --git a/tests/test_client.py b/tests/test_client.py
deleted file mode 100644
index 5ee51a5..0000000
--- a/tests/test_client.py
+++ /dev/null
@@ -1,141 +0,0 @@
-import collections
-import socket as s
-from unittest.mock import call, patch
-
-from pymemcache.exceptions import (
- MemcacheUnknownCommandError,
- MemcacheUnknownError,
-)
-from pytest import raises
-
-from django_elastipymemcache.client import ConfigurationEndpointClient
-
-EXAMPLE_RESPONSE = [
- b'CONFIG cluster 0 147\r\n',
- b'12\n'
- b'myCluster.pc4ldq.0001.use1.cache.amazonaws.com|10.82.235.120|11211 '
- b'myCluster.pc4ldq.0002.use1.cache.amazonaws.com|10.80.249.27|11211\n\r\n',
- b'END\r\n',
-]
-
-
-@patch('socket.getaddrinfo')
-@patch('socket.socket')
-def test_get_cluster_info(socket, getaddrinfo):
- recv_bufs = collections.deque([
- b'VERSION 1.4.14\r\n',
- ] + EXAMPLE_RESPONSE)
-
- getaddrinfo.return_value = [
- (s.AF_INET, s.SOCK_STREAM, 0, '', ('127.0.0.1', 0)),
- ]
- client = socket.return_value
- client.recv.side_effect = lambda *args, **kwargs: recv_bufs.popleft()
- cluster_info = ConfigurationEndpointClient(('h', 0)).get_cluster_info()
- assert cluster_info['nodes'] == [
- '10.82.235.120:11211',
- '10.80.249.27:11211',
- ]
- client.sendall.assert_has_calls([
- call(b'version\r\n'),
- call(b'config get cluster\r\n'),
- ])
-
-
-@patch('socket.getaddrinfo')
-@patch('socket.socket')
-def test_get_cluster_info_before_1_4_13(socket, getaddrinfo):
- recv_bufs = collections.deque([
- b'VERSION 1.4.13\r\n',
- ] + EXAMPLE_RESPONSE)
-
- getaddrinfo.return_value = [
- (s.AF_INET, s.SOCK_STREAM, 0, '', ('127.0.0.1', 0)),
- ]
- client = socket.return_value
- client.recv.side_effect = lambda *args, **kwargs: recv_bufs.popleft()
- cluster_info = ConfigurationEndpointClient(('h', 0)).get_cluster_info()
- assert cluster_info['nodes'] == [
- '10.82.235.120:11211',
- '10.80.249.27:11211',
- ]
- client.sendall.assert_has_calls([
- call(b'version\r\n'),
- call(b'get AmazonElastiCache:cluster\r\n'),
- ])
-
-
-@patch('socket.getaddrinfo')
-@patch('socket.socket')
-def test_no_configuration_protocol_support_with_errors(socket, getaddrinfo):
- with raises(MemcacheUnknownCommandError):
- recv_bufs = collections.deque([
- b'VERSION 1.4.13\r\n',
- b'ERROR\r\n',
- ])
-
- getaddrinfo.return_value = [
- (s.AF_INET, s.SOCK_STREAM, 0, '', ('127.0.0.1', 0)),
- ]
- client = socket.return_value
- client.recv.side_effect = lambda *args, **kwargs: recv_bufs.popleft()
- ConfigurationEndpointClient(('h', 0)).get_cluster_info()
-
-
-@patch('socket.getaddrinfo')
-@patch('socket.socket')
-def test_cannot_parse_version(socket, getaddrinfo):
- with raises(MemcacheUnknownError):
- recv_bufs = collections.deque([
- b'VERSION 1.4.34\r\n',
- b'CONFIG cluster 0 147\r\n',
- b'fail\nhost|ip|11211 host|ip|11211\n\r\n',
- b'END\r\n',
- ])
-
- getaddrinfo.return_value = [
- (s.AF_INET, s.SOCK_STREAM, 0, '', ('127.0.0.1', 0)),
- ]
- client = socket.return_value
- client.recv.side_effect = lambda *args, **kwargs: recv_bufs.popleft()
- ConfigurationEndpointClient(('h', 0)).get_cluster_info()
-
-
-@patch('socket.getaddrinfo')
-@patch('socket.socket')
-def test_cannot_parse_nodes(socket, getaddrinfo):
- with raises(MemcacheUnknownError):
- recv_bufs = collections.deque([
- b'VERSION 1.4.34\r\n',
- b'CONFIG cluster 0 147\r\n',
- b'1\nfail\n\r\n',
- b'END\r\n',
- ])
-
- getaddrinfo.return_value = [
- (s.AF_INET, s.SOCK_STREAM, 0, '', ('127.0.0.1', 0)),
- ]
- client = socket.return_value
- client.recv.side_effect = lambda *args, **kwargs: recv_bufs.popleft()
- ConfigurationEndpointClient(('h', 0)).get_cluster_info()
-
-
-@patch('socket.getaddrinfo')
-@patch('socket.socket')
-def test_ignore_erros(socket, getaddrinfo):
- recv_bufs = collections.deque([
- b'VERSION 1.4.34\r\n',
- b'fail\nfail\n\r\n',
- b'END\r\n',
- ])
-
- getaddrinfo.return_value = [
- (s.AF_INET, s.SOCK_STREAM, 0, '', ('127.0.0.1', 0)),
- ]
- client = socket.return_value
- client.recv.side_effect = lambda *args, **kwargs: recv_bufs.popleft()
- cluster_info = ConfigurationEndpointClient(
- ('h', 0),
- ignore_cluster_errors=True,
- ).get_cluster_info()
- assert cluster_info['nodes'] == ['h:0']
diff --git a/tests/test_configuration_endpoint_client.py b/tests/test_configuration_endpoint_client.py
new file mode 100644
index 0000000..b2a57f8
--- /dev/null
+++ b/tests/test_configuration_endpoint_client.py
@@ -0,0 +1,89 @@
+import pytest
+from pymemcache.exceptions import MemcacheError
+
+from django_elastipymemcache.client import _ConfigurationEndpointClient
+
+from .conftest import FakeSocketModule
+
+EXAMPLE_RESPONSE = (
+ b"CONFIG cluster 0 147\r\n"
+ b"12\n"
+ b"test.0001.use1.cache.amazonaws.com|10.0.0.1|11211 "
+ b"test.0002.use1.cache.amazonaws.com|10.0.0.2|11211\n\r\n"
+ b"END\r\n"
+)
+
+
+def _client(
+ use_vpc_ip: bool,
+ socket_module: FakeSocketModule,
+) -> _ConfigurationEndpointClient:
+ return _ConfigurationEndpointClient(
+ configuration_endpoint="config.use1.cache.amazonaws.com:11211",
+ default_kwargs={
+ "socket_module": socket_module,
+ },
+ use_pooling=False,
+ use_vpc_ip_address=use_vpc_ip,
+ )
+
+
+def test_raw_command_vpc_ip() -> None:
+ fake_socket_module = FakeSocketModule([EXAMPLE_RESPONSE])
+ client = _client(use_vpc_ip=True, socket_module=fake_socket_module)
+
+ nodes = client.config_get_cluster()
+
+ assert nodes == [
+ ("10.0.0.1", 11211),
+ ("10.0.0.2", 11211),
+ ]
+ assert fake_socket_module.sockets, "client did not open a socket"
+ assert fake_socket_module.sockets[0].sent[-1] == b"config get cluster\r\n"
+
+
+def test_raw_command_hostnames() -> None:
+ fake_socket_module = FakeSocketModule([EXAMPLE_RESPONSE])
+ client = _client(use_vpc_ip=False, socket_module=fake_socket_module)
+
+ nodes = client.config_get_cluster()
+
+ assert nodes == [
+ ("test.0001.use1.cache.amazonaws.com", 11211),
+ ("test.0002.use1.cache.amazonaws.com", 11211),
+ ]
+
+
+def test_parse_multiline_membership() -> None:
+ payload = (
+ b"CONFIG cluster 0 147\r\n"
+ b"12\n"
+ b"test.0001.use1.cache.amazonaws.com|10.0.0.1|11211\n"
+ b"test.0002.use1.cache.amazonaws.com|10.0.0.2|11211\n\r\n"
+ b"END\r\n"
+ )
+ fake_socket_module = FakeSocketModule([payload])
+ client = _client(use_vpc_ip=True, socket_module=fake_socket_module)
+
+ nodes = client.config_get_cluster()
+ assert nodes == [
+ ("10.0.0.1", 11211),
+ ("10.0.0.2", 11211),
+ ]
+
+
+@pytest.mark.parametrize(
+ "payload",
+ [
+ b"", # empty reply
+ b"CONFIG cluster 0 1\r\nX\r\n", # only header + bad body
+ b"CONFIG cluster 0 1\r\n\n\r\nEND\r\n", # blank version/body
+ b"CONFIG cluster 0 1\r\nbad|format\r\nEND\r\n", # malformed token
+ ],
+)
+def test_parse_errors_via_raw_command(payload: bytes) -> None:
+ fake_socket_module = FakeSocketModule([payload])
+ client = _client(use_vpc_ip=True, socket_module=fake_socket_module)
+
+ with pytest.raises(MemcacheError):
+ client.config_get_cluster()
diff --git a/tox.ini b/tox.ini
deleted file mode 100644
index a541841..0000000
--- a/tox.ini
+++ /dev/null
@@ -1,60 +0,0 @@
-[tox]
-envlist =
- py{37,38,39,310}-dj32,
- py{38,39,310}-dj40,
- py{38,39,310}-dj41,
- py{38,39,310}-dj42,
- py{310}-djdev,
- flake8,
- isort,
- readme,
- check-manifest
-
-[gh-actions]
-python =
- 3.7: py37
- 3.8: py38
- 3.9: py39
- 3.10: py310
-
-[testenv]
-passenv = TOXENV, CI, TRAVIS, TRAVIS_*, CODECOV_*
-deps =
- dj32: Django>=3.2,<4.0
- dj40: Django>=4.0,<4.1
- dj41: Django>=4.1,<4.2
- django-pymemcache<2.0
- djdev: https://github.com/django/django/archive/master.tar.gz
- -r{toxinidir}/requirements.txt
- py310-dj41: codecov
-setenv =
- PYTHONPATH = {toxinidir}
-commands =
- coverage run --source=django_elastipymemcache -m pytest --verbose
- py310-dj41: coverage report
- py310-dj41: coverage xml
- py310-dj41: codecov
-
-[testenv:flake8]
-skip_install = true
-basepython = python3.10
-commands = flake8
-deps = flake8
-
-[testenv:isort]
-skip_install = true
-basepython = python3.10
-commands = isort --verbose --check-only --diff django_elastipymemcache tests setup.py
-deps = isort
-
-[testenv:readme]
-skip_install = true
-basepython = python3.10
-commands = python setup.py check -r -s
-deps = readme_renderer
-
-[testenv:check-manifest]
-skip_install = true
-basepython = python3.10
-commands = check-manifest {toxinidir}
-deps = check-manifest
diff --git a/uv.lock b/uv.lock
new file mode 100644
index 0000000..1c47bca
--- /dev/null
+++ b/uv.lock
@@ -0,0 +1,468 @@
+version = 1
+revision = 3
+requires-python = ">=3.10"
+
+[[package]]
+name = "asgiref"
+version = "3.10.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/46/08/4dfec9b90758a59acc6be32ac82e98d1fbfc321cb5cfa410436dbacf821c/asgiref-3.10.0.tar.gz", hash = "sha256:d89f2d8cd8b56dada7d52fa7dc8075baa08fb836560710d38c292a7a3f78c04e", size = 37483, upload-time = "2025-10-05T09:15:06.557Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/17/9c/fc2331f538fbf7eedba64b2052e99ccf9ba9d6888e2f41441ee28847004b/asgiref-3.10.0-py3-none-any.whl", hash = "sha256:aef8a81283a34d0ab31630c9b7dfe70c812c95eba78171367ca8745e88124734", size = 24050, upload-time = "2025-10-05T09:15:05.11Z" },
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+]
+
+[[package]]
+name = "coverage"
+version = "7.11.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1c/38/ee22495420457259d2f3390309505ea98f98a5eed40901cf62196abad006/coverage-7.11.0.tar.gz", hash = "sha256:167bd504ac1ca2af7ff3b81d245dfea0292c5032ebef9d66cc08a7d28c1b8050", size = 811905, upload-time = "2025-10-15T15:15:08.542Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/12/95/c49df0aceb5507a80b9fe5172d3d39bf23f05be40c23c8d77d556df96cec/coverage-7.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:eb53f1e8adeeb2e78962bade0c08bfdc461853c7969706ed901821e009b35e31", size = 215800, upload-time = "2025-10-15T15:12:19.824Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/c6/7bb46ce01ed634fff1d7bb53a54049f539971862cc388b304ff3c51b4f66/coverage-7.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d9a03ec6cb9f40a5c360f138b88266fd8f58408d71e89f536b4f91d85721d075", size = 216198, upload-time = "2025-10-15T15:12:22.549Z" },
+ { url = "https://files.pythonhosted.org/packages/94/b2/75d9d8fbf2900268aca5de29cd0a0fe671b0f69ef88be16767cc3c828b85/coverage-7.11.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0d7f0616c557cbc3d1c2090334eddcbb70e1ae3a40b07222d62b3aa47f608fab", size = 242953, upload-time = "2025-10-15T15:12:24.139Z" },
+ { url = "https://files.pythonhosted.org/packages/65/ac/acaa984c18f440170525a8743eb4b6c960ace2dbad80dc22056a437fc3c6/coverage-7.11.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e44a86a47bbdf83b0a3ea4d7df5410d6b1a0de984fbd805fa5101f3624b9abe0", size = 244766, upload-time = "2025-10-15T15:12:25.974Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/0d/938d0bff76dfa4a6b228c3fc4b3e1c0e2ad4aa6200c141fcda2bd1170227/coverage-7.11.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:596763d2f9a0ee7eec6e643e29660def2eef297e1de0d334c78c08706f1cb785", size = 246625, upload-time = "2025-10-15T15:12:27.387Z" },
+ { url = "https://files.pythonhosted.org/packages/38/54/8f5f5e84bfa268df98f46b2cb396b1009734cfb1e5d6adb663d284893b32/coverage-7.11.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ef55537ff511b5e0a43edb4c50a7bf7ba1c3eea20b4f49b1490f1e8e0e42c591", size = 243568, upload-time = "2025-10-15T15:12:28.799Z" },
+ { url = "https://files.pythonhosted.org/packages/68/30/8ba337c2877fe3f2e1af0ed7ff4be0c0c4aca44d6f4007040f3ca2255e99/coverage-7.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9cbabd8f4d0d3dc571d77ae5bdbfa6afe5061e679a9d74b6797c48d143307088", size = 244665, upload-time = "2025-10-15T15:12:30.297Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/fb/c6f1d6d9a665536b7dde2333346f0cc41dc6a60bd1ffc10cd5c33e7eb000/coverage-7.11.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e24045453384e0ae2a587d562df2a04d852672eb63051d16096d3f08aa4c7c2f", size = 242681, upload-time = "2025-10-15T15:12:32.326Z" },
+ { url = "https://files.pythonhosted.org/packages/be/38/1b532319af5f991fa153c20373291dc65c2bf532af7dbcffdeef745c8f79/coverage-7.11.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:7161edd3426c8d19bdccde7d49e6f27f748f3c31cc350c5de7c633fea445d866", size = 242912, upload-time = "2025-10-15T15:12:34.079Z" },
+ { url = "https://files.pythonhosted.org/packages/67/3d/f39331c60ef6050d2a861dc1b514fa78f85f792820b68e8c04196ad733d6/coverage-7.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d4ed4de17e692ba6415b0587bc7f12bc80915031fc9db46a23ce70fc88c9841", size = 243559, upload-time = "2025-10-15T15:12:35.809Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/55/cb7c9df9d0495036ce582a8a2958d50c23cd73f84a23284bc23bd4711a6f/coverage-7.11.0-cp310-cp310-win32.whl", hash = "sha256:765c0bc8fe46f48e341ef737c91c715bd2a53a12792592296a095f0c237e09cf", size = 218266, upload-time = "2025-10-15T15:12:37.429Z" },
+ { url = "https://files.pythonhosted.org/packages/68/a8/b79cb275fa7bd0208767f89d57a1b5f6ba830813875738599741b97c2e04/coverage-7.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:24d6f3128f1b2d20d84b24f4074475457faedc3d4613a7e66b5e769939c7d969", size = 219169, upload-time = "2025-10-15T15:12:39.25Z" },
+ { url = "https://files.pythonhosted.org/packages/49/3a/ee1074c15c408ddddddb1db7dd904f6b81bc524e01f5a1c5920e13dbde23/coverage-7.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3d58ecaa865c5b9fa56e35efc51d1014d4c0d22838815b9fce57a27dd9576847", size = 215912, upload-time = "2025-10-15T15:12:40.665Z" },
+ { url = "https://files.pythonhosted.org/packages/70/c4/9f44bebe5cb15f31608597b037d78799cc5f450044465bcd1ae8cb222fe1/coverage-7.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b679e171f1c104a5668550ada700e3c4937110dbdd153b7ef9055c4f1a1ee3cc", size = 216310, upload-time = "2025-10-15T15:12:42.461Z" },
+ { url = "https://files.pythonhosted.org/packages/42/01/5e06077cfef92d8af926bdd86b84fb28bf9bc6ad27343d68be9b501d89f2/coverage-7.11.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ca61691ba8c5b6797deb221a0d09d7470364733ea9c69425a640f1f01b7c5bf0", size = 246706, upload-time = "2025-10-15T15:12:44.001Z" },
+ { url = "https://files.pythonhosted.org/packages/40/b8/7a3f1f33b35cc4a6c37e759137533119560d06c0cc14753d1a803be0cd4a/coverage-7.11.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:aef1747ede4bd8ca9cfc04cc3011516500c6891f1b33a94add3253f6f876b7b7", size = 248634, upload-time = "2025-10-15T15:12:45.768Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/41/7f987eb33de386bc4c665ab0bf98d15fcf203369d6aacae74f5dd8ec489a/coverage-7.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1839d08406e4cba2953dcc0ffb312252f14d7c4c96919f70167611f4dee2623", size = 250741, upload-time = "2025-10-15T15:12:47.222Z" },
+ { url = "https://files.pythonhosted.org/packages/23/c1/a4e0ca6a4e83069fb8216b49b30a7352061ca0cb38654bd2dc96b7b3b7da/coverage-7.11.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e0eb0a2dcc62478eb5b4cbb80b97bdee852d7e280b90e81f11b407d0b81c4287", size = 246837, upload-time = "2025-10-15T15:12:48.904Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/03/ced062a17f7c38b4728ff76c3acb40d8465634b20b4833cdb3cc3a74e115/coverage-7.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bc1fbea96343b53f65d5351d8fd3b34fd415a2670d7c300b06d3e14a5af4f552", size = 248429, upload-time = "2025-10-15T15:12:50.73Z" },
+ { url = "https://files.pythonhosted.org/packages/97/af/a7c6f194bb8c5a2705ae019036b8fe7f49ea818d638eedb15fdb7bed227c/coverage-7.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:214b622259dd0cf435f10241f1333d32caa64dbc27f8790ab693428a141723de", size = 246490, upload-time = "2025-10-15T15:12:52.646Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/c3/aab4df02b04a8fde79068c3c41ad7a622b0ef2b12e1ed154da986a727c3f/coverage-7.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:258d9967520cca899695d4eb7ea38be03f06951d6ca2f21fb48b1235f791e601", size = 246208, upload-time = "2025-10-15T15:12:54.586Z" },
+ { url = "https://files.pythonhosted.org/packages/30/d8/e282ec19cd658238d60ed404f99ef2e45eed52e81b866ab1518c0d4163cf/coverage-7.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cf9e6ff4ca908ca15c157c409d608da77a56a09877b97c889b98fb2c32b6465e", size = 247126, upload-time = "2025-10-15T15:12:56.485Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/17/a635fa07fac23adb1a5451ec756216768c2767efaed2e4331710342a3399/coverage-7.11.0-cp311-cp311-win32.whl", hash = "sha256:fcc15fc462707b0680cff6242c48625da7f9a16a28a41bb8fd7a4280920e676c", size = 218314, upload-time = "2025-10-15T15:12:58.365Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/29/2ac1dfcdd4ab9a70026edc8d715ece9b4be9a1653075c658ee6f271f394d/coverage-7.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:865965bf955d92790f1facd64fe7ff73551bd2c1e7e6b26443934e9701ba30b9", size = 219203, upload-time = "2025-10-15T15:12:59.902Z" },
+ { url = "https://files.pythonhosted.org/packages/03/21/5ce8b3a0133179115af4c041abf2ee652395837cb896614beb8ce8ddcfd9/coverage-7.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:5693e57a065760dcbeb292d60cc4d0231a6d4b6b6f6a3191561e1d5e8820b745", size = 217879, upload-time = "2025-10-15T15:13:01.35Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/db/86f6906a7c7edc1a52b2c6682d6dd9be775d73c0dfe2b84f8923dfea5784/coverage-7.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9c49e77811cf9d024b95faf86c3f059b11c0c9be0b0d61bc598f453703bd6fd1", size = 216098, upload-time = "2025-10-15T15:13:02.916Z" },
+ { url = "https://files.pythonhosted.org/packages/21/54/e7b26157048c7ba555596aad8569ff903d6cd67867d41b75287323678ede/coverage-7.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a61e37a403a778e2cda2a6a39abcc895f1d984071942a41074b5c7ee31642007", size = 216331, upload-time = "2025-10-15T15:13:04.403Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/19/1ce6bf444f858b83a733171306134a0544eaddf1ca8851ede6540a55b2ad/coverage-7.11.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c79cae102bb3b1801e2ef1511fb50e91ec83a1ce466b2c7c25010d884336de46", size = 247825, upload-time = "2025-10-15T15:13:05.92Z" },
+ { url = "https://files.pythonhosted.org/packages/71/0b/d3bcbbc259fcced5fb67c5d78f6e7ee965f49760c14afd931e9e663a83b2/coverage-7.11.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:16ce17ceb5d211f320b62df002fa7016b7442ea0fd260c11cec8ce7730954893", size = 250573, upload-time = "2025-10-15T15:13:07.471Z" },
+ { url = "https://files.pythonhosted.org/packages/58/8d/b0ff3641a320abb047258d36ed1c21d16be33beed4152628331a1baf3365/coverage-7.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:80027673e9d0bd6aef86134b0771845e2da85755cf686e7c7c59566cf5a89115", size = 251706, upload-time = "2025-10-15T15:13:09.4Z" },
+ { url = "https://files.pythonhosted.org/packages/59/c8/5a586fe8c7b0458053d9c687f5cff515a74b66c85931f7fe17a1c958b4ac/coverage-7.11.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d3ffa07a08657306cd2215b0da53761c4d73cb54d9143b9303a6481ec0cd415", size = 248221, upload-time = "2025-10-15T15:13:10.964Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/ff/3a25e3132804ba44cfa9a778cdf2b73dbbe63ef4b0945e39602fc896ba52/coverage-7.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a3b6a5f8b2524fd6c1066bc85bfd97e78709bb5e37b5b94911a6506b65f47186", size = 249624, upload-time = "2025-10-15T15:13:12.5Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/12/ff10c8ce3895e1b17a73485ea79ebc1896a9e466a9d0f4aef63e0d17b718/coverage-7.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:fcc0a4aa589de34bc56e1a80a740ee0f8c47611bdfb28cd1849de60660f3799d", size = 247744, upload-time = "2025-10-15T15:13:14.554Z" },
+ { url = "https://files.pythonhosted.org/packages/16/02/d500b91f5471b2975947e0629b8980e5e90786fe316b6d7299852c1d793d/coverage-7.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dba82204769d78c3fd31b35c3d5f46e06511936c5019c39f98320e05b08f794d", size = 247325, upload-time = "2025-10-15T15:13:16.438Z" },
+ { url = "https://files.pythonhosted.org/packages/77/11/dee0284fbbd9cd64cfce806b827452c6df3f100d9e66188e82dfe771d4af/coverage-7.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:81b335f03ba67309a95210caf3eb43bd6fe75a4e22ba653ef97b4696c56c7ec2", size = 249180, upload-time = "2025-10-15T15:13:17.959Z" },
+ { url = "https://files.pythonhosted.org/packages/59/1b/cdf1def928f0a150a057cab03286774e73e29c2395f0d30ce3d9e9f8e697/coverage-7.11.0-cp312-cp312-win32.whl", hash = "sha256:037b2d064c2f8cc8716fe4d39cb705779af3fbf1ba318dc96a1af858888c7bb5", size = 218479, upload-time = "2025-10-15T15:13:19.608Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/55/e5884d55e031da9c15b94b90a23beccc9d6beee65e9835cd6da0a79e4f3a/coverage-7.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:d66c0104aec3b75e5fd897e7940188ea1892ca1d0235316bf89286d6a22568c0", size = 219290, upload-time = "2025-10-15T15:13:21.593Z" },
+ { url = "https://files.pythonhosted.org/packages/23/a8/faa930cfc71c1d16bc78f9a19bb73700464f9c331d9e547bfbc1dbd3a108/coverage-7.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:d91ebeac603812a09cf6a886ba6e464f3bbb367411904ae3790dfe28311b15ad", size = 217924, upload-time = "2025-10-15T15:13:23.39Z" },
+ { url = "https://files.pythonhosted.org/packages/60/7f/85e4dfe65e400645464b25c036a26ac226cf3a69d4a50c3934c532491cdd/coverage-7.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cc3f49e65ea6e0d5d9bd60368684fe52a704d46f9e7fc413918f18d046ec40e1", size = 216129, upload-time = "2025-10-15T15:13:25.371Z" },
+ { url = "https://files.pythonhosted.org/packages/96/5d/dc5fa98fea3c175caf9d360649cb1aa3715e391ab00dc78c4c66fabd7356/coverage-7.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f39ae2f63f37472c17b4990f794035c9890418b1b8cca75c01193f3c8d3e01be", size = 216380, upload-time = "2025-10-15T15:13:26.976Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/f5/3da9cc9596708273385189289c0e4d8197d37a386bdf17619013554b3447/coverage-7.11.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7db53b5cdd2917b6eaadd0b1251cf4e7d96f4a8d24e174bdbdf2f65b5ea7994d", size = 247375, upload-time = "2025-10-15T15:13:28.923Z" },
+ { url = "https://files.pythonhosted.org/packages/65/6c/f7f59c342359a235559d2bc76b0c73cfc4bac7d61bb0df210965cb1ecffd/coverage-7.11.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10ad04ac3a122048688387828b4537bc9cf60c0bf4869c1e9989c46e45690b82", size = 249978, upload-time = "2025-10-15T15:13:30.525Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/8c/042dede2e23525e863bf1ccd2b92689692a148d8b5fd37c37899ba882645/coverage-7.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4036cc9c7983a2b1f2556d574d2eb2154ac6ed55114761685657e38782b23f52", size = 251253, upload-time = "2025-10-15T15:13:32.174Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/a9/3c58df67bfa809a7bddd786356d9c5283e45d693edb5f3f55d0986dd905a/coverage-7.11.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7ab934dd13b1c5e94b692b1e01bd87e4488cb746e3a50f798cb9464fd128374b", size = 247591, upload-time = "2025-10-15T15:13:34.147Z" },
+ { url = "https://files.pythonhosted.org/packages/26/5b/c7f32efd862ee0477a18c41e4761305de6ddd2d49cdeda0c1116227570fd/coverage-7.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:59a6e5a265f7cfc05f76e3bb53eca2e0dfe90f05e07e849930fecd6abb8f40b4", size = 249411, upload-time = "2025-10-15T15:13:38.425Z" },
+ { url = "https://files.pythonhosted.org/packages/76/b5/78cb4f1e86c1611431c990423ec0768122905b03837e1b4c6a6f388a858b/coverage-7.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:df01d6c4c81e15a7c88337b795bb7595a8596e92310266b5072c7e301168efbd", size = 247303, upload-time = "2025-10-15T15:13:40.464Z" },
+ { url = "https://files.pythonhosted.org/packages/87/c9/23c753a8641a330f45f221286e707c427e46d0ffd1719b080cedc984ec40/coverage-7.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8c934bd088eed6174210942761e38ee81d28c46de0132ebb1801dbe36a390dcc", size = 247157, upload-time = "2025-10-15T15:13:42.087Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/42/6e0cc71dc8a464486e944a4fa0d85bdec031cc2969e98ed41532a98336b9/coverage-7.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5a03eaf7ec24078ad64a07f02e30060aaf22b91dedf31a6b24d0d98d2bba7f48", size = 248921, upload-time = "2025-10-15T15:13:43.715Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/1c/743c2ef665e6858cccb0f84377dfe3a4c25add51e8c7ef19249be92465b6/coverage-7.11.0-cp313-cp313-win32.whl", hash = "sha256:695340f698a5f56f795b2836abe6fb576e7c53d48cd155ad2f80fd24bc63a040", size = 218526, upload-time = "2025-10-15T15:13:45.336Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/d5/226daadfd1bf8ddbccefbd3aa3547d7b960fb48e1bdac124e2dd13a2b71a/coverage-7.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:2727d47fce3ee2bac648528e41455d1b0c46395a087a229deac75e9f88ba5a05", size = 219317, upload-time = "2025-10-15T15:13:47.401Z" },
+ { url = "https://files.pythonhosted.org/packages/97/54/47db81dcbe571a48a298f206183ba8a7ba79200a37cd0d9f4788fcd2af4a/coverage-7.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:0efa742f431529699712b92ecdf22de8ff198df41e43aeaaadf69973eb93f17a", size = 217948, upload-time = "2025-10-15T15:13:49.096Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/8b/cb68425420154e7e2a82fd779a8cc01549b6fa83c2ad3679cd6c088ebd07/coverage-7.11.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:587c38849b853b157706407e9ebdca8fd12f45869edb56defbef2daa5fb0812b", size = 216837, upload-time = "2025-10-15T15:13:51.09Z" },
+ { url = "https://files.pythonhosted.org/packages/33/55/9d61b5765a025685e14659c8d07037247de6383c0385757544ffe4606475/coverage-7.11.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b971bdefdd75096163dd4261c74be813c4508477e39ff7b92191dea19f24cd37", size = 217061, upload-time = "2025-10-15T15:13:52.747Z" },
+ { url = "https://files.pythonhosted.org/packages/52/85/292459c9186d70dcec6538f06ea251bc968046922497377bf4a1dc9a71de/coverage-7.11.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:269bfe913b7d5be12ab13a95f3a76da23cf147be7fa043933320ba5625f0a8de", size = 258398, upload-time = "2025-10-15T15:13:54.45Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/e2/46edd73fb8bf51446c41148d81944c54ed224854812b6ca549be25113ee0/coverage-7.11.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:dadbcce51a10c07b7c72b0ce4a25e4b6dcb0c0372846afb8e5b6307a121eb99f", size = 260574, upload-time = "2025-10-15T15:13:56.145Z" },
+ { url = "https://files.pythonhosted.org/packages/07/5e/1df469a19007ff82e2ca8fe509822820a31e251f80ee7344c34f6cd2ec43/coverage-7.11.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ed43fa22c6436f7957df036331f8fe4efa7af132054e1844918866cd228af6c", size = 262797, upload-time = "2025-10-15T15:13:58.635Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/50/de216b31a1434b94d9b34a964c09943c6be45069ec704bfc379d8d89a649/coverage-7.11.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9516add7256b6713ec08359b7b05aeff8850c98d357784c7205b2e60aa2513fa", size = 257361, upload-time = "2025-10-15T15:14:00.409Z" },
+ { url = "https://files.pythonhosted.org/packages/82/1e/3f9f8344a48111e152e0fd495b6fff13cc743e771a6050abf1627a7ba918/coverage-7.11.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb92e47c92fcbcdc692f428da67db33337fa213756f7adb6a011f7b5a7a20740", size = 260349, upload-time = "2025-10-15T15:14:02.188Z" },
+ { url = "https://files.pythonhosted.org/packages/65/9b/3f52741f9e7d82124272f3070bbe316006a7de1bad1093f88d59bfc6c548/coverage-7.11.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d06f4fc7acf3cabd6d74941d53329e06bab00a8fe10e4df2714f0b134bfc64ef", size = 258114, upload-time = "2025-10-15T15:14:03.907Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/8b/918f0e15f0365d50d3986bbd3338ca01178717ac5678301f3f547b6619e6/coverage-7.11.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:6fbcee1a8f056af07ecd344482f711f563a9eb1c2cad192e87df00338ec3cdb0", size = 256723, upload-time = "2025-10-15T15:14:06.324Z" },
+ { url = "https://files.pythonhosted.org/packages/44/9e/7776829f82d3cf630878a7965a7d70cc6ca94f22c7d20ec4944f7148cb46/coverage-7.11.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dbbf012be5f32533a490709ad597ad8a8ff80c582a95adc8d62af664e532f9ca", size = 259238, upload-time = "2025-10-15T15:14:08.002Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/b8/49cf253e1e7a3bedb85199b201862dd7ca4859f75b6cf25ffa7298aa0760/coverage-7.11.0-cp313-cp313t-win32.whl", hash = "sha256:cee6291bb4fed184f1c2b663606a115c743df98a537c969c3c64b49989da96c2", size = 219180, upload-time = "2025-10-15T15:14:09.786Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/e1/1a541703826be7ae2125a0fb7f821af5729d56bb71e946e7b933cc7a89a4/coverage-7.11.0-cp313-cp313t-win_amd64.whl", hash = "sha256:a386c1061bf98e7ea4758e4313c0ab5ecf57af341ef0f43a0bf26c2477b5c268", size = 220241, upload-time = "2025-10-15T15:14:11.471Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/d1/5ee0e0a08621140fd418ec4020f595b4d52d7eb429ae6a0c6542b4ba6f14/coverage-7.11.0-cp313-cp313t-win_arm64.whl", hash = "sha256:f9ea02ef40bb83823b2b04964459d281688fe173e20643870bb5d2edf68bc836", size = 218510, upload-time = "2025-10-15T15:14:13.46Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/06/e923830c1985ce808e40a3fa3eb46c13350b3224b7da59757d37b6ce12b8/coverage-7.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c770885b28fb399aaf2a65bbd1c12bf6f307ffd112d6a76c5231a94276f0c497", size = 216110, upload-time = "2025-10-15T15:14:15.157Z" },
+ { url = "https://files.pythonhosted.org/packages/42/82/cdeed03bfead45203fb651ed756dfb5266028f5f939e7f06efac4041dad5/coverage-7.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a3d0e2087dba64c86a6b254f43e12d264b636a39e88c5cc0a01a7c71bcfdab7e", size = 216395, upload-time = "2025-10-15T15:14:16.863Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/ba/e1c80caffc3199aa699813f73ff097bc2df7b31642bdbc7493600a8f1de5/coverage-7.11.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:73feb83bb41c32811973b8565f3705caf01d928d972b72042b44e97c71fd70d1", size = 247433, upload-time = "2025-10-15T15:14:18.589Z" },
+ { url = "https://files.pythonhosted.org/packages/80/c0/5b259b029694ce0a5bbc1548834c7ba3db41d3efd3474489d7efce4ceb18/coverage-7.11.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c6f31f281012235ad08f9a560976cc2fc9c95c17604ff3ab20120fe480169bca", size = 249970, upload-time = "2025-10-15T15:14:20.307Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/86/171b2b5e1aac7e2fd9b43f7158b987dbeb95f06d1fbecad54ad8163ae3e8/coverage-7.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9570ad567f880ef675673992222746a124b9595506826b210fbe0ce3f0499cd", size = 251324, upload-time = "2025-10-15T15:14:22.419Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/7e/7e10414d343385b92024af3932a27a1caf75c6e27ee88ba211221ff1a145/coverage-7.11.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8badf70446042553a773547a61fecaa734b55dc738cacf20c56ab04b77425e43", size = 247445, upload-time = "2025-10-15T15:14:24.205Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/3b/e4f966b21f5be8c4bf86ad75ae94efa0de4c99c7bbb8114476323102e345/coverage-7.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a09c1211959903a479e389685b7feb8a17f59ec5a4ef9afde7650bd5eabc2777", size = 249324, upload-time = "2025-10-15T15:14:26.234Z" },
+ { url = "https://files.pythonhosted.org/packages/00/a2/8479325576dfcd909244d0df215f077f47437ab852ab778cfa2f8bf4d954/coverage-7.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:5ef83b107f50db3f9ae40f69e34b3bd9337456c5a7fe3461c7abf8b75dd666a2", size = 247261, upload-time = "2025-10-15T15:14:28.42Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/d8/3a9e2db19d94d65771d0f2e21a9ea587d11b831332a73622f901157cc24b/coverage-7.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f91f927a3215b8907e214af77200250bb6aae36eca3f760f89780d13e495388d", size = 247092, upload-time = "2025-10-15T15:14:30.784Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/b1/bbca3c472544f9e2ad2d5116b2379732957048be4b93a9c543fcd0207e5f/coverage-7.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cdbcd376716d6b7fbfeedd687a6c4be019c5a5671b35f804ba76a4c0a778cba4", size = 248755, upload-time = "2025-10-15T15:14:32.585Z" },
+ { url = "https://files.pythonhosted.org/packages/89/49/638d5a45a6a0f00af53d6b637c87007eb2297042186334e9923a61aa8854/coverage-7.11.0-cp314-cp314-win32.whl", hash = "sha256:bab7ec4bb501743edc63609320aaec8cd9188b396354f482f4de4d40a9d10721", size = 218793, upload-time = "2025-10-15T15:14:34.972Z" },
+ { url = "https://files.pythonhosted.org/packages/30/cc/b675a51f2d068adb3cdf3799212c662239b0ca27f4691d1fff81b92ea850/coverage-7.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:3d4ba9a449e9364a936a27322b20d32d8b166553bfe63059bd21527e681e2fad", size = 219587, upload-time = "2025-10-15T15:14:37.047Z" },
+ { url = "https://files.pythonhosted.org/packages/93/98/5ac886876026de04f00820e5094fe22166b98dcb8b426bf6827aaf67048c/coverage-7.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:ce37f215223af94ef0f75ac68ea096f9f8e8c8ec7d6e8c346ee45c0d363f0479", size = 218168, upload-time = "2025-10-15T15:14:38.861Z" },
+ { url = "https://files.pythonhosted.org/packages/14/d1/b4145d35b3e3ecf4d917e97fc8895bcf027d854879ba401d9ff0f533f997/coverage-7.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:f413ce6e07e0d0dc9c433228727b619871532674b45165abafe201f200cc215f", size = 216850, upload-time = "2025-10-15T15:14:40.651Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/d1/7f645fc2eccd318369a8a9948acc447bb7c1ade2911e31d3c5620544c22b/coverage-7.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:05791e528a18f7072bf5998ba772fe29db4da1234c45c2087866b5ba4dea710e", size = 217071, upload-time = "2025-10-15T15:14:42.755Z" },
+ { url = "https://files.pythonhosted.org/packages/54/7d/64d124649db2737ceced1dfcbdcb79898d5868d311730f622f8ecae84250/coverage-7.11.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cacb29f420cfeb9283b803263c3b9a068924474ff19ca126ba9103e1278dfa44", size = 258570, upload-time = "2025-10-15T15:14:44.542Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/3f/6f5922f80dc6f2d8b2c6f974835c43f53eb4257a7797727e6ca5b7b2ec1f/coverage-7.11.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314c24e700d7027ae3ab0d95fbf8d53544fca1f20345fd30cd219b737c6e58d3", size = 260738, upload-time = "2025-10-15T15:14:46.436Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/5f/9e883523c4647c860b3812b417a2017e361eca5b635ee658387dc11b13c1/coverage-7.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:630d0bd7a293ad2fc8b4b94e5758c8b2536fdf36c05f1681270203e463cbfa9b", size = 262994, upload-time = "2025-10-15T15:14:48.3Z" },
+ { url = "https://files.pythonhosted.org/packages/07/bb/43b5a8e94c09c8bf51743ffc65c4c841a4ca5d3ed191d0a6919c379a1b83/coverage-7.11.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e89641f5175d65e2dbb44db15fe4ea48fade5d5bbb9868fdc2b4fce22f4a469d", size = 257282, upload-time = "2025-10-15T15:14:50.236Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/e5/0ead8af411411330b928733e1d201384b39251a5f043c1612970310e8283/coverage-7.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c9f08ea03114a637dab06cedb2e914da9dc67fa52c6015c018ff43fdde25b9c2", size = 260430, upload-time = "2025-10-15T15:14:52.413Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/66/03dd8bb0ba5b971620dcaac145461950f6d8204953e535d2b20c6b65d729/coverage-7.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce9f3bde4e9b031eaf1eb61df95c1401427029ea1bfddb8621c1161dcb0fa02e", size = 258190, upload-time = "2025-10-15T15:14:54.268Z" },
+ { url = "https://files.pythonhosted.org/packages/45/ae/28a9cce40bf3174426cb2f7e71ee172d98e7f6446dff936a7ccecee34b14/coverage-7.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:e4dc07e95495923d6fd4d6c27bf70769425b71c89053083843fd78f378558996", size = 256658, upload-time = "2025-10-15T15:14:56.436Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/7c/3a44234a8599513684bfc8684878fd7b126c2760f79712bb78c56f19efc4/coverage-7.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:424538266794db2861db4922b05d729ade0940ee69dcf0591ce8f69784db0e11", size = 259342, upload-time = "2025-10-15T15:14:58.538Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/e6/0108519cba871af0351725ebdb8660fd7a0fe2ba3850d56d32490c7d9b4b/coverage-7.11.0-cp314-cp314t-win32.whl", hash = "sha256:4c1eeb3fb8eb9e0190bebafd0462936f75717687117339f708f395fe455acc73", size = 219568, upload-time = "2025-10-15T15:15:00.382Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/76/44ba876e0942b4e62fdde23ccb029ddb16d19ba1bef081edd00857ba0b16/coverage-7.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b56efee146c98dbf2cf5cffc61b9829d1e94442df4d7398b26892a53992d3547", size = 220687, upload-time = "2025-10-15T15:15:02.322Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/0c/0df55ecb20d0d0ed5c322e10a441775e1a3a5d78c60f0c4e1abfe6fcf949/coverage-7.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b5c2705afa83f49bd91962a4094b6b082f94aef7626365ab3f8f4bd159c5acf3", size = 218711, upload-time = "2025-10-15T15:15:04.575Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/04/642c1d8a448ae5ea1369eac8495740a79eb4e581a9fb0cbdce56bbf56da1/coverage-7.11.0-py3-none-any.whl", hash = "sha256:4b7589765348d78fb4e5fb6ea35d07564e387da2fc5efff62e0222971f155f68", size = 207761, upload-time = "2025-10-15T15:15:06.439Z" },
+]
+
+[package.optional-dependencies]
+toml = [
+ { name = "tomli", marker = "python_full_version <= '3.11'" },
+]
+
+[[package]]
+name = "django"
+version = "5.2.7"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "asgiref" },
+ { name = "sqlparse" },
+ { name = "tzdata", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/96/bd84e2bb997994de8bcda47ae4560991084e86536541d7214393880f01a8/django-5.2.7.tar.gz", hash = "sha256:e0f6f12e2551b1716a95a63a1366ca91bbcd7be059862c1b18f989b1da356cdd", size = 10865812, upload-time = "2025-10-01T14:22:12.081Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8f/ef/81f3372b5dd35d8d354321155d1a38894b2b766f576d0abffac4d8ae78d9/django-5.2.7-py3-none-any.whl", hash = "sha256:59a13a6515f787dec9d97a0438cd2efac78c8aca1c80025244b0fe507fe0754b", size = 8307145, upload-time = "2025-10-01T14:22:49.476Z" },
+]
+
+[[package]]
+name = "django-elastipymemcache"
+source = { editable = "." }
+dependencies = [
+ { name = "django" },
+ { name = "pymemcache" },
+]
+
+[package.optional-dependencies]
+dev = [
+ { name = "coverage", extra = ["toml"] },
+ { name = "django-stubs", extra = ["compatible-mypy"] },
+ { name = "mypy" },
+ { name = "pytest" },
+ { name = "pytest-django" },
+ { name = "ruff" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "coverage", extras = ["toml"], marker = "extra == 'dev'", specifier = ">=7.11" },
+ { name = "django", specifier = ">=4.2" },
+ { name = "django-stubs", extras = ["compatible-mypy"], marker = "extra == 'dev'", specifier = ">=4.2" },
+ { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.18" },
+ { name = "pymemcache", specifier = ">=4.0" },
+ { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
+ { name = "pytest-django", marker = "extra == 'dev'", specifier = ">=4.11" },
+ { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.14.1" },
+]
+provides-extras = ["dev"]
+
+[[package]]
+name = "django-stubs"
+version = "5.2.7"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "django" },
+ { name = "django-stubs-ext" },
+ { name = "tomli", marker = "python_full_version < '3.11'" },
+ { name = "types-pyyaml" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5d/a8/bc8c55212978f1e666486b60a4bfb0bc3a066de8212fa7389ff0f3dca639/django_stubs-5.2.7.tar.gz", hash = "sha256:2a07e47a8a867836a763c6bba8bf3775847b4fd9555bfa940360e32d0ee384a1", size = 257339, upload-time = "2025-10-08T08:01:18.237Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ad/66/1c8063eee88a943f01d073dbbbda34ed093bf6e19738178506a66abbd5ad/django_stubs-5.2.7-py3-none-any.whl", hash = "sha256:2864e74b56ead866ff1365a051f24d852f6ed02238959664f558a6c9601c95bf", size = 507733, upload-time = "2025-10-08T08:01:16.172Z" },
+]
+
+[package.optional-dependencies]
+compatible-mypy = [
+ { name = "mypy" },
+]
+
+[[package]]
+name = "django-stubs-ext"
+version = "5.2.7"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "django" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9b/6f/a0bab0e6a7676ab3ca02d51b459444e9bd6dd747e3a43b9c24cae6d0a1c6/django_stubs_ext-5.2.7.tar.gz", hash = "sha256:b690655bd4cb8a44ae57abb314e0995dc90414280db8f26fff0cb9fb367d1cac", size = 6524, upload-time = "2025-10-08T08:00:38.895Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f8/c9/60445606e26706d3fccadf3b80ee1a9f32c1012683ff2ada7580937b2da9/django_stubs_ext-5.2.7-py3-none-any.whl", hash = "sha256:0466a7132587d49c5bbe12082ac9824d117a0dedcad5d0ada75a6e0d3aca6f60", size = 9979, upload-time = "2025-10-08T08:00:37.499Z" },
+]
+
+[[package]]
+name = "exceptiongroup"
+version = "1.3.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
+]
+
+[[package]]
+name = "mypy"
+version = "1.18.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "mypy-extensions" },
+ { name = "pathspec" },
+ { name = "tomli", marker = "python_full_version < '3.11'" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c0/77/8f0d0001ffad290cef2f7f216f96c814866248a0b92a722365ed54648e7e/mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b", size = 3448846, upload-time = "2025-09-19T00:11:10.519Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/03/6f/657961a0743cff32e6c0611b63ff1c1970a0b482ace35b069203bf705187/mypy-1.18.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c1eab0cf6294dafe397c261a75f96dc2c31bffe3b944faa24db5def4e2b0f77c", size = 12807973, upload-time = "2025-09-19T00:10:35.282Z" },
+ { url = "https://files.pythonhosted.org/packages/10/e9/420822d4f661f13ca8900f5fa239b40ee3be8b62b32f3357df9a3045a08b/mypy-1.18.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7a780ca61fc239e4865968ebc5240bb3bf610ef59ac398de9a7421b54e4a207e", size = 11896527, upload-time = "2025-09-19T00:10:55.791Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/73/a05b2bbaa7005f4642fcfe40fb73f2b4fb6bb44229bd585b5878e9a87ef8/mypy-1.18.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:448acd386266989ef11662ce3c8011fd2a7b632e0ec7d61a98edd8e27472225b", size = 12507004, upload-time = "2025-09-19T00:11:05.411Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/01/f6e4b9f0d031c11ccbd6f17da26564f3a0f3c4155af344006434b0a05a9d/mypy-1.18.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9e171c465ad3901dc652643ee4bffa8e9fef4d7d0eece23b428908c77a76a66", size = 13245947, upload-time = "2025-09-19T00:10:46.923Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/97/19727e7499bfa1ae0773d06afd30ac66a58ed7437d940c70548634b24185/mypy-1.18.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:592ec214750bc00741af1f80cbf96b5013d81486b7bb24cb052382c19e40b428", size = 13499217, upload-time = "2025-09-19T00:09:39.472Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/4f/90dc8c15c1441bf31cf0f9918bb077e452618708199e530f4cbd5cede6ff/mypy-1.18.2-cp310-cp310-win_amd64.whl", hash = "sha256:7fb95f97199ea11769ebe3638c29b550b5221e997c63b14ef93d2e971606ebed", size = 9766753, upload-time = "2025-09-19T00:10:49.161Z" },
+ { url = "https://files.pythonhosted.org/packages/88/87/cafd3ae563f88f94eec33f35ff722d043e09832ea8530ef149ec1efbaf08/mypy-1.18.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:807d9315ab9d464125aa9fcf6d84fde6e1dc67da0b6f80e7405506b8ac72bc7f", size = 12731198, upload-time = "2025-09-19T00:09:44.857Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/e0/1e96c3d4266a06d4b0197ace5356d67d937d8358e2ee3ffac71faa843724/mypy-1.18.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:776bb00de1778caf4db739c6e83919c1d85a448f71979b6a0edd774ea8399341", size = 11817879, upload-time = "2025-09-19T00:09:47.131Z" },
+ { url = "https://files.pythonhosted.org/packages/72/ef/0c9ba89eb03453e76bdac5a78b08260a848c7bfc5d6603634774d9cd9525/mypy-1.18.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1379451880512ffce14505493bd9fe469e0697543717298242574882cf8cdb8d", size = 12427292, upload-time = "2025-09-19T00:10:22.472Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/52/ec4a061dd599eb8179d5411d99775bec2a20542505988f40fc2fee781068/mypy-1.18.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1331eb7fd110d60c24999893320967594ff84c38ac6d19e0a76c5fd809a84c86", size = 13163750, upload-time = "2025-09-19T00:09:51.472Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/5f/2cf2ceb3b36372d51568f2208c021870fe7834cf3186b653ac6446511839/mypy-1.18.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ca30b50a51e7ba93b00422e486cbb124f1c56a535e20eff7b2d6ab72b3b2e37", size = 13351827, upload-time = "2025-09-19T00:09:58.311Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/7d/2697b930179e7277529eaaec1513f8de622818696857f689e4a5432e5e27/mypy-1.18.2-cp311-cp311-win_amd64.whl", hash = "sha256:664dc726e67fa54e14536f6e1224bcfce1d9e5ac02426d2326e2bb4e081d1ce8", size = 9757983, upload-time = "2025-09-19T00:10:09.071Z" },
+ { url = "https://files.pythonhosted.org/packages/07/06/dfdd2bc60c66611dd8335f463818514733bc763e4760dee289dcc33df709/mypy-1.18.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33eca32dd124b29400c31d7cf784e795b050ace0e1f91b8dc035672725617e34", size = 12908273, upload-time = "2025-09-19T00:10:58.321Z" },
+ { url = "https://files.pythonhosted.org/packages/81/14/6a9de6d13a122d5608e1a04130724caf9170333ac5a924e10f670687d3eb/mypy-1.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a3c47adf30d65e89b2dcd2fa32f3aeb5e94ca970d2c15fcb25e297871c8e4764", size = 11920910, upload-time = "2025-09-19T00:10:20.043Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/a9/b29de53e42f18e8cc547e38daa9dfa132ffdc64f7250e353f5c8cdd44bee/mypy-1.18.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d6c838e831a062f5f29d11c9057c6009f60cb294fea33a98422688181fe2893", size = 12465585, upload-time = "2025-09-19T00:10:33.005Z" },
+ { url = "https://files.pythonhosted.org/packages/77/ae/6c3d2c7c61ff21f2bee938c917616c92ebf852f015fb55917fd6e2811db2/mypy-1.18.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01199871b6110a2ce984bde85acd481232d17413868c9807e95c1b0739a58914", size = 13348562, upload-time = "2025-09-19T00:10:11.51Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/31/aec68ab3b4aebdf8f36d191b0685d99faa899ab990753ca0fee60fb99511/mypy-1.18.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a2afc0fa0b0e91b4599ddfe0f91e2c26c2b5a5ab263737e998d6817874c5f7c8", size = 13533296, upload-time = "2025-09-19T00:10:06.568Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/83/abcb3ad9478fca3ebeb6a5358bb0b22c95ea42b43b7789c7fb1297ca44f4/mypy-1.18.2-cp312-cp312-win_amd64.whl", hash = "sha256:d8068d0afe682c7c4897c0f7ce84ea77f6de953262b12d07038f4d296d547074", size = 9828828, upload-time = "2025-09-19T00:10:28.203Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/04/7f462e6fbba87a72bc8097b93f6842499c428a6ff0c81dd46948d175afe8/mypy-1.18.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:07b8b0f580ca6d289e69209ec9d3911b4a26e5abfde32228a288eb79df129fcc", size = 12898728, upload-time = "2025-09-19T00:10:01.33Z" },
+ { url = "https://files.pythonhosted.org/packages/99/5b/61ed4efb64f1871b41fd0b82d29a64640f3516078f6c7905b68ab1ad8b13/mypy-1.18.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ed4482847168439651d3feee5833ccedbf6657e964572706a2adb1f7fa4dfe2e", size = 11910758, upload-time = "2025-09-19T00:10:42.607Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/46/d297d4b683cc89a6e4108c4250a6a6b717f5fa96e1a30a7944a6da44da35/mypy-1.18.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3ad2afadd1e9fea5cf99a45a822346971ede8685cc581ed9cd4d42eaf940986", size = 12475342, upload-time = "2025-09-19T00:11:00.371Z" },
+ { url = "https://files.pythonhosted.org/packages/83/45/4798f4d00df13eae3bfdf726c9244bcb495ab5bd588c0eed93a2f2dd67f3/mypy-1.18.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a431a6f1ef14cf8c144c6b14793a23ec4eae3db28277c358136e79d7d062f62d", size = 13338709, upload-time = "2025-09-19T00:11:03.358Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/09/479f7358d9625172521a87a9271ddd2441e1dab16a09708f056e97007207/mypy-1.18.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ab28cc197f1dd77a67e1c6f35cd1f8e8b73ed2217e4fc005f9e6a504e46e7ba", size = 13529806, upload-time = "2025-09-19T00:10:26.073Z" },
+ { url = "https://files.pythonhosted.org/packages/71/cf/ac0f2c7e9d0ea3c75cd99dff7aec1c9df4a1376537cb90e4c882267ee7e9/mypy-1.18.2-cp313-cp313-win_amd64.whl", hash = "sha256:0e2785a84b34a72ba55fb5daf079a1003a34c05b22238da94fcae2bbe46f3544", size = 9833262, upload-time = "2025-09-19T00:10:40.035Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/0c/7d5300883da16f0063ae53996358758b2a2df2a09c72a5061fa79a1f5006/mypy-1.18.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:62f0e1e988ad41c2a110edde6c398383a889d95b36b3e60bcf155f5164c4fdce", size = 12893775, upload-time = "2025-09-19T00:10:03.814Z" },
+ { url = "https://files.pythonhosted.org/packages/50/df/2cffbf25737bdb236f60c973edf62e3e7b4ee1c25b6878629e88e2cde967/mypy-1.18.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8795a039bab805ff0c1dfdb8cd3344642c2b99b8e439d057aba30850b8d3423d", size = 11936852, upload-time = "2025-09-19T00:10:51.631Z" },
+ { url = "https://files.pythonhosted.org/packages/be/50/34059de13dd269227fb4a03be1faee6e2a4b04a2051c82ac0a0b5a773c9a/mypy-1.18.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ca1e64b24a700ab5ce10133f7ccd956a04715463d30498e64ea8715236f9c9c", size = 12480242, upload-time = "2025-09-19T00:11:07.955Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/11/040983fad5132d85914c874a2836252bbc57832065548885b5bb5b0d4359/mypy-1.18.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d924eef3795cc89fecf6bedc6ed32b33ac13e8321344f6ddbf8ee89f706c05cb", size = 13326683, upload-time = "2025-09-19T00:09:55.572Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/ba/89b2901dd77414dd7a8c8729985832a5735053be15b744c18e4586e506ef/mypy-1.18.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20c02215a080e3a2be3aa50506c67242df1c151eaba0dcbc1e4e557922a26075", size = 13514749, upload-time = "2025-09-19T00:10:44.827Z" },
+ { url = "https://files.pythonhosted.org/packages/25/bc/cc98767cffd6b2928ba680f3e5bc969c4152bf7c2d83f92f5a504b92b0eb/mypy-1.18.2-cp314-cp314-win_amd64.whl", hash = "sha256:749b5f83198f1ca64345603118a6f01a4e99ad4bf9d103ddc5a3200cc4614adf", size = 9982959, upload-time = "2025-09-19T00:10:37.344Z" },
+ { url = "https://files.pythonhosted.org/packages/87/e3/be76d87158ebafa0309946c4a73831974d4d6ab4f4ef40c3b53a385a66fd/mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e", size = 2352367, upload-time = "2025-09-19T00:10:15.489Z" },
+]
+
+[[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 = "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 = "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 = "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 = "pymemcache"
+version = "4.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d9/b6/4541b664aeaad025dfb8e851dcddf8e25ab22607e674dd2b562ea3e3586f/pymemcache-4.0.0.tar.gz", hash = "sha256:27bf9bd1bbc1e20f83633208620d56de50f14185055e49504f4f5e94e94aff94", size = 70176, upload-time = "2022-10-17T16:53:07.726Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/41/ba/2f7b22d8135b51c4fefb041461f8431e1908778e6539ff5af6eeaaee367a/pymemcache-4.0.0-py2.py3-none-any.whl", hash = "sha256:f507bc20e0dc8d562f8df9d872107a278df049fa496805c1431b926f3ddd0eab", size = 60772, upload-time = "2022-10-17T16:53:04.388Z" },
+]
+
+[[package]]
+name = "pytest"
+version = "8.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "exceptiongroup", marker = "python_full_version < '3.11'" },
+ { name = "iniconfig" },
+ { name = "packaging" },
+ { name = "pluggy" },
+ { name = "pygments" },
+ { name = "tomli", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
+]
+
+[[package]]
+name = "pytest-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 = "ruff"
+version = "0.14.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9e/58/6ca66896635352812de66f71cdf9ff86b3a4f79071ca5730088c0cd0fc8d/ruff-0.14.1.tar.gz", hash = "sha256:1dd86253060c4772867c61791588627320abcb6ed1577a90ef432ee319729b69", size = 5513429, upload-time = "2025-10-16T18:05:41.766Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8d/39/9cc5ab181478d7a18adc1c1e051a84ee02bec94eb9bdfd35643d7c74ca31/ruff-0.14.1-py3-none-linux_armv6l.whl", hash = "sha256:083bfc1f30f4a391ae09c6f4f99d83074416b471775b59288956f5bc18e82f8b", size = 12445415, upload-time = "2025-10-16T18:04:48.227Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/2e/1226961855ccd697255988f5a2474890ac7c5863b080b15bd038df820818/ruff-0.14.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f6fa757cd717f791009f7669fefb09121cc5f7d9bd0ef211371fad68c2b8b224", size = 12784267, upload-time = "2025-10-16T18:04:52.515Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/ea/fd9e95863124ed159cd0667ec98449ae461de94acda7101f1acb6066da00/ruff-0.14.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d6191903d39ac156921398e9c86b7354d15e3c93772e7dbf26c9fcae59ceccd5", size = 11781872, upload-time = "2025-10-16T18:04:55.396Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/5a/e890f7338ff537dba4589a5e02c51baa63020acfb7c8cbbaea4831562c96/ruff-0.14.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed04f0e04f7a4587244e5c9d7df50e6b5bf2705d75059f409a6421c593a35896", size = 12226558, upload-time = "2025-10-16T18:04:58.166Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/7a/8ab5c3377f5bf31e167b73651841217542bcc7aa1c19e83030835cc25204/ruff-0.14.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5c9e6cf6cd4acae0febbce29497accd3632fe2025c0c583c8b87e8dbdeae5f61", size = 12187898, upload-time = "2025-10-16T18:05:01.455Z" },
+ { url = "https://files.pythonhosted.org/packages/48/8d/ba7c33aa55406955fc124e62c8259791c3d42e3075a71710fdff9375134f/ruff-0.14.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a6fa2458527794ecdfbe45f654e42c61f2503a230545a91af839653a0a93dbc6", size = 12939168, upload-time = "2025-10-16T18:05:04.397Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/c2/70783f612b50f66d083380e68cbd1696739d88e9b4f6164230375532c637/ruff-0.14.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:39f1c392244e338b21d42ab29b8a6392a722c5090032eb49bb4d6defcdb34345", size = 14386942, upload-time = "2025-10-16T18:05:07.102Z" },
+ { url = "https://files.pythonhosted.org/packages/48/44/cd7abb9c776b66d332119d67f96acf15830d120f5b884598a36d9d3f4d83/ruff-0.14.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7382fa12a26cce1f95070ce450946bec357727aaa428983036362579eadcc5cf", size = 13990622, upload-time = "2025-10-16T18:05:09.882Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/56/4259b696db12ac152fe472764b4f78bbdd9b477afd9bc3a6d53c01300b37/ruff-0.14.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd0bf2be3ae8521e1093a487c4aa3b455882f139787770698530d28ed3fbb37c", size = 13431143, upload-time = "2025-10-16T18:05:13.46Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/35/266a80d0eb97bd224b3265b9437bd89dde0dcf4faf299db1212e81824e7e/ruff-0.14.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cabcaa9ccf8089fb4fdb78d17cc0e28241520f50f4c2e88cb6261ed083d85151", size = 13132844, upload-time = "2025-10-16T18:05:16.1Z" },
+ { url = "https://files.pythonhosted.org/packages/65/6e/d31ce218acc11a8d91ef208e002a31acf315061a85132f94f3df7a252b18/ruff-0.14.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:747d583400f6125ec11a4c14d1c8474bf75d8b419ad22a111a537ec1a952d192", size = 13401241, upload-time = "2025-10-16T18:05:19.395Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/b5/dbc4221bf0b03774b3b2f0d47f39e848d30664157c15b965a14d890637d2/ruff-0.14.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5a6e74c0efd78515a1d13acbfe6c90f0f5bd822aa56b4a6d43a9ffb2ae6e56cd", size = 12132476, upload-time = "2025-10-16T18:05:22.163Z" },
+ { url = "https://files.pythonhosted.org/packages/98/4b/ac99194e790ccd092d6a8b5f341f34b6e597d698e3077c032c502d75ea84/ruff-0.14.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0ea6a864d2fb41a4b6d5b456ed164302a0d96f4daac630aeba829abfb059d020", size = 12139749, upload-time = "2025-10-16T18:05:25.162Z" },
+ { url = "https://files.pythonhosted.org/packages/47/26/7df917462c3bb5004e6fdfcc505a49e90bcd8a34c54a051953118c00b53a/ruff-0.14.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0826b8764f94229604fa255918d1cc45e583e38c21c203248b0bfc9a0e930be5", size = 12544758, upload-time = "2025-10-16T18:05:28.018Z" },
+ { url = "https://files.pythonhosted.org/packages/64/d0/81e7f0648e9764ad9b51dd4be5e5dac3fcfff9602428ccbae288a39c2c22/ruff-0.14.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cbc52160465913a1a3f424c81c62ac8096b6a491468e7d872cb9444a860bc33d", size = 13221811, upload-time = "2025-10-16T18:05:30.707Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/07/3c45562c67933cc35f6d5df4ca77dabbcd88fddaca0d6b8371693d29fd56/ruff-0.14.1-py3-none-win32.whl", hash = "sha256:e037ea374aaaff4103240ae79168c0945ae3d5ae8db190603de3b4012bd1def6", size = 12319467, upload-time = "2025-10-16T18:05:33.261Z" },
+ { url = "https://files.pythonhosted.org/packages/02/88/0ee4ca507d4aa05f67e292d2e5eb0b3e358fbcfe527554a2eda9ac422d6b/ruff-0.14.1-py3-none-win_amd64.whl", hash = "sha256:59d599cdff9c7f925a017f6f2c256c908b094e55967f93f2821b1439928746a1", size = 13401123, upload-time = "2025-10-16T18:05:35.984Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/81/4b6387be7014858d924b843530e1b2a8e531846807516e9bea2ee0936bf7/ruff-0.14.1-py3-none-win_arm64.whl", hash = "sha256:e3b443c4c9f16ae850906b8d0a707b2a4c16f8d2f0a7fe65c475c5886665ce44", size = 12436636, upload-time = "2025-10-16T18:05:38.995Z" },
+]
+
+[[package]]
+name = "sqlparse"
+version = "0.5.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e5/40/edede8dd6977b0d3da179a342c198ed100dd2aba4be081861ee5911e4da4/sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272", size = 84999, upload-time = "2024-12-10T12:05:30.728Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a9/5c/bfd6bd0bf979426d405cc6e71eceb8701b148b16c21d2dc3c261efc61c7b/sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca", size = 44415, upload-time = "2024-12-10T12:05:27.824Z" },
+]
+
+[[package]]
+name = "tomli"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" },
+ { url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" },
+ { url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" },
+ { url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" },
+ { url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" },
+ { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" },
+ { url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" },
+ { url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" },
+ { url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" },
+ { url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" },
+ { url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" },
+ { url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" },
+ { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" },
+ { url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" },
+ { url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" },
+ { url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" },
+ { url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" },
+ { url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" },
+ { url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" },
+ { url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" },
+ { url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" },
+ { url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" },
+ { url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" },
+ { url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" },
+ { url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" },
+ { url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" },
+ { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" },
+]
+
+[[package]]
+name = "types-pyyaml"
+version = "6.0.12.20250915"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7e/69/3c51b36d04da19b92f9e815be12753125bd8bc247ba0470a982e6979e71c/types_pyyaml-6.0.12.20250915.tar.gz", hash = "sha256:0f8b54a528c303f0e6f7165687dd33fafa81c807fcac23f632b63aa624ced1d3", size = 17522, upload-time = "2025-09-15T03:01:00.728Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/bd/e0/1eed384f02555dde685fff1a1ac805c1c7dcb6dd019c916fe659b1c1f9ec/types_pyyaml-6.0.12.20250915-py3-none-any.whl", hash = "sha256:e7d4d9e064e89a3b3cae120b4990cd370874d2bf12fa5f46c97018dd5d3c9ab6", size = 20338, upload-time = "2025-09-15T03:00:59.218Z" },
+]
+
+[[package]]
+name = "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.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" },
+]