diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..55885e3 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,44 @@ +name: Django Tests + +on: + push: + branches: + - master + pull_request: + branches: + - master + +jobs: + test: + strategy: + matrix: + python-version: ['3.5', '3.6', '3.7', '3.8', '3.9', '3.10', '3.11', '3.12'] + django-version: ['2.0', '2.1', '2.2', '3.0', '3.1', '3.2', '4.0', '4.1', '4.2', '5.0', '5.1'] + os-version: [ubuntu-18.04, ubuntu-22.04] + + exclude: + - python-version: '3.5' + os-version: ubuntu-22.04 + - python-version: '3.6' + os-version: ubuntu-22.04 + - python-version: '3.7' + os-version: ubuntu-22.04 + + runs-on: ${{ matrix.os-version }} + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip setuptools packaging + python -m pip install Django==${{ matrix.django-version }} pytest pytest-django + + - name: Run Django tests + run: | + python -m pytest diff --git a/.gitignore b/.gitignore index 1a3df04..2f8c178 100644 --- a/.gitignore +++ b/.gitignore @@ -125,4 +125,3 @@ dmypy.json # Pyre type checker .pyre/ - diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..ad9e276 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,20 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: debug-statements + - id: end-of-file-fixer + - id: trailing-whitespace + - id: detect-private-key + + - repo: https://github.com/psf/black + rev: 24.4.2 + hooks: + - id: black + args: [--line-length=120] + + - repo: https://github.com/pycqa/isort + rev: 5.13.2 + hooks: + - id: isort + args: [--profile=black, -m=3, -l=120] diff --git a/LICENSE b/LICENSE index 0d9b245..244229e 100644 --- a/LICENSE +++ b/LICENSE @@ -18,4 +18,4 @@ 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. \ No newline at end of file +SOFTWARE. diff --git a/README.md b/README.md index 79d28e0..ed49fb8 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ [![Build Status](https://app.travis-ci.com/isik-kaplan/django-http-exceptions.svg?token=GLRwtEHQ7cKeAZcq6ZpN&branch=master)](https://app.travis-ci.com/isik-kaplan/django-http-exceptions) -[![codecov](https://codecov.io/gh/isik-kaplan/django-http-exceptions/branch/master/graph/badge.svg)](https://codecov.io/gh/isik-kaplan/django-http-exceptions) +[![codecov](https://codecov.io/gh/isik-kaplan/django-http-exceptions/branch/master/graph/badge.svg)](https://codecov.io/gh/isik-kaplan/django-http-exceptions) [![Python 3.5+](https://img.shields.io/badge/python-3.5+-brightgreen.svg)](#) [![Django 2.0+](https://img.shields.io/badge/django-2.0+-brightgreen.svg)](#) [![PyPI - License](https://img.shields.io/pypi/l/django-http-exceptions.svg)](https://pypi.org/project/django-http-exceptions/) @@ -14,7 +14,7 @@ It is raisable exceptions for your django views. ## What is it good for? -It makes this +It makes this ````py def some_function(): @@ -34,11 +34,11 @@ def some_function(): raise HTTPExceptions.FORBIDDEN # HTTPExceptions.from_status(403) def view(request): - return some_function() - + return some_function() + ```` -meaning that is saves you from boilerplate code. +meaning that is saves you from boilerplate code. It also allows you to hook default views to **all possible http response codes**, meaning that you can use more than the 5-6 django provided error handlers. @@ -61,21 +61,21 @@ And that is it, you are ready to raise your http exceptions. -## What else? +## What else? #### `HTTPExceptions` Base class that provides all the exceptions to be raised. -#### `HTTPExceptions.from_status(status)` -In case you don't want to write -`HTTPExceptions.REQUEST_HEADER_FIELDS_TOO_LARGE` -You can just write +#### `HTTPExceptions.from_status(status)` +In case you don't want to write +`HTTPExceptions.REQUEST_HEADER_FIELDS_TOO_LARGE` +You can just write `HTTPExceptions.from_status(431)` -#### `HTTPExceptions.BASE_EXCEPTON` +#### `HTTPExceptions.BASE_EXCEPTON` The base exception for all http exception #### `HTTPExceptions.register_base_exception(exception)` @@ -83,44 +83,44 @@ Given that `exception` is a class that inherits from `HTTPException` you can cus Keep in mind that `HTTPException` is an `Exception` subclass itself. -#### `HTTPExceptions.BASE_EXCEPTION.with_response(response)` +#### `HTTPExceptions.BASE_EXCEPTION.with_response(response)` This is the method for raising exceptions with a response. You can put any response in this method while raising your error. - + Let's say you have a view named `index`, then this example would return what `index` function would return, but with -status code `410` +status code `410` `HTTPExceptions.GONE.with_response(index(request))` -#### `HTTPExceptions.BASE_EXCEPTION.with_content(content)` +#### `HTTPExceptions.BASE_EXCEPTION.with_content(content)` This method allow to raise an **HTTPException** with a custom message (can be either `str` or `bytes`). For instance, `HTTPExceptions.NOT_FOUND.with_content("The user named 'username' could not be found")` would return something equivalent to `HttpResponse("The user named 'username' could not be found", status=404)`. #### `HTTPExceptions.BASE_EXCEPTION.with_json(json_data)` -This method allow to raise an **HTTPException** with a custom json response, +This method allow to raise an **HTTPException** with a custom json response, `json_data` can be anything that `JsonResponse` accepts. -#### `HTTPExceptions.BASE_EXCEPTION.register_default_view(view)` +#### `HTTPExceptions.BASE_EXCEPTION.register_default_view(view)` `view` is a function that takes only one argument, `request` when you register a default view to an error class with -`HTTPExceptions.NOT_FOUND.register_defaul_view(view)` when `HTTPExceptions.GONE` is raised it returns the view function, -but again, with `404` status code. If the error has been raised with `.with_response`, that is used instead. +`HTTPExceptions.NOT_FOUND.register_defaul_view(view)` when `HTTPExceptions.GONE` is raised it returns the view function, +but again, with `404` status code. If the error has been raised with `.with_response`, that is used instead. #### `get_current_request` -This function gets you the current request anywhere in your django application, making it easier for your dynamic error +This function gets you the current request anywhere in your django application, making it easier for your dynamic error responses to be created, like in the `HTTPExceptions.GONE.with_response(index(request))` example. - - -#### `ExceptionHandlerMiddleware` + + +#### `ExceptionHandlerMiddleware` Just there for to exception handling to work. - - -#### `ThreadLocalRequestMiddleware` - + + +#### `ThreadLocalRequestMiddleware` + Just there for to `get_current_request` to work. @@ -134,7 +134,7 @@ class Subscribe(TemplateView): template = SUBSCRIBE_TEMPLATE ```` - + ## Avaliable Exceptions ```py HTTPExceptions.CONTINUE # HTTPExceptions.from_status(100) diff --git a/django_http_exceptions/__init__.py b/django_http_exceptions/__init__.py index 6277c5b..1ec1c09 100644 --- a/django_http_exceptions/__init__.py +++ b/django_http_exceptions/__init__.py @@ -1,11 +1,10 @@ +from ._exceptions import * # Not adding this to __all__, there are 57 variables here from .exceptions import HTTPExceptions -from ._exceptions import * # Not adding this to __all__, there are 57 variables here -from .middleware import (get_current_request, ExceptionHandlerMiddleware, - ThreadLocalRequestMiddleware) +from .middleware import ExceptionHandlerMiddleware, ThreadLocalRequestMiddleware, get_current_request __all__ = [ - 'HTTPExceptions', - 'ExceptionHandlerMiddleware', - 'ThreadLocalRequestMiddleware', - 'get_current_request', + "HTTPExceptions", + "ExceptionHandlerMiddleware", + "ThreadLocalRequestMiddleware", + "get_current_request", ] diff --git a/django_http_exceptions/exceptions.py b/django_http_exceptions/exceptions.py index d31ba71..3d14324 100644 --- a/django_http_exceptions/exceptions.py +++ b/django_http_exceptions/exceptions.py @@ -5,27 +5,19 @@ def _is_dunder(name): """Returns True if a __dunder__ name, False otherwise.""" - return (name[:2] == name[-2:] == '__' and - name[2:3] != '_' and - name[-3:-2] != '_' and - len(name) > 4) + return name[:2] == name[-2:] == "__" and name[2:3] != "_" and name[-3:-2] != "_" and len(name) > 4 class transform(type): - """A metaclass to help automatically apply a function to all 3rd party members of a class - - @DynamicAttrs.""" + """A metaclass to help automatically apply a function to all 3rd party members of a class""" def __setattr__(self, key, value): return super().__setattr__(key, self.__transform__(value)) @staticmethod def __raise_on_new(klass): - """We don't want our transform types to be initilizable, they are only there to group - together similiar items.""" - def __new__(cls, *a, **kw): - raise TypeError('{} can not be initilazed.'.format(klass)) + raise TypeError("{} can not be initilazed.".format(klass)) return __new__ @@ -38,22 +30,15 @@ def __noop_checks(key, value, classdict): return True def __new__(mcs, cls, bases, classdict): - _transform = classdict.get('__transform__', mcs.__noop_transform) - _checks = classdict.get('__checks__', mcs.__noop_checks) - return type.__new__( - mcs, - cls, - bases, - { - **{k: _transform(k, v, classdict) if _checks(k, v, classdict) else v - for k, v in classdict.items()}, - '__new__': mcs.__raise_on_new(cls) - } - ) + c = classdict + _transform = c.get("__transform__", mcs.__noop_transform) + _checks = c.get("__checks__", mcs.__noop_checks) + transformed_classdict = {k: _transform(k, v, c) if _checks(k, v, c) else v for k, v in c.items()} + new_classdict = {**transformed_classdict, "__new__": mcs.__raise_on_new(cls)} + return type.__new__(mcs, cls, bases, new_classdict) class HTTPException(Exception): - """@DynamicAttrs.""" _error_handlers = [] @classmethod @@ -90,7 +75,7 @@ def remove_error_handler(cls, handler): @classmethod def _has_default_view(cls): - return hasattr(cls, '_default_view') + return hasattr(cls, "_default_view") @classmethod def _get_default_view_response(cls, request): @@ -104,31 +89,31 @@ def __call__(self, *args): class HTTPExceptions(metaclass=transform): - """@DynamicAttrs.""" # PycharmDisableInspection BASE_EXCEPTION = HTTPException - encapsulated = ['exceptions', 'register_base_exception'] + encapsulated = ["exceptions", "register_base_exception"] exceptions = [] def __transform__(key, value, classdict): - base_exception = classdict.get('BASE_EXCEPTION') or HTTPException + base_exception = classdict.get("BASE_EXCEPTION") or HTTPException if not issubclass(base_exception, HTTPException): - raise TypeError('BASE_EXCEPTION must be a subclass of HTTPException.') + raise TypeError("BASE_EXCEPTION must be a subclass of HTTPException.") return type( value.name, (base_exception,), - {'__module__': 'HTTPExceptions', 'status': value.value, - 'description': value.description} + {"__module__": "HTTPExceptions", "status": value.value, "description": value.description}, ) def __checks__(key, value, classdict): - return all(( - not _is_dunder(key), - not callable(value), - not isinstance(value, Exception), - not isinstance(value, classmethod), - key != 'encapsulated', - key not in classdict.get('encapsulated', []), - )) + return all( + ( + not _is_dunder(key), + not callable(value), + not isinstance(value, Exception), + not isinstance(value, classmethod), + key != "encapsulated", + key not in classdict.get("encapsulated", []), + ) + ) @classmethod def from_status(cls, code): @@ -139,7 +124,7 @@ def from_status(cls, code): def register_base_exception(cls, new_exception): for exception in cls.exceptions: if not issubclass(new_exception, HTTPException): - raise TypeError('New exception must be a subclass of HTTPException.') + raise TypeError("New exception must be a subclass of HTTPException.") getattr(cls, exception).__bases__ = (new_exception,) # Add all possible HTTP status codes from http.HTTPStatus diff --git a/django_http_exceptions/middleware.py b/django_http_exceptions/middleware.py index 8f36a9f..68dbc5a 100644 --- a/django_http_exceptions/middleware.py +++ b/django_http_exceptions/middleware.py @@ -12,7 +12,7 @@ def process_exception(request, exc): if isinstance(exc, HTTPExceptions.BASE_EXCEPTION): for handler in exc._error_handlers: handler(request, exc) - response = getattr(exc, 'response', None) + response = getattr(exc, "response", None) if not response and exc._has_default_view(): response = exc._get_default_view_response(request) if not response: @@ -24,22 +24,21 @@ def process_exception(request, exc): def get_current_request(): - return getattr(_thread_locals, 'request', None) + return getattr(_thread_locals, "request", None) class ThreadLocalRequestMiddleware(MiddlewareMixin): - @staticmethod def process_request(request): _thread_locals.request = request @staticmethod def process_response(request, response): - if hasattr(_thread_locals, 'request'): + if hasattr(_thread_locals, "request"): del _thread_locals.request return response @staticmethod def process_exception(request, exception): - if hasattr(_thread_locals, 'request'): + if hasattr(_thread_locals, "request"): del _thread_locals.request diff --git a/django_http_exceptions/utils.py b/django_http_exceptions/utils.py index b5c3677..8195c94 100644 --- a/django_http_exceptions/utils.py +++ b/django_http_exceptions/utils.py @@ -14,7 +14,7 @@ def decorator(view): def _errorify_class(cls, error): - return method_decorator(functools.partial(_errorify_function, error=error), name='dispatch')(cls) + return method_decorator(functools.partial(_errorify_function, error=error), name="dispatch")(cls) def _errorify_function(f, error): diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3987a55 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,26 @@ +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "django_http_exceptions" +version = "1.4.1" +description = "django raisable http exceptions" +readme = "README.md" +readme-content-type = "text/markdown" +license = {text = "MIT LICENSE"} +authors = [ + {name = "isik-kaplan", email = "isik.kaplan.social@outlook.com"} +] +homepage = "https://github.com/isik-kaplan/django_http_exceptions" +requires-python = ">=3.5" +dependencies = [ + "django>=2.0" +] + +classifiers = [ + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Framework :: Django", + "Development Status :: 5 - Production/Stable" +] diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index a2b7814..0000000 --- a/setup.cfg +++ /dev/null @@ -1,60 +0,0 @@ -######################## -##### Tox settings ##### -######################## -[tox:tox] -distshare = {homedir}/.tox/distshare -envlist = - py{35,36,37,38}-django{20,21,22} # Django 2.x - py{36,37,38,39}-django{30,31,32} # Django 3.x - py{37,38,39,310}-django{40,41,42} # Django 4.x - py{310,311,312}-django{50,51} # Django 5.x -skip_missing_interpreters = true -indexserver = - pypi = https://pypi.python.org/simple - -[testenv] -passenv = TRAVIS TRAVIS_* -setenv = PYTHONPATH = {toxinidir} -commands = - pip install -e . - coverage run --source django_http_exceptions test/manage.py test - coverage report -m -deps = - pytest-pep8 - pytest-cov - django20: Django>=2.0,<2.1 - django21: Django>=2.1,<2.2 - django22: Django>=2.2,<2.3 - django30: Django>=3.0,<3.1 - django31: Django>=3.1,<3.2 - django32: Django>=3.2,<3.3 - django40: Django>=4.0,<4.1 - django41: Django>=4.1,<4.2 - django42: Django>=4.2,<4.3 - django50: Django>=5.0,<5.1 - django51: Django>=5.1,<5.2 - -########################### -##### Flake8 settings ##### -########################### -[tool:pytest] -addopts = -vvl -pep8maxlinelength = 120 - -############################# -##### Coverage settings ##### -############################# -[coverage:report] -exclude_lines = - pragma: no cover - def __repr__ - def __str__ - -[coverage:run] -branch = True -omit = - */htmlcov/* - *.tox/* - -[coverage:html] -title = django_http_exception Coverage diff --git a/setup.py b/setup.py deleted file mode 100644 index c1d1aaa..0000000 --- a/setup.py +++ /dev/null @@ -1,25 +0,0 @@ -from setuptools import setup - -with open('README.md') as f: - long_description = f.read() - -setup( - name='django_http_exceptions', - version='1.4.1', - packages=['django_http_exceptions'], - url='https://github.com/isik-kaplan/django_http_exceptions', - description="django raisable http exceptions", - long_description=long_description, - long_description_content_type="text/markdown", - license='MIT LICENSE', - author='isik-kaplan', - author_email='', - python_requires=">=3.5", - install_requires=['django>=2.0'], - classifiers=[ - 'License :: OSI Approved :: MIT License', - 'Programming Language :: Python :: 3', - 'Framework :: Django', - 'Development Status :: 5 - Production/Stable', - ], -) diff --git a/test/manage.py b/test/manage.py index d712a14..7108219 100644 --- a/test/manage.py +++ b/test/manage.py @@ -1,7 +1,8 @@ #!/usr/bin/env python import os + from django.core import management -os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.settings' +os.environ["DJANGO_SETTINGS_MODULE"] = "tests.settings" if __name__ == "__main__": management.execute_from_command_line() diff --git a/test/tests/settings.py b/test/tests/settings.py index 293ecd7..ed80f46 100644 --- a/test/tests/settings.py +++ b/test/tests/settings.py @@ -1,22 +1,22 @@ -SECRET_KEY = 'TERCES' +SECRET_KEY = "TERCES" INSTALLED_APPS = [ - 'tests', - 'django.contrib.contenttypes', - 'django.contrib.sessions', + "tests", + "django.contrib.contenttypes", + "django.contrib.sessions", ] # Templates engines TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.contrib.messages.context_processors.messages', - 'django.template.context_processors.request', + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.contrib.messages.context_processors.messages", + "django.template.context_processors.request", ], }, }, @@ -24,14 +24,14 @@ # Middlewares MIDDLEWARE = MIDDLEWARE_CLASSES = [ - 'django_http_exceptions.middleware.ExceptionHandlerMiddleware', - 'django_http_exceptions.middleware.ThreadLocalRequestMiddleware', + "django_http_exceptions.middleware.ExceptionHandlerMiddleware", + "django_http_exceptions.middleware.ThreadLocalRequestMiddleware", ] -ROOT_URLCONF = 'tests.urls' +ROOT_URLCONF = "tests.urls" # Database DATABASES = {} # Allow test without database -TEST_RUNNER = 'tests.testing.DatabaselessTestRunner' +TEST_RUNNER = "tests.testing.DatabaselessTestRunner" diff --git a/test/tests/tests.py b/test/tests/tests.py index 1e6ef86..3f9f7bd 100644 --- a/test/tests/tests.py +++ b/test/tests/tests.py @@ -1,11 +1,13 @@ import contextlib import json -from django.test import Client, SimpleTestCase from http import HTTPStatus from io import StringIO +from django.test import Client, SimpleTestCase + from django_http_exceptions import HTTPExceptions from django_http_exceptions.exceptions import HTTPException + from . import views @@ -33,9 +35,9 @@ def test_with_content(self): self.assertContains(response, "It is indeed not found", status_code=404) def test_with_json(self): - response = self.client.get('/with_json/') + response = self.client.get("/with_json/") response_json = response.json() - self.assertJSONEqual(json.dumps(response_json), {'response_type': 'json'}) + self.assertJSONEqual(json.dumps(response_json), {"response_type": "json"}) def test_register_default_view(self): HTTPExceptions.BAD_REQUEST.register_default_view(views.default_view) @@ -95,9 +97,9 @@ def handler(request, exc): stdout = StringIO() with contextlib.redirect_stdout(stdout): - self.client.get('/not_found/') + self.client.get("/not_found/") - self.assertEqual(stdout.getvalue().strip(), '404 logged') + self.assertEqual(stdout.getvalue().strip(), "404 logged") HTTPExceptions.NOT_FOUND.remove_error_handler(handler) def test_global_error_handler(self): @@ -108,9 +110,9 @@ def handler(request, exc): stdout = StringIO() with contextlib.redirect_stdout(stdout): - self.client.get('/not_found/') + self.client.get("/not_found/") - self.assertEqual(stdout.getvalue().strip(), 'error logged') + self.assertEqual(stdout.getvalue().strip(), "error logged") HTTPExceptions.NOT_FOUND.remove_error_handler(handler) def test_global_and_single_error_handlers_together(self): @@ -125,8 +127,8 @@ def handler_404(request, exc): stdout = StringIO() with contextlib.redirect_stdout(stdout): - self.client.get('/not_found/') + self.client.get("/not_found/") - self.assertEqual(stdout.getvalue().strip(), 'error logged\n404 logged') + self.assertEqual(stdout.getvalue().strip(), "error logged\n404 logged") HTTPExceptions.NOT_FOUND.remove_error_handler(handler_404) HTTPExceptions.BASE_EXCEPTION.remove_error_handler(handler_global) diff --git a/test/tests/urls.py b/test/tests/urls.py index 1db5b1d..1259b3f 100644 --- a/test/tests/urls.py +++ b/test/tests/urls.py @@ -1,30 +1,17 @@ -"""REST URL Configuration +"""REST URL Configuration""" -The `urlpatterns` list routes URLs to views. For more information please see: - https://docs.djangoproject.com/en/2.0/topics/http/urls/ -Examples: -Function views - 1. Add an import: from my_app import views - 2. Add a URL to urlpatterns: path('', views.home, name='home') -Class-based views - 1. Add an import: from other_app.views import Home - 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') -Including another URLconf - 1. Import the include() function: from django.urls import include, path - 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) -""" from django.urls import path from . import views urlpatterns = [ - path('from_status//', views.from_status), - path('from_name//', views.from_name), - path('with_response/', views.with_response), - path('with_content/', views.with_content), - path('with_json/', views.with_json), - path('exception/', views.exception), - path('errorify/403/', views.errorify_403), - path('errorify/404/', views.Errorify404.as_view()), - path('not_found/', views.not_found) + path("from_status//", views.from_status), + path("from_name//", views.from_name), + path("with_response/", views.with_response), + path("with_content/", views.with_content), + path("with_json/", views.with_json), + path("exception/", views.exception), + path("errorify/403/", views.errorify_403), + path("errorify/404/", views.Errorify404.as_view()), + path("not_found/", views.not_found), ]