├── .bumpversion.cfg ├── .editorconfig ├── .github └── workflows │ └── master.yml ├── .gitignore ├── .travis.yml ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── django_toolkit ├── __init__.py ├── concurrent │ ├── __init__.py │ └── locks.py ├── fallbacks │ ├── __init__.py │ └── circuit_breaker │ │ ├── __init__.py │ │ ├── circuit_breaker.py │ │ └── rules.py ├── logs │ ├── __init__.py │ └── filters.py ├── middlewares.py ├── oauth2 │ ├── __init__.py │ ├── apps.py │ ├── receivers.py │ └── validators.py ├── shortcuts.py └── toolkit_settings.py ├── docker-compose.yml ├── docs ├── concurrent.md ├── fallbacks.md ├── index.md ├── logs.md ├── middlewares.md ├── oauth2.md └── shortcuts.md ├── mkdocs.yml ├── pytest.ini ├── requirements-dev.txt ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── concurrent └── test_locks.py ├── fake ├── __init__.py └── fallbacks │ ├── __init__.py │ └── circuit_breaker │ ├── __init__.py │ └── rules.py ├── fallbacks ├── __init__.py └── circuit_breaker │ ├── test_circuit_breaker.py │ └── test_rules.py ├── logs ├── __init__.py └── test_filters.py ├── oauth2 ├── __init__.py ├── conftest.py ├── test_receivers.py └── test_validators.py ├── settings.py ├── test_middlewares.py └── test_shortcuts.py /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 2.2.4 3 | commit = True 4 | tag = True 5 | tag_name = {new_version} 6 | 7 | [bumpversion:file:django_toolkit/__init__.py] 8 | 9 | [bumpversion:file:setup.py] 10 | 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | indent_style = space 7 | indent_size = 4 8 | charset = utf-8 9 | 10 | [Makefile] 11 | indent_style = tab 12 | 13 | [*.yml] 14 | indent_size = 2 15 | -------------------------------------------------------------------------------- /.github/workflows/master.yml: -------------------------------------------------------------------------------- 1 | name: Python package 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | tags: 8 | - '*' 9 | pull_request: 10 | branches: 11 | - '*' 12 | 13 | jobs: 14 | build: 15 | strategy: 16 | matrix: 17 | python-version: ["2.7"] 18 | runs-on: ubuntu-20.04 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Set up Python ${{ matrix.python-version }} 22 | uses: LizardByte/setup-python-action@master 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | 26 | - name: Install dependencies 27 | run: | 28 | # if [ ${{ matrix.dependency }} == django ]; then pip install Django<2.2; fi 29 | pip install -r requirements-dev.txt 30 | pip install codecov pyOpenSSL wheel 31 | 32 | 33 | - name: Run tests 34 | run: | 35 | make test check coverage 36 | codecov 37 | 38 | - name: Build 39 | run: 40 | python setup.py sdist bdist_wheel 41 | 42 | - name: Publish a Python distribution to PyPI 43 | uses: pypa/gh-action-pypi-publish@master 44 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 45 | with: 46 | user: ${{ secrets.pypi_user }} 47 | password: ${{ secrets.pypi_password }} 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | include/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # PyInstaller 27 | # Usually these files are written by a python script from a template 28 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 29 | *.manifest 30 | *.spec 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .coverage 40 | .cache 41 | nosetests.xml 42 | coverage.xml 43 | 44 | # Translations 45 | *.mo 46 | *.pot 47 | 48 | # Django stuff: 49 | *.log 50 | 51 | # Sphinx documentation 52 | docs/_build/ 53 | 54 | # PyBuilder 55 | target/ 56 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: xenial 2 | language: python 3 | python: 4 | - "2.7" 5 | - "3.5" 6 | - "3.6" 7 | - "3.7" 8 | 9 | cache: 10 | directories: 11 | - $HOME/.cache/pip 12 | 13 | install: 14 | - pip install "$DJANGO" 15 | - pip install -r requirements-dev.txt 16 | 17 | env: 18 | matrix: 19 | - DJANGO="Django<1.11" 20 | - DJANGO="Django<1.12" 21 | - DJANGO="Django<2.1" 22 | - DJANGO="Django<2.2" 23 | 24 | script: 25 | - make check coverage 26 | 27 | after_success: 28 | - pip install codecov pyOpenSSL 29 | - codecov 30 | 31 | deploy: 32 | provider: pypi 33 | user: luizalabs 34 | distributions: "sdist bdist_wheel" 35 | password: 36 | secure: P0tYDziArYdHogReZ1x7z4DOR0s1YptI2F/dKWFShJLimQ0GtgAouZF4e2FdxgCdMfzJhZUQl6YXWrumqVbWmF/9R2BXeUQgJbPiK5dQkG5tPPLjXlFZ+9dktH86TerOgcLvXAQVFSkgtGbDHt7XOU43jg3u2UQGyezJnXqRg2mNRNC9oa2mAhIl4Qi4cZZ3V2CMJiz3z+dz3lJpKM5IyqSsCRsuC2piQqO1GsT3MiPZtVGM998n7Cc0zhKplo6+qzVsyobgEO+YnVOU55RYNzof81dtg5WwHeyTSB1oQwXq/8dTtMirLgTaC5qaPRCsnchUL7PX0ilJHI2KuiZwR+NVzQAJ5bZeN+cx5ITnKPKGxw0k0PNUko+q44aLb77kRWj54GQMYqUEIt/Xczw51FjAKECtchcwtXtZlebzN+hG9Y9K9PtYREe9Fki0z7XnSAbeNzBuZCQf0Nvbhot6iEYgDSdSWPVzu+qtHBhi2HNTvlpUgPl1riBxUkiuSwA1mQCTOHwvLDn0z/PS2Lk72aIF+6EO/Sa9tcI30zhLkRUEHn3/qRh46sOQBhmOM4lgtbdQNjda8I0yXH5OZzXUIWAdYKwPG6jHvri8Qkd1jxy0+G+44AfCrRR86j/Q63r78dOpBNxSrwzBBMhzdGLjRWV01nK5OWnkCYiw3Es/p2o= 37 | on: 38 | tags: true 39 | repo: luizalabs/django-toolkit 40 | skip_cleanup: true 41 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:2.7 2 | 3 | ENV PYTHONDONTWRITEBYTECODE 1 4 | ENV PYTHONUNBUFFERED 1 5 | 6 | RUN mkdir /app 7 | 8 | WORKDIR /app 9 | 10 | COPY . /app/ 11 | 12 | RUN pip install --upgrade pip 13 | 14 | RUN pip install -r requirements-dev.txt 15 | 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2016 LuizaLabs 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst 3 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: help 2 | 3 | help: ## This help 4 | @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) 5 | 6 | clean: ## Clean cache and temporary files 7 | @find . -name "*.pyc" | xargs rm -rf 8 | @find . -name "*.pyo" | xargs rm -rf 9 | @find . -name "__pycache__" -type d | xargs rm -rf 10 | @rm -rf *.egg-info 11 | @rm -f .coverage 12 | 13 | check: ## Run static code checks 14 | @flake8 . 15 | @isort --check 16 | 17 | test: clean ## Run unit tests 18 | @py.test -x tests/ 19 | 20 | coverage: ## Run unit tests and generate code coverage report 21 | @py.test -x --cov django_toolkit/ --cov-report=xml --cov-report=term-missing tests/ 22 | 23 | install: ## Install development dependencies 24 | @pip install -r requirements-dev.txt 25 | 26 | outdate: 27 | @pip list --outdated --format=columns ## Show outdated dependencies 28 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Django Toolkit 2 | ============== 3 | 4 | .. image:: https://travis-ci.org/luizalabs/django-toolkit.svg?branch=master 5 | :target: https://travis-ci.org/luizalabs/django-toolkit 6 | 7 | .. image:: https://codecov.io/gh/luizalabs/django-toolkit/branch/master/graph/badge.svg 8 | :target: https://codecov.io/gh/luizalabs/django-toolkit 9 | 10 | .. image:: https://readthedocs.org/projects/luizalabs-django-toolkit/badge/?version=stable 11 | :target: http://django-toolkit.readthedocs.io/en/stable/ 12 | 13 | .. image:: https://badge.fury.io/py/luizalabs-django-toolkit.svg 14 | :target: https://badge.fury.io/py/luizalabs-django-toolkit 15 | 16 | Our set of tools to develop projects using the Django framework 17 | 18 | Instalation 19 | ----------- 20 | 21 | .. code-block:: bash 22 | 23 | pip install luizalabs-django-toolkit 24 | 25 | 26 | More 27 | ---- 28 | 29 | * `Documentation `_ 30 | -------------------------------------------------------------------------------- /django_toolkit/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | version = '2.2.4' 3 | -------------------------------------------------------------------------------- /django_toolkit/concurrent/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luizalabs/django-toolkit/23a78a284d5d327c6b5c0bee2a646fc92131e4f5/django_toolkit/concurrent/__init__.py -------------------------------------------------------------------------------- /django_toolkit/concurrent/locks.py: -------------------------------------------------------------------------------- 1 | from django.core.cache import caches 2 | from django.core.cache.backends.base import DEFAULT_TIMEOUT 3 | 4 | 5 | class LockActiveError(Exception): 6 | pass 7 | 8 | 9 | class LockAcquireError(Exception): 10 | pass 11 | 12 | 13 | class LockReleaseError(Exception): 14 | pass 15 | 16 | 17 | class Lock(object): 18 | 19 | def __init__(self): 20 | self.active = False 21 | 22 | 23 | class LocalMemoryLock(Lock): 24 | """ 25 | A context manager to handle a lock status in memory 26 | 27 | The active status is True on __enter__ and False on __exit__ 28 | """ 29 | 30 | def __enter__(self): 31 | 32 | if self.active: 33 | raise LockActiveError('Lock is already active') 34 | 35 | self.active = True 36 | return self 37 | 38 | def __exit__(self, *args, **kwargs): 39 | self.active = False 40 | 41 | 42 | class CacheLock(Lock): 43 | """ 44 | A context manager to handle a lock status using Django cache 45 | 46 | The active status is True on __enter__ and False on __exit__ 47 | 48 | The cache will be deleted on context __exit__ 49 | """ 50 | 51 | def __init__( 52 | self, 53 | key, 54 | cache_alias='default', 55 | expire=DEFAULT_TIMEOUT, 56 | raise_exception=True, 57 | delete_on_exit=True 58 | ): 59 | super(CacheLock, self).__init__() 60 | self._key = key 61 | self._expire = expire 62 | self.cache = caches[cache_alias] 63 | self.raise_exception = raise_exception 64 | self.delete_on_exit = delete_on_exit 65 | 66 | def __enter__(self): 67 | try: 68 | self.active = self.cache.add(self._key, True, self._expire) 69 | except Exception as e: 70 | raise LockAcquireError( 71 | 'Could not acquire a lock. Caused by: {}'.format(e) 72 | ) 73 | 74 | if not self.active and self.raise_exception: 75 | raise LockActiveError('For key {key}'.format(key=self._key)) 76 | 77 | return self 78 | 79 | def __exit__(self, *args, **kwargs): 80 | if self.active and self.delete_on_exit: 81 | try: 82 | self.cache.delete(self._key) 83 | except Exception as e: 84 | raise LockReleaseError( 85 | 'Could not release a lock. Caused by: {}'.format(e) 86 | ) 87 | 88 | self.active = False 89 | -------------------------------------------------------------------------------- /django_toolkit/fallbacks/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luizalabs/django-toolkit/23a78a284d5d327c6b5c0bee2a646fc92131e4f5/django_toolkit/fallbacks/__init__.py -------------------------------------------------------------------------------- /django_toolkit/fallbacks/circuit_breaker/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from .circuit_breaker import CircuitBreaker, circuit_breaker # noqa 3 | -------------------------------------------------------------------------------- /django_toolkit/fallbacks/circuit_breaker/circuit_breaker.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import inspect 3 | import logging 4 | from functools import wraps 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | class CircuitBreaker: 10 | 11 | def __init__( 12 | self, 13 | rule, 14 | cache, 15 | failure_exception, 16 | failure_timeout=None, 17 | circuit_timeout=None, 18 | catch_exceptions=None, 19 | ): 20 | self.rule = rule 21 | self.cache = cache 22 | self.failure_timeout = failure_timeout 23 | self.circuit_timeout = circuit_timeout 24 | self.circuit_cache_key = 'circuit_{}'.format(rule.failure_cache_key) 25 | self.failure_exception = failure_exception 26 | self.catch_exceptions = catch_exceptions or (Exception,) 27 | 28 | @property 29 | def is_circuit_open(self): 30 | return self.cache.get(self.circuit_cache_key) or False 31 | 32 | @property 33 | def total_failures(self): 34 | return self.cache.get(self.rule.failure_cache_key) or 0 35 | 36 | @property 37 | def total_requests(self): 38 | return self.cache.get(self.rule.request_cache_key) or 0 39 | 40 | def open_circuit(self): 41 | self.cache.set(self.circuit_cache_key, True, self.circuit_timeout) 42 | 43 | # Delete the cache key to mitigate multiple sequentials openings 44 | # when a key is created accidentally without timeout (from an incr 45 | # operation) 46 | self.cache.delete(self.rule.failure_cache_key) 47 | self.cache.delete(self.rule.request_cache_key) 48 | 49 | logger.critical( 50 | 'Open circuit for {failure_cache_key} {cicuit_cache_key}'.format( 51 | failure_cache_key=self.rule.failure_cache_key, 52 | cicuit_cache_key=self.circuit_cache_key 53 | ) 54 | ) 55 | 56 | def __enter__(self): 57 | if self.is_circuit_open: 58 | raise self.failure_exception 59 | 60 | return self 61 | 62 | def __exit__(self, exc_type, exc_value, traceback): 63 | self._increase_request_count() 64 | if inspect.isclass(exc_type) and any( 65 | issubclass(exc_type, exception_class) 66 | for exception_class in self.catch_exceptions 67 | ): 68 | 69 | self._increase_failure_count() 70 | 71 | if self.rule.should_open_circuit( 72 | total_failures=self.total_failures, 73 | total_requests=self.total_requests 74 | ): 75 | self.open_circuit() 76 | 77 | logger.info( 78 | 'Max failures exceeded by: {}'.format( 79 | self.rule.failure_cache_key 80 | ) 81 | ) 82 | 83 | raise self.failure_exception 84 | 85 | def __call__(self, func): 86 | @wraps(func) 87 | def inner(*args, **kwargs): 88 | with self: 89 | return func(*args, **kwargs) 90 | return inner 91 | 92 | def _increase_failure_count(self): 93 | if ( 94 | self.is_circuit_open or 95 | not self.rule.should_increase_failure_count() 96 | ): 97 | return 98 | 99 | # Between the cache.add and cache.incr, the cache MAY expire, 100 | # which will lead to a circuit that will eventually open 101 | self.cache.add(self.rule.failure_cache_key, 0, self.failure_timeout) 102 | total = 0 103 | try: 104 | total = self.cache.incr(self.rule.failure_cache_key) 105 | except ValueError: 106 | logger.warning('Key {key} expired!' 107 | .format(key=self.rule.failure_cache_key)) 108 | 109 | self.rule.log_increase_failures( 110 | total_failures=total, 111 | total_requests=self.total_requests 112 | ) 113 | 114 | def _increase_request_count(self): 115 | if ( 116 | self.is_circuit_open or 117 | not self.rule.should_increase_request_count() 118 | ): 119 | return 120 | 121 | self.cache.add(self.rule.request_cache_key, 0, self.failure_timeout) 122 | # To calculate the exact percentage, the cache of requests and the 123 | # cache of failures must expire at the same time. 124 | if self.rule.should_increase_failure_count(): 125 | self.cache.add( 126 | self.rule.failure_cache_key, 0, self.failure_timeout 127 | ) 128 | 129 | try: 130 | self.cache.incr(self.rule.request_cache_key) 131 | except ValueError: 132 | logger.warning('Key {key} expired!' 133 | .format(key=self.rule.request_cache_key)) 134 | 135 | 136 | circuit_breaker = CircuitBreaker 137 | -------------------------------------------------------------------------------- /django_toolkit/fallbacks/circuit_breaker/rules.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import abc 3 | import logging 4 | 5 | logger = logging.getLogger(__name__) 6 | 7 | 8 | class Rule(object): 9 | __metaclass__ = abc.ABCMeta 10 | 11 | def __init__(self, failure_cache_key, request_cache_key=None): 12 | self.failure_cache_key = failure_cache_key 13 | self.request_cache_key = request_cache_key 14 | 15 | @abc.abstractmethod 16 | def should_open_circuit(self, total_failures, total_requests): 17 | pass 18 | 19 | def should_increase_failure_count(self): 20 | return self.failure_cache_key is not None 21 | 22 | def should_increase_request_count(self): 23 | return self.request_cache_key is not None 24 | 25 | @abc.abstractmethod 26 | def log_increase_failures(self, total_failures, total_requests): 27 | pass 28 | 29 | 30 | class MaxFailuresRule(Rule): 31 | 32 | def __init__(self, max_failures, failure_cache_key): 33 | super(MaxFailuresRule, self).__init__( 34 | failure_cache_key=failure_cache_key, 35 | ) 36 | self.max_failures = max_failures 37 | 38 | def should_open_circuit(self, total_failures, total_requests): 39 | return total_failures >= self.max_failures 40 | 41 | def log_increase_failures(self, total_failures, total_requests): 42 | logger.info( 43 | 'Increase failure for: {key} - ' 44 | 'max failures {max_failures} - ' 45 | 'total requests {total_requests} - ' 46 | 'total failures {total_failures}'.format( 47 | key=self.failure_cache_key, 48 | max_failures=self.max_failures, 49 | total_requests=total_requests, 50 | total_failures=total_failures 51 | ) 52 | ) 53 | 54 | 55 | class PercentageFailuresRule(Rule): 56 | 57 | def __init__( 58 | self, 59 | max_failures_percentage, 60 | failure_cache_key, 61 | min_accepted_requests, 62 | request_cache_key, 63 | ): 64 | super(PercentageFailuresRule, self).__init__( 65 | failure_cache_key=failure_cache_key, 66 | request_cache_key=request_cache_key, 67 | ) 68 | self.max_failures_percentage = max_failures_percentage 69 | self.min_accepted_requests = min_accepted_requests 70 | 71 | def _get_percentage_failures(self, total_failures, total_requests): 72 | if total_requests > 0: 73 | return (total_failures * 100) / total_requests 74 | return 0 75 | 76 | def should_open_circuit(self, total_failures, total_requests): 77 | percentage_failures = self._get_percentage_failures( 78 | total_failures=total_failures, 79 | total_requests=total_requests 80 | ) 81 | return all([ 82 | total_requests > self.min_accepted_requests, 83 | percentage_failures >= self.max_failures_percentage 84 | ]) 85 | 86 | def log_increase_failures(self, total_failures, total_requests): 87 | logger.info( 88 | 'Increase failure for: {key} - ' 89 | 'max failures {max_failures_percentage}% - ' 90 | 'total failures {total_failures} - ' 91 | 'min accepted requests {min_accepted_requests} - ' 92 | 'total requests {total_requests} - ' 93 | 'percentage failures {percentage_failures}%'.format( 94 | key=self.failure_cache_key, 95 | max_failures_percentage=self.max_failures_percentage, 96 | total_failures=total_failures, 97 | min_accepted_requests=self.min_accepted_requests, 98 | total_requests=total_requests, 99 | percentage_failures=self._get_percentage_failures( 100 | total_failures=total_failures, 101 | total_requests=total_requests 102 | ), 103 | ) 104 | ) 105 | -------------------------------------------------------------------------------- /django_toolkit/logs/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luizalabs/django-toolkit/23a78a284d5d327c6b5c0bee2a646fc92131e4f5/django_toolkit/logs/__init__.py -------------------------------------------------------------------------------- /django_toolkit/logs/filters.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import logging 3 | import socket 4 | 5 | 6 | class AddHostName(logging.Filter): 7 | """ 8 | Add the %(hostname)s entry to log record, so that it can be included in the 9 | logs using a formatter 10 | """ 11 | hostname = socket.gethostname() 12 | 13 | def filter(self, record): 14 | record.hostname = self.hostname 15 | return True 16 | 17 | 18 | class IgnoreIfContains(logging.Filter): 19 | """ 20 | Ignore log record if message entry contains any substring set on 21 | log filter configuration. 22 | """ 23 | 24 | def __init__(self, substrings=None): 25 | self.substrings = substrings or [] 26 | 27 | def filter(self, record): 28 | message = record.getMessage() 29 | 30 | return not any( 31 | substring in message 32 | for substring in self.substrings 33 | ) 34 | -------------------------------------------------------------------------------- /django_toolkit/middlewares.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import logging 3 | 4 | from django.utils.deprecation import MiddlewareMixin 5 | 6 | from .shortcuts import get_oauth2_app 7 | from .toolkit_settings import API_VERSION, MIDDLEWARE_ACCESS_LOG_FORMAT 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class VersionHeaderMiddleware(MiddlewareMixin): 13 | """ 14 | Add a X-API-Version header to the response. The version is taken from 15 | TOOLKIT['API_VERSION'] setting. 16 | """ 17 | 18 | def process_response(self, request, response): 19 | response['X-API-Version'] = API_VERSION 20 | return response 21 | 22 | 23 | class AccessLogMiddleware(MiddlewareMixin): 24 | 25 | LOG_FORMAT = MIDDLEWARE_ACCESS_LOG_FORMAT 26 | UNKNOWN_APP_NAME = 'unknown' 27 | 28 | def process_response(self, request, response): 29 | app = get_oauth2_app(request) 30 | 31 | app_name = getattr(app, 'name', self.UNKNOWN_APP_NAME) 32 | 33 | logger.info( 34 | self.LOG_FORMAT.format(app_name=app_name, 35 | request=request, 36 | response=response) 37 | ) 38 | 39 | return response 40 | -------------------------------------------------------------------------------- /django_toolkit/oauth2/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = 'django_toolkit.oauth2.apps.OAuth2AppConfig' 2 | -------------------------------------------------------------------------------- /django_toolkit/oauth2/apps.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.apps import AppConfig 3 | 4 | 5 | class OAuth2AppConfig(AppConfig): 6 | name = 'django_toolkit.oauth2' 7 | verbose_name = 'OAuth2' 8 | 9 | def ready(self): 10 | super(OAuth2AppConfig, self).ready() 11 | from . import receivers # noqa 12 | -------------------------------------------------------------------------------- /django_toolkit/oauth2/receivers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.core.cache import caches 3 | from django.db.models.signals import post_delete 4 | from django.dispatch import receiver 5 | from oauth2_provider.models import get_access_token_model 6 | 7 | from django_toolkit import toolkit_settings 8 | 9 | cache = caches[toolkit_settings.ACCESS_TOKEN_CACHE_BACKEND] 10 | AccessToken = get_access_token_model() 11 | 12 | 13 | @receiver(post_delete, sender=AccessToken) 14 | def invalidate_token_cache(sender, instance, **kwargs): 15 | cache.delete(instance.token) 16 | -------------------------------------------------------------------------------- /django_toolkit/oauth2/validators.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.core.cache import caches 3 | from django.utils import timezone 4 | from oauth2_provider.models import get_access_token_model 5 | from oauth2_provider.oauth2_validators import OAuth2Validator 6 | 7 | from django_toolkit import toolkit_settings 8 | 9 | cache = caches[toolkit_settings.ACCESS_TOKEN_CACHE_BACKEND] 10 | AccessToken = get_access_token_model() 11 | 12 | 13 | class CachedOAuth2Validator(OAuth2Validator): 14 | 15 | def get_queryset(self): 16 | return AccessToken.objects.select_related('application', 'user') 17 | 18 | def validate_bearer_token(self, token, scopes, request): 19 | if not token: 20 | return False 21 | 22 | try: 23 | access_token = self._get_access_token(token) 24 | if access_token.is_valid(scopes): 25 | request.client = access_token.application 26 | request.user = access_token.user 27 | request.scopes = scopes 28 | 29 | # this is needed by django rest framework 30 | request.access_token = access_token 31 | return True 32 | return False 33 | except AccessToken.DoesNotExist: 34 | return False 35 | 36 | def _get_access_token(self, token): 37 | access_token = cache.get(token) 38 | 39 | if access_token is None: 40 | access_token = self.get_queryset().get( 41 | token=token 42 | ) 43 | now = timezone.now() 44 | if (access_token.expires > now): 45 | timeout = (access_token.expires - now).seconds 46 | 47 | cache.set(token, access_token, timeout) 48 | 49 | return access_token 50 | -------------------------------------------------------------------------------- /django_toolkit/shortcuts.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | def get_oauth2_app(request): 3 | """ 4 | Return the Application object from a Django Rest Framework request when 5 | authenticated with a Django OAuth Toolkit access token, or return None 6 | when it is not 7 | """ 8 | try: 9 | return request.auth.application 10 | except AttributeError: 11 | return None 12 | -------------------------------------------------------------------------------- /django_toolkit/toolkit_settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | _toolkit_settings = getattr(settings, 'TOOLKIT', {}) 4 | 5 | ACCESS_TOKEN_CACHE_BACKEND = _toolkit_settings.get( 6 | 'ACCESS_TOKEN_CACHE_BACKEND', 7 | 'access_token' 8 | ) 9 | 10 | API_VERSION = _toolkit_settings.get('API_VERSION') 11 | 12 | MIDDLEWARE_ACCESS_LOG_FORMAT = _toolkit_settings.get( 13 | 'MIDDLEWARE_ACCESS_LOG_FORMAT', 14 | u'[{app_name}] {response.status_code} {request.method} {request.path}' 15 | ) 16 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | app: 5 | build: . 6 | volumes: 7 | - .:/app 8 | command: "make test" -------------------------------------------------------------------------------- /docs/concurrent.md: -------------------------------------------------------------------------------- 1 | # Concurrent 2 | 3 | ## Locks 4 | 5 | The lock context is used to indicate that a block of code entered in a [critical section](https://en.wikipedia.org/wiki/Critical_section). 6 | 7 | ### Local memory lock 8 | 9 | Use in memory lock. 10 | 11 | #### Example 12 | 13 | ```python 14 | from django_toolkit.concurrent.locks import LocalMemoryLock 15 | 16 | def run(*args, **kwargs): 17 | with LocalMemoryLock() as lock: 18 | assert lock.active is True 19 | # do some stuff 20 | ``` 21 | 22 | ### Cache lock 23 | 24 | `CacheLock` uses django cache system and `default` cache alias by default. 25 | By default, the context manager raises `LockActiveError` when the lock was already active. 26 | 27 | #### Arguments 28 | 29 | `key` 30 | 31 | Cache key. 32 | 33 | `cache_alias` 34 | 35 | Django cache alias. 36 | 37 | `expire` 38 | 39 | Time in seconds to expire the cache. 40 | This argument defaults to the backend timeout configuration. 41 | 42 | `raise_exception` 43 | 44 | Condition to raise `LockActiveError` when lock was already active. 45 | 46 | `delete_on_exit` 47 | 48 | By default the value of this argument is `True` to have the default `CacheLock` behavior 49 | When set to `False`, the lock persists during the key expiration time in the cache 50 | 51 | #### Example 52 | 53 | Django cache config 54 | 55 | ```python 56 | CACHES = { 57 | 'default': { 58 | 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 59 | }, 60 | 'locks': { 61 | 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 62 | }, 63 | } 64 | ``` 65 | 66 | Basic usage. 67 | 68 | ```python 69 | from django_toolkit.concurrent.locks import CacheLock 70 | 71 | def run(*args, **kwargs): 72 | with CacheLock(key='key') as lock: 73 | assert lock.active 74 | # do some stuff 75 | ``` 76 | 77 | You can set the timeout and alias of cache. 78 | 79 | ```python 80 | from django_toolkit.concurrent.locks import CacheLock 81 | 82 | def run(*args, **kwargs): 83 | with CacheLock(key='key', cache_alias='lock', expire=10): 84 | # do some stuff 85 | ``` 86 | 87 | You can control lock to don't raise `LockActiveError` exception. 88 | The attribute `active` will indicate whether lock acquiring was successful or 89 | not. 90 | 91 | ```python 92 | from django_toolkit.concurrent.locks import CacheLock 93 | 94 | def run(*args, **kwargs): 95 | with CacheLock(key='key', raise_exception=False) as lock: 96 | assert lock.active 97 | 98 | with CacheLock(key='key', raise_exception=False) as lock: 99 | if lock.active: 100 | # do some stuff, lock is now active 101 | else: 102 | # do other stuff, lock was not acquired 103 | ``` 104 | 105 | You can persist the lock in the cache by setting delete_on_exit = False. 106 | This makes the key remains in cache until the end expiration time 107 | 108 | ```python 109 | from django_toolkit.concurrent.locks import CacheLock 110 | 111 | def run(*args, **kwargs): 112 | with CacheLock(key='key', delete_on_exit=False) as lock: 113 | assert lock.active 114 | assert lock.cache.get(lock._key) 115 | ``` 116 | -------------------------------------------------------------------------------- /docs/fallbacks.md: -------------------------------------------------------------------------------- 1 | # Fallbacks 2 | 3 | ## Circuit Breaker 4 | 5 | An implementation of [Circuit Breaker](http://martinfowler.com/bliki/CircuitBreaker.html) pattern. 6 | 7 | #### class CircuitBreaker(rule, cache, failure_exception, failure_timeout=None, circuit_timeout=None, catch_exceptions=None) 8 | 9 | You can use the circuit break with a context manager or a decorator 10 | 11 | ```python 12 | from django_toolkit.failures.circuit_breaker import CircuitBreaker 13 | 14 | with CircuitBreaker(): 15 | ``` 16 | 17 | ```python 18 | from django_toolkit.failures.circuit_breaker import circuit_breaker 19 | 20 | @circuit_breaker() 21 | def some_func(): 22 | pass 23 | ``` 24 | 25 | #### Arguments 26 | 27 | `rule` 28 | 29 | Instance of class [Rule](#rule). 30 | 31 | `cache` 32 | 33 | Django cache object. 34 | 35 | `failure_exception` 36 | 37 | Exception to be raised when it exceeds the maximum number of errors and when the circuit is open. 38 | 39 | `failure_timeout` 40 | 41 | This value is set on first error. It is used to validate the number of errors by time. 42 | 43 | `circuit_timeout` 44 | 45 | Time that the circuit will be open. 46 | 47 | `catch_exceptions` 48 | 49 | List of exceptions catched to increase the number of errors. 50 | 51 | ## Rule 52 | 53 | Abstract Base Class that defines rules to open circuit 54 | 55 | ### MaxFailuresRule 56 | Rule to open circuit based on maximum number of failures 57 | 58 | #### class MaxFailuresRule(max_failures, failure_cache_key) 59 | 60 | #### Arguments 61 | 62 | `max_failures` 63 | 64 | Maximum number of errors. 65 | 66 | `failure_cache_key` 67 | 68 | Cache key where the number of errors is incremented. 69 | 70 | #### Maximum failures example 71 | 72 | ```python 73 | from django_toolkit.failures.circuit_breaker import CircuitBreaker 74 | from django_toolkit.failures.circuit_breaker.rules import MaxFailuresRule 75 | from django.core.cache import caches 76 | 77 | cache = caches['default'] 78 | 79 | class MyException(Exception): 80 | pass 81 | 82 | with CircuitBreaker( 83 | rule=MaxFailuresRule( 84 | max_failures=1000, 85 | failure_cache_key='failure_cache_key', 86 | ), 87 | cache=cache, 88 | failure_exception=MyException, 89 | failure_timeout=3600, 90 | circuit_timeout=5000, 91 | catch_exceptions=(ValueError, StandardError, LookupError), 92 | ) as circuit_breaker: 93 | assert not circuit_breaker.is_circuit_open 94 | ``` 95 | 96 | ### PercentageFailuresRule 97 | Rule to open circuit based on a percentage of failures. 98 | 99 | #### class PercentageFailuresRule(max_failures_percentage, failure_cache_key, min_accepted_requests, request_cache_key) 100 | 101 | #### Arguments 102 | 103 | `max_failures_percentage` 104 | 105 | Maximum percentage of errors. 106 | 107 | `failure_cache_key` 108 | 109 | Cache key where the number of errors is incremented. 110 | 111 | `min_accepted_requests` 112 | 113 | Minimum number of requests accepted to not open circuit breaker. 114 | 115 | `request_cache_key` 116 | 117 | Cache key where the number of requests is incremented. 118 | 119 | #### Maximum percentage failures example 120 | 121 | ```python 122 | from django_toolkit.failures.circuit_breaker import CircuitBreaker 123 | from django_toolkit.failures.circuit_breaker.rules import ( 124 | PercentageFailuresRule 125 | ) 126 | from django.core.cache import caches 127 | 128 | cache = caches['default'] 129 | 130 | class MyException(Exception): 131 | pass 132 | 133 | 134 | with CircuitBreaker( 135 | rule=PercentageFailuresRule( 136 | max_failures_percentage=60, 137 | failure_cache_key='failure_cache_key', 138 | min_accepted_requests=100, 139 | request_cache_key='request_cache_key', 140 | ), 141 | cache=cache, 142 | failure_exception=MyException, 143 | failure_timeout=3600, 144 | circuit_timeout=5000, 145 | catch_exceptions=(ValueError, StandardError, LookupError), 146 | ) as circuit_breaker: 147 | assert not circuit_breaker.is_circuit_open 148 | ``` 149 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | Welcome to Django Toolkit 2 | ========================= 3 | 4 | Django Toolkit is the set of useful tools we use at Luizalabs to develop 5 | projects using the [Django web framework][django-website]. 6 | 7 | This package includes the utility modules: 8 | 9 | * [concurrent](concurrent) 10 | * [fallbacks](fallbacks) 11 | * [logs](logs) 12 | * [middlewares](middlewares) 13 | * [oauth2](oauth2) 14 | * [shortcuts](shortcuts) 15 | 16 | [django-website]: https://www.djangoproject.com/ 17 | -------------------------------------------------------------------------------- /docs/logs.md: -------------------------------------------------------------------------------- 1 | Logs 2 | ==== 3 | 4 | Logs are critical to monitor the production environment. So we have some tools 5 | that, even though aren't Django exclusive, helps us interact with the native 6 | python logging module. 7 | 8 | Filters 9 | ------- 10 | 11 | >Filters can be used by Handlers and Loggers for more sophisticated 12 | filtering than is provided by levels. The base filter class only allows 13 | events which are below a certain point in the logger hierarchy. 14 | > 15 | >— [Python Docs][cite] 16 | 17 | [cite]: https://docs.python.org/3/library/logging.html#filter-objects 18 | 19 | Filters can remove some records from the output, but they also can include 20 | additional attributes to the [LogRecord object][log-record], so that we can use 21 | with a [formatter][log-formatter]. 22 | 23 | [log-formatter]: https://docs.python.org/3/library/logging.html#formatter-objects 24 | [log-record]: https://docs.python.org/3/library/logging.html#logrecord-objects 25 | 26 | 27 | ### AddHostName 28 | 29 | Adds the `%(hostname)s` entry to the log record. Its `filter` method always 30 | return `True`, so that no log record is removed from the output by this filter. 31 | 32 | In order to use it, include a filter entry in your logging dictconfig. 33 | 34 | #### Example 35 | 36 | ```python 37 | LOGGING = { 38 | 'filters': { 39 | 'add_hostname': { 40 | '()': 'django_toolkit.logs.filters.AddHostName', 41 | } 42 | }, 43 | 'formatters': { 44 | 'simple': { 45 | 'format': '%(hostname)s %(levelname)s %(name)s %(message)s' 46 | # hostname is now available 47 | }, 48 | }, 49 | } 50 | ``` 51 | 52 | ### IgnoreIfContains 53 | 54 | Ignore log record if message entry contains any substring set on log filter configuration. 55 | 56 | In order to use it, include a filter entry in your logging dictconfig. 57 | 58 | #### Example 59 | 60 | ```python 61 | LOGGING = { 62 | 'version': 1, 63 | 'filters': { 64 | 'ignore_if_contains': { 65 | '()': 'django_toolkit.logs.filters.IgnoreIfContains', 66 | 'substrings': ['/ping', '/healthcheck'], 67 | } 68 | }, 69 | 'handlers': { 70 | 'console': { 71 | 'class': 'logging.StreamHandler', 72 | 'filters': ['ignore_if_contains'] 73 | } 74 | }, 75 | 'root': { 76 | 'level': 'DEBUG', 77 | 'handlers': ['console'] 78 | }, 79 | } 80 | ``` 81 | -------------------------------------------------------------------------------- /docs/middlewares.md: -------------------------------------------------------------------------------- 1 | Middlewares 2 | =========== 3 | 4 | > Middleware is a framework of hooks into Django’s request/response processing. 5 | It’s a light, low-level “plugin” system for globally altering Django’s input 6 | or output. 7 | > 8 | >— [Django Docs][cite] 9 | 10 | [cite]: https://docs.djangoproject.com/pt-br/1.9/topics/http/middleware/ 11 | 12 | Middlewares must be [registered][register-middleware] in the Django's 13 | `MIDDLEWARE_CLASSES` setting. 14 | 15 | [register-middleware]: https://docs.djangoproject.com/pt-br/1.9/topics/http/middleware/#activating-middleware 16 | 17 | ### VersionHeaderMiddleware 18 | 19 | `django_toolkit.middlewares.VersionHeaderMiddleware` 20 | 21 | Adds a `X-API-Version` header to the Django's response. In order to use this 22 | middleware, you should fill the `API_VERSION` key in the `TOOLKIT` setting. 23 | 24 | 25 | ### AccessLogMiddleware 26 | 27 | `django_toolkit.middlewares.AccessLogMiddleware` 28 | 29 | Creates an access log entry with this format: 30 | 31 | ``` 32 | [{app_name}] {response.status_code} {request.method} {request.path} 33 | ``` 34 | 35 | You can specify the log format in the `TOOLKIT` settings variable. 36 | Example: 37 | 38 | ```python 39 | # toolkit settings 40 | TOOLKIT = { 41 | 'MIDDLEWARE_ACCESS_LOG_FORMAT': '{app_name} {request.method} {response.status_code}' 42 | } 43 | -------------------------------------------------------------------------------- /docs/oauth2.md: -------------------------------------------------------------------------------- 1 | Oauth2 2 | ====== 3 | 4 | Oauth2 is a django app that will can be used to cache your `django-oauth-toolkit` 5 | access token model. 6 | 7 | 8 | Usage 9 | ----- 10 | To start caching your api access tokens, add `django_toolkit.oauth2` to your 11 | `INSTALLED_APPS` and then add the oauth2 validator class in the `OAUTH2_PROVIDER` 12 | settings. 13 | 14 | 15 | Example: 16 | ```python 17 | OAUTH2_PROVIDER = { 18 | 'OAUTH2_VALIDATOR_CLASS': 'django_toolkit.oauth2.validators.CachedOAuth2Validator', 19 | } 20 | ``` 21 | 22 | You can specify wich cache you want to use by setting the cache name 23 | in the `TOOLKIT` settings variable. If no name is specified, `access_token` will be used. 24 | Example: 25 | ```python 26 | # toolkit settings 27 | TOOLKIT = { 28 | 'ACCESS_TOKEN_CACHE_BACKEND': 'access_token' 29 | } 30 | 31 | # django cache settings 32 | CACHES = { 33 | 'access_token': { 34 | 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 35 | 'KEY_PREFIX': 'token', 36 | } 37 | } 38 | ``` 39 | 40 | If you want to provide a custom queryset, you can subclass `CachedOAuth2Validator` 41 | and override the `get_queryset` method. 42 | 43 | Example (Python 3): 44 | 45 | ```python 46 | # custom_validator.py 47 | from oauth2_provider.models import AccessToken 48 | 49 | from django_toolkit.oauth2.validators import CachedOAuth2Validator 50 | 51 | class CustomOAuth2QuerySetValidator(CachedOAuth2Validator): 52 | 53 | def get_queryset(self): 54 | return super().get_queryset().prefetch_related( 55 | 'application__custom_table' 56 | ) 57 | 58 | # settings 59 | OAUTH2_PROVIDER = { 60 | 'OAUTH2_VALIDATOR_CLASS': 'custom_validator.CustomOAuth2QuerySetValidator', 61 | } 62 | ``` 63 | -------------------------------------------------------------------------------- /docs/shortcuts.md: -------------------------------------------------------------------------------- 1 | Shortcuts 2 | ========= 3 | 4 | Like [Django's shortcuts][django-shortcuts], `django_toolkit.shortcuts` provide 5 | some helper functions to day-to-day development. 6 | 7 | [django-shortcuts]: https://docs.djangoproject.com/en/1.10/topics/http/shortcuts/ 8 | 9 | 10 | ### get_oauth2_app 11 | 12 | `django_toolkit.shortcuts.get_oauth2_app` 13 | 14 | Return the Application object from a *Django Rest Framework* request when 15 | authenticated with a *Django OAuth Toolkit* access token, or return `None` when 16 | it is not. 17 | 18 | #### Arguments 19 | 20 | `request` 21 | 22 | The Django's request object 23 | 24 | 25 | #### Example 26 | 27 | ```python 28 | from rest_framework.decorators import api_view 29 | from django_toolkit import shortcuts 30 | 31 | @api_view() 32 | def app_view(request): 33 | app = shortcuts.get_oauth2_app(request) 34 | return Response({"app": app.name}) 35 | ``` 36 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: LuizaLabs Django Toolkit 2 | theme: readthedocs 3 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE=tests.settings 3 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | -e . 2 | bumpversion==0.5.3 3 | django-oauth-toolkit==1.0.0 4 | djangorestframework==3.8.2 5 | flake8==3.5.0 6 | isort==4.3.4 7 | mkdocs==1.0.4 8 | mock==2.0.0 9 | model-mommy==1.6.0 10 | pytest-cov==2.6.0 11 | pytest-django==3.4.3 12 | pytest==3.8.2 13 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal=1 3 | 4 | [isort] 5 | known_first_party=django_toolkit,tests 6 | atomic=true 7 | line_length=79 8 | multi_line_output=3 9 | use_parentheses=true 10 | not_skip = __init__.py 11 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | 4 | from setuptools import find_packages, setup 5 | 6 | 7 | def read(fname): 8 | path = os.path.join(os.path.dirname(__file__), fname) 9 | with open(path) as f: 10 | return f.read() 11 | 12 | 13 | setup( 14 | name='luizalabs-django-toolkit', 15 | version='2.2.4', 16 | description=( 17 | 'The LuizaLabs set of tools ' 18 | 'to develop projects using the Django framework' 19 | ), 20 | long_description=read('README.rst'), 21 | author='Luizalabs', 22 | author_email='pypi@luizalabs.com', 23 | url='https://github.com/luizalabs/django-toolkit', 24 | keywords='django tools logs middleware utils', 25 | install_requires=[ 26 | 'Django>=1.8', 27 | ], 28 | extras_require={ 29 | 'oauth2': ['django-oauth-toolkit==1.0.0'], 30 | }, 31 | packages=find_packages(exclude=[ 32 | 'tests*' 33 | ]), 34 | classifiers=[ 35 | 'Framework :: Django', 36 | 'Intended Audience :: Developers', 37 | 'License :: OSI Approved :: MIT License', 38 | 'Operating System :: OS Independent', 39 | 'Programming Language :: Python :: 2', 40 | 'Programming Language :: Python :: 3', 41 | ] 42 | ) 43 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luizalabs/django-toolkit/23a78a284d5d327c6b5c0bee2a646fc92131e4f5/tests/__init__.py -------------------------------------------------------------------------------- /tests/concurrent/test_locks.py: -------------------------------------------------------------------------------- 1 | import mock 2 | import pytest 3 | 4 | from django_toolkit.concurrent.locks import ( 5 | CacheLock, 6 | LocalMemoryLock, 7 | LockAcquireError, 8 | LockActiveError, 9 | LockReleaseError 10 | ) 11 | 12 | 13 | class TestLocalMemoryLock: 14 | 15 | @pytest.fixture 16 | def lock(self): 17 | return LocalMemoryLock() 18 | 19 | def test_should_not_be_active_on_create(self, lock): 20 | assert not lock.active 21 | 22 | def test_should_be_active_on_enter(self, lock): 23 | with lock: 24 | assert lock.active 25 | 26 | def test_should_not_be_active_on_exit(self, lock): 27 | with lock: 28 | pass 29 | 30 | assert not lock.active 31 | 32 | def test_should_raise_exception_when_lock_is_already_active(self, lock): 33 | with pytest.raises(LockActiveError): 34 | with lock: 35 | with lock: 36 | pass 37 | 38 | 39 | class TestCacheLock: 40 | 41 | def test_should_be_true_when_lock_is_acquired(self): 42 | with CacheLock(key='test') as lock: 43 | assert lock.active 44 | 45 | def test_should_release_lock(self): 46 | with CacheLock(key='test') as lock: 47 | pass 48 | 49 | assert not lock.active 50 | 51 | def test_should_raise_exception_when_try_lock_some_lock_key(self): 52 | with pytest.raises(LockActiveError): 53 | with CacheLock(key='test', expire=1000): 54 | with CacheLock(key='test'): 55 | pass 56 | 57 | def test_should_not_raise_exception_when_raise_exception_is_false(self): 58 | with CacheLock(key='test', expire=1000) as lock: 59 | assert lock.active is True 60 | 61 | with CacheLock(key='test', raise_exception=False) as lock: 62 | assert lock.active is False 63 | 64 | def test_should_use_default_cache_timeout_when_expire_is_not_given(self): 65 | with CacheLock(cache_alias='explicit_timeout', key='test') as lock: 66 | assert lock.cache.get(lock._key) 67 | 68 | def test_should_expire_immediately_when_expire_is_zero(self): 69 | with CacheLock(key='test', expire=0) as lock: 70 | assert not lock.cache.get(lock._key) 71 | 72 | def test_should_not_release_when_lock_is_already_acquired(self): 73 | """ 74 | It should release the lock only if it was acquired successfully. 75 | A lock that do not acquired a lock should not release it. 76 | """ 77 | with CacheLock(key='test', raise_exception=False) as lock: 78 | with CacheLock(key='test', raise_exception=False): 79 | pass 80 | 81 | assert lock.cache.get(lock._key) 82 | 83 | assert not lock.cache.get(lock._key) 84 | 85 | def test_should_not_release_when_lock_when_lock_active_error_is_raised( 86 | self 87 | ): 88 | with CacheLock(key='test') as lock: 89 | with pytest.raises(LockActiveError): 90 | with CacheLock(key='test'): 91 | pass 92 | 93 | assert lock.cache.get(lock._key) 94 | 95 | def test_should_persist_lock_when_delete_on_exit_is_false( 96 | self 97 | ): 98 | with CacheLock(key='test_persist', delete_on_exit=False) as lock: 99 | assert lock.active 100 | assert lock.cache.get(lock._key) 101 | 102 | def test_should_release_lock_when_delete_on_exit_is_true( 103 | self 104 | ): 105 | with CacheLock(key='test', delete_on_exit=True) as lock: 106 | pass 107 | assert not lock.cache.get(lock._key) 108 | 109 | def test_should_raise_an_exception_when_an_error_happens_on_acquire_lock( 110 | self 111 | ): 112 | lock = CacheLock(key='test') 113 | with mock.patch.object( 114 | lock.cache, 115 | 'add', 116 | side_effect=Exception('oops') 117 | ): 118 | with pytest.raises(LockAcquireError): 119 | with lock: 120 | pass 121 | 122 | def test_should_raise_an_exception_when_an_error_happens_on_release_lock( 123 | self 124 | ): 125 | lock = CacheLock(key='test') 126 | with mock.patch.object( 127 | lock.cache, 128 | 'delete', 129 | side_effect=Exception('oops') 130 | ): 131 | with pytest.raises(LockReleaseError): 132 | with lock: 133 | pass 134 | -------------------------------------------------------------------------------- /tests/fake/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luizalabs/django-toolkit/23a78a284d5d327c6b5c0bee2a646fc92131e4f5/tests/fake/__init__.py -------------------------------------------------------------------------------- /tests/fake/fallbacks/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luizalabs/django-toolkit/23a78a284d5d327c6b5c0bee2a646fc92131e4f5/tests/fake/fallbacks/__init__.py -------------------------------------------------------------------------------- /tests/fake/fallbacks/circuit_breaker/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luizalabs/django-toolkit/23a78a284d5d327c6b5c0bee2a646fc92131e4f5/tests/fake/fallbacks/circuit_breaker/__init__.py -------------------------------------------------------------------------------- /tests/fake/fallbacks/circuit_breaker/rules.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django_toolkit.fallbacks.circuit_breaker.rules import Rule 3 | 4 | 5 | class FakeRuleShouldOpen(Rule): 6 | 7 | def should_open_circuit(self, total_failures, total_requests): 8 | return True 9 | 10 | def log_increase_failures(self, total_failures, total_requests): 11 | pass 12 | 13 | 14 | class FakeRuleShouldNotOpen(Rule): 15 | 16 | def should_open_circuit(self, total_failures, total_requests): 17 | return False 18 | 19 | def log_increase_failures(self, total_failures, total_requests): 20 | pass 21 | 22 | 23 | class FakeRuleShouldNotIncreaseFailure(Rule): 24 | 25 | def should_increase_failure_count(self): 26 | return False 27 | 28 | def should_open_circuit(self, total_failures, total_requests): 29 | return False 30 | 31 | def log_increase_failures(self, total_failures, total_requests): 32 | pass 33 | 34 | 35 | class FakeRuleShouldNotIncreaseRequest(Rule): 36 | 37 | def should_increase_request_count(self): 38 | return False 39 | 40 | def should_open_circuit(self, total_failures, total_requests): 41 | return False 42 | 43 | def log_increase_failures(self, total_failures, total_requests): 44 | pass 45 | -------------------------------------------------------------------------------- /tests/fallbacks/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luizalabs/django-toolkit/23a78a284d5d327c6b5c0bee2a646fc92131e4f5/tests/fallbacks/__init__.py -------------------------------------------------------------------------------- /tests/fallbacks/circuit_breaker/test_circuit_breaker.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.core.cache import caches 3 | from mock import mock 4 | 5 | from django_toolkit.fallbacks.circuit_breaker import ( 6 | CircuitBreaker, 7 | circuit_breaker 8 | ) 9 | from django_toolkit.fallbacks.circuit_breaker.rules import ( 10 | MaxFailuresRule, 11 | PercentageFailuresRule 12 | ) 13 | from tests.fake.fallbacks.circuit_breaker.rules import ( 14 | FakeRuleShouldNotIncreaseFailure, 15 | FakeRuleShouldNotIncreaseRequest, 16 | FakeRuleShouldNotOpen, 17 | FakeRuleShouldOpen 18 | ) 19 | 20 | cache = caches['default'] 21 | 22 | 23 | class MyException(Exception): 24 | pass 25 | 26 | 27 | def success_function(): 28 | return True 29 | 30 | 31 | def fail_function(): 32 | raise ValueError() 33 | 34 | 35 | class TestCircuitBreaker: 36 | 37 | @pytest.fixture 38 | def failure_cache_key(self): 39 | return 'fail' 40 | 41 | @pytest.fixture 42 | def request_cache_key(self): 43 | return 'request' 44 | 45 | @pytest.fixture 46 | def rule_should_open(self, failure_cache_key, request_cache_key): 47 | return FakeRuleShouldOpen( 48 | failure_cache_key=failure_cache_key, 49 | request_cache_key=request_cache_key, 50 | ) 51 | 52 | @pytest.fixture 53 | def rule_should_not_open(self, failure_cache_key, request_cache_key): 54 | return FakeRuleShouldNotOpen( 55 | failure_cache_key=failure_cache_key, 56 | request_cache_key=request_cache_key, 57 | ) 58 | 59 | @pytest.fixture 60 | def rule_should_not_increase_failure( 61 | self, 62 | failure_cache_key, 63 | request_cache_key 64 | ): 65 | return FakeRuleShouldNotIncreaseFailure( 66 | failure_cache_key=failure_cache_key, 67 | request_cache_key=request_cache_key, 68 | ) 69 | 70 | @pytest.fixture 71 | def rule_should_not_increase_request( 72 | self, 73 | failure_cache_key, 74 | request_cache_key 75 | ): 76 | return FakeRuleShouldNotIncreaseRequest( 77 | failure_cache_key=failure_cache_key, 78 | request_cache_key=request_cache_key, 79 | ) 80 | 81 | @pytest.fixture 82 | def max_failures_rule(self, failure_cache_key): 83 | return MaxFailuresRule( 84 | max_failures=3, 85 | failure_cache_key=failure_cache_key 86 | ) 87 | 88 | @pytest.fixture 89 | def percentage_failures_rule(self, failure_cache_key, request_cache_key): 90 | return PercentageFailuresRule( 91 | max_failures=50, 92 | failure_cache_key=failure_cache_key, 93 | max_accepted_failures=2, 94 | request_cache_key=request_cache_key 95 | ) 96 | 97 | @pytest.fixture(autouse=True) 98 | def clear_cache(self, request_cache_key, failure_cache_key): 99 | cache.delete(request_cache_key) 100 | cache.delete(failure_cache_key) 101 | cache.delete('circuit_{}'.format(failure_cache_key)) 102 | 103 | def test_success_result(self, rule_should_not_open): 104 | with CircuitBreaker( 105 | rule=rule_should_not_open, 106 | cache=cache, 107 | failure_exception=None, 108 | catch_exceptions=None, 109 | ): 110 | success_function() 111 | 112 | def test_success_result_with_decorator(self, rule_should_not_open): 113 | @circuit_breaker( 114 | rule=rule_should_not_open, 115 | cache=cache, 116 | failure_exception=None, 117 | catch_exceptions=None, 118 | ) 119 | def inner_func(): 120 | success_function() 121 | 122 | inner_func() 123 | 124 | def test_should_raise_error(self, rule_should_open): 125 | with pytest.raises(MyException): 126 | with CircuitBreaker( 127 | rule=rule_should_open, 128 | cache=cache, 129 | failure_exception=MyException, 130 | catch_exceptions=(ValueError,), 131 | ): 132 | fail_function() 133 | 134 | def test_should_raise_error_with_decorator(self, rule_should_open): 135 | @circuit_breaker( 136 | rule=rule_should_open, 137 | cache=cache, 138 | failure_exception=MyException, 139 | catch_exceptions=(ValueError,), 140 | ) 141 | def inner(): 142 | fail_function() 143 | 144 | with pytest.raises(MyException): 145 | inner() 146 | 147 | def test_should_increase_fail_cache_count( 148 | self, 149 | failure_cache_key, 150 | rule_should_not_open 151 | ): 152 | cache.set(failure_cache_key, 1) 153 | 154 | with pytest.raises(ValueError): 155 | with CircuitBreaker( 156 | rule=rule_should_not_open, 157 | cache=cache, 158 | failure_exception=MyException, 159 | catch_exceptions=(ValueError,), 160 | ): 161 | fail_function() 162 | 163 | assert cache.get(failure_cache_key) == 2 164 | 165 | def test_should_increase_request_cache_count( 166 | self, 167 | request_cache_key, 168 | rule_should_not_open 169 | ): 170 | cache.set(request_cache_key, 0) 171 | 172 | with CircuitBreaker( 173 | rule=rule_should_not_open, 174 | cache=cache, 175 | failure_exception=MyException, 176 | catch_exceptions=(ValueError,), 177 | ): 178 | success_function() 179 | 180 | with pytest.raises(ValueError): 181 | with CircuitBreaker( 182 | rule=rule_should_not_open, 183 | cache=cache, 184 | failure_exception=MyException, 185 | catch_exceptions=(ValueError,), 186 | ): 187 | fail_function() 188 | 189 | assert cache.get(request_cache_key) == 2 190 | 191 | def test_should_open_circuit_when_failures_exceeds( 192 | self, 193 | rule_should_open, 194 | failure_cache_key, 195 | ): 196 | cache.set(failure_cache_key, 3) 197 | 198 | with pytest.raises(MyException): 199 | with CircuitBreaker( 200 | rule=rule_should_open, 201 | cache=cache, 202 | failure_exception=MyException, 203 | catch_exceptions=(ValueError,), 204 | ) as circuit_breaker: 205 | fail_function() 206 | 207 | assert circuit_breaker.is_circuit_open 208 | 209 | def test_should_raise_exception_when_circuit_is_open( 210 | self, 211 | rule_should_open, 212 | failure_cache_key 213 | ): 214 | circuit_cache_key = 'circuit_{}'.format(failure_cache_key) 215 | cache.set(circuit_cache_key, True) 216 | 217 | with pytest.raises(MyException): 218 | with CircuitBreaker( 219 | rule=rule_should_open, 220 | cache=cache, 221 | failure_exception=MyException, 222 | catch_exceptions=(ValueError,), 223 | ) as circuit_breaker: 224 | success_function() 225 | 226 | assert circuit_breaker.is_circuit_open 227 | 228 | def test_should_not_call_exit_when_circuit_is_open( 229 | self, 230 | rule_should_open, 231 | failure_cache_key 232 | ): 233 | circuit_cache_key = 'circuit_{}'.format(failure_cache_key) 234 | cache.set(circuit_cache_key, True) 235 | 236 | with pytest.raises(MyException): 237 | with mock.patch( 238 | 'django_toolkit.fallbacks.circuit_breaker.CircuitBreaker.__exit__' # noqa 239 | ) as exit: 240 | with CircuitBreaker( 241 | rule=rule_should_open, 242 | cache=cache, 243 | failure_exception=MyException, 244 | catch_exceptions=(ValueError,), 245 | ): 246 | success_function() 247 | 248 | assert not exit.called 249 | 250 | def test_should_not_increment_fail_when_circuit_is_open( 251 | self, 252 | rule_should_open, 253 | failure_cache_key 254 | ): 255 | """ 256 | It should not increment fail count over the max failures limit, when 257 | circuit breaker is open after a successful enter. 258 | """ 259 | cache.set(failure_cache_key, 3) 260 | 261 | with pytest.raises(MyException): 262 | with CircuitBreaker( 263 | rule=rule_should_open, 264 | cache=cache, 265 | failure_exception=MyException, 266 | catch_exceptions=(ValueError,), 267 | ): 268 | fail_function() 269 | 270 | assert not cache.get(failure_cache_key) 271 | 272 | def test_should_not_increment_request_when_circuit_is_open( 273 | self, 274 | rule_should_open, 275 | failure_cache_key, 276 | request_cache_key 277 | ): 278 | """ 279 | It should not increment request count over the max failures limit, when 280 | circuit breaker is open after a successful enter. 281 | """ 282 | cache.set(failure_cache_key, 2) 283 | cache.set(request_cache_key, 5) 284 | 285 | with pytest.raises(MyException): 286 | with CircuitBreaker( 287 | rule=rule_should_open, 288 | cache=cache, 289 | failure_exception=MyException, 290 | catch_exceptions=(ValueError,), 291 | ): 292 | fail_function() 293 | 294 | assert not cache.get(request_cache_key) 295 | 296 | def test_should_not_increment_request_when_rule_is_false( 297 | self, 298 | rule_should_not_increase_request, 299 | request_cache_key 300 | ): 301 | cache.set(request_cache_key, 5) 302 | 303 | with pytest.raises(ValueError): 304 | with CircuitBreaker( 305 | rule=rule_should_not_increase_request, 306 | cache=cache, 307 | failure_exception=MyException, 308 | catch_exceptions=(ValueError,), 309 | ): 310 | fail_function() 311 | 312 | assert cache.get(request_cache_key) == 5 313 | 314 | def test_should_not_increment_failure_when_rule_is_false( 315 | self, 316 | rule_should_not_increase_failure, 317 | failure_cache_key 318 | ): 319 | cache.set(failure_cache_key, 5) 320 | 321 | with pytest.raises(ValueError): 322 | with CircuitBreaker( 323 | rule=rule_should_not_increase_failure, 324 | cache=cache, 325 | failure_exception=MyException, 326 | catch_exceptions=(ValueError,), 327 | ): 328 | fail_function() 329 | 330 | assert cache.get(failure_cache_key) == 5 331 | 332 | def test_should_delete_count_key_when_circuit_is_open( 333 | self, 334 | rule_should_open, 335 | failure_cache_key, 336 | request_cache_key 337 | ): 338 | cache.set(failure_cache_key, 2) 339 | cache.set(request_cache_key, 5) 340 | 341 | with pytest.raises(MyException): 342 | with CircuitBreaker( 343 | rule=rule_should_open, 344 | cache=cache, 345 | failure_exception=MyException, 346 | catch_exceptions=(ValueError,), 347 | ) as circuit_breaker: 348 | fail_function() 349 | 350 | assert circuit_breaker.is_circuit_open 351 | 352 | assert cache.get(failure_cache_key) is None 353 | assert cache.get(request_cache_key) is None 354 | 355 | def test_should_create_failure_cache_when_increase_request_count( 356 | self, 357 | rule_should_not_open, 358 | failure_cache_key, 359 | request_cache_key, 360 | ): 361 | assert cache.get(failure_cache_key) is None 362 | 363 | with CircuitBreaker( 364 | rule=rule_should_not_open, 365 | cache=cache, 366 | failure_exception=MyException, 367 | catch_exceptions=(ValueError,), 368 | ): 369 | success_function() 370 | 371 | assert cache.get(failure_cache_key) == 0 372 | -------------------------------------------------------------------------------- /tests/fallbacks/circuit_breaker/test_rules.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from django_toolkit.fallbacks.circuit_breaker.rules import ( 4 | MaxFailuresRule, 5 | PercentageFailuresRule, 6 | Rule 7 | ) 8 | 9 | 10 | class TestMaxFailuresRule: 11 | 12 | @pytest.fixture 13 | def rule(self): 14 | return MaxFailuresRule( 15 | max_failures=10, 16 | failure_cache_key='fail' 17 | ) 18 | 19 | def test_max_failures_rule_should_return_rule_instance(self, rule): 20 | assert isinstance(rule, Rule) 21 | 22 | def test_should_not_open_circuit(self, rule): 23 | assert rule.should_open_circuit( 24 | total_failures=5, 25 | total_requests=5 26 | ) is False 27 | 28 | def test_should_open_circuit(self, rule): 29 | assert rule.should_open_circuit( 30 | total_failures=20, 31 | total_requests=5 32 | ) is True 33 | 34 | def test_should_increase_failure_count(self, rule): 35 | assert rule.should_increase_failure_count() is True 36 | 37 | def test_should_not_increase_request_count(self, rule): 38 | assert rule.should_increase_request_count() is False 39 | 40 | 41 | class TestPercentageFailureRule: 42 | 43 | @pytest.fixture 44 | def rule(self): 45 | return PercentageFailuresRule( 46 | max_failures_percentage=50, 47 | failure_cache_key='fail', 48 | min_accepted_requests=5, 49 | request_cache_key='request' 50 | ) 51 | 52 | def test_percentage_failures_rule_should_return_rule_instance(self, rule): 53 | assert isinstance(rule, Rule) 54 | 55 | def test_should_not_open_circuit(self, rule): 56 | assert rule.should_open_circuit( 57 | total_failures=0, 58 | total_requests=49 59 | ) is False 60 | 61 | def test_should_not_open_circuit_with_min_accepted_number_of_requests( 62 | self, 63 | rule 64 | ): 65 | assert rule.should_open_circuit( 66 | total_failures=5, 67 | total_requests=5 68 | ) is False 69 | 70 | def test_should_open_circuit(self, rule): 71 | assert rule.should_open_circuit( 72 | total_failures=20, 73 | total_requests=40 74 | ) is True 75 | 76 | def test_should_increase_failure_count(self, rule): 77 | assert rule.should_increase_failure_count() is True 78 | 79 | def test_should_increase_request_count(self, rule): 80 | assert rule.should_increase_request_count() is True 81 | -------------------------------------------------------------------------------- /tests/logs/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luizalabs/django-toolkit/23a78a284d5d327c6b5c0bee2a646fc92131e4f5/tests/logs/__init__.py -------------------------------------------------------------------------------- /tests/logs/test_filters.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import socket 3 | 4 | import pytest 5 | from mock import Mock 6 | 7 | from django_toolkit.logs.filters import AddHostName, IgnoreIfContains 8 | 9 | 10 | class TestAddHostName(object): 11 | 12 | @pytest.fixture 13 | def hostname_filter(self): 14 | return AddHostName() 15 | 16 | def test_filter_should_add_a_hostname_to_the_given_record( 17 | self, 18 | hostname_filter 19 | ): 20 | record = Mock() 21 | hostname_filter.filter(record) 22 | 23 | assert record.hostname == socket.gethostname() 24 | 25 | def test_filter_should_return_true(self, hostname_filter): 26 | record = Mock() 27 | assert hostname_filter.filter(record) 28 | 29 | 30 | class TestIgnoreIfContains(object): 31 | 32 | @pytest.fixture 33 | def log_filter(self): 34 | return IgnoreIfContains(substrings=['/healthcheck', '/ping']) 35 | 36 | @pytest.mark.parametrize('message', [ 37 | 'GET /healthcheck/', 38 | 'GET /ping/' 39 | ]) 40 | def test_should_ignore_record(self, message, log_filter): 41 | record = Mock() 42 | record.getMessage.return_value = message 43 | 44 | assert log_filter.filter(record) is False 45 | 46 | @pytest.mark.parametrize('message', [ 47 | 'GET /endpoint/', 48 | 'GET /success/' 49 | ]) 50 | def test_should_accept_record(self, message, log_filter): 51 | record = Mock() 52 | record.getMessage.return_value = message 53 | 54 | assert log_filter.filter(record) is True 55 | -------------------------------------------------------------------------------- /tests/oauth2/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luizalabs/django-toolkit/23a78a284d5d327c6b5c0bee2a646fc92131e4f5/tests/oauth2/__init__.py -------------------------------------------------------------------------------- /tests/oauth2/conftest.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from datetime import timedelta 3 | 4 | import pytest 5 | from django.contrib.auth import get_user_model 6 | from django.core.cache import caches 7 | from django.utils import timezone 8 | from oauth2_provider.models import ( 9 | get_access_token_model, 10 | get_application_model 11 | ) 12 | 13 | 14 | @pytest.fixture(autouse=True) 15 | def cache(): 16 | cache = caches['access_token'] 17 | yield cache 18 | cache.clear() 19 | 20 | 21 | @pytest.fixture 22 | def scopes(): 23 | return ['permission:read', 'permission:write'] 24 | 25 | 26 | @pytest.fixture 27 | def user(): 28 | UserModel = get_user_model() 29 | 30 | return UserModel.objects.create_user( 31 | 'my-user', 'my@user.com', '123456' 32 | ) 33 | 34 | 35 | @pytest.fixture 36 | def application(user): 37 | Application = get_application_model() 38 | application = Application.objects.first() 39 | 40 | if application: 41 | return application 42 | 43 | application = Application.objects.create( 44 | user=user, 45 | name='Test Application', 46 | client_type=Application.CLIENT_CONFIDENTIAL, 47 | authorization_grant_type=Application.GRANT_CLIENT_CREDENTIALS, 48 | ) 49 | 50 | return application 51 | 52 | 53 | @pytest.fixture 54 | def access_token(application, scopes): 55 | AccessToken = get_access_token_model() 56 | return AccessToken.objects.create( 57 | scope=' '.join(scopes), 58 | expires=timezone.now() + timedelta(seconds=300), 59 | token='secret-access-token-key', 60 | application=application 61 | ) 62 | -------------------------------------------------------------------------------- /tests/oauth2/test_receivers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import pytest 3 | 4 | 5 | @pytest.mark.django_db 6 | class TestDeleteAccessTokenCache(object): 7 | 8 | def test_should_delete_token_cache(self, cache, access_token): 9 | key = 'my-token' 10 | access_token.token = key 11 | access_token.save() 12 | cache.set(key, 'a value') 13 | 14 | access_token.delete() 15 | 16 | assert cache.get(key) is None 17 | -------------------------------------------------------------------------------- /tests/oauth2/test_validators.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from datetime import timedelta 3 | 4 | import pytest 5 | from django.db import connection 6 | from django.db.models.query import QuerySet 7 | from django.test.utils import CaptureQueriesContext 8 | from django.utils import timezone 9 | from oauth2_provider.models import get_access_token_model 10 | 11 | from django_toolkit.oauth2.validators import CachedOAuth2Validator 12 | 13 | 14 | @pytest.mark.django_db 15 | class TestCachedOAuth2Validator(object): 16 | 17 | @pytest.fixture 18 | def validator(self): 19 | return CachedOAuth2Validator() 20 | 21 | @pytest.fixture 22 | def http_request(self, rf): 23 | return rf.get('/foo') 24 | 25 | def _warm_up_cache(self, validator, token, scopes, request): 26 | return validator.validate_bearer_token(token, scopes, request) 27 | 28 | def test_validate_bearer_token_should_not_reach_db_when_cached( 29 | self, 30 | access_token, 31 | validator, 32 | http_request, 33 | scopes 34 | ): 35 | db_result = self._warm_up_cache( 36 | validator, 37 | access_token.token, 38 | scopes, 39 | http_request 40 | ) 41 | 42 | with CaptureQueriesContext(connection) as context: 43 | cached_result = validator.validate_bearer_token( 44 | access_token.token, 45 | scopes, 46 | http_request 47 | ) 48 | 49 | assert len(context.captured_queries) == 0 50 | assert db_result == cached_result 51 | 52 | def test_validate_bearer_token_should_set_request_attributes( 53 | self, 54 | access_token, 55 | validator, 56 | scopes, 57 | rf 58 | ): 59 | self._warm_up_cache( 60 | validator, 61 | access_token.token, 62 | scopes, 63 | rf.get('/foo') 64 | ) 65 | 66 | request = rf.get('/foo') 67 | validator.validate_bearer_token( 68 | access_token.token, 69 | scopes, 70 | request 71 | ) 72 | 73 | assert request.client == access_token.application 74 | assert request.user == access_token.user 75 | assert request.scopes == scopes 76 | assert request.access_token == access_token 77 | 78 | def test_validate_bearer_token_should_get_cache_expiration_from_token( 79 | self, 80 | access_token, 81 | validator, 82 | scopes, 83 | http_request 84 | ): 85 | expires = timezone.now() - timedelta(seconds=5) 86 | access_token.expires = expires 87 | access_token.save() 88 | 89 | self._warm_up_cache( 90 | validator, 91 | access_token.token, 92 | scopes, 93 | http_request 94 | ) 95 | 96 | with CaptureQueriesContext(connection) as context: 97 | validator.validate_bearer_token( 98 | access_token.token, 99 | scopes, 100 | http_request 101 | ) 102 | 103 | assert len(context.captured_queries) == 1 104 | 105 | def test_validate_bearer_returns_false_when_no_token_is_provided( 106 | self, 107 | validator, 108 | scopes, 109 | http_request 110 | ): 111 | token = None 112 | is_valid = validator.validate_bearer_token( 113 | token, 114 | scopes, 115 | http_request 116 | ) 117 | assert not is_valid 118 | 119 | def test_validate_bearer_returns_false_when_invalid_token_is_provided( 120 | self, 121 | validator, 122 | scopes, 123 | http_request 124 | ): 125 | token = 'invalid-token' 126 | is_valid = validator.validate_bearer_token( 127 | token, 128 | scopes, 129 | http_request 130 | ) 131 | assert not is_valid 132 | 133 | def test_get_queryset_should_return_an_access_token_queryset( 134 | self, 135 | validator, 136 | ): 137 | queryset = validator.get_queryset() 138 | 139 | assert isinstance(queryset, QuerySet) 140 | assert queryset.model == get_access_token_model() 141 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | SECRET_KEY = 'test' 3 | DATABASES = { 4 | 'default': { 5 | 'ENGINE': 'django.db.backends.sqlite3', 6 | 'NAME': ':memory:', 7 | } 8 | } 9 | MIDDLEWARE_CLASSES = ( 10 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 11 | ) 12 | INSTALLED_APPS = ( 13 | 'django.contrib.auth', 14 | 'django.contrib.contenttypes', 15 | 'oauth2_provider', 16 | 17 | 'django_toolkit.oauth2', 18 | ) 19 | 20 | REST_FRAMEWORK = { 21 | 'DEFAULT_AUTHENTICATION_CLASSES': ( 22 | 'oauth2_provider.ext.rest_framework.OAuth2Authentication', 23 | ), 24 | } 25 | 26 | CACHES = { 27 | 'default': { 28 | 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 29 | }, 30 | 'locks': { 31 | 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 32 | }, 33 | 'access_token': { 34 | 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 35 | }, 36 | 'explicit_timeout': { 37 | 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 38 | 'TIMEOUT': 300, 39 | }, 40 | } 41 | 42 | TOOLKIT = { 43 | 'API_VERSION': '1.2.3', 44 | } 45 | -------------------------------------------------------------------------------- /tests/test_middlewares.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import pytest 3 | from django.conf import settings 4 | from django.http import HttpResponse 5 | from mock import Mock, PropertyMock, patch 6 | 7 | from django_toolkit import middlewares 8 | 9 | 10 | @pytest.fixture 11 | def http_request(rf): 12 | return rf.get('/') 13 | 14 | 15 | @pytest.fixture 16 | def http_response(): 17 | return HttpResponse() 18 | 19 | 20 | class TestVersionHeaderMiddleware(object): 21 | 22 | @pytest.fixture(autouse=True) 23 | def settings(self, settings): 24 | settings.TOOLKIT = { 25 | 'API_VERSION': '1.2.3', 26 | } 27 | return settings 28 | 29 | @pytest.fixture 30 | def middleware(self): 31 | return middlewares.VersionHeaderMiddleware() 32 | 33 | def test_should_return_a_response( 34 | self, 35 | middleware, 36 | http_request, 37 | http_response 38 | ): 39 | response = middleware.process_response(http_request, http_response) 40 | assert isinstance(response, HttpResponse) 41 | 42 | def test_should_add_a_version_header_to_the_response( 43 | self, 44 | middleware, 45 | http_request, 46 | http_response 47 | ): 48 | response = middleware.process_response(http_request, http_response) 49 | 50 | assert 'X-API-Version' in response 51 | assert response['X-API-Version'] == settings.TOOLKIT['API_VERSION'] 52 | 53 | 54 | @pytest.mark.django_db 55 | class TestAccessLogMiddleware(object): 56 | 57 | @pytest.fixture 58 | def middleware(self): 59 | return middlewares.AccessLogMiddleware() 60 | 61 | @pytest.fixture 62 | def patched_logger(self): 63 | return patch('django_toolkit.middlewares.logger') 64 | 65 | @pytest.fixture 66 | def patched_format(self): 67 | return patch( 68 | 'django_toolkit.middlewares.AccessLogMiddleware.LOG_FORMAT', 69 | new_callable=PropertyMock 70 | ) 71 | 72 | @pytest.fixture 73 | def authenticated_http_request(self, http_request): 74 | http_request.user = u'jovem' 75 | http_request.auth = Mock(application=Mock(name='myapp')) 76 | return http_request 77 | 78 | def test_should_return_a_response( 79 | self, 80 | middleware, 81 | http_request, 82 | http_response 83 | ): 84 | response = middleware.process_response(http_request, http_response) 85 | assert isinstance(response, HttpResponse) 86 | 87 | def test_should_log_responses( 88 | self, 89 | middleware, 90 | http_request, 91 | http_response, 92 | patched_logger, 93 | patched_format 94 | ): 95 | with patched_logger as mock_logger: 96 | middleware.process_response(http_request, http_response) 97 | 98 | assert mock_logger.info.called 99 | 100 | def test_should_include_request_and_response_in_the_message( 101 | self, 102 | middleware, 103 | http_request, 104 | http_response, 105 | patched_logger, 106 | patched_format 107 | ): 108 | with patched_logger as mock_logger: 109 | with patched_format as mock_format_property: 110 | middleware.process_response(http_request, http_response) 111 | 112 | mock_format_string = mock_format_property.return_value 113 | 114 | assert mock_format_string.format.called 115 | mock_format_string.format.assert_called_once_with( 116 | app_name=middleware.UNKNOWN_APP_NAME, 117 | request=http_request, 118 | response=http_response 119 | ) 120 | mock_logger.info.assert_called_once_with( 121 | mock_format_string.format.return_value 122 | ) 123 | 124 | def test_should_include_the_authenticated_app_in_the_message( 125 | self, 126 | middleware, 127 | authenticated_http_request, 128 | http_response, 129 | patched_logger, 130 | patched_format 131 | ): 132 | with patched_format as mock_format_property: 133 | middleware.process_response( 134 | authenticated_http_request, 135 | http_response 136 | ) 137 | 138 | mock_format_string = mock_format_property.return_value 139 | 140 | assert mock_format_string.format.called 141 | mock_format_string.format.assert_called_once_with( 142 | app_name=authenticated_http_request.auth.application.name, 143 | request=authenticated_http_request, 144 | response=http_response 145 | ) 146 | -------------------------------------------------------------------------------- /tests/test_shortcuts.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import pytest 3 | from model_mommy import mommy 4 | from oauth2_provider.models import ( 5 | get_access_token_model, 6 | get_application_model 7 | ) 8 | from rest_framework.request import Request 9 | from rest_framework.test import APIRequestFactory, force_authenticate 10 | 11 | from django_toolkit import shortcuts 12 | 13 | Application = get_application_model() 14 | AccessToken = get_access_token_model() 15 | 16 | 17 | @pytest.mark.django_db 18 | class TestGetCurrentApp(object): 19 | 20 | @pytest.fixture 21 | def application(self): 22 | return mommy.make(Application) 23 | 24 | @pytest.fixture 25 | def token(self, application): 26 | return mommy.make(AccessToken, application=application) 27 | 28 | def test_should_return_the_client_applicaton(self, token): 29 | factory = APIRequestFactory() 30 | request = factory.get('/') 31 | force_authenticate(request, token=token) 32 | rest_request = Request(request) 33 | 34 | app = shortcuts.get_oauth2_app(rest_request) 35 | 36 | assert isinstance(app, Application) 37 | assert app == token.application 38 | 39 | def test_should_return_none_when_not_authenticated(self): 40 | factory = APIRequestFactory() 41 | request = factory.get('/') 42 | rest_request = Request(request) 43 | 44 | app = shortcuts.get_oauth2_app(rest_request) 45 | 46 | assert app is None 47 | --------------------------------------------------------------------------------