├── .flake8 ├── .github └── workflows │ ├── publish.yml │ └── tests.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── codecov.yml ├── django_dynamodb_cache ├── __init__.py ├── backend.py ├── cache.py ├── dynamodb.py ├── encode │ ├── __init__.py │ ├── base.py │ └── pickle.py ├── exceptions.py ├── helper.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── createcachetable.py └── settings.py ├── docker-compose.yml ├── manage.py ├── pyproject.toml ├── scripts ├── lint-test.sh └── publish.sh ├── tests ├── __init__.py ├── conf.py ├── conftest.py ├── settings │ ├── __init__.py │ └── settings.py ├── test_cache_simple.py ├── test_create_table.py ├── test_django.py ├── test_settings.py └── urls.py └── tox.ini /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length=120 3 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: 6 | - created 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Dump GitHub context 13 | env: 14 | GITHUB_CONTEXT: ${{ toJson(github) }} 15 | run: echo "$GITHUB_CONTEXT" 16 | - uses: actions/checkout@v2 17 | - name: Set up Python 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: "3.8" 21 | - uses: actions/cache@v2 22 | id: cache 23 | with: 24 | path: ${{ env.pythonLocation }} 25 | key: ${{ runner.os }}-python-${{ env.pythonLocation }}-${{ hashFiles('pyproject.toml') }}-publish 26 | - name: Install poetry 27 | if: steps.cache.outputs.cache-hit != 'true' 28 | run: pip install poetry 29 | - name: Install Dependencies 30 | if: steps.cache.outputs.cache-hit != 'true' 31 | run: poetry install 32 | - name: Publish 33 | env: 34 | POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_TOKEN }} 35 | run: bash scripts/publish.sh 36 | - name: Dump GitHub context 37 | env: 38 | GITHUB_CONTEXT: ${{ toJson(github) }} 39 | run: echo "$GITHUB_CONTEXT" 40 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | types: [opened, synchronize] 8 | jobs: 9 | lint-test: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v1 13 | - name: Set up Python 3.8 14 | uses: actions/setup-python@v2 15 | with: 16 | python-version: 3.8 17 | - name: Install Poetry 18 | run: | 19 | python -m pip install poetry 20 | - name: Install dependencies 21 | run: | 22 | poetry install 23 | - name: Publish 24 | run: bash scripts/lint-test.sh 25 | test: 26 | runs-on: ubuntu-latest 27 | strategy: 28 | max-parallel: 8 29 | matrix: 30 | python-version: [3.8, 3.9, "3.10", "3.11", "3.12"] 31 | 32 | name: ${{ matrix.experimental && 'Django master [ok to fail]' || format('Python {0}', matrix.python-version) }} 33 | steps: 34 | - name: Setup DynamoDB Local 35 | uses: rrainn/dynamodb-action@v2.0.1 36 | with: 37 | port: 8000 38 | cors: '*' 39 | - uses: actions/checkout@v2 40 | - name: Setup python 41 | uses: actions/setup-python@v2 42 | with: 43 | python-version: ${{ matrix.python-version }} 44 | architecture: x64 45 | 46 | - name: "Install Dependencies" 47 | run: pip install tox tox-gh-actions 48 | 49 | - name: "Run tests" 50 | run: | 51 | tox ${{ matrix.experimental }} 52 | env: 53 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 54 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 55 | AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION_NAME }} 56 | - uses: codecov/codecov-action@v2 57 | with: 58 | token: ${{ secrets.CODECOV_TOKEN }} 59 | files: coverage.xml 60 | fail_ci_if_error: true # optional (default = false) 61 | verbose: true # optional (default = false) 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | cov.xml 50 | *.cover 51 | *.py,cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | .python-version 87 | 88 | # pipenv 89 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 90 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 91 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 92 | # install all needed dependencies. 93 | #Pipfile.lock 94 | 95 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 96 | __pypackages__/ 97 | 98 | # Celery stuff 99 | celerybeat-schedule 100 | celerybeat.pid 101 | 102 | # SageMath parsed files 103 | *.sage.py 104 | 105 | # Environments 106 | .env 107 | .venv 108 | env/ 109 | venv/ 110 | ENV/ 111 | env.bak/ 112 | venv.bak/ 113 | 114 | # Spyder project settings 115 | .spyderproject 116 | .spyproject 117 | 118 | # Rope project settings 119 | .ropeproject 120 | 121 | # mkdocs documentation 122 | /site 123 | 124 | # mypy 125 | .mypy_cache/ 126 | .dmypy.json 127 | dmypy.json 128 | 129 | # Pyre type checker 130 | .pyre/ 131 | .DS_Store 132 | .vscode/ 133 | poetry.lock 134 | 135 | # docker 136 | docker/ 137 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.4.0 4 | hooks: 5 | - id: check-yaml 6 | args: [--allow-multiple-documents] 7 | - id: end-of-file-fixer 8 | - id: trailing-whitespace 9 | - repo: https://github.com/charliermarsh/ruff-pre-commit 10 | rev: 'v0.0.253' 11 | hooks: 12 | - id: ruff 13 | args: [ --fix, --exit-non-zero-on-fix ] 14 | - repo: https://github.com/psf/black 15 | rev: 23.3.0 16 | hooks: 17 | - id: black 18 | default_language_version: 19 | python: python3.10 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Joon Hwan 김준환 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-dynamodb-cache 2 | 3 | Fast, safe, cost-effective DynamoDB cache backend for Django 4 | 5 |

6 | 7 | Tests 8 | 9 | 10 | Coverage 11 | 12 | 13 | Package version 14 | 15 | 16 | Supported Python versions 17 | 18 | 19 | Supported django versions 20 | 21 | 22 | License 23 | 24 |

