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 +

+ django-dysession + +

+ + + codecov + + + Supported Python version badge + +
+ + + Github Issue badge + + + Lience badge + + + Downloads badge + +
+

+
+ + +## 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