├── .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 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
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 |
--------------------------------------------------------------------------------