diff --git a/.coveragerc b/.coveragerc
new file mode 100644
index 0000000..1b8521c
--- /dev/null
+++ b/.coveragerc
@@ -0,0 +1,9 @@
+[run]
+omit =
+ tests/*.py
+ runtests.py
+[report]
+# Regexes for lines to exclude from consideration
+exclude_lines =
+ pragma: no cover
+ NotImplementedError
\ No newline at end of file
diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml
new file mode 100644
index 0000000..f5de38c
--- /dev/null
+++ b/.github/workflows/codecov.yml
@@ -0,0 +1,25 @@
+name: CodeCov
+on: [push, pull_request]
+jobs:
+ run:
+ runs-on: ubuntu-latest
+ env:
+ OS: ubuntu-latest
+ PYTHON: '3.9'
+ steps:
+ - uses: actions/checkout@v3
+
+ - name: Setup Python
+ uses: actions/setup-python@v4
+ with:
+ python-version: 3.8
+
+ - name: Generate Report
+ run: |
+ curl -Os https://uploader.codecov.io/latest/linux/codecov
+ chmod +x codecov
+ pip install coverage pytest
+ pip install -r requirements/coverage.txt
+ coverage run runtests.py
+ coverage report
+ ./codecov
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..1eff3ea
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,10 @@
+{
+ "python.formatting.provider": "black",
+ "[python]": {
+ "editor.wordBasedSuggestions": true,
+ "editor.formatOnSave": true,
+ "editor.codeActionsOnSave": {
+ "source.organizeImports": true
+ }
+ }
+}
\ No newline at end of file
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000..4cf3920
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,3 @@
+include LICENSE
+include README*
+recursive-include django-dysession *.py
\ No newline at end of file
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..29cac4c
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,18 @@
+all: coverage
+
+.PHONY: lint
+lint:
+ flake8
+
+.PHONY: coverage
+coverage:
+ coverage run runtests.py
+ coverage html
+
+.PHONY: release
+release:
+ python3 setup.py bdist
+
+clean:
+ find . -type f -name *.pyc -delete
+ find . -type d -name __pycache__ -delete
\ No newline at end of file
diff --git a/README.md b/README.md
index 30af32c..1677c71 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,42 @@
-# django-dysession
-django-dysessino is a django extension by using AWS DynamoDB as a session backend
+
+
django-dysession
+
+ django-dysessino is a django extension by using AWS DynamoDB as a session backend
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+## What is a django-session?
+
+## Requirements
+
+## Installation
+
+## Example
diff --git a/codecov.yml b/codecov.yml
new file mode 100644
index 0000000..e69de29
diff --git a/dysession/__init__.py b/dysession/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/dysession/admin.py b/dysession/admin.py
new file mode 100644
index 0000000..a23ff81
--- /dev/null
+++ b/dysession/admin.py
@@ -0,0 +1 @@
+from django.contrib import admin
\ No newline at end of file
diff --git a/dysession/apps.py b/dysession/apps.py
new file mode 100644
index 0000000..6519e4c
--- /dev/null
+++ b/dysession/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class DjangoDysessionConfig(AppConfig):
+ name = "dysession"
+ verbose_name = 'Django DynamoDB Session Backend'
\ No newline at end of file
diff --git a/dysession/aws/__init__.py b/dysession/aws/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/dysession/aws/dynamodb.py b/dysession/aws/dynamodb.py
new file mode 100644
index 0000000..db1972f
--- /dev/null
+++ b/dysession/aws/dynamodb.py
@@ -0,0 +1,164 @@
+from datetime import datetime
+from typing import Any, Dict, Literal, Optional, Union
+
+import boto3
+from botocore import client as botoClitent
+from django.utils import timezone
+
+from dysession.aws.error import DynamodbItemNotFound, DynamodbTableNotFound
+from dysession.backends.error import (SessionKeyDoesNotExist,
+ SessionKeyDuplicated)
+from dysession.backends.model import SessionDataModel
+
+from ..settings import get_config
+
+
+def create_dynamodb_table(options: Dict[str, Union[str, int]], client=None) -> Dict:
+
+ if client is None:
+ client = boto3.client("dynamodb", region_name=get_config()["DYNAMODB_REGION"])
+
+ response = client.create_table(
+ AttributeDefinitions=[
+ {"AttributeName": options["pk"], "AttributeType": "S"},
+ # {"AttributeName": options["sk"], "AttributeType": "S"},
+ ],
+ TableName=options["table"],
+ KeySchema=[
+ {"AttributeName": options["pk"], "KeyType": "HASH"},
+ # {"AttributeName": options["sk"], "KeyType": "RANGE"},
+ ],
+ BillingMode="PAY_PER_REQUEST",
+ TableClass="STANDARD",
+ )
+
+ return response
+
+
+def destory_dynamodb_table(options: Dict[str, Union[str, int]], client=None) -> Dict:
+
+ if client is None:
+ client = boto3.client("dynamodb", region_name=get_config()["DYNAMODB_REGION"])
+
+ response = client.delete_table(TableName=options["table"])
+ return response
+
+
+def check_dynamodb_table_exists(table_name: Optional[str] = None, client=None) -> Dict:
+
+ if client is None:
+ client = boto3.client("dynamodb", region_name=get_config()["DYNAMODB_REGION"])
+
+ if table_name is None:
+ table_name = get_config()["DYNAMODB_TABLENAME"]
+
+ response = client.list_tables()
+ if table_name not in response["TableNames"]:
+ raise DynamodbTableNotFound
+
+ return response
+
+
+def key_exists(session_key: str, table_name: Optional[str] = None, client=None) -> bool:
+
+ if client is None:
+ client = boto3.client("dynamodb", region_name=get_config()["DYNAMODB_REGION"])
+
+ if table_name is None:
+ table_name = get_config()["DYNAMODB_TABLENAME"]
+
+ assert type(session_key) is str, "session_key should be string type"
+
+ pk = get_config()["PARTITION_KEY_NAME"]
+
+ response = client.get_item(
+ TableName=table_name,
+ Key={
+ pk: {"S": session_key},
+ },
+ ProjectionExpression=f"{pk}",
+ )
+
+ return "Item" in response
+
+
+def get_item(session_key: str, table_name: Optional[str] = None, client=None) -> bool:
+
+ if client is None:
+ client = boto3.client("dynamodb", region_name=get_config()["DYNAMODB_REGION"])
+
+ if table_name is None:
+ table_name = get_config()["DYNAMODB_TABLENAME"]
+
+ assert type(session_key) is str, "session_key should be string type"
+
+ pk = get_config()["PARTITION_KEY_NAME"]
+
+ response = client.get_item(
+ TableName=table_name,
+ Key={
+ pk: {"S": session_key},
+ },
+ )
+
+ if "Item" not in response:
+ raise DynamodbItemNotFound()
+
+ return response
+
+
+def insert_session_item(
+ data: SessionDataModel,
+ table_name: Optional[str] = None,
+ return_consumed_capacity: Literal["INDEXES", "TOTAL", "NONE"] = "TOTAL",
+) -> bool:
+ """Insert a session key"""
+
+ assert type(data.session_key) is str, "session_key should be string type"
+
+ if table_name is None:
+ table_name = get_config()["DYNAMODB_TABLENAME"]
+
+ resource = boto3.resource("dynamodb", region_name=get_config()["DYNAMODB_REGION"])
+ table = resource.Table(table_name)
+ pk = get_config()["PARTITION_KEY_NAME"]
+
+ insert_item = {pk: data.session_key}
+ for key in data:
+ insert_item[key] = data[key]
+
+ response = table.put_item(
+ TableName=table_name,
+ Item=insert_item,
+ ReturnConsumedCapacity=return_consumed_capacity,
+ )
+
+ return response
+
+
+class DynamoDB:
+ def __init__(self, client) -> None:
+ self.client = client
+
+ def get(
+ self, session_key: Optional[str] = None, ttl: Optional[datetime] = None
+ ) -> Dict[str, Any]:
+ """Return session data if dynamodb partision key is matched with inputed session_key"""
+ if session_key is None:
+ raise ValueError("session_key should be str type")
+
+
+ # if not found then raise
+ # raise SessionKeyDoesNotExist
+ # if key is expired
+ # raise SessionExpired
+
+ def set(self, session_key: Optional[str] = None, session_data=None) -> None:
+ return
+ # Partision key duplicated
+ raise SessionKeyDuplicated
+
+ def exists(self, session_key: Optional[str] = None) -> bool:
+ return False
+ # if not found then raise
+ raise SessionKeyDoesNotExist
diff --git a/dysession/aws/error.py b/dysession/aws/error.py
new file mode 100644
index 0000000..ed1a55e
--- /dev/null
+++ b/dysession/aws/error.py
@@ -0,0 +1,6 @@
+
+class DynamodbTableNotFound(Exception):
+ pass
+
+class DynamodbItemNotFound(Exception):
+ pass
\ No newline at end of file
diff --git a/dysession/backends/__init__.py b/dysession/backends/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/dysession/backends/db.py b/dysession/backends/db.py
new file mode 100644
index 0000000..febc3d8
--- /dev/null
+++ b/dysession/backends/db.py
@@ -0,0 +1,126 @@
+import logging
+from typing import Any, Dict, Optional
+
+import boto3
+from django.contrib import auth
+from django.contrib.sessions.backends.base import CreateError, SessionBase
+from django.core.exceptions import SuspiciousOperation
+from django.utils import timezone
+
+from dysession.aws.dynamodb import DynamoDB
+from dysession.backends.error import (
+ SessionExpired,
+ SessionKeyDoesNotExist,
+ SessionKeyDuplicated,
+)
+from dysession.backends.model import SessionDataModel
+
+
+class SessionStore(SessionBase):
+ """Implement DynamoDB session store"""
+
+ def __init__(self, session_key: Optional[str], **kwargs: Any) -> None:
+ super().__init__(session_key, **kwargs)
+ # self.client = boto3.client("dynamodb")
+ self.db = DynamoDB(client=boto3.client("dynamodb"))
+
+ def _get_session_from_ddb(self) -> SessionDataModel:
+ try:
+ return self.db.get(session_key=self.session_key, ttl=timezone.now())
+ except (SessionKeyDoesNotExist, SessionExpired, SuspiciousOperation) as e:
+ if isinstance(e, SuspiciousOperation):
+ logger = logging.getLogger(f"django.security.{e.__class__.__name__}")
+ logger.warning(str(e))
+ self._session_key = None
+
+ def _get_session(self, no_load=False) -> SessionDataModel:
+ """
+ Lazily load session from storage (unless "no_load" is True, when only
+ an empty dict is stored) and store it in the current instance.
+ """
+ self.accessed = True
+ try:
+ return self._session_cache
+ except AttributeError:
+ if self.session_key is None or no_load:
+ self._session_cache = SessionDataModel()
+ else:
+ self._session_cache = self.load()
+ return self._session_cache
+
+ @property
+ def key_salt(self):
+ return "dysession.backends." + self.__class__.__qualname__
+
+ def is_empty(self):
+ "Return True when there is no session_key and the session is empty."
+ try:
+ return not self._session_key and not self._session_cache.is_empty
+ except AttributeError:
+ return True
+
+ def clear(self):
+ super().clear()
+ self._session_cache = SessionDataModel()
+
+ # ====== Methods that subclass must implement
+ def exists(self, session_key: str) -> bool:
+ """
+ Return True if the given session_key already exists.
+ """
+ return self.db.exists(session_key)
+
+ def create(self) -> None:
+ """
+ Create a new session instance. Guaranteed to create a new object with
+ a unique key and will have saved the result once (with empty data)
+ before the method returns.
+ """
+ while True:
+ self._session_key = self._get_new_session_key()
+ try:
+ # Save immediately to ensure we have a unique entry in the database.
+ self.save(must_create=True)
+ except (CreateError, SessionKeyDuplicated):
+ # Key wasn't unique. Try again.
+ continue
+ self.modified = True
+ return
+
+ def save(self, must_create: bool = ...) -> None:
+ """
+ Save the session data. If 'must_create' is True, create a new session
+ object (or raise CreateError). Otherwise, only update an existing
+ object and don't create one (raise UpdateError if needed).
+ """
+ try:
+ self.db.set(
+ session_key=self._session_key,
+ session_data=self._get_session(must_create),
+ )
+ except SessionKeyDuplicated:
+ if must_create:
+ raise SessionKeyDuplicated
+
+ def delete(self, request, *args, **kwargs):
+ """
+ Delete the session data under this key. If the key is None, use the
+ current session key value.
+ """
+ try:
+ self.db.delete(session_key=self._session_key)
+ except:
+ pass
+
+ def load(self) -> SessionDataModel:
+ """
+ Load the session data and return a dictionary.
+ """
+ s = self._get_session_from_ddb()
+ return s if s else SessionDataModel()
+
+ @classmethod
+ def clear_expired(cls) -> None:
+ # clearing expired-items from dynamodb table is extremely expensive
+ # Instead of clearing expired-items, we should use ttl attribube and which is much cost-effective
+ ...
diff --git a/dysession/backends/error.py b/dysession/backends/error.py
new file mode 100644
index 0000000..ce37bac
--- /dev/null
+++ b/dysession/backends/error.py
@@ -0,0 +1,10 @@
+class SessionExpired(Exception):
+ ...
+
+
+class SessionKeyDoesNotExist(Exception):
+ ...
+
+
+class SessionKeyDuplicated(Exception):
+ ...
diff --git a/dysession/backends/model.py b/dysession/backends/model.py
new file mode 100644
index 0000000..68f486e
--- /dev/null
+++ b/dysession/backends/model.py
@@ -0,0 +1,51 @@
+from typing import Any, Optional
+
+
+class SessionDataModel:
+ def __init__(self, session_key: Optional[str] = None) -> None:
+
+ if type(session_key) is not str and session_key is not None:
+ raise TypeError("session_key should be type str or None")
+
+ self.session_key = session_key
+ self.__variables_names = set()
+
+ def __getitem__(self, key) -> Any:
+ return getattr(self, key)
+
+ def __setitem__(self, key, value):
+ if key == "session_key":
+ raise ValueError()
+
+ setattr(self, key, value)
+ self.__variables_names.add(key)
+
+ def __delitem__(self, key):
+ self.__variables_names.remove(key)
+ delattr(self, key)
+
+ def __iter__(self):
+ return iter(self.__variables_names)
+
+ def __is_empty(self):
+ return len(self.__variables_names) == 0
+
+ is_empty = property(__is_empty)
+
+ def get(self, key, default=...):
+ try:
+ return self[key]
+ except AttributeError:
+ if default is Ellipsis:
+ raise
+ return default
+
+ def pop(self, key, default=...):
+ try:
+ ret = self[key]
+ del self[key]
+ return ret
+ except AttributeError:
+ if default is Ellipsis:
+ raise
+ return default
diff --git a/dysession/management/__init__.py b/dysession/management/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/dysession/management/commands/_arg_types.py b/dysession/management/commands/_arg_types.py
new file mode 100644
index 0000000..16c663b
--- /dev/null
+++ b/dysession/management/commands/_arg_types.py
@@ -0,0 +1,8 @@
+import argparse
+
+
+def positive_int(value: str) -> int:
+ ivalue = int(value)
+ if ivalue <= 0:
+ raise argparse.ArgumentTypeError("%s is an invalid positive int value" % value)
+ return ivalue
diff --git a/dysession/management/commands/dysession_clear.py b/dysession/management/commands/dysession_clear.py
new file mode 100644
index 0000000..2c9a2e7
--- /dev/null
+++ b/dysession/management/commands/dysession_clear.py
@@ -0,0 +1,31 @@
+from typing import Any, Optional
+from django.core.management.base import BaseCommand
+from django.core.management.base import CommandParser
+
+
+__all__ = ["Command"]
+
+
+class Command(BaseCommand):
+
+ help = "Clear all session record which stored in DynamoDB"
+
+ def add_arguments(self, parser: CommandParser) -> None:
+ parser.add_argument(
+ "-u",
+ "--uid",
+ action="append",
+ help=" Indicate to clear specified user's session data.",
+ required=False,
+ )
+ return super().add_arguments(parser)
+
+ def handle(self, *args: Any, **options: Any) -> Optional[str]:
+ userids = options.get("uid", None)
+ if userids:
+ print(f"Ready to clear {userids} session data.")
+ return
+
+ print("Clearing whole session data")
+ return
+
diff --git a/dysession/management/commands/dysession_destory.py b/dysession/management/commands/dysession_destory.py
new file mode 100644
index 0000000..d451e07
--- /dev/null
+++ b/dysession/management/commands/dysession_destory.py
@@ -0,0 +1,36 @@
+from typing import Any, Optional
+
+from django.core.management.base import BaseCommand, CommandParser
+from dysession.aws.dynamodb import destory_dynamodb_table
+
+from dysession.settings import get_config
+
+
+__all__ = ["Command"]
+
+
+class Command(BaseCommand):
+
+ help = "Clear all session record which stored in DynamoDB"
+
+ def add_arguments(self, parser: CommandParser) -> None:
+ config = get_config()
+ parser.add_argument(
+ "-n",
+ "--table",
+ type=str,
+ default=config["DYNAMODB_TABLENAME"],
+ help=" Indicate to clear specified user's session data.",
+ required=False,
+ )
+ parser.add_argument(
+ "--region",
+ type=str,
+ default=config["DYNAMODB_REGION"],
+ help=" Indicate to clear specified user's session data.",
+ required=False,
+ )
+ return super().add_arguments(parser)
+
+ def handle(self, *args: Any, **options: Any) -> Optional[str]:
+ destory_dynamodb_table(options=options)
diff --git a/dysession/management/commands/dysession_init.py b/dysession/management/commands/dysession_init.py
new file mode 100644
index 0000000..6006320
--- /dev/null
+++ b/dysession/management/commands/dysession_init.py
@@ -0,0 +1,67 @@
+from typing import Any, Optional
+
+import boto3
+from django.conf import settings
+from django.core.management.base import BaseCommand, CommandParser
+
+from dysession.aws.dynamodb import create_dynamodb_table
+from dysession.settings import get_config
+
+from ._arg_types import positive_int
+
+__all__ = ["Command"]
+
+
+class Command(BaseCommand):
+
+ help = "Clear all session record which stored in DynamoDB"
+
+ def add_arguments(self, parser: CommandParser) -> None:
+ config = get_config()
+ parser.add_argument(
+ "-n",
+ "--table",
+ type=str,
+ default=config["DYNAMODB_TABLENAME"],
+ help=" Indicate to clear specified user's session data.",
+ required=False,
+ )
+ parser.add_argument(
+ "--pk",
+ action="append",
+ default=config["PARTITION_KEY_NAME"],
+ help=" Indicate to clear specified user's session data.",
+ required=False,
+ )
+ parser.add_argument(
+ "--sk",
+ type=str,
+ default=config["SORT_KEY_NAME"],
+ help=" Indicate to clear specified user's session data.",
+ required=False,
+ )
+ parser.add_argument(
+ "--ttl",
+ type=str,
+ default=config["TTL_ATTRIBUTE_NAME"],
+ help=" Indicate to clear specified user's session data.",
+ required=False,
+ )
+ parser.add_argument(
+ "--region",
+ type=str,
+ default=config["DYNAMODB_REGION"],
+ help=" Indicate to clear specified user's session data.",
+ required=False,
+ )
+ parser.add_argument(
+ "--period",
+ type=positive_int,
+ default=config["CACHE_PERIOD"],
+ help=" Indicate to clear specified user's session data.",
+ required=False,
+ )
+ return super().add_arguments(parser)
+
+ def handle(self, *args: Any, **options: Any) -> Optional[str]:
+ create_dynamodb_table(options=options)
diff --git a/dysession/middleware.py b/dysession/middleware.py
new file mode 100644
index 0000000..ce7c5e1
--- /dev/null
+++ b/dysession/middleware.py
@@ -0,0 +1,12 @@
+from django.conf import settings
+from django.contrib.sessions.middleware import SessionMiddleware as DjSessionMiddleware
+
+
+class SessionMiddleware(DjSessionMiddleware):
+ def process_request(self, request):
+ # SESSION_COOKIE_NAME can be change by developers
+ # https://docs.djangoproject.com/en/3.2/ref/settings/#session-cookie-name
+ session_key = request.COOKIES.get(settings.SESSION_COOKIE_NAME)
+ request.session = self.SessionStore(
+ session_key=session_key,
+ )
\ No newline at end of file
diff --git a/dysession/settings.py b/dysession/settings.py
new file mode 100644
index 0000000..b53a728
--- /dev/null
+++ b/dysession/settings.py
@@ -0,0 +1,32 @@
+from functools import lru_cache
+from typing import Dict, Union
+
+from django.conf import settings
+from django.dispatch import receiver
+from django.test.signals import setting_changed
+
+DEFAULT_CONFIG = {
+ "DYNAMODB_TABLENAME": "sessions",
+ "PARTITION_KEY_NAME": "PK",
+ "SORT_KEY_NAME": "SK",
+ "TTL_ATTRIBUTE_NAME": "ttl",
+ "CACHE_PERIOD": 3600,
+ "DYNAMODB_REGION": "ap-northeast-1",
+}
+
+
+@lru_cache
+def get_config() -> Dict[str, Union[str, int]]:
+ config = DEFAULT_CONFIG.copy()
+ custom_config = getattr(settings, "DYSESSION_CONFIG", {})
+ config.update(custom_config)
+ return config
+
+
+@receiver(setting_changed)
+def update_dysession_config(*, setting, **kwargs):
+ if setting == "DYSESSION_CONFIG": # pragma: no cover
+ get_config.cache_clear() # pragma: no cover
+
+
+__all__ = ["get_config"]
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..2b789ac
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,5 @@
+[tool.coverage.run]
+omit = [
+ "tests/*",
+ "runtests.py"
+]
\ No newline at end of file
diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 0000000..f17f840
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,9 @@
+# pytest.ini
+[pytest]
+minversion = 6.0
+addopts = -ra -q
+testpaths =
+ runtests.py
+filterwarnings =
+ ignore::DeprecationWarning
+ ignore:function ham\(\) is deprecated:DeprecationWarning
\ No newline at end of file
diff --git a/requirements/base.txt b/requirements/base.txt
new file mode 100644
index 0000000..34db20d
--- /dev/null
+++ b/requirements/base.txt
@@ -0,0 +1 @@
+django >= 3.2
\ No newline at end of file
diff --git a/requirements/coverage.txt b/requirements/coverage.txt
new file mode 100644
index 0000000..001631e
--- /dev/null
+++ b/requirements/coverage.txt
@@ -0,0 +1,2 @@
+coverage==7.0.3
+-r test.txt
\ No newline at end of file
diff --git a/requirements/lint.txt b/requirements/lint.txt
new file mode 100644
index 0000000..e69de29
diff --git a/requirements/test.txt b/requirements/test.txt
new file mode 100644
index 0000000..50470b9
--- /dev/null
+++ b/requirements/test.txt
@@ -0,0 +1,4 @@
+-r base.txt
+moto[dynamodb]
+moto==4.0.13
+parameterized==0.8.1
\ No newline at end of file
diff --git a/requirements/tox.txt b/requirements/tox.txt
new file mode 100644
index 0000000..0ada407
--- /dev/null
+++ b/requirements/tox.txt
@@ -0,0 +1,2 @@
+tox==3.28.0
+-r requirements/coverage.txt
\ No newline at end of file
diff --git a/runtests.py b/runtests.py
new file mode 100644
index 0000000..4af5e73
--- /dev/null
+++ b/runtests.py
@@ -0,0 +1,49 @@
+"""
+This code provides a mechanism for running django_ses' internal
+test suite without having a full Django project. It sets up the
+global configuration, then dispatches out to `call_command` to
+kick off the test suite.
+## The Code
+"""
+
+# Setup and configure the minimal settings necessary to
+# run the test suite. Note that Django requires that the
+# `DATABASES` value be present and configured in order to
+# do anything.
+
+from argparse import ArgumentParser
+
+import django
+from django.conf import settings
+from django.core.management import call_command
+
+if __name__ == "__main__":
+
+ # Run test args parse
+ parser = ArgumentParser()
+ parser.add_argument(
+ "-v", "--verbosity", action="count", help="increase output verbosity", default=0
+ )
+ args = parser.parse_args()
+
+ # Django Setup
+ settings.configure(
+ INSTALLED_APPS=[
+ "dysession",
+ ],
+ DATABASES={
+ "default": {
+ "ENGINE": "django.db.backends.sqlite3",
+ "NAME": ":memory:",
+ }
+ },
+ MIDDLEWARE_CLASSES=(
+ "django.middleware.common.CommonMiddleware",
+ "django.middleware.csrf.CsrfViewMiddleware",
+ ),
+ SECRET_KEY="not-secret",
+ )
+
+ django.setup()
+ # Start the test suite now that the settings are configured.
+ call_command("test", "tests", verbosity=args.verbosity)
diff --git a/setup.cfg b/setup.cfg
new file mode 100644
index 0000000..943c9d8
--- /dev/null
+++ b/setup.cfg
@@ -0,0 +1,40 @@
+[metadata]
+name = django-dysession
+version = 0.0.1
+description = django-dysession is a django extension by using AWS DynamoDB as a session backend
+long_description = file:README.md
+keywords = "django,session,aws,dynamodb"
+url = https://github.com/MissterHao/django-dysession
+download_url = "https://pypi.org/project/django-dysession/"
+author = "Hao-Wei, Li"
+author_email = "henryliking@gmail.com"
+maintainer = "MissterHao"
+maintainer_email = "henryliking@gmail.com"
+license = MIT
+classifiers =
+ Development Status :: 4 - Beta
+ Environment :: Web Environment
+ Framework :: Django
+ Framework :: Django :: 3.2
+ Intended Audience :: Developers
+ License :: OSI Approved :: MIT License
+ Operating System :: OS Independent
+ Programming Language :: Python :: 3.10
+ Programming Language :: Python :: 3.9
+ Programming Language :: Python :: 3.8
+ Programming Language :: Python :: 3.7
+ Programming Language :: Python :: 3.6
+ Programming Language :: Python :: Implementation :: CPython
+ Topic :: Software Development :: Libraries :: Application Frameworks
+ Topic :: Software Development :: Libraries :: Python Modules
+ Topic :: Internet :: WWW/HTTP :: Session
+ Topic :: Security
+
+[options]
+packages=find:
+include_package_data = true
+python_requires = >=3.6
+setup_requires =
+ setuptools >= 38.3.0
+install_requires =
+ Django>=3.2
\ No newline at end of file
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..5f1ce40
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,5 @@
+#!/usr/bin/env python
+
+if __name__ == "__main__":
+ import setuptools
+ setuptools.setup()
\ No newline at end of file
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/test_admin.py b/tests/test_admin.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/test_apps.py b/tests/test_apps.py
new file mode 100644
index 0000000..7a5ffc2
--- /dev/null
+++ b/tests/test_apps.py
@@ -0,0 +1,22 @@
+from django.test import TestCase
+from parameterized import parameterized
+
+from dysession.apps import DjangoDysessionConfig
+
+
+class DysessionInitTestCase(TestCase):
+ @parameterized.expand(
+ [
+ ("DYNAMODB_TABLENAME",),
+ ("PARTITION_KEY_NAME",),
+ ("SORT_KEY_NAME",),
+ ("TTL_ATTRIBUTE_NAME",),
+ ("CACHE_PERIOD",),
+ ("DYNAMODB_REGION",),
+ ]
+ )
+ def test_django_app_config_is_correct(self, config_key_name):
+ self.assertEqual(DjangoDysessionConfig.name, "dysession")
+ self.assertEqual(
+ DjangoDysessionConfig.verbose_name, "Django DynamoDB Session Backend"
+ )
diff --git a/tests/test_aws_dynamodb.py b/tests/test_aws_dynamodb.py
new file mode 100644
index 0000000..9416904
--- /dev/null
+++ b/tests/test_aws_dynamodb.py
@@ -0,0 +1,489 @@
+import boto3
+from django.test import TestCase
+from moto import mock_dynamodb
+from parameterized import parameterized
+
+from dysession.aws.dynamodb import (
+ check_dynamodb_table_exists,
+ create_dynamodb_table,
+ destory_dynamodb_table,
+ get_item,
+ insert_session_item,
+ key_exists,
+)
+from dysession.aws.error import DynamodbItemNotFound, DynamodbTableNotFound
+from dysession.backends.model import SessionDataModel
+from dysession.settings import get_config
+
+
+class AWSDynamoDBTestCase(TestCase):
+ @mock_dynamodb
+ def test_init_dynamodb_table(self):
+
+ options = {
+ "pk": get_config()["PARTITION_KEY_NAME"],
+ "sk": get_config()["SORT_KEY_NAME"],
+ "table": "sessions",
+ "region": "ap-northeast-1",
+ }
+
+ client = boto3.client("dynamodb", region_name=options["region"])
+ response = create_dynamodb_table(
+ options={
+ "pk": options["pk"],
+ "sk": options["sk"],
+ "table": options["table"],
+ },
+ client=client,
+ )
+
+ self.assertEqual(response["ResponseMetadata"]["HTTPStatusCode"], 200)
+ self.assertEqual(response["TableDescription"]["TableName"], options["table"])
+
+ @mock_dynamodb
+ def test_init_dynamodb_table_with_client_input(self):
+
+ options = {
+ "pk": get_config()["PARTITION_KEY_NAME"],
+ "sk": get_config()["SORT_KEY_NAME"],
+ "table": "sessions",
+ "region": "ap-northeast-1",
+ }
+
+ client = boto3.client("dynamodb", region_name=options["region"])
+ response = create_dynamodb_table(
+ options={
+ "pk": options["pk"],
+ "sk": options["sk"],
+ "table": options["table"],
+ },
+ )
+
+ self.assertEqual(response["ResponseMetadata"]["HTTPStatusCode"], 200)
+ self.assertEqual(response["TableDescription"]["TableName"], options["table"])
+
+ @mock_dynamodb
+ def test_destory_dynamodb_table(self):
+
+ options = {
+ "pk": get_config()["PARTITION_KEY_NAME"],
+ "sk": get_config()["SORT_KEY_NAME"],
+ "table": "sessions",
+ "region": "ap-northeast-1",
+ }
+
+ client = boto3.client("dynamodb", region_name=options["region"])
+ create_dynamodb_table(
+ options={
+ "pk": options["pk"],
+ "sk": options["sk"],
+ "table": options["table"],
+ },
+ client=client,
+ )
+ response = destory_dynamodb_table(options=options, client=client)
+
+ self.assertEqual(response["ResponseMetadata"]["HTTPStatusCode"], 200)
+ self.assertEqual(response["TableDescription"]["TableName"], options["table"])
+
+ @mock_dynamodb
+ def test_destory_dynamodb_table_with_client_input(self):
+
+ options = {
+ "pk": get_config()["PARTITION_KEY_NAME"],
+ "sk": get_config()["SORT_KEY_NAME"],
+ "table": "sessions",
+ "region": "ap-northeast-1",
+ }
+
+ create_dynamodb_table(
+ options={
+ "pk": options["pk"],
+ "sk": options["sk"],
+ "table": options["table"],
+ },
+ )
+ response = destory_dynamodb_table(options=options)
+
+ self.assertEqual(response["ResponseMetadata"]["HTTPStatusCode"], 200)
+ self.assertEqual(response["TableDescription"]["TableName"], options["table"])
+
+ @mock_dynamodb
+ def test_destory_dynamodb_table(self):
+
+ options = {
+ "pk": get_config()["PARTITION_KEY_NAME"],
+ "sk": get_config()["SORT_KEY_NAME"],
+ "table": "sessions",
+ "region": "ap-northeast-1",
+ }
+
+ client = boto3.client("dynamodb", region_name=options["region"])
+ create_dynamodb_table(
+ options={
+ "pk": options["pk"],
+ "sk": options["sk"],
+ "table": options["table"],
+ },
+ client=client,
+ )
+ response = check_dynamodb_table_exists(client=client)
+
+ @mock_dynamodb
+ def test_if_dynamodb_table_not_exist_with_client_input(self):
+
+ options = {
+ "pk": get_config()["PARTITION_KEY_NAME"],
+ "sk": get_config()["SORT_KEY_NAME"],
+ "table": "sessions",
+ "region": "ap-northeast-1",
+ }
+
+ client = boto3.client("dynamodb", region_name=options["region"])
+ create_dynamodb_table(
+ options={
+ "pk": options["pk"],
+ "sk": options["sk"],
+ "table": options["table"],
+ },
+ client=client,
+ )
+
+ with self.assertRaises(DynamodbTableNotFound):
+ response = check_dynamodb_table_exists(table_name="notexist", client=client)
+
+ @mock_dynamodb
+ def test_if_dynamodb_table_not_exist(self):
+
+ options = {
+ "pk": get_config()["PARTITION_KEY_NAME"],
+ "sk": get_config()["SORT_KEY_NAME"],
+ "table": "sessions",
+ "region": "ap-northeast-1",
+ }
+
+ create_dynamodb_table(
+ options={
+ "pk": options["pk"],
+ "sk": options["sk"],
+ "table": options["table"],
+ },
+ )
+
+ with self.assertRaises(DynamodbTableNotFound):
+ response = check_dynamodb_table_exists(table_name="notexist")
+
+ @mock_dynamodb
+ def test_if_dynamodb_table_exist_then_no_exception_raised(self):
+
+ options = {
+ "pk": get_config()["PARTITION_KEY_NAME"],
+ "sk": get_config()["SORT_KEY_NAME"],
+ "table": "sessions",
+ "region": "ap-northeast-1",
+ }
+
+ client = boto3.client("dynamodb", region_name=options["region"])
+ create_dynamodb_table(
+ options={
+ "pk": options["pk"],
+ "sk": options["sk"],
+ "table": options["table"],
+ },
+ client=client,
+ )
+
+ check_dynamodb_table_exists(table_name=options["table"], client=client)
+
+ # key exist
+ @mock_dynamodb
+ def test_check_if_key_not_exist(self):
+
+ options = {
+ "pk": get_config()["PARTITION_KEY_NAME"],
+ "sk": get_config()["SORT_KEY_NAME"],
+ "table": "sessions",
+ "region": "ap-northeast-1",
+ }
+
+ client = boto3.client("dynamodb", region_name=options["region"])
+ try:
+ check_dynamodb_table_exists(table_name=options["table"], client=client)
+ except DynamodbTableNotFound:
+ create_dynamodb_table(
+ options={
+ "pk": options["pk"],
+ "sk": options["sk"],
+ "table": options["table"],
+ },
+ client=client,
+ )
+
+ key_exists(
+ session_key="opiugyf",
+ table_name=options["table"],
+ client=client,
+ )
+
+ @mock_dynamodb
+ def test_check_if_key_exist(self):
+
+ options = {
+ "pk": get_config()["PARTITION_KEY_NAME"],
+ "sk": get_config()["SORT_KEY_NAME"],
+ "table": "sessions",
+ "region": "ap-northeast-1",
+ }
+
+ client = boto3.client("dynamodb", region_name=options["region"])
+ try:
+ check_dynamodb_table_exists(table_name=options["table"], client=client)
+ except DynamodbTableNotFound:
+ create_dynamodb_table(
+ options={
+ "pk": options["pk"],
+ "sk": options["sk"],
+ "table": options["table"],
+ },
+ client=client,
+ )
+ key_exists(
+ session_key="oijhugvfc",
+ table_name=options["table"],
+ client=client,
+ )
+
+ @mock_dynamodb
+ def test_check_if_key_exist_with_not_passing_tablename(self):
+
+ options = {
+ "pk": get_config()["PARTITION_KEY_NAME"],
+ "sk": get_config()["SORT_KEY_NAME"],
+ "table": "sessions",
+ "region": "ap-northeast-1",
+ }
+
+ client = boto3.client("dynamodb", region_name=options["region"])
+ try:
+ check_dynamodb_table_exists(table_name=options["table"], client=client)
+ except DynamodbTableNotFound:
+ create_dynamodb_table(
+ options={
+ "pk": options["pk"],
+ "sk": options["sk"],
+ "table": options["table"],
+ },
+ client=client,
+ )
+
+ key_exists(
+ session_key="oijhugvfc",
+ client=client,
+ )
+
+ @mock_dynamodb
+ def test_check_if_key_exist_with_not_client(self):
+
+ options = {
+ "pk": get_config()["PARTITION_KEY_NAME"],
+ "sk": get_config()["SORT_KEY_NAME"],
+ "table": "sessions",
+ "region": "ap-northeast-1",
+ }
+
+ client = boto3.client("dynamodb", region_name=options["region"])
+ try:
+ check_dynamodb_table_exists(table_name=options["table"], client=client)
+ except DynamodbTableNotFound:
+ create_dynamodb_table(
+ options={
+ "pk": options["pk"],
+ "sk": options["sk"],
+ "table": options["table"],
+ },
+ client=client,
+ )
+
+ key_exists(session_key="oijhugvfc")
+
+ @mock_dynamodb
+ def test_check_key_wrong_type(self):
+
+ options = {
+ "pk": get_config()["PARTITION_KEY_NAME"],
+ "sk": get_config()["SORT_KEY_NAME"],
+ "table": "sessions",
+ "region": "ap-northeast-1",
+ }
+
+ client = boto3.client("dynamodb", region_name=options["region"])
+ try:
+ check_dynamodb_table_exists(table_name=options["table"], client=client)
+ except DynamodbTableNotFound:
+ create_dynamodb_table(
+ options={
+ "pk": options["pk"],
+ "sk": options["sk"],
+ "table": options["table"],
+ },
+ client=client,
+ )
+
+ with self.assertRaises(AssertionError):
+ key_exists(
+ session_key=123,
+ table_name=options["table"],
+ client=client,
+ )
+
+ # Get Item
+ @mock_dynamodb
+ def test_get_item_without_client(self):
+
+ options = {
+ "pk": get_config()["PARTITION_KEY_NAME"],
+ "sk": get_config()["SORT_KEY_NAME"],
+ "table": "sessions",
+ "region": "ap-northeast-1",
+ }
+
+ try:
+ check_dynamodb_table_exists(table_name=options["table"])
+ except DynamodbTableNotFound:
+ create_dynamodb_table(
+ options={
+ "pk": options["pk"],
+ "sk": options["sk"],
+ "table": options["table"],
+ },
+ )
+
+ session_key = "aaaaaaaaaa"
+ model = SessionDataModel(session_key)
+ model["a"] = 1
+ model["b"] = "qwerty"
+
+ resp = insert_session_item(data=model)
+ self.assertEqual(resp["ResponseMetadata"]["HTTPStatusCode"], 200)
+
+ resp = get_item(session_key=session_key, table_name=options["table"])
+ self.assertIn("Item", resp)
+
+ @mock_dynamodb
+ def test_get_item_using_not_exist_key(self):
+
+ options = {
+ "pk": get_config()["PARTITION_KEY_NAME"],
+ "sk": get_config()["SORT_KEY_NAME"],
+ "table": "sessions",
+ "region": "ap-northeast-1",
+ }
+
+ client = boto3.client("dynamodb", region_name=options["region"])
+ try:
+ check_dynamodb_table_exists(table_name=options["table"], client=client)
+ except DynamodbTableNotFound:
+ create_dynamodb_table(
+ options={
+ "pk": options["pk"],
+ "sk": options["sk"],
+ "table": options["table"],
+ },
+ client=client,
+ )
+
+ with self.assertRaises(DynamodbItemNotFound):
+ resp = get_item(
+ session_key="not_exist_key", table_name=options["table"], client=client
+ )
+
+ # Insert Item
+ @parameterized.expand(
+ [
+ ["aaaaaaaaa"],
+ ["bbbbbbbbb"],
+ ["ccccccccc"],
+ ]
+ )
+ @mock_dynamodb
+ def test_insert_item_with_tablename(self, session_key: str):
+
+ options = {
+ "pk": get_config()["PARTITION_KEY_NAME"],
+ "sk": get_config()["SORT_KEY_NAME"],
+ "table": "sessions",
+ "region": "ap-northeast-1",
+ }
+
+ client = boto3.client("dynamodb", region_name=options["region"])
+ try:
+ check_dynamodb_table_exists(table_name=options["table"], client=client)
+ except DynamodbTableNotFound:
+ create_dynamodb_table(
+ options={
+ "pk": options["pk"],
+ "sk": options["sk"],
+ "table": options["table"],
+ },
+ client=client,
+ )
+
+ model = SessionDataModel(session_key)
+ model["a"] = 1
+ model["b"] = 2
+ model["c"] = 3
+ model["d"] = 4
+ model["e"] = "qwerty"
+
+ insert_session_item(data=model, table_name=options["table"])
+
+ resp = insert_session_item(data=model)
+ self.assertEqual(resp["ResponseMetadata"]["HTTPStatusCode"], 200)
+
+ resp = get_item(
+ session_key=session_key, table_name=options["table"], client=client
+ )
+ self.assertIn("Item", resp)
+
+ @parameterized.expand(
+ [
+ ["aaaaaaaaa"],
+ ["bbbbbbbbb"],
+ ["ccccccccc"],
+ ]
+ )
+ @mock_dynamodb
+ def test_insert_item_without_tablename(self, session_key: str):
+
+ options = {
+ "pk": get_config()["PARTITION_KEY_NAME"],
+ "sk": get_config()["SORT_KEY_NAME"],
+ "table": "sessions",
+ "region": "ap-northeast-1",
+ }
+
+ client = boto3.client("dynamodb", region_name=options["region"])
+ try:
+ check_dynamodb_table_exists(table_name=options["table"], client=client)
+ except DynamodbTableNotFound:
+ create_dynamodb_table(
+ options={
+ "pk": options["pk"],
+ "sk": options["sk"],
+ "table": options["table"],
+ },
+ client=client,
+ )
+
+ model = SessionDataModel(session_key)
+ model["a"] = 1
+ model["b"] = 2
+ model["c"] = 3
+ model["d"] = 4
+ model["e"] = "qwerty"
+
+ resp = insert_session_item(data=model)
+ self.assertEqual(resp["ResponseMetadata"]["HTTPStatusCode"], 200)
+
+ resp = get_item(session_key=session_key, client=client)
+ self.assertIn("Item", resp)
diff --git a/tests/test_backend_model.py b/tests/test_backend_model.py
new file mode 100644
index 0000000..b4facdd
--- /dev/null
+++ b/tests/test_backend_model.py
@@ -0,0 +1,114 @@
+from typing import Any
+
+import boto3
+from django.test import TestCase
+from parameterized import parameterized
+
+from dysession.backends.model import SessionDataModel
+
+
+class SessionDataModelTestCase(TestCase):
+ def test_init_without_session_key(self):
+ model = SessionDataModel()
+ self.assertIsNone(model.session_key)
+
+ def test_init_with_session_key(self):
+ model = SessionDataModel("string")
+ self.assertEqual(model.session_key, "string")
+
+ @parameterized.expand(
+ [
+ [1],
+ [1.0],
+ [True],
+ ]
+ )
+ def test_init_with_wrong_type_session_key(self, wrong_type_session_key: Any):
+ with self.assertRaises(TypeError):
+ model = SessionDataModel(wrong_type_session_key)
+
+ def test_set_attribute(self):
+ model = SessionDataModel()
+ model["good_key"] = 0
+
+ def test_set_attribute_session_key(self):
+ model = SessionDataModel()
+
+ with self.assertRaises(ValueError):
+ model["session_key"] = 0
+
+ def test_get_attribute(self):
+ model = SessionDataModel()
+ model["good_key"] = 0
+
+ self.assertEqual(model["good_key"], 0)
+
+ with self.assertRaises(AttributeError):
+ model["not_exist_key"]
+
+ def test_get_function(self):
+ model = SessionDataModel()
+ model["good_key"] = 0
+
+ self.assertEqual(model.get("good_key"), 0)
+ self.assertEqual(model.get("not_exist_key", 10), 10)
+
+ with self.assertRaises(AttributeError):
+ model.get("not_exist_key")
+
+ def test_get_function_default_value(self):
+ model = SessionDataModel()
+ model["good_key"] = 0
+
+ self.assertEqual(model.get("good_key", 1), 0)
+ self.assertIsNone(model.get("not_exist_key", default=None))
+
+ def test_del_attribute(self):
+ model = SessionDataModel()
+ model["good_key"] = 0
+
+ self.assertEqual(model["good_key"], 0)
+
+ del model["good_key"]
+
+ with self.assertRaises(AttributeError):
+ model["good_key"]
+
+ def test_pop_function(self):
+ model = SessionDataModel()
+ model["good_key"] = 0
+
+ self.assertEqual(model.get("good_key"), 0)
+ self.assertEqual(model.pop("good_key"), 0)
+
+ with self.assertRaises(AttributeError):
+ model.pop("not_exist_key")
+
+ def test_get_function_default_value(self):
+ model = SessionDataModel()
+ model["good_key"] = 0
+
+ self.assertEqual(model.get("good_key"), 0)
+ self.assertEqual(model.pop("good_key"), 0)
+
+ self.assertEqual(model.pop("good_key", 1), 1)
+
+ def test_is_empty(self):
+ model = SessionDataModel()
+ self.assertTrue(model.is_empty)
+
+ model["good_key"] = 0
+ self.assertFalse(model.is_empty)
+
+ del model["good_key"]
+ self.assertTrue(model.is_empty)
+
+ def test_iter(self):
+ model = SessionDataModel()
+
+ model["a"] = 1
+ model["b"] = 1
+ model["c"] = 1
+ model["d"] = 1
+
+ self.assertEqual(set(model), set(["a", "b", "c", "d"]))
diff --git a/tests/test_command_arg_types.py b/tests/test_command_arg_types.py
new file mode 100644
index 0000000..4fe4ead
--- /dev/null
+++ b/tests/test_command_arg_types.py
@@ -0,0 +1,21 @@
+from argparse import ArgumentTypeError
+
+from django.test import TestCase
+from parameterized import parameterized
+
+from dysession.management.commands._arg_types import positive_int
+
+
+class DysessionInitTestCase(TestCase):
+ @parameterized.expand([(1,), (2,), (3,), (4,), (5,), (6,), (7,), (8,), (9,)])
+ def test_pass_positive_int_to_positive_int(self, value):
+
+ value = positive_int(value)
+ assert value == value
+
+ @parameterized.expand([(0,), (-1,), (-2,), (-3,), (-4,)])
+ def test_pass_non_positive_number_to_positive_int(self, value):
+
+ with self.assertRaises(ArgumentTypeError):
+ value = positive_int(value)
+
diff --git a/tests/test_commands.py b/tests/test_commands.py
new file mode 100644
index 0000000..30da24c
--- /dev/null
+++ b/tests/test_commands.py
@@ -0,0 +1,48 @@
+from io import StringIO
+
+import boto3
+from django.core.management import call_command
+from django.test import TestCase
+
+
+class CommandTestCase:
+ def call_command(self, command: str, *args, stdout=None, stderr=None, **kwargs):
+ if stdout is None:
+ stdout = StringIO()
+ if stderr is None:
+ stderr = StringIO()
+
+ call_command(
+ command,
+ *args,
+ stdout=stdout,
+ stderr=stderr,
+ **kwargs,
+ )
+ return stdout.getvalue(), stderr.getvalue()
+
+
+# class DysessionInitTestCase(CommandTestCase, TestCase):
+# @mock_dynamodb
+# def test_init_dynamodb_table(self):
+
+# client = boto3.client("dynamodb")
+# print(client)
+# pass
+
+
+# class DysessionClearTestCase(CommandTestCase, TestCase):
+# def test_call_help(self):
+# out = StringIO()
+# call_command("dysession_clear", "-h", stdout=out)
+# print(out.read())
+
+# call_command("dysession_clear", *["-u", "XD"], "-h", stdout=out)
+# print(out.read())
+
+
+# class DysessionDestoryTestCase(CommandTestCase, TestCase):
+# def test_call_help(self):
+# out = StringIO()
+# call_command("dysession_destory", "-h", stdout=out)
+# print(out.read())
diff --git a/tests/test_settings.py b/tests/test_settings.py
new file mode 100644
index 0000000..91a2ed1
--- /dev/null
+++ b/tests/test_settings.py
@@ -0,0 +1,19 @@
+from django.test import TestCase
+from parameterized import parameterized
+
+from dysession.settings import get_config
+
+
+class DysessionInitTestCase(TestCase):
+ @parameterized.expand(
+ [
+ ("DYNAMODB_TABLENAME",),
+ ("PARTITION_KEY_NAME",),
+ ("SORT_KEY_NAME",),
+ ("TTL_ATTRIBUTE_NAME",),
+ ("CACHE_PERIOD",),
+ ("DYNAMODB_REGION",),
+ ]
+ )
+ def test_get_config_must_return_value_settings(self, config_key_name: str):
+ self.assertTrue(config_key_name in get_config().keys())
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 0000000..e69de29