25 | 26 | - [django-dynamodb-cache](#django-dynamodb-cache) 27 | - [Introduce](#introduce) 28 | - [Why should I use this?](#why-should-i-use-this) 29 | - [Installation](#installation) 30 | - [Setup on Django](#setup-on-django) 31 | - [Aws credentials](#aws-credentials) 32 | - [Create cache table command](#create-cache-table-command) 33 | - [Future improvements](#future-improvements) 34 | - [How to contribute](#how-to-contribute) 35 | - [Debug](#debug) 36 | 37 | ## Introduce 38 | 39 | This project is a cache backend using aws dynamodb. 40 | 41 | This is compatible with the django official cache framework. 42 | 43 | Did you set the boto3 permission? 44 | 45 | Enter the django official command createcachetable and get started easily. 46 | 47 | ## Why should I use this? 48 | 49 | - There are few management points, because dynamodb is a fully managed service. 50 | - Because you only pay for what you use, it saves money on light projects such as side projects or back offices. 51 | - If you need more performance, you can easily switch to DAX. 52 | 53 | ## Installation 54 | 55 | ```sh 56 | pip install django-dynamodb-cache 57 | ``` 58 | 59 | ## Setup on Django 60 | 61 | On Django `settings.py` 62 | 63 | ```python 64 | 65 | 66 | INSTALLED_APPS = [ 67 | ... 68 | "django_dynamodb_cache" 69 | ] 70 | 71 | CACHES = { 72 | "default": { 73 | "BACKEND": "django_dynamodb_cache.backend.DjangoCacheBackend", 74 | "LOCATION": "table-name", # (mandatory) 75 | "TIMEOUT": 300, # (optional) seconds 76 | "KEY_PREFIX": "django_dynamodb_cache", # (optional) 77 | "VERSION": 1, # (optional) 78 | "KEY_FUNCTION": "path.to.function", # (optional) f"{prefix}:{key}:{version}" 79 | "OPTIONS": { 80 | "aws_region_name": "us-east-1", # (optional) 81 | "aws_access_key_id": "aws_access_key_id", # (optional) 82 | "aws_secret_access_key": "aws_secret_access_key", # (optional) 83 | "is_on_demand": False, # (optional) default: True 84 | "read_capacity_units": 1, # (optional) 85 | "write_capacity_units": 1, # (optional) 86 | "encode": "django_dynamodb_cache.encode.PickleEncode" # (optional) 87 | } 88 | } 89 | } 90 | ``` 91 | 92 | ## Aws credentials 93 | 94 | The same method as configuring-credentials provided in the boto3 documentation is used. 95 | 96 | 97 | ## Create cache table command 98 | 99 | Run manage command to create cache table on Dynamodb before using 100 | 101 | ```zsh 102 | python manage.py createcachetable 103 | ``` 104 | 105 | ## Future improvements 106 | 107 | In this project, the following can be improved in the future. 108 | 109 | - A full scan is included to achieve `cache.clear()`. 110 | This can lead to performance degradation when there is a lot of cached data. 111 | 112 | 113 | ## How to contribute 114 | 115 | This project is welcome to contributions! 116 | 117 | Please submit an issue ticket before submitting a patch. 118 | 119 | Pull requests are merged into the main branch and should always remain available. 120 | 121 | After passing all test code, it is reviewed and merged. 122 | 123 | ### Debug 124 | 125 | Tests must be run in a sandbox environment. 126 | 127 | To run the Dynamodb sandbox: 128 | ``` 129 | docker compose up --build 130 | ``` 131 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | require_ci_to_pass: yes 3 | 4 | coverage: 5 | status: 6 | project: 7 | target: auto 8 | threshold: 1% 9 | patch: 10 | default: 11 | target: 0% # new contributions should have a coverage at least equal to target 12 | -------------------------------------------------------------------------------- /django_dynamodb_cache/__init__.py: -------------------------------------------------------------------------------- 1 | from .cache import Cache # noqa 2 | from .settings import Settings # noqa 3 | -------------------------------------------------------------------------------- /django_dynamodb_cache/backend.py: -------------------------------------------------------------------------------- 1 | from .cache import Cache 2 | from .settings import Settings 3 | 4 | 5 | class DjangoCacheBackend(Cache): 6 | def __init__(self, location, params): 7 | table_name = location 8 | timeout = params.get("TIMEOUT", None) 9 | key_prefix = params.get("KEY_PREFIX", None) 10 | version = params.get("VERSION", None) 11 | key_function = params.get("KEY_FUNCTION", None) 12 | options = params.get("OPTIONS", {}) 13 | settings = Settings( 14 | table_name=table_name, 15 | timeout=timeout, 16 | key_prefix=key_prefix, 17 | version=version, 18 | key_function=key_function, 19 | **options, 20 | ) 21 | self._table = settings.table_name 22 | super().__init__(settings) 23 | -------------------------------------------------------------------------------- /django_dynamodb_cache/cache.py: -------------------------------------------------------------------------------- 1 | import time 2 | import warnings 3 | from decimal import Decimal 4 | 5 | from django.core.cache.backends.base import BaseCache 6 | 7 | from .dynamodb import get_dynamodb, get_table 8 | from .exceptions import CacheKeyWarning 9 | from .helper import logger 10 | from .settings import MEMCACHE_MAX_KEY_LENGTH 11 | 12 | _NOT_SET = object() 13 | 14 | 15 | class Cache(BaseCache): 16 | def __init__(self, settings): 17 | self.version = settings.version 18 | self.key_func = settings.key_func 19 | self.key_prefix = settings.key_prefix 20 | self.timeout = settings.timeout 21 | 22 | self.dynamodb = get_dynamodb(settings) 23 | self.table = get_table(settings, self.dynamodb) 24 | 25 | self.encode = settings.module("encode") 26 | 27 | self.settings = settings 28 | 29 | def make_expiration(self, timeout): 30 | if timeout is None: 31 | return None 32 | elif timeout == _NOT_SET: 33 | timeout = self.timeout 34 | else: 35 | timeout = timeout 36 | timeout_d = Decimal(timeout) 37 | now = Decimal(time.time()) 38 | return now + timeout_d 39 | 40 | def make_key(self, key, version=None): 41 | """ 42 | Construct the key used by all other methods. By default, use the 43 | key_func to generate a key (which, by default, prepends the 44 | `key_prefix' and 'version'). A different key function can be provided 45 | at the time of cache construction; alternatively, you can subclass the 46 | cache backend to provide custom key making behavior. 47 | """ 48 | if version is None: 49 | version = self.version 50 | if key.split(":")[-1] == str(version): 51 | new_key = key 52 | else: 53 | new_key = self.key_func(self.key_prefix, key, version) 54 | return new_key 55 | 56 | def make_item(self, key, expiration, value): 57 | return { 58 | self.settings.key_column: key, 59 | self.settings.expiration_column: expiration, 60 | self.settings.content_column: value, 61 | } 62 | 63 | def add(self, key, value, timeout=_NOT_SET, version=None): 64 | if self.has_key(key, version): # noqa: W601 65 | return False 66 | self.set(key, value, timeout, version) 67 | logger.debug('Add "%s" on dynamodb "%s" table', key, self.table.table_name) 68 | return True 69 | 70 | def get(self, key, default=None, version=None): 71 | key = self.make_key(key, version) 72 | 73 | response = self.table.get_item(Key={self.settings.key_column: key}) 74 | 75 | if "Item" not in response: 76 | logger.debug( 77 | 'Get EMPTY value for "%s" on dynamodb "%s" table', 78 | key, 79 | self.table.table_name, 80 | ) 81 | return default 82 | 83 | item = response["Item"] 84 | if item[self.settings.expiration_column] is not None and item[ 85 | self.settings.expiration_column 86 | ] < self.make_expiration(1): 87 | logger.debug( 88 | 'Get EXPIRED value for "%s" on dynamodb "%s" table', 89 | key, 90 | self.table.table_name, 91 | ) 92 | return default 93 | 94 | value = item[self.settings.content_column] 95 | logger.debug('Get for "%s" on dynamodb "%s" table', key, self.table.table_name) 96 | value = self.encode.loads(value.value) 97 | return value 98 | 99 | def set(self, key, value, timeout=_NOT_SET, version=None, batch=None): 100 | key = self.make_key(key, version=version) 101 | self.validate_key(key) 102 | 103 | expiration = self.make_expiration(timeout) 104 | value = self.encode.dumps(value) 105 | 106 | table = batch or self.table 107 | 108 | if not self.has_key(key, version): # noqa: W601 109 | response = table.put_item(Item=self.make_item(key, expiration, value)) 110 | else: 111 | response = table.update_item( # noqa 112 | Key={self.settings.key_column: key}, 113 | UpdateExpression=f"SET {self.settings.expiration_column} = :ex, {self.settings.content_column} = :vl", 114 | ExpressionAttributeValues={":ex": expiration, ":vl": value}, 115 | ) 116 | 117 | logger.debug( 118 | 'Set "%s" with "%s" timout on dynamodb "%s" table', 119 | key, 120 | timeout, 121 | self.table.table_name, 122 | ) 123 | 124 | def touch(self, key, timeout=_NOT_SET, version=None): 125 | key = self.make_key(key, version=version) 126 | self.validate_key(key) 127 | expiration = self.make_expiration(timeout) 128 | item = self.table.update_item( 129 | Key={self.settings.key_column: key}, 130 | UpdateExpression=f"SET {self.settings.expiration_column} = :ex", 131 | ExpressionAttributeValues={":ex": expiration}, 132 | ) 133 | logger.debug( 134 | 'Reset expiration "%s" to %s on dynamodb "%s" table', 135 | key, 136 | timeout, 137 | self.table.table_name, 138 | ) 139 | return item 140 | 141 | def delete(self, key, version=None, batch=None): 142 | key = self.make_key(key, version=version) 143 | self.validate_key(key) 144 | 145 | table = batch or self.table 146 | 147 | table.delete_item(Key={self.settings.key_column: key}) 148 | logger.debug("Delete %s on dynamodb %s table", key, self.settings.table_name) 149 | 150 | def delete_many(self, keys, version=None): 151 | """ 152 | Delete a bunch of values in the cache at once. 153 | """ 154 | 155 | with self.table.batch_writer() as batch: 156 | for key in keys: 157 | self.delete(key, version=version, batch=batch) 158 | 159 | def set_many(self, data, timeout=_NOT_SET, version=None): 160 | """ 161 | Set a bunch of values in the cache at once from a dict of key/value 162 | pairs. 163 | 164 | If timeout is given, use that timeout for the key; otherwise use the 165 | default cache timeout. 166 | 167 | On backends that support it, return a list of keys that failed 168 | insertion, or an empty list if all keys were inserted successfully. 169 | """ 170 | 171 | with self.table.batch_writer() as batch: 172 | for key, value in data.items(): 173 | self.set(key, value, timeout=timeout, version=version, batch=batch) 174 | return [] 175 | 176 | def _extract_version(self, key): 177 | """ 178 | Extract the version number from the cache key. 179 | """ 180 | try: 181 | return int(key.split(":")[-1]) 182 | except (IndexError, ValueError): 183 | return None 184 | 185 | def has_key(self, key, version=None): 186 | version = self._extract_version(key) 187 | 188 | key = self.make_key(key, version) 189 | 190 | response = self.table.get_item( 191 | Key={self.settings.key_column: key}, 192 | ReturnConsumedCapacity="NONE", 193 | # ProjectionExpression=self.settings.key_column, 194 | ) 195 | return "Item" in response 196 | 197 | def clear(self): 198 | scan = self.table.scan(ProjectionExpression=self.settings.key_column) 199 | 200 | with self.table.batch_writer() as batch: 201 | for each in scan["Items"]: 202 | batch.delete_item(Key=each) 203 | return True 204 | 205 | def get_many(self, keys, version=None): 206 | """ 207 | Fetch a bunch of keys from the cache. For certain backends (memcached, 208 | pgsql) this can be *much* faster when fetching multiple values. 209 | 210 | Return a dict mapping each key in keys to its value. If the given 211 | key is missing, it will be missing from the response dict. 212 | """ 213 | d = {} 214 | for k in keys: 215 | val = self.get(k, version=version) 216 | if val is not None: 217 | d[k] = val 218 | return d 219 | 220 | # region copy from django 221 | 222 | def get_or_set(self, key, default, timeout=_NOT_SET, version=None): 223 | """ 224 | Fetch a given key from the cache. If the key does not exist, 225 | add the key and set it to the default value. The default value can 226 | also be any callable. If timeout is given, use that timeout for the 227 | key; otherwise use the default cache timeout. 228 | 229 | Return the value of the key stored or retrieved. 230 | """ 231 | val = self.get(key, version=version) 232 | if val is None: 233 | if callable(default): 234 | default = default() 235 | if default is not None: 236 | self.add(key, default, timeout=timeout, version=version) 237 | # Fetch the value again to avoid a race condition if another 238 | # caller added a value between the first get() and the add() 239 | # above. 240 | return self.get(key, default, version=version) 241 | return val 242 | 243 | def incr(self, key, delta=1, version=None): 244 | """ 245 | Add delta to value in the cache. If the key does not exist, raise a 246 | ValueError exception. 247 | """ 248 | value = self.get(key, version=version) 249 | if value is None: 250 | raise ValueError("Key '%s' not found" % key) 251 | new_value = value + delta 252 | self.set(key, new_value, version=version) 253 | return new_value 254 | 255 | def decr(self, key, delta=1, version=None): 256 | """ 257 | Subtract delta from value in the cache. If the key does not exist, raise 258 | a ValueError exception. 259 | """ 260 | return self.incr(key, -delta, version=version) 261 | 262 | def __contains__(self, key): 263 | """ 264 | Return True if the key is in the cache and has not expired. 265 | """ 266 | # This is a separate method, rather than just a copy of has_key(), 267 | # so that it always has the same functionality as has_key(), even 268 | # if a subclass overrides it. 269 | return self.has_key(key) # noqa: W601 270 | 271 | def validate_key(self, key): 272 | """ 273 | Warn about keys that would not be portable to the memcached 274 | backend. This encourages (but does not force) writing backend-portable 275 | cache code. 276 | """ 277 | if len(key) > MEMCACHE_MAX_KEY_LENGTH: 278 | warnings.warn( 279 | "Cache key will cause errors if used with memcached: %r " 280 | "(longer than %s)" % (key, MEMCACHE_MAX_KEY_LENGTH), 281 | CacheKeyWarning, 282 | ) 283 | for char in key: 284 | if ord(char) < 33 or ord(char) == 127: 285 | warnings.warn( 286 | "Cache key contains characters that will cause errors if " "used with memcached: %r" % key, 287 | CacheKeyWarning, 288 | ) 289 | break 290 | 291 | def incr_version(self, key, delta=1, version=None): 292 | """ 293 | Add delta to the cache version for the supplied key. Return the new version. 294 | """ 295 | if version is None: 296 | version = self.version 297 | 298 | value = self.get(key, version=version) 299 | if value is None: 300 | raise ValueError("Key '%s' not found" % key) 301 | 302 | self.set(key, value, version=version + delta) 303 | self.delete(key, version=version) 304 | return version + delta 305 | 306 | def decr_version(self, key, delta=1, version=None): 307 | """ 308 | Subtract delta from the cache version for the supplied key. Return the new version. 309 | """ 310 | return self.incr_version(key, -delta, version) 311 | 312 | def close(self, **kwargs): 313 | """Close the cache connection""" 314 | logger.info("Close connection with %s table", self.table.table_name) 315 | -------------------------------------------------------------------------------- /django_dynamodb_cache/dynamodb.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import boto3 4 | from botocore.exceptions import ClientError 5 | 6 | from .helper import logger 7 | 8 | 9 | def get_dynamodb(settings): 10 | session = boto3.session.Session( 11 | aws_access_key_id=settings.aws_access_key_id, 12 | aws_secret_access_key=settings.aws_secret_access_key, 13 | ) 14 | region = settings.aws_region_name or session.region_name 15 | 16 | if "pytest" in sys.modules: 17 | dynamodb = session.resource("dynamodb", region_name=region, endpoint_url="http://localhost:8000") 18 | else: 19 | dynamodb = session.resource("dynamodb", region_name=region) # pragma: no cover 20 | return dynamodb 21 | 22 | 23 | def get_table(settings, dynamodb): 24 | return dynamodb.Table(settings.table_name) 25 | 26 | 27 | def create_table(settings, dynamodb): 28 | table = None 29 | exists = False 30 | try: 31 | kwargs = { 32 | "TableName": settings.table_name, 33 | "KeySchema": [ 34 | { 35 | "AttributeName": settings.key_column, 36 | "KeyType": "HASH", # Partition key 37 | } 38 | ], 39 | "AttributeDefinitions": [{"AttributeName": settings.key_column, "AttributeType": "S"}], 40 | "BillingMode": "PAY_PER_REQUEST" if settings.is_on_demand else "PROVISIONED", 41 | } 42 | if not settings.is_on_demand: 43 | kwargs["ProvisionedThroughput"] = { 44 | "ReadCapacityUnits": settings.read_capacity_units, 45 | "WriteCapacityUnits": settings.write_capacity_units, 46 | } 47 | table = dynamodb.create_table(**kwargs) 48 | except ClientError as e: 49 | if e.response["Error"]["Code"] == "LimitExceededException": 50 | logger.warn("API call limit exceeded; backing off and retrying...") 51 | raise e 52 | elif e.response["Error"]["Code"] == "ResourceInUseException": 53 | logger.info("Table %s already exists", settings.table_name) 54 | exists = True 55 | else: 56 | raise e 57 | 58 | if not table: 59 | table = dynamodb.Table(settings.table_name) 60 | 61 | logger.info("Waiting %s status: %s", table.table_name, table.table_status) 62 | table.meta.client.get_waiter("table_exists").wait(TableName=settings.table_name) 63 | table = dynamodb.Table(settings.table_name) 64 | 65 | if not exists: 66 | create_ttl(table, settings) 67 | 68 | logger.info("Table %s status: %s", table.table_name, table.table_status) 69 | 70 | return table 71 | 72 | 73 | def create_ttl(table, settings): 74 | try: 75 | response = table.meta.client.update_time_to_live( # noqa 76 | TableName=settings.table_name, 77 | TimeToLiveSpecification={ 78 | "Enabled": True, 79 | "AttributeName": settings.expiration_column, 80 | }, 81 | ) 82 | return True 83 | except Exception as e: 84 | logger.exception("Error on TTL creation", exc_info=e) 85 | return False 86 | -------------------------------------------------------------------------------- /django_dynamodb_cache/encode/__init__.py: -------------------------------------------------------------------------------- 1 | from .pickle import PickleEncode # noqa 2 | -------------------------------------------------------------------------------- /django_dynamodb_cache/encode/base.py: -------------------------------------------------------------------------------- 1 | class BaseEncode: 2 | @classmethod 3 | def dumps(cls, value): 4 | raise NotImplementedError 5 | 6 | @classmethod 7 | def loads(cls, value): 8 | raise NotImplementedError 9 | -------------------------------------------------------------------------------- /django_dynamodb_cache/encode/pickle.py: -------------------------------------------------------------------------------- 1 | import pickle 2 | 3 | from django_dynamodb_cache.encode.base import BaseEncode 4 | 5 | 6 | class PickleEncode(BaseEncode): 7 | PROTOCOL = pickle.HIGHEST_PROTOCOL 8 | 9 | @classmethod 10 | def dumps(cls, value): 11 | return pickle.dumps(value, cls.PROTOCOL) 12 | 13 | @classmethod 14 | def loads(cls, value): 15 | return pickle.loads(value) 16 | -------------------------------------------------------------------------------- /django_dynamodb_cache/exceptions.py: -------------------------------------------------------------------------------- 1 | class CacheKeyWarning(RuntimeWarning): 2 | pass 3 | -------------------------------------------------------------------------------- /django_dynamodb_cache/helper.py: -------------------------------------------------------------------------------- 1 | from importlib import import_module 2 | from logging import getLogger 3 | 4 | try: 5 | import django 6 | except ImportError: 7 | django = None 8 | 9 | try: 10 | import werkzeug 11 | except ImportError: 12 | werkzeug = None # type: ignore 13 | 14 | logger = getLogger("django_dynamodb_cache") 15 | 16 | 17 | def import_string(dotted_path): 18 | """ 19 | Import a dotted module path and return the attribute/class designated by the 20 | last name in the path. Raise ImportError if the import failed. 21 | 22 | Copy from django 23 | """ 24 | 25 | if "." not in dotted_path: 26 | return import_module(dotted_path) 27 | 28 | module_path, class_name = dotted_path.rsplit(".", 1) 29 | module = import_module(module_path) 30 | 31 | try: 32 | return getattr(module, class_name) 33 | except AttributeError as err: 34 | raise ImportError('Module "%s" does not define a "%s" attribute/class' % (module_path, class_name)) from err 35 | -------------------------------------------------------------------------------- /django_dynamodb_cache/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xncbf/django-dynamodb-cache/354e6e754abd5d058ec632ee2039fc7fa0a496da/django_dynamodb_cache/management/__init__.py -------------------------------------------------------------------------------- /django_dynamodb_cache/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xncbf/django-dynamodb-cache/354e6e754abd5d058ec632ee2039fc7fa0a496da/django_dynamodb_cache/management/commands/__init__.py -------------------------------------------------------------------------------- /django_dynamodb_cache/management/commands/createcachetable.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from django.conf import settings 4 | from django.core.cache import caches 5 | from django.core.management.base import BaseCommand 6 | 7 | from django_dynamodb_cache.backend import DjangoCacheBackend 8 | from django_dynamodb_cache.dynamodb import create_table 9 | 10 | 11 | class Command(BaseCommand): 12 | help = "Creates the tables needed to use the DynamoDB cache backend." 13 | 14 | requires_system_checks: List = [] 15 | 16 | def add_arguments(self, parser): 17 | parser.add_argument( 18 | "--database", 19 | default=None, 20 | help='Not used in this command (see settings.CACHES["options"] to change the table name)', 21 | ) 22 | 23 | def handle(self, *tablenames, **options): 24 | for cache_alias in settings.CACHES: 25 | cache = caches[cache_alias] 26 | if isinstance(cache, DjangoCacheBackend): 27 | self.create_table(cache_alias, cache._table) 28 | 29 | def create_table(self, cache_alias, tablename): 30 | from django_dynamodb_cache.backend import DjangoCacheBackend 31 | 32 | backend = DjangoCacheBackend(tablename, settings.CACHES[cache_alias]) 33 | _settings = backend.settings 34 | dynamodb = backend.dynamodb 35 | # dynamodb = get_dynamodb(settings) 36 | table = create_table(_settings, dynamodb) 37 | self.stdout.write(self.style.SUCCESS(f"Cache table {table.table_arn} created for cache {cache_alias}")) 38 | -------------------------------------------------------------------------------- /django_dynamodb_cache/settings.py: -------------------------------------------------------------------------------- 1 | from django.core.cache.backends.base import default_key_func 2 | 3 | from .helper import import_string 4 | 5 | MEMCACHE_MAX_KEY_LENGTH = 250 6 | 7 | 8 | class Settings(object): 9 | def __init__(self, **kwargs): 10 | # defaults 11 | self.encode = "django_dynamodb_cache.encode.PickleEncode" 12 | self.timeout = 300 13 | 14 | self.table_name = "django_dynamodb_cache" 15 | self.version = 1 16 | self.key_prefix = "django_cache" 17 | self.key_func = lambda p, k, v: f"{p}:{k}:{v}" 18 | 19 | self.key_column = "cache_key" 20 | self.expiration_column = "expiration" 21 | self.content_column = "content" 22 | 23 | self.aws_access_key_id = None 24 | self.aws_secret_access_key = None 25 | self.aws_region_name = None 26 | 27 | self.is_on_demand = True 28 | self.read_capacity_units = 1 29 | self.write_capacity_units = 1 30 | 31 | for key, value in kwargs.items(): 32 | if value is not None: 33 | setattr(self, key, value) 34 | 35 | if hasattr(self, "key_function"): 36 | self.key_func = self.get_key_func(self.key_function) 37 | 38 | def get_key_func(self, key_func): 39 | """ 40 | Function to decide which key function to use. 41 | Default to ``default_key_func``. 42 | """ 43 | if key_func is not None: 44 | if callable(key_func): 45 | return key_func 46 | else: 47 | return import_string(key_func) 48 | return default_key_func 49 | 50 | def get(self, key): 51 | value = getattr(self, key) 52 | if not value: 53 | raise AttributeError("Key %s not exists in settings", key) 54 | 55 | return value 56 | 57 | def module(self, key, *args, **kwargs): 58 | path = self.get(key) 59 | return import_string(path) 60 | 61 | def instance(self, key, *args, **kwargs): 62 | path = self.get(key) 63 | return import_string(path)(*args, **kwargs) 64 | 65 | def __getitem__(self, key): 66 | return self.get(key) 67 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | services: 3 | dynamodb-local: 4 | command: "-jar DynamoDBLocal.jar -sharedDb -dbPath ./data" 5 | image: "amazon/dynamodb-local:latest" 6 | container_name: dynamodb-local 7 | ports: 8 | - "8000:8000" 9 | volumes: 10 | - "./docker/dynamodb:/home/dynamodblocal/data" 11 | working_dir: /home/dynamodblocal 12 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": # pragma: no cover 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings.settings") 7 | try: 8 | from django.core.management import execute_from_command_line 9 | except ImportError as exc: 10 | raise ImportError( 11 | "Couldn't import Django. Are you sure it's installed and " 12 | "available on your PYTHONPATH environment variable? Did you " 13 | "forget to activate a virtual environment?" 14 | ) from exc 15 | execute_from_command_line(sys.argv) 16 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "django-dynamodb-cache" 3 | version = "0.6.0" 4 | description = "" 5 | authors = ["xncbf "] 6 | keywords = ["django", "dynamodb", "cache", "django cache backend"] 7 | classifiers = [ 8 | "Framework :: Django :: 3.2", 9 | "Framework :: Django :: 4.2", 10 | "Framework :: Django :: 5.0", 11 | "Programming Language :: Python :: 3.8", 12 | "Programming Language :: Python :: 3.9", 13 | "Programming Language :: Python :: 3.10", 14 | "Programming Language :: Python :: 3.11", 15 | "Programming Language :: Python :: 3.12", 16 | ] 17 | homepage = "https://github.com/xncbf/django-dynamodb-cache" 18 | repository = "https://github.com/xncbf/django-dynamodb-cache" 19 | license = "MIT" 20 | readme = "README.md" 21 | 22 | [tool.poetry.dependencies] 23 | python = "^3.8" 24 | Django = ">=3.2,<6" 25 | boto3 = "^1.21.9" 26 | botocore = "^1.24.9" 27 | 28 | 29 | [tool.poetry.dev-dependencies] 30 | pytest-cov = "^3.0.0" 31 | pytest = "^6.2.5" 32 | black = "21.11b1" 33 | mypy = "^0.931" 34 | Faker = "^11.3.0" 35 | factory-boy = "^3.2.1" 36 | moto = {extras = ["all"], version = "^3.1.1"} 37 | tox = "^3.24.5" 38 | ruff = "0.0.285" 39 | 40 | [tool.black] 41 | line-length = 120 42 | target-version = ['py38', 'py39', 'py310', 'py311', 'py312'] 43 | include = '\.pyi?$' 44 | extend-exclude = ''' 45 | # A regex preceded with ^/ will apply only to files and directories 46 | # in the root of the project. 47 | ^/foo.py # exclude a file named foo.py in the root of the project (in addition to the defaults) 48 | ''' 49 | 50 | [tool.mypy] 51 | python_version = '3.10' 52 | ignore_missing_imports = 'True' 53 | 54 | # https://docs.pytest.org/en/6.2.x/reference.html 55 | [tool.pytest.ini_options] 56 | minversion = "6.2.5" 57 | log_cli = "true" 58 | log_level = "INFO" 59 | 60 | [build-system] 61 | build-backend = "poetry.core.masonry.api" 62 | requires = [ 63 | "poetry-core>=1.0.0", 64 | ] 65 | 66 | 67 | [tool.ruff] 68 | # Enable pycodestyle (`E`) and Pyflakes (`F`) codes by default. 69 | select = ["E", "F", "I001"] 70 | ignore = [] 71 | 72 | # Allow autofix for all enabled rules (when `--fix`) is provided. 73 | fixable = ["A", "B", "C", "D", "E", "F", "I001"] 74 | unfixable = [] 75 | 76 | # Exclude a variety of commonly ignored directories. 77 | exclude = [ 78 | ".bzr", 79 | ".direnv", 80 | ".eggs", 81 | ".git", 82 | ".hg", 83 | ".mypy_cache", 84 | ".nox", 85 | ".pants.d", 86 | ".pytype", 87 | ".ruff_cache", 88 | ".svn", 89 | ".tox", 90 | ".venv", 91 | "__pypackages__", 92 | "_build", 93 | "buck-out", 94 | "build", 95 | "dist", 96 | "node_modules", 97 | "venv", 98 | "backlog", 99 | ] 100 | per-file-ignores = {} 101 | 102 | # Same as Black. 103 | line-length = 120 104 | 105 | # Allow unused variables when underscore-prefixed. 106 | dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" 107 | 108 | # Assume Python 3.10. 109 | target-version = "py310" 110 | 111 | [tool.ruff.mccabe] 112 | # Unlike Flake8, default to a complexity level of 10. 113 | max-complexity = 50 114 | -------------------------------------------------------------------------------- /scripts/lint-test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -x 4 | 5 | poetry run black django_dynamodb_cache --check 6 | poetry run ruff check --exit-zero . 7 | -------------------------------------------------------------------------------- /scripts/publish.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | poetry publish --build 6 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xncbf/django-dynamodb-cache/354e6e754abd5d058ec632ee2039fc7fa0a496da/tests/__init__.py -------------------------------------------------------------------------------- /tests/conf.py: -------------------------------------------------------------------------------- 1 | from random import random 2 | 3 | TABLE_NAME = f"test-django-dynamodb-cache-{random()}" 4 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from django_dynamodb_cache import Cache 4 | from django_dynamodb_cache.dynamodb import create_table, get_dynamodb 5 | from django_dynamodb_cache.settings import Settings 6 | 7 | from .conf import TABLE_NAME 8 | 9 | 10 | @pytest.fixture(scope="session") 11 | def cache(): 12 | settings = Settings(aws_region_name="us-east-1", table_name=TABLE_NAME) 13 | dynamodb = get_dynamodb(settings) 14 | cache = Cache(settings) 15 | table = create_table(settings, dynamodb) 16 | yield cache 17 | print("teardown") 18 | table.delete( 19 | TableName=settings.table_name, 20 | ) 21 | -------------------------------------------------------------------------------- /tests/settings/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xncbf/django-dynamodb-cache/354e6e754abd5d058ec632ee2039fc7fa0a496da/tests/settings/__init__.py -------------------------------------------------------------------------------- /tests/settings/settings.py: -------------------------------------------------------------------------------- 1 | from random import random 2 | 3 | 4 | def make_key(key, key_prefix, version=None): 5 | return f"{key_prefix}:{version}:{key}" 6 | 7 | 8 | SECRET_KEY = "django-insecure-jv!jxo%un3wse2^#s2_e$awvo1-cpb1z-)f7o14nry-9se7=ui" 9 | 10 | INSTALLED_APPS = [ 11 | "django_dynamodb_cache", 12 | ] 13 | 14 | CACHES = { 15 | "default": { 16 | "BACKEND": "django_dynamodb_cache.backend.DjangoCacheBackend", 17 | "LOCATION": f"test-django-dynamodb-cache-default-{random()}", 18 | "TIMEOUT": 60, 19 | "MAX_ENTRIES": 300, 20 | "KEY_PREFIX": "django-dynamodb-cache", 21 | "KEY_FUNCTION": "tests.settings.settings.make_key", 22 | "VERSION": 1, 23 | "OPTIONS": { 24 | "aws_region_name": "us-east-1", 25 | "is_on_demand": False, 26 | "read_capacity_units": 1, 27 | "write_capacity_units": 1, 28 | "encode": "django_dynamodb_cache.encode.PickleEncode", 29 | }, 30 | }, 31 | "replica": { 32 | "BACKEND": "django_dynamodb_cache.backend.DjangoCacheBackend", 33 | "LOCATION": f"test-django-dynamodb-cache-{random()}", 34 | }, 35 | } 36 | USE_TZ = False 37 | 38 | DATABASES = { 39 | "default": { 40 | "ENGINE": "django.db.backends.sqlite3", 41 | "NAME": ":memory:", 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /tests/test_cache_simple.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | from django_dynamodb_cache.cache import Cache 4 | 5 | 6 | def test_set_simple(cache: Cache): 7 | cache.set("set_simple", "test") 8 | item = cache.get("set_simple") 9 | assert item == "test" 10 | 11 | cache.set("set_simple", "test2") 12 | item = cache.get("set_simple") 13 | assert item == "test2" 14 | 15 | cache.delete("set_simple") 16 | value = cache.get("set_simple", 1001) 17 | assert value == 1001 18 | 19 | 20 | def test_set_cache_without_timeout(cache: Cache): 21 | cache.set("set_simple", "test", timeout=None) 22 | item = cache.get("set_simple") 23 | assert item == "test" 24 | 25 | 26 | def test_set_cache_with_version(cache: Cache): 27 | cache.clear() 28 | cache.set("set_simple", "test", version=2) 29 | item = cache.get("set_simple") 30 | assert item is None 31 | item = cache.get("set_simple", version=2) 32 | assert item == "test" 33 | 34 | 35 | def test_get_delete_many(cache: Cache): 36 | items = {f"get_delete_many_{i}": f"test {i}" for i in range(10)} 37 | 38 | cache.set_many(items) 39 | from_cache = cache.get_many(items.keys()) 40 | assert items == from_cache 41 | cache.delete_many(items.keys()) 42 | 43 | value = cache.get("get_delete_many_1", 1001) 44 | assert value == 1001 45 | 46 | 47 | def test_add(cache: Cache): 48 | cache.add("test_add", "some set") 49 | cache.add("test_add", "another add") 50 | value = cache.get("test_add", "default") 51 | assert value == "some set" 52 | 53 | 54 | def test_expired(cache: Cache): 55 | cache.set("expired", "lost data", -1000) 56 | value = cache.get("expired", 1001) 57 | assert value == 1001 58 | 59 | 60 | def test_incr_dec_version(cache: Cache): 61 | cache.set("incr", 1001, version=10) 62 | cache.decr_version("incr", version=10) 63 | value = cache.get("incr", version=9) 64 | assert value == 1001 65 | 66 | 67 | @patch("warnings.warn") 68 | def test_verify_key(mock_warn, cache: Cache): 69 | BIG = "0" * 251 70 | cache.validate_key(BIG) 71 | mock_warn.assert_called() 72 | 73 | mock_warn.reset_mock() 74 | invalid = " " 75 | cache.validate_key(invalid) 76 | mock_warn.assert_called() 77 | 78 | 79 | def test_clear(cache: Cache): 80 | cache.set("key_1", "value_1") 81 | cache.set("key_2", "value_2") 82 | cache.set("key_3", "value_3") 83 | 84 | assert cache.get("key_1") == "value_1" 85 | assert cache.get("key_2") == "value_2" 86 | assert cache.get("key_3") == "value_3" 87 | 88 | cache.clear() 89 | 90 | assert cache.get("key_1") is None 91 | assert cache.get("key_2") is None 92 | assert cache.get("key_3") is None 93 | 94 | 95 | def test_incr_decr_value(cache: Cache): 96 | cache.set("i", 10) 97 | cache.decr("i", 42) 98 | value = cache.get("i") 99 | assert value == -32 100 | 101 | 102 | def test_touch(cache: Cache): 103 | value = cache.touch("touch") 104 | assert value["ResponseMetadata"]["HTTPStatusCode"] == 200 105 | 106 | 107 | def test_get_or_set(cache: Cache): 108 | value = cache.get("get_or_set") 109 | assert value is None 110 | value = cache.get_or_set("get_or_set", 10) 111 | assert value == 10 112 | value = cache.get_or_set("get_or_set", 11) 113 | assert value == 10 114 | cache.delete("get_or_set") 115 | value = cache.get("get_or_set") 116 | assert value is None 117 | value = cache.get_or_set("get_or_set", lambda: 12) 118 | assert value == 12 119 | -------------------------------------------------------------------------------- /tests/test_create_table.py: -------------------------------------------------------------------------------- 1 | from django_dynamodb_cache.dynamodb import create_table, get_dynamodb 2 | from django_dynamodb_cache.settings import Settings 3 | from tests.conf import TABLE_NAME 4 | 5 | 6 | def test_create_table_simple(): 7 | settings = Settings(aws_region_name="us-east-1", table_name=TABLE_NAME) 8 | dynamodb = get_dynamodb(settings) 9 | table = create_table(settings, dynamodb) 10 | assert table.table_name == TABLE_NAME 11 | assert table.table_status == "ACTIVE" 12 | -------------------------------------------------------------------------------- /tests/test_django.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | 4 | import django 5 | from django.conf import settings 6 | from django.core.cache import caches 7 | from django.core.management import call_command 8 | from django.test import TestCase 9 | 10 | from django_dynamodb_cache.backend import DjangoCacheBackend 11 | from django_dynamodb_cache.dynamodb import get_table 12 | 13 | os.environ["DJANGO_SETTINGS_MODULE"] = "tests.settings.settings" 14 | django.setup() 15 | 16 | 17 | class TestDjango(TestCase): 18 | @classmethod 19 | def teardown_class(cls): 20 | for cache_alias in settings.CACHES: 21 | cache = caches[cache_alias] 22 | if isinstance(cache, DjangoCacheBackend): 23 | backend = DjangoCacheBackend(cache._table, settings.CACHES[cache_alias]) 24 | get_table(backend.settings, backend.dynamodb).delete() 25 | 26 | def test_command(self): 27 | shutil.rmtree("tests/migrations", True) 28 | call_command("createcachetable") 29 | 30 | 31 | # class TestDjangoApp(TestCase): 32 | # @classmethod 33 | # def setUpClass(cls): 34 | # cls.settings = Settings(aws_region_name="us-east-1", table_name=TABLE_NAME) 35 | # cls.dynamodb = get_dynamodb(cls.settings) 36 | # cls.cache = Cache(cls.settings) 37 | # cls.table = create_table(cls.settings, cls.dynamodb) 38 | # super().setUpClass() 39 | 40 | # @classmethod 41 | # def teardown_class(cls): 42 | # cls.table.delete( 43 | # TableName=cls.settings.table_name, 44 | # ) 45 | # super().tearDownClass() 46 | -------------------------------------------------------------------------------- /tests/test_settings.py: -------------------------------------------------------------------------------- 1 | from django_dynamodb_cache import Cache 2 | from django_dynamodb_cache.settings import Settings 3 | 4 | 5 | def test_simples(): 6 | settings = Settings(version=10) 7 | 8 | assert settings["version"] == settings.version 9 | assert settings.get("version") == settings.version 10 | assert settings.version == 10 11 | 12 | 13 | def test_encode(): 14 | settings = Settings(encode="json") 15 | 16 | cache = Cache(settings) 17 | 18 | j = cache.encode.dumps({"a": 99}) 19 | assert j == '{"a": 99}' 20 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse 2 | from django.urls import path 3 | 4 | 5 | def test(request): 6 | 7 | return HttpResponse(b"teste") 8 | 9 | 10 | urlpatterns = [path("test", test)] 11 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | isolated_build = True 3 | envlist = 4 | py{38,39}-dj{30,31,32,40,41}, 5 | py310-dj{32,40,41,42,50}, 6 | py311-dj{41,42,50}, 7 | py312-dj{42,50}, 8 | skipsdist = true 9 | 10 | 11 | [tox:.package] 12 | # note tox will use the same python version as under what tox is installed to package 13 | # so unless this is python 3 you can require a given python version for the packaging 14 | # environment via the basepython key 15 | basepython = python3 16 | 17 | [gh-actions] 18 | python = 19 | 3.8: py38 20 | 3.9: py39 21 | 3.10: py310 22 | 3.11: py311 23 | 3.12: py312 24 | 25 | [testenv] 26 | allowlist_externals = poetry 27 | commands = 28 | pytest --cov=django_dynamodb_cache tests/ --cov-report=xml --cov-append 29 | passenv = * 30 | deps = 31 | dj30: Django>=3.0,<3.1 32 | dj31: Django>=3.1,<3.2 33 | dj32: Django>=3.2,<3.3 34 | dj40: Django>=4.0a,<4.1 35 | dj41: Django>=4.1,<4.2 36 | dj42: Django>=4.2,<5.0 37 | dj50: Django>=5.0,<5.1 38 | djmaster: git+https://github.com/django/django 39 | pytest 40 | pytest-cov 41 | moto 42 | boto3 43 | botocore 44 | 45 | [testenv:clean] 46 | deps = coverage 47 | skip_install = true 48 | commands = coverage erase 49 | --------------------------------------------------------------------------------