From daad37dad1721559870d54c2038773061cecbec9 Mon Sep 17 00:00:00 2001 From: Kosei Kitahara Date: Sat, 18 Oct 2025 17:03:32 +0900 Subject: [PATCH 01/14] Copy from github/gitignore --- .gitignore | 124 ++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 113 insertions(+), 11 deletions(-) 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__/ From 18146247c245356cba24f8187224000b43e0344d Mon Sep 17 00:00:00 2001 From: Kosei Kitahara Date: Sat, 18 Oct 2025 17:48:51 +0900 Subject: [PATCH 02/14] Migrate project to uv --- .python-version | 1 + django_elastipymemcache/__init__.py | 3 +- pyproject.toml | 112 +++++++ requirements.txt | 8 - setup.cfg | 27 -- setup.py | 42 --- tox.ini | 60 ---- uv.lock | 468 ++++++++++++++++++++++++++++ 8 files changed, 582 insertions(+), 139 deletions(-) create mode 100644 .python-version create mode 100644 pyproject.toml delete mode 100644 requirements.txt delete mode 100644 setup.cfg delete mode 100644 setup.py delete mode 100644 tox.ini create mode 100644 uv.lock 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/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/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b95b6a9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,112 @@ +[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.rst" +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" +extend-select = [ + "I", # isort +] +lint.ignore = [ + "E501", # rely on formatter for wrapping +] + +[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 +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.rst", + "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/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" }, +] From c5eb5f825690827ce5d6f42095ececfc75ec1f79 Mon Sep 17 00:00:00 2001 From: Kosei Kitahara Date: Mon, 27 Oct 2025 03:07:13 +0900 Subject: [PATCH 03/14] Re-write backend & client (HashClient based) --- README.md | 107 ++++++ README.rst | 55 --- django_elastipymemcache/backend.py | 143 ++------ django_elastipymemcache/client.py | 373 ++++++++++++++++---- pyproject.toml | 6 +- tests/settings.py | 8 + tests/test_aws_elasticache_client.py | 94 +++++ tests/test_backend.py | 288 ++++----------- tests/test_client.py | 141 -------- tests/test_configuration_endpoint_client.py | 62 ++++ 10 files changed, 678 insertions(+), 599 deletions(-) create mode 100644 README.md delete mode 100644 README.rst create mode 100644 tests/settings.py create mode 100644 tests/test_aws_elasticache_client.py delete mode 100644 tests/test_client.py create mode 100644 tests/test_configuration_endpoint_client.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..0372df0 --- /dev/null +++ b/README.md @@ -0,0 +1,107 @@ +# django-elastipymemcache + +[![Coverage](https://codecov.io/gh/harikitech/django-elastipymemcache/branch/master/graph/badge.svg)](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. 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/backend.py b/django_elastipymemcache/backend.py index 22b38fb..26faa7e 100644 --- a/django_elastipymemcache/backend.py +++ b/django_elastipymemcache/backend.py @@ -1,139 +1,38 @@ -""" -Backend for django cache -""" import logging -import socket -from functools import wraps 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 - """ +class ElastiPymemcache(PyMemcacheCache): 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, - ) + super().__init__(server, params) + self._class = AWSElastiCacheClient + self._endpoint = self._validate_endpoint() - if len(self._servers) > 1: + def _validate_endpoint(self) -> str: + if not self._servers or len(self._servers) != 1: raise InvalidCacheBackendError( - 'ElastiCache should be configured with only one server ' - '(Configuration Endpoint)', + "ElastiCache requires exactly one Configuration Endpoint (host:port)." ) - try: - host, port = self._servers[0].split(':') - port = int(port) - except ValueError: + + endpoint = self._servers[0] + if not isinstance( + endpoint, str + ) or not _AWS_CONFIGURATION_ENDPOINT_PATTERN.fullmatch(endpoint): raise InvalidCacheBackendError( - 'Server configuration should be in format IP:Port', + f"Invalid Configuration Endpoint '{endpoint}'. Expected 'host:port'." ) + return endpoint - self.configuration_endpoint_client = ConfigurationEndpointClient( - (host, port), - ignore_cluster_errors=self._ignore_cluster_errors, + @cached_property + def _cache(self): + return self._class( + configuration_endpoint=self._endpoint, **self._options, ) - - def clear_cluster_nodes_cache(self): - """Clear internal cache with list of nodes in cluster""" - if hasattr(self, '_client'): - del self._client - - 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) diff --git a/django_elastipymemcache/client.py b/django_elastipymemcache/client.py index 4f6a2f5..c5d0a2d 100644 --- a/django_elastipymemcache/client.py +++ b/django_elastipymemcache/client.py @@ -1,79 +1,320 @@ +""" +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 socket +import threading +import time -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.client.rendezvous import RendezvousHash +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 | 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)) -class ConfigurationEndpointClient(Client): - # https://docs.aws.amazon.com/AmazonElastiCache/latest/mem-ug/AutoDiscovery.AddingToYourClientLibrary.html + self._default_kwargs = default_kwargs or {} + self._use_pooling = bool(use_pooling) + self._use_vpc_ip_address = use_vpc_ip_address - def __init__(self, *args, ignore_cluster_errors=False, **kwargs): - client = super().__init__(*args, **kwargs) - self.ignore_cluster_errors = ignore_cluster_errors + self._lock = threading.Lock() + self._client: PooledClient | None = None + + 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 _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 _recycle_client(self) -> None: + if not self._use_pooling: + return - 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: + 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() + finally: + self._client = None + + def _raw_config_get_cluster(self, client: Client | PooledClient) -> bytes: + return 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 line.strip() + ] + + if not lines: + raise MemcacheError("ElastiCache discovery: empty response") + elif len(lines) < 3: + raise MemcacheError( + f"ElastiCache discovery: response too short: {len(lines)}" + ) + elif "END" not in lines: + raise MemcacheError("ElastiCache discovery: response missing END token") + + membership_lines = lines[lines.index("END") - 1] + if not membership_lines: + raise MemcacheError("ElastiCache discovery: no membership line found") + + nodes: list[tuple[str, int]] = [] + for token in membership_lines.split(" "): + try: + host, ip, port = token.split("|") + except ValueError: + continue + + addr = self._use_vpc_ip_address and ip or host + nodes.append((addr, int(port))) + if not nodes: + 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: + self._recycle_client() raise + finally: + if not self._use_pooling: + try: + client.close() + except Exception: + pass + + return self._parse_config_get_cluster_response(response) + + def close(self) -> None: + self._recycle_client() + + +def _retry_refresh_clients(method): + def wrapped(self, *args, **kwargs): + last_exception: Exception | None = None + + for attempt in range(self.retry_attempts + 1): + try: + return method(self, *args, **kwargs) + except (MemcacheError, OSError) as exc: + last_exception = exc + time.sleep(self._discovery_retry_delay) + self._refresh_clients(force=True) + + raise last_exception + + return wrapped + + +class AWSElastiCacheClient(HashClient): + """ElastiCache-aware HashClient with""" + + def __init__( + self, + configuration_endpoint: str, + *, + # Data client & behavior + hasher: object = RendezvousHash, + serde: object | None = None, + serializer: object | None = None, + deserializer: object | None = None, + connect_timeout: float | None = None, + timeout: float | None = None, + no_delay: bool = False, + socket_module=socket, + socket_keepalive: object | None = None, + key_prefix: bytes = b"", + max_pool_size: int | None = None, + pool_idle_timeout: int = 0, + lock_generator: object | None = None, + retry_attempts: int = 2, + retry_timeout: int = 1, + dead_timeout: int = 60, + use_pooling: bool = False, + ignore_exc: bool = False, + allow_unicode_keys: bool = False, + default_noreply: bool = True, + encoding: str = "ascii", + tls_context: object | None = None, + # Discovery & topology management + use_vpc_ip_address: bool = True, + discovery_interval: float | int = 0.0, + discovery_retry_delay: float | int = 0.0, + ): + if not _AWS_CONFIGURATION_ENDPOINT_PATTERN.fullmatch(configuration_endpoint): + raise ValueError( + f"Invalid configuration endpoint '{configuration_endpoint}' " + f"(expected 'host:port' or '[ip]:port')." + ) + + self.configuration_endpoint: str = configuration_endpoint + + # HashClient core fields (names, semantics) + self.clients: dict[str, Client] = {} + self.retry_attempts: int = retry_attempts + self.retry_timeout: int = retry_timeout + self.dead_timeout: int = dead_timeout + self.use_pooling: bool = use_pooling + self.key_prefix: bytes = key_prefix + self.ignore_exc: bool = ignore_exc + self.allow_unicode_keys: bool = allow_unicode_keys + + self._failed_clients: dict[str, Client] = {} + self._dead_clients: dict[str, Client] = {} + self._last_dead_check_time: float = time.time() + self.hasher = hasher() + + self.default_kwargs = { + "connect_timeout": connect_timeout, + "timeout": timeout, + "no_delay": no_delay, + "socket_module": socket_module, + "socket_keepalive": socket_keepalive, + "key_prefix": key_prefix, + "serde": serde, + "serializer": serializer, + "deserializer": deserializer, + "allow_unicode_keys": allow_unicode_keys, + "default_noreply": default_noreply, + "encoding": encoding, + "tls_context": tls_context, + } + + if use_pooling: + self.default_kwargs.update( + { + "max_pool_size": max_pool_size, + "pool_idle_timeout": pool_idle_timeout, + "lock_generator": lock_generator, + } + ) + + self.encoding = encoding + self.tls_context = tls_context + + 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]: + node = self._configuration_endpoint_client.config_get_cluster() + return set(map(self._make_client_key, node)) + + 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): + self._refresh_clients() + return super()._get_client(key) diff --git a/pyproject.toml b/pyproject.toml index b95b6a9..f14770e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ build-backend = "hatchling.build" [project] name = "django-elastipymemcache" description = "pymemcache-based Django cache backend for Amazon ElastiCache with auto discovery" -readme = "README.rst" +readme = "README.md" requires-python = ">=3.10" license = { text = "MIT"} authors = [ @@ -71,10 +71,12 @@ skip_covered = true [tool.ruff] line-length = 120 target-version = "py310" + +[tool.ruff.lint] extend-select = [ "I", # isort ] -lint.ignore = [ +ignore = [ "E501", # rely on formatter for wrapping ] diff --git a/tests/settings.py b/tests/settings.py new file mode 100644 index 0000000..7931100 --- /dev/null +++ b/tests/settings.py @@ -0,0 +1,8 @@ +SECRET_KEY = "test" +INSTALLED_APPS = [] +CACHES = { + "default": { + "BACKEND": "django_elastipymemcache.backend.ElastiPymemcache", + "LOCATION": "localhost:11211", + } +} diff --git a/tests/test_aws_elasticache_client.py b/tests/test_aws_elasticache_client.py new file mode 100644 index 0000000..4ee6373 --- /dev/null +++ b/tests/test_aws_elasticache_client.py @@ -0,0 +1,94 @@ +import time +from unittest.mock import Mock + +import pytest +from pymemcache.client import Client, PooledClient + +from django_elastipymemcache.client import AWSElastiCacheClient + + +@pytest.fixture +def mock_discovery(monkeypatch): + def _set(nodes: list[tuple[str, int]]): + monkeypatch.setattr( + "django_elastipymemcache.client._ConfigurationEndpointClient.config_get_cluster", + lambda self: list(nodes), + ) + + return _set + + +def make_client(**options): + return AWSElastiCacheClient( + "test.0000.use1.cache.amazonaws.com:11211", + **options, + ) + + +def test_initial_refresh_builds_clients(mock_discovery): + 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): + 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, mock_discovery): + 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 + 11.0 + client._refresh_clients() + assert set(client.clients.keys()) == {"10.0.0.2:11211"} + + +def test_use_pooling_creates_pooled_clients(mock_discovery): + 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, mock_discovery): + 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(b"test") + + assert isinstance(data_node, (Client, PooledClient)) diff --git a/tests/test_backend.py b/tests/test_backend.py index a61237e..f78ae59 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -1,226 +1,88 @@ -from unittest import TestCase from unittest.mock import Mock, patch -import django +import pytest from django.core.cache import InvalidCacheBackendError -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): + def _set(nodes: list[tuple[str, int]]): + 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(): + 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(): + 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): + 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): + 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): + 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(b"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..a0015f2 --- /dev/null +++ b/tests/test_configuration_endpoint_client.py @@ -0,0 +1,62 @@ +import pytest +from pymemcache.exceptions import MemcacheError + +from django_elastipymemcache.client import _ConfigurationEndpointClient + +EXAMPLE_RESPONSE = ( + b"CONFIG cluster 0 147\r\n" + b"12\n" + b"test.0001.use1.cache.amazonaws.com|10.82.235.120|11211 " + b"test.0002.use1.cache.amazonaws.com|10.80.249.27|11211\n\r\n" + b"END\r\n" +) + + +def _client(use_vpc_ip=True): + return _ConfigurationEndpointClient( + configuration_endpoint="config.example:11211", + default_kwargs={}, + use_pooling=False, + use_vpc_ip_address=use_vpc_ip, + ignore_exc=False, + ) + + +def test_parse_ok_with_vpc_ip(monkeypatch): + client = _client(use_vpc_ip=True) + monkeypatch.setattr( + client, "_raw_config_get_cluster", lambda _client: EXAMPLE_RESPONSE + ) + + nodes = client.config_get_cluster() + assert nodes == [("10.82.235.120", 11211), ("10.80.249.27", 11211)] + + +def test_parse_ok_with_hostnames(monkeypatch): + client = _client(use_vpc_ip=False) + monkeypatch.setattr( + client, "_raw_config_get_cluster", lambda _client: EXAMPLE_RESPONSE + ) + + nodes = client.config_get_cluster() + assert nodes == [ + ("test.0001.use1.cache.amazonaws.com", 11211), + ("test.0002.use1.cache.amazonaws.com", 11211), + ] + + +@pytest.mark.parametrize( + "payload", + [ + b"", + b"CONFIG cluster 0 1\r\nX\r\n", + b"CONFIG cluster 0 1\r\n\n\r\nEND\r\n", + b"CONFIG cluster 0 1\r\nbad|format\r\nEND\r\n", + ], +) +def test_parse_errors(monkeypatch, payload): + client = _client() + monkeypatch.setattr(client, "_raw_config_get_cluster", lambda _client: payload) + + with pytest.raises(MemcacheError): + client.config_get_cluster() From 70d60ad9728af5de88c914912609968e432df035 Mon Sep 17 00:00:00 2001 From: Kosei Kitahara Date: Mon, 27 Oct 2025 16:33:03 +0900 Subject: [PATCH 04/14] Fix lint bugs & enable CI --- .github/workflows/build.yaml | 45 -------------- .github/workflows/test.yml | 56 +++++++++++++++++ django_elastipymemcache/backend.py | 29 +++++---- django_elastipymemcache/client.py | 68 ++++++++++++--------- pyproject.toml | 18 ++++++ tests/settings.py | 2 +- tests/test_aws_elasticache_client.py | 34 ++++++++--- tests/test_backend.py | 26 ++++---- tests/test_configuration_endpoint_client.py | 18 +++--- 9 files changed, 175 insertions(+), 121 deletions(-) delete mode 100644 .github/workflows/build.yaml create mode 100644 .github/workflows/test.yml 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/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..fe310fd --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,56 @@ +--- +name: Test + +"on": + push: + pull_request: + +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 --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 --dev + uv pip install "django~=${{ matrix.django-version }}" + - name: Test (pytest) + run: uv run pytest -v diff --git a/django_elastipymemcache/backend.py b/django_elastipymemcache/backend.py index 26faa7e..e2db874 100644 --- a/django_elastipymemcache/backend.py +++ b/django_elastipymemcache/backend.py @@ -1,4 +1,5 @@ import logging +from typing import Any, Sequence from django.core.cache import InvalidCacheBackendError from django.core.cache.backends.memcached import PyMemcacheCache @@ -10,29 +11,27 @@ class ElastiPymemcache(PyMemcacheCache): - def __init__(self, server, params): + 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: - raise InvalidCacheBackendError( - "ElastiCache requires exactly one Configuration Endpoint (host:port)." - ) - - endpoint = self._servers[0] - if not isinstance( - endpoint, str - ) or not _AWS_CONFIGURATION_ENDPOINT_PATTERN.fullmatch(endpoint): - raise InvalidCacheBackendError( - f"Invalid Configuration Endpoint '{endpoint}'. Expected 'host:port'." - ) + 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): + def _cache(self) -> AWSElastiCacheClient: return self._class( configuration_endpoint=self._endpoint, - **self._options, + **self._options, # type: ignore[attr-defined] ) diff --git a/django_elastipymemcache/client.py b/django_elastipymemcache/client.py index c5d0a2d..1aa541b 100644 --- a/django_elastipymemcache/client.py +++ b/django_elastipymemcache/client.py @@ -10,6 +10,8 @@ import socket import threading import time +from types import ModuleType +from typing import Any, Callable, Concatenate, ParamSpec, TypeVar from django.utils.encoding import force_str from pymemcache import MemcacheUnknownCommandError @@ -37,7 +39,7 @@ class _ConfigurationEndpointClient: def __init__( self, configuration_endpoint: str, - default_kwargs: dict | None = None, + default_kwargs: dict[str, Any] | None = None, use_pooling: bool = False, use_vpc_ip_address: bool = True, ) -> None: @@ -80,24 +82,20 @@ def _recycle_client(self) -> None: self._client = None def _raw_config_get_cluster(self, client: Client | PooledClient) -> bytes: - return client.raw_command( - b"config get cluster", - end_tokens=b"\n\r\nEND\r\n", + 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 line.strip() - ] + def _parse_config_get_cluster_response(self, response: bytes) -> list[tuple[str, int]]: + lines = [force_str(line.strip()) for line in response.splitlines() if line.strip()] if not lines: raise MemcacheError("ElastiCache discovery: empty response") elif len(lines) < 3: - raise MemcacheError( - f"ElastiCache discovery: response too short: {len(lines)}" - ) + raise MemcacheError(f"ElastiCache discovery: response too short: {len(lines)}") elif "END" not in lines: raise MemcacheError("ElastiCache discovery: response missing END token") @@ -139,39 +137,54 @@ def close(self) -> None: self._recycle_client() -def _retry_refresh_clients(method): - def wrapped(self, *args, **kwargs): +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 attempt in range(self.retry_attempts + 1): + for _ in range(self.retry_attempts + 1): try: return method(self, *args, **kwargs) except (MemcacheError, OSError) as exc: last_exception = exc - time.sleep(self._discovery_retry_delay) - self._refresh_clients(force=True) + 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 refresh_exc: + logger.debug("Discovery refresh failed during retry: %r", refresh_exc) + assert last_exception is not None raise last_exception return wrapped -class AWSElastiCacheClient(HashClient): +class AWSElastiCacheClient(HashClient): # type: ignore[misc] """ElastiCache-aware HashClient with""" def __init__( self, configuration_endpoint: str, - *, # Data client & behavior - hasher: object = RendezvousHash, + hasher: type[RendezvousHash] = RendezvousHash, serde: object | None = None, serializer: object | None = None, deserializer: object | None = None, connect_timeout: float | None = None, timeout: float | None = None, no_delay: bool = False, - socket_module=socket, + socket_module: ModuleType = socket, socket_keepalive: object | None = None, key_prefix: bytes = b"", max_pool_size: int | None = None, @@ -190,11 +203,10 @@ def __init__( use_vpc_ip_address: bool = True, discovery_interval: float | int = 0.0, discovery_retry_delay: float | int = 0.0, - ): + ) -> None: if not _AWS_CONFIGURATION_ENDPOINT_PATTERN.fullmatch(configuration_endpoint): raise ValueError( - f"Invalid configuration endpoint '{configuration_endpoint}' " - f"(expected 'host:port' or '[ip]:port')." + f"Invalid configuration endpoint '{configuration_endpoint}' (expected 'host:port' or '[ip]:port')." ) self.configuration_endpoint: str = configuration_endpoint @@ -209,8 +221,8 @@ def __init__( self.ignore_exc: bool = ignore_exc self.allow_unicode_keys: bool = allow_unicode_keys - self._failed_clients: dict[str, Client] = {} - self._dead_clients: dict[str, Client] = {} + self._failed_clients: dict[tuple[str, int], Client] = {} + self._dead_clients: dict[tuple[str, int], Client] = {} self._last_dead_check_time: float = time.time() self.hasher = hasher() @@ -315,6 +327,6 @@ def _refresh_clients(self, force: bool = False) -> None: logger.exception("Failed to close during topology refresh") @_retry_refresh_clients - def _get_client(self, key): + def _get_client(self, key: str) -> Client | PooledClient: self._refresh_clients() return super()._get_client(key) diff --git a/pyproject.toml b/pyproject.toml index f14770e..7d795ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,6 +71,15 @@ 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 = [ @@ -79,6 +88,11 @@ extend-select = [ ignore = [ "E501", # rely on formatter for wrapping ] +[tool.ruff.lint.isort] +known-first-party = [ + "django_elastipymemcache", + "tests", +] [tool.ruff.format] quote-style = "double" @@ -90,6 +104,10 @@ strict = true warn_unused_ignores = true warn_redundant_casts = true ignore_missing_imports = true +files = [ + "django_elastipymemcache", + "tests", +] plugins = [ "mypy_django_plugin.main", ] diff --git a/tests/settings.py b/tests/settings.py index 7931100..77c8c03 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -1,5 +1,5 @@ SECRET_KEY = "test" -INSTALLED_APPS = [] +INSTALLED_APPS: list[str] = [] CACHES = { "default": { "BACKEND": "django_elastipymemcache.backend.ElastiPymemcache", diff --git a/tests/test_aws_elasticache_client.py b/tests/test_aws_elasticache_client.py index 4ee6373..e0d89a6 100644 --- a/tests/test_aws_elasticache_client.py +++ b/tests/test_aws_elasticache_client.py @@ -1,15 +1,17 @@ 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): - def _set(nodes: list[tuple[str, int]]): +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), @@ -18,21 +20,25 @@ def _set(nodes: list[tuple[str, int]]): return _set -def make_client(**options): +def make_client(**options: Any) -> AWSElastiCacheClient: return AWSElastiCacheClient( "test.0000.use1.cache.amazonaws.com:11211", **options, ) -def test_initial_refresh_builds_clients(mock_discovery): +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): +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) @@ -44,7 +50,10 @@ def test_add_and_remove_nodes(mock_discovery): assert set(client.clients.keys()) == {"10.0.0.2:11211", "10.0.0.3:11211"} -def test_periodic_refresh_respects_interval(monkeypatch, mock_discovery): +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) @@ -58,12 +67,14 @@ def test_periodic_refresh_respects_interval(monkeypatch, mock_discovery): client._refresh_clients() assert set(client.clients.keys()) == {"10.0.0.1:11211"} - mock_monotonic.return_value = now + 11.0 + 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): +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, @@ -74,7 +85,10 @@ def test_use_pooling_creates_pooled_clients(mock_discovery): assert all(isinstance(c, PooledClient) for c in client.clients.values()) -def test_get_client_triggers_retry_refresh_when_ring_empty(monkeypatch, mock_discovery): +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) @@ -89,6 +103,6 @@ def test_get_client_triggers_retry_refresh_when_ring_empty(monkeypatch, mock_dis mock_config_get, ) - data_node = client._get_client(b"test") + 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 f78ae59..623ef71 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -1,14 +1,16 @@ +from typing import Callable from unittest.mock import Mock, patch import pytest from django.core.cache import InvalidCacheBackendError +from pytest import MonkeyPatch from django_elastipymemcache.backend import ElastiPymemcache @pytest.fixture -def mock_discovery(monkeypatch): - def _set(nodes: list[tuple[str, int]]): +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), @@ -17,7 +19,7 @@ def _set(nodes: list[tuple[str, int]]): return _set -def test_multiple_servers(): +def test_multiple_servers() -> None: with pytest.raises(InvalidCacheBackendError): ElastiPymemcache( "test.0001.use1.cache.amazonaws.com:11211,test.0002.use1.cache.amazonaws.com:11211", @@ -25,7 +27,7 @@ def test_multiple_servers(): ) -def test_wrong_server_format(): +def test_wrong_server_format() -> None: with pytest.raises(InvalidCacheBackendError): ElastiPymemcache( "test.0000.use1.cache.amazonaws.com", @@ -33,7 +35,9 @@ def test_wrong_server_format(): ) -def test_split_servers(mock_discovery): +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) @@ -44,12 +48,12 @@ def test_split_servers(mock_discovery): MockClient.assert_called_once() _, kwargs = MockClient.call_args - assert ( - kwargs["configuration_endpoint"] == "test.0000.use1.cache.amazonaws.com:11211" - ) + assert kwargs["configuration_endpoint"] == "test.0000.use1.cache.amazonaws.com:11211" -def test_node_info_cache(mock_discovery): +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) @@ -69,7 +73,7 @@ def test_node_info_cache(mock_discovery): MockClient.assert_called_once() -def test_failed_to_connect_servers(monkeypatch): +def test_failed_to_connect_servers(monkeypatch: MonkeyPatch) -> None: mock_config_get = Mock( side_effect=[ OSError("boom"), # 1st call raises @@ -84,5 +88,5 @@ def test_failed_to_connect_servers(monkeypatch): backend = ElastiPymemcache("test.0000.use1.cache.amazonaws.com:11211", {}) - client = backend._cache._get_client(b"test") + client = backend._cache._get_client("test") assert client is not None diff --git a/tests/test_configuration_endpoint_client.py b/tests/test_configuration_endpoint_client.py index a0015f2..52878a1 100644 --- a/tests/test_configuration_endpoint_client.py +++ b/tests/test_configuration_endpoint_client.py @@ -1,5 +1,6 @@ import pytest from pymemcache.exceptions import MemcacheError +from pytest import MonkeyPatch from django_elastipymemcache.client import _ConfigurationEndpointClient @@ -12,31 +13,26 @@ ) -def _client(use_vpc_ip=True): +def _client(use_vpc_ip: bool = True) -> _ConfigurationEndpointClient: return _ConfigurationEndpointClient( configuration_endpoint="config.example:11211", default_kwargs={}, use_pooling=False, use_vpc_ip_address=use_vpc_ip, - ignore_exc=False, ) -def test_parse_ok_with_vpc_ip(monkeypatch): +def test_parse_ok_with_vpc_ip(monkeypatch: MonkeyPatch) -> None: client = _client(use_vpc_ip=True) - monkeypatch.setattr( - client, "_raw_config_get_cluster", lambda _client: EXAMPLE_RESPONSE - ) + monkeypatch.setattr(client, "_raw_config_get_cluster", lambda _client: EXAMPLE_RESPONSE) nodes = client.config_get_cluster() assert nodes == [("10.82.235.120", 11211), ("10.80.249.27", 11211)] -def test_parse_ok_with_hostnames(monkeypatch): +def test_parse_ok_with_hostnames(monkeypatch: MonkeyPatch) -> None: client = _client(use_vpc_ip=False) - monkeypatch.setattr( - client, "_raw_config_get_cluster", lambda _client: EXAMPLE_RESPONSE - ) + monkeypatch.setattr(client, "_raw_config_get_cluster", lambda _client: EXAMPLE_RESPONSE) nodes = client.config_get_cluster() assert nodes == [ @@ -54,7 +50,7 @@ def test_parse_ok_with_hostnames(monkeypatch): b"CONFIG cluster 0 1\r\nbad|format\r\nEND\r\n", ], ) -def test_parse_errors(monkeypatch, payload): +def test_parse_errors(monkeypatch: MonkeyPatch, payload: bytes) -> None: client = _client() monkeypatch.setattr(client, "_raw_config_get_cluster", lambda _client: payload) From 50ce232fbf852f2b3817180c584439657346c67e Mon Sep 17 00:00:00 2001 From: Kosei Kitahara Date: Mon, 27 Oct 2025 16:44:12 +0900 Subject: [PATCH 05/14] Log exceptions --- django_elastipymemcache/client.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/django_elastipymemcache/client.py b/django_elastipymemcache/client.py index 1aa541b..ddf2181 100644 --- a/django_elastipymemcache/client.py +++ b/django_elastipymemcache/client.py @@ -95,24 +95,32 @@ def _parse_config_get_cluster_response(self, response: bytes) -> list[tuple[str, if not lines: raise MemcacheError("ElastiCache discovery: empty response") elif len(lines) < 3: + logger.warning("ElastiCache discovery: response too short: %r", lines) raise MemcacheError(f"ElastiCache discovery: response too short: {len(lines)}") elif "END" not in lines: + logger.warning("ElastiCache discovery: response missing END token: %r", lines) raise MemcacheError("ElastiCache discovery: response missing END token") - membership_lines = lines[lines.index("END") - 1] - if not membership_lines: + membership_line = lines[lines.index("END") - 1] + if not membership_line: + logger.warning("ElastiCache discovery: no membership line in response: %r", lines) raise MemcacheError("ElastiCache discovery: no membership line found") nodes: list[tuple[str, int]] = [] - for token in membership_lines.split(" "): + 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( + "ElastiCache discovery: no nodes parsed from response: %r", + membership_line, + ) raise MemcacheError("ElastiCache discovery: no nodes parsed") return nodes @@ -122,6 +130,7 @@ def config_get_cluster(self) -> list[tuple[str, int]]: try: response = self._raw_config_get_cluster(client) except Exception: + logger.warning("ElastiCache discovery: config get cluster failed", exc_info=True) self._recycle_client() raise finally: From 20e47be7cc3b6383213179063713af6f0ff58fb1 Mon Sep 17 00:00:00 2001 From: Kosei Kitahara Date: Mon, 27 Oct 2025 19:46:08 +0900 Subject: [PATCH 06/14] Install dev packages --- .github/workflows/test.yml | 4 ++-- MANIFEST.in | 2 +- pyproject.toml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fe310fd..deaafb2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,7 +21,7 @@ jobs: with: enable-cache: true - name: Install dependencies - run: uv sync --dev + run: uv sync --extra dev - name: Lint (ruff format --check) run: uv run ruff format --check - name: Lint (ruff check) @@ -50,7 +50,7 @@ jobs: enable-cache: true - name: Install dependencies run: | - uv sync --dev + uv sync --extra dev uv pip install "django~=${{ matrix.django-version }}" - name: Test (pytest) run: uv run pytest -v 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/pyproject.toml b/pyproject.toml index 7d795ae..b1b4786 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -122,7 +122,7 @@ path = "django_elastipymemcache/__init__.py" include = [ "django_elastipymemcache", "tests", - "README.rst", + "README.md", "LICENSE", ] From 2fda862adc8162b381e9c6d771acd17de77af755 Mon Sep 17 00:00:00 2001 From: Kosei Kitahara Date: Sat, 1 Nov 2025 18:18:44 +0900 Subject: [PATCH 07/14] Use pooling & safer close --- django_elastipymemcache/backend.py | 10 ++++++++ django_elastipymemcache/client.py | 40 +++++++++++++++++++----------- 2 files changed, 35 insertions(+), 15 deletions(-) diff --git a/django_elastipymemcache/backend.py b/django_elastipymemcache/backend.py index e2db874..5dbac17 100644 --- a/django_elastipymemcache/backend.py +++ b/django_elastipymemcache/backend.py @@ -35,3 +35,13 @@ def _cache(self) -> AWSElastiCacheClient: configuration_endpoint=self._endpoint, **self._options, # type: ignore[attr-defined] ) + + def _safe_close(self, **kwargs: Any) -> None: + client = self.__dict__.pop("_cache", None) + if not client: + return + + try: + 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 ddf2181..7b8442e 100644 --- a/django_elastipymemcache/client.py +++ b/django_elastipymemcache/client.py @@ -70,14 +70,16 @@ def _get_client(self) -> Client | PooledClient: self._client = self._new_client() return self._client - def _recycle_client(self) -> None: - if not self._use_pooling: + def _close_client(self) -> None: + if self._use_pooling: return with self._lock: if self._client is not None: try: self._client.close() + except Exception: + logger.warning("ElastiCache discovery: failed to close client", exc_info=True) finally: self._client = None @@ -94,12 +96,9 @@ def _parse_config_get_cluster_response(self, response: bytes) -> list[tuple[str, if not lines: raise MemcacheError("ElastiCache discovery: empty response") - elif len(lines) < 3: - logger.warning("ElastiCache discovery: response too short: %r", lines) - raise MemcacheError(f"ElastiCache discovery: response too short: {len(lines)}") - elif "END" not in lines: - logger.warning("ElastiCache discovery: response missing END token: %r", lines) - raise MemcacheError("ElastiCache discovery: response missing END token") + elif len(lines) < 3 or "END" not in lines: + logger.warning("ElastiCache discovery: failed to parse response: %r", lines) + raise MemcacheError("ElastiCache discovery: invalid response format") membership_line = lines[lines.index("END") - 1] if not membership_line: @@ -131,19 +130,18 @@ def config_get_cluster(self) -> list[tuple[str, int]]: response = self._raw_config_get_cluster(client) except Exception: logger.warning("ElastiCache discovery: config get cluster failed", exc_info=True) - self._recycle_client() + self._close_client() raise - finally: + else: if not self._use_pooling: try: client.close() except Exception: - pass + logger.warning("ElastiCache discovery: failed to close client", exc_info=True) return self._parse_config_get_cluster_response(response) - def close(self) -> None: - self._recycle_client() + close = _close_client P = ParamSpec("P") @@ -170,8 +168,8 @@ def wrapped( time.sleep(self._discovery_retry_delay) try: self._refresh_clients(force=True) - except Exception as refresh_exc: - logger.debug("Discovery refresh failed during retry: %r", refresh_exc) + except Exception as e: + logger.debug("Discovery refresh failed during retry: %r", e) assert last_exception is not None raise last_exception @@ -339,3 +337,15 @@ def _refresh_clients(self, force: bool = False) -> None: def _get_client(self, key: str) -> Client | PooledClient: self._refresh_clients() return super()._get_client(key) + + def close(self) -> None: + if self._configuration_endpoint_client: + try: + self._configuration_endpoint_client.close() + except Exception: + logger.warning( + "Exception occurred while closing configuration endpoint client", + exc_info=True, + ) + if not self.use_pooling: + super().close() From f10f31add281e89d377e1ace5e0e61d8db707ad5 Mon Sep 17 00:00:00 2001 From: Kosei Kitahara Date: Sun, 2 Nov 2025 15:10:38 +0900 Subject: [PATCH 08/14] Fix wrap & dd-trace bugs --- django_elastipymemcache/client.py | 130 ++++++++++-------------------- 1 file changed, 41 insertions(+), 89 deletions(-) diff --git a/django_elastipymemcache/client.py b/django_elastipymemcache/client.py index 7b8442e..ce79fbf 100644 --- a/django_elastipymemcache/client.py +++ b/django_elastipymemcache/client.py @@ -7,17 +7,14 @@ import logging import random import re -import socket import threading import time -from types import ModuleType from typing import Any, Callable, Concatenate, ParamSpec, TypeVar 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.client.rendezvous import RendezvousHash from pymemcache.exceptions import MemcacheError logger = logging.getLogger(__name__) @@ -70,8 +67,8 @@ def _get_client(self) -> Client | PooledClient: self._client = self._new_client() return self._client - def _close_client(self) -> None: - if self._use_pooling: + def _close_client(self, force: bool = False) -> None: + if not force and self._use_pooling: return with self._lock: @@ -130,14 +127,10 @@ def config_get_cluster(self) -> list[tuple[str, int]]: response = self._raw_config_get_cluster(client) except Exception: logger.warning("ElastiCache discovery: config get cluster failed", exc_info=True) - self._close_client() + self._close_client(force=True) raise else: - if not self._use_pooling: - try: - client.close() - except Exception: - logger.warning("ElastiCache discovery: failed to close client", exc_info=True) + self._close_client() return self._parse_config_get_cluster_response(response) @@ -183,84 +176,28 @@ class AWSElastiCacheClient(HashClient): # type: ignore[misc] def __init__( self, configuration_endpoint: str, - # Data client & behavior - hasher: type[RendezvousHash] = RendezvousHash, - serde: object | None = None, - serializer: object | None = None, - deserializer: object | None = None, - connect_timeout: float | None = None, - timeout: float | None = None, - no_delay: bool = False, - socket_module: ModuleType = socket, - socket_keepalive: object | None = None, - key_prefix: bytes = b"", - max_pool_size: int | None = None, - pool_idle_timeout: int = 0, - lock_generator: object | None = None, - retry_attempts: int = 2, - retry_timeout: int = 1, - dead_timeout: int = 60, + # pymemcache.HashClient params use_pooling: bool = False, - ignore_exc: bool = False, - allow_unicode_keys: bool = False, - default_noreply: bool = True, - encoding: str = "ascii", - tls_context: object | None = None, + 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')." ) - self.configuration_endpoint: str = configuration_endpoint - - # HashClient core fields (names, semantics) - self.clients: dict[str, Client] = {} - self.retry_attempts: int = retry_attempts - self.retry_timeout: int = retry_timeout - self.dead_timeout: int = dead_timeout - self.use_pooling: bool = use_pooling - self.key_prefix: bytes = key_prefix - self.ignore_exc: bool = ignore_exc - self.allow_unicode_keys: bool = allow_unicode_keys - - self._failed_clients: dict[tuple[str, int], Client] = {} - self._dead_clients: dict[tuple[str, int], Client] = {} - self._last_dead_check_time: float = time.time() - self.hasher = hasher() - - self.default_kwargs = { - "connect_timeout": connect_timeout, - "timeout": timeout, - "no_delay": no_delay, - "socket_module": socket_module, - "socket_keepalive": socket_keepalive, - "key_prefix": key_prefix, - "serde": serde, - "serializer": serializer, - "deserializer": deserializer, - "allow_unicode_keys": allow_unicode_keys, - "default_noreply": default_noreply, - "encoding": encoding, - "tls_context": tls_context, - } - - if use_pooling: - self.default_kwargs.update( - { - "max_pool_size": max_pool_size, - "pool_idle_timeout": pool_idle_timeout, - "lock_generator": lock_generator, - } - ) - - self.encoding = encoding - self.tls_context = tls_context + super().__init__( + servers=[], + 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, @@ -274,6 +211,7 @@ def __init__( retry_delay=discovery_retry_delay, do_not_retry_for=(MemcacheUnknownCommandError,), ) + self._use_auto_discovery = bool(discovery_interval) self._discovery_interval = ( self._use_auto_discovery @@ -290,8 +228,12 @@ def __init__( logger.exception(f"Initial discovery failed: {e}") def _discover_client_keys(self) -> set[str]: - node = self._configuration_endpoint_client.config_get_cluster() - return set(map(self._make_client_key, node)) + 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: @@ -338,14 +280,24 @@ def _get_client(self, key: str) -> Client | PooledClient: self._refresh_clients() return super()._get_client(key) - def close(self) -> None: - if self._configuration_endpoint_client: - try: - self._configuration_endpoint_client.close() - except Exception: - logger.warning( - "Exception occurred while closing configuration endpoint client", - exc_info=True, - ) - if not self.use_pooling: + 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() From 37571bcc355f70874a445ebc6012df37676a29ff Mon Sep 17 00:00:00 2001 From: Kosei Kitahara Date: Sun, 2 Nov 2025 21:59:41 +0900 Subject: [PATCH 09/14] Fix wrong format --- README.md | 25 ++++++ django_elastipymemcache/client.py | 32 ++++---- tests/conftest.py | 86 +++++++++++++++++++++ tests/test_configuration_endpoint_client.py | 71 ++++++++++++----- 4 files changed, 179 insertions(+), 35 deletions(-) create mode 100644 tests/conftest.py diff --git a/README.md b/README.md index 0372df0..f51b2c3 100644 --- a/README.md +++ b/README.md @@ -105,3 +105,28 @@ The backend accepts a combination of **ElastiPymemcache-specific options** and 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/django_elastipymemcache/client.py b/django_elastipymemcache/client.py index ce79fbf..b6f60b7 100644 --- a/django_elastipymemcache/client.py +++ b/django_elastipymemcache/client.py @@ -80,6 +80,8 @@ def _close_client(self, force: bool = False) -> None: finally: self._client = None + close = _close_client + def _raw_config_get_cluster(self, client: Client | PooledClient) -> bytes: return bytes( client.raw_command( @@ -89,19 +91,21 @@ def _raw_config_get_cluster(self, client: Client | PooledClient) -> bytes: ) def _parse_config_get_cluster_response(self, response: bytes) -> list[tuple[str, int]]: - lines = [force_str(line.strip()) for line in response.splitlines() if line.strip()] - + 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) < 3 or "END" not in lines: - logger.warning("ElastiCache discovery: failed to parse response: %r", lines) - raise MemcacheError("ElastiCache discovery: invalid response format") - - membership_line = lines[lines.index("END") - 1] - if not membership_line: - logger.warning("ElastiCache discovery: no membership line in response: %r", lines) - raise MemcacheError("ElastiCache discovery: no membership line found") - + 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: @@ -112,10 +116,10 @@ def _parse_config_get_cluster_response(self, response: bytes) -> list[tuple[str, addr = self._use_vpc_ip_address and ip or host nodes.append((addr, int(port))) + if not nodes: logger.warning( - "ElastiCache discovery: no nodes parsed from response: %r", - membership_line, + f"ElastiCache discovery: no nodes parsed from response: {body!r}", ) raise MemcacheError("ElastiCache discovery: no nodes parsed") @@ -134,8 +138,6 @@ def config_get_cluster(self) -> list[tuple[str, int]]: return self._parse_config_get_cluster_response(response) - close = _close_client - P = ParamSpec("P") R = TypeVar("R") 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/test_configuration_endpoint_client.py b/tests/test_configuration_endpoint_client.py index 52878a1..b2a57f8 100644 --- a/tests/test_configuration_endpoint_client.py +++ b/tests/test_configuration_endpoint_client.py @@ -1,58 +1,89 @@ import pytest from pymemcache.exceptions import MemcacheError -from pytest import MonkeyPatch 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.82.235.120|11211 " - b"test.0002.use1.cache.amazonaws.com|10.80.249.27|11211\n\r\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 = True) -> _ConfigurationEndpointClient: +def _client( + use_vpc_ip: bool, + socket_module: FakeSocketModule, +) -> _ConfigurationEndpointClient: return _ConfigurationEndpointClient( - configuration_endpoint="config.example:11211", - default_kwargs={}, + 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_parse_ok_with_vpc_ip(monkeypatch: MonkeyPatch) -> None: - client = _client(use_vpc_ip=True) - monkeypatch.setattr(client, "_raw_config_get_cluster", lambda _client: EXAMPLE_RESPONSE) +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.82.235.120", 11211), ("10.80.249.27", 11211)] + + 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_parse_ok_with_hostnames(monkeypatch: MonkeyPatch) -> None: - client = _client(use_vpc_ip=False) - monkeypatch.setattr(client, "_raw_config_get_cluster", lambda _client: EXAMPLE_RESPONSE) +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"", - b"CONFIG cluster 0 1\r\nX\r\n", - b"CONFIG cluster 0 1\r\n\n\r\nEND\r\n", - b"CONFIG cluster 0 1\r\nbad|format\r\nEND\r\n", + 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(monkeypatch: MonkeyPatch, payload: bytes) -> None: - client = _client() - monkeypatch.setattr(client, "_raw_config_get_cluster", lambda _client: payload) +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() From 3240df34dba0d56bf53c3b74247039d3d94ba892 Mon Sep 17 00:00:00 2001 From: Kosei Kitahara Date: Sun, 2 Nov 2025 22:15:42 +0900 Subject: [PATCH 10/14] Use uv --- .github/dependabot.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 From 55365738c67b8af3615519a55a6dc36c3f923cb3 Mon Sep 17 00:00:00 2001 From: Kosei Kitahara Date: Sun, 2 Nov 2025 22:35:46 +0900 Subject: [PATCH 11/14] Add comments --- django_elastipymemcache/client.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/django_elastipymemcache/client.py b/django_elastipymemcache/client.py index b6f60b7..12caf16 100644 --- a/django_elastipymemcache/client.py +++ b/django_elastipymemcache/client.py @@ -193,7 +193,7 @@ def __init__( ) super().__init__( - servers=[], + servers=[], # Discovery after initialization use_pooling=use_pooling, retry_attempts=retry_attempts, **kwargs, @@ -298,7 +298,10 @@ def _close_configuration_endpoint_client(self) -> None: try: self._configuration_endpoint_client.close() except Exception: - logger.warning("Exception occurred while closing configuration endpoint client", exc_info=True) + logger.warning( + "Exception occurred while closing configuration endpoint client", + exc_info=True, + ) def close(self) -> None: self._close_clients() From ea2b1adaa1d0380a31c804d1783d25aaed2c05d9 Mon Sep 17 00:00:00 2001 From: Kosei Kitahara Date: Mon, 3 Nov 2025 00:05:32 +0900 Subject: [PATCH 12/14] Add GitHub Actions to publish --- .github/workflows/publish.yml | 32 ++++++++++++++++++++++++++++++++ tests/smoke_test.py | 17 +++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 .github/workflows/publish.yml create mode 100644 tests/smoke_test.py 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/tests/smoke_test.py b/tests/smoke_test.py new file mode 100644 index 0000000..583e513 --- /dev/null +++ b/tests/smoke_test.py @@ -0,0 +1,17 @@ +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_basic() -> None: + from django_elastipymemcache.client import _ConfigurationEndpointClient + + client = _ConfigurationEndpointClient("localhost:11211") + assert client.configuration_endpoint == "localhost:11211" From b24813db14619861cf345a8b2d8e67a2a2062eb1 Mon Sep 17 00:00:00 2001 From: Kosei Kitahara Date: Mon, 3 Nov 2025 00:17:38 +0900 Subject: [PATCH 13/14] Limit branches --- .github/workflows/test.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index deaafb2..a9a08d0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,7 +3,11 @@ name: Test "on": push: + branches: + - main pull_request: + branches: + - main jobs: lint: From 049e2f244399a8e97d843c29c3a493f08f21369b Mon Sep 17 00:00:00 2001 From: Kosei Kitahara Date: Mon, 3 Nov 2025 00:50:30 +0900 Subject: [PATCH 14/14] Add client import tests --- tests/smoke_test.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/smoke_test.py b/tests/smoke_test.py index 583e513..c1c5202 100644 --- a/tests/smoke_test.py +++ b/tests/smoke_test.py @@ -10,6 +10,16 @@ def test_backend_importable() -> None: 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