├── test ├── __init__.py ├── tests │ ├── __init__.py │ ├── testing.py │ ├── urls.py │ ├── settings.py │ ├── views.py │ └── tests.py └── manage.py ├── django_http_exceptions ├── __init__.py ├── utils.py ├── middleware.py ├── _exceptions.py └── exceptions.py ├── .pre-commit-config.yaml ├── pyproject.toml ├── .github └── workflows │ ├── publish.yml │ └── tests.yml ├── LICENSE ├── .gitignore └── README.md /test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | 4 | from django.core import management 5 | 6 | os.environ["DJANGO_SETTINGS_MODULE"] = "tests.settings" 7 | if __name__ == "__main__": 8 | management.execute_from_command_line() 9 | -------------------------------------------------------------------------------- /django_http_exceptions/__init__.py: -------------------------------------------------------------------------------- 1 | from ._exceptions import * # Not adding this to __all__, there are 57 variables here 2 | from .exceptions import HTTPExceptions 3 | from .middleware import ExceptionHandlerMiddleware, ThreadLocalRequestMiddleware, get_current_request 4 | 5 | __all__ = [ 6 | "HTTPExceptions", 7 | "ExceptionHandlerMiddleware", 8 | "ThreadLocalRequestMiddleware", 9 | "get_current_request", 10 | ] 11 | -------------------------------------------------------------------------------- /test/tests/testing.py: -------------------------------------------------------------------------------- 1 | """Support for no database testing.""" 2 | 3 | from django.test.runner import DiscoverRunner 4 | 5 | 6 | class DatabaselessTestRunner(DiscoverRunner): 7 | """A test suite runner that does not set up and tear down a database.""" 8 | 9 | def setup_databases(self, *args, **kwargs): 10 | """Overrides DjangoTestSuiteRunner""" 11 | pass 12 | 13 | def teardown_databases(self, *args, **kwargs): 14 | """Overrides DjangoTestSuiteRunner""" 15 | pass 16 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.6.0 4 | hooks: 5 | - id: debug-statements 6 | - id: end-of-file-fixer 7 | - id: trailing-whitespace 8 | - id: detect-private-key 9 | 10 | - repo: https://github.com/psf/black 11 | rev: 24.4.2 12 | hooks: 13 | - id: black 14 | args: [--line-length=120] 15 | 16 | - repo: https://github.com/pycqa/isort 17 | rev: 5.13.2 18 | hooks: 19 | - id: isort 20 | args: [--profile=black, -m=3, -l=120] 21 | -------------------------------------------------------------------------------- /test/tests/urls.py: -------------------------------------------------------------------------------- 1 | """REST URL Configuration""" 2 | 3 | from django.urls import path 4 | 5 | from . import views 6 | 7 | urlpatterns = [ 8 | path("from_status//", views.from_status), 9 | path("from_name//", views.from_name), 10 | path("with_response/", views.with_response), 11 | path("with_content/", views.with_content), 12 | path("with_json/", views.with_json), 13 | path("exception/", views.exception), 14 | path("errorify/403/", views.errorify_403), 15 | path("errorify/404/", views.Errorify404.as_view()), 16 | path("not_found/", views.not_found), 17 | ] 18 | -------------------------------------------------------------------------------- /django_http_exceptions/utils.py: -------------------------------------------------------------------------------- 1 | import functools 2 | 3 | from django.utils.decorators import method_decorator 4 | 5 | 6 | def errorify(error): 7 | def decorator(view): 8 | if isinstance(view, type): 9 | return _errorify_class(view, error) 10 | else: 11 | return _errorify_function(view, error) 12 | 13 | return decorator 14 | 15 | 16 | def _errorify_class(cls, error): 17 | return method_decorator(functools.partial(_errorify_function, error=error), name="dispatch")(cls) 18 | 19 | 20 | def _errorify_function(f, error): 21 | @functools.wraps(f) 22 | def inner(*a, **kw): 23 | raise error.with_response(f(*a, **kw)) 24 | 25 | return inner 26 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "django_http_exceptions" 7 | version = "1.4.2" 8 | description = "Django raisable HTTP exceptions" 9 | readme = "README.md" 10 | license = {text = "MIT LICENSE"} 11 | authors = [ 12 | {name = "isik-kaplan", email = "isik.kaplan.social@outlook.com"} 13 | ] 14 | requires-python = ">=3.5" 15 | dependencies = [ 16 | "django>=2.0" 17 | ] 18 | 19 | classifiers = [ 20 | "License :: OSI Approved :: MIT License", 21 | "Programming Language :: Python :: 3", 22 | "Framework :: Django", 23 | "Development Status :: 5 - Production/Stable" 24 | ] 25 | 26 | [project.urls] 27 | Homepage = "https://github.com/isik-kaplan/django_http_exceptions" 28 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v3 14 | 15 | - name: Set up Python 16 | uses: actions/setup-python@v4 17 | with: 18 | python-version: '3.12' 19 | 20 | - name: Install build tools 21 | run: | 22 | python -m pip install --upgrade pip setuptools wheel build 23 | 24 | - name: Build package 25 | run: | 26 | python -m build 27 | 28 | - name: Publish to PyPI 29 | uses: pypa/gh-action-pypi-publish@release/v1 30 | with: 31 | username: __token__ 32 | password: ${{ secrets.PYPI_TOKEN }} 33 | packages-dir: dist 34 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | test: 13 | strategy: 14 | matrix: 15 | python-version: ['3.10', '3.11', '3.12'] 16 | django-version: ['4.0', '4.1', '4.2', '5.0', '5.1'] 17 | os-version: [ubuntu-22.04] 18 | 19 | runs-on: ${{ matrix.os-version }} 20 | 21 | steps: 22 | - uses: actions/checkout@v3 23 | 24 | - name: Set up Python ${{ matrix.python-version }} 25 | uses: actions/setup-python@v4 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | 29 | - name: Install dependencies 30 | run: | 31 | python -m pip install --upgrade pip setuptools packaging 32 | python -m pip install Django==${{ matrix.django-version }} pytest 33 | 34 | - name: Run Django tests 35 | run: | 36 | python test/manage.py test 37 | -------------------------------------------------------------------------------- /test/tests/settings.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = "TERCES" 2 | 3 | INSTALLED_APPS = [ 4 | "tests", 5 | "django.contrib.contenttypes", 6 | "django.contrib.sessions", 7 | ] 8 | 9 | # Templates engines 10 | TEMPLATES = [ 11 | { 12 | "BACKEND": "django.template.backends.django.DjangoTemplates", 13 | "DIRS": [], 14 | "APP_DIRS": True, 15 | "OPTIONS": { 16 | "context_processors": [ 17 | "django.template.context_processors.debug", 18 | "django.contrib.messages.context_processors.messages", 19 | "django.template.context_processors.request", 20 | ], 21 | }, 22 | }, 23 | ] 24 | 25 | # Middlewares 26 | MIDDLEWARE = MIDDLEWARE_CLASSES = [ 27 | "django_http_exceptions.middleware.ExceptionHandlerMiddleware", 28 | "django_http_exceptions.middleware.ThreadLocalRequestMiddleware", 29 | ] 30 | 31 | ROOT_URLCONF = "tests.urls" 32 | 33 | # Database 34 | DATABASES = {} 35 | 36 | # Allow test without database 37 | TEST_RUNNER = "tests.testing.DatabaselessTestRunner" 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/tests/views.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse 2 | from django.views import View 3 | 4 | from django_http_exceptions import HTTPExceptions 5 | from django_http_exceptions.utils import errorify 6 | 7 | 8 | def from_status(_, status): 9 | raise HTTPExceptions.from_status(status) 10 | 11 | 12 | def from_name(_, name): 13 | raise getattr(HTTPExceptions, name) 14 | 15 | 16 | def with_response(_): 17 | raise HTTPExceptions.NOT_FOUND.with_response(HttpResponse("It is indeed not found")) 18 | 19 | 20 | def with_content(_): 21 | raise HTTPExceptions.NOT_FOUND.with_content("It is indeed not found") 22 | 23 | 24 | def with_json(_): 25 | raise HTTPExceptions.NOT_FOUND.with_json({"response_type": "json"}) 26 | 27 | 28 | def exception(_): 29 | raise Exception 30 | 31 | 32 | def default_view(_): 33 | return HttpResponse("I am a default view") 34 | 35 | 36 | @errorify(HTTPExceptions.FORBIDDEN) 37 | def errorify_403(_): 38 | return HttpResponse() 39 | 40 | 41 | @errorify(HTTPExceptions.NOT_FOUND) 42 | class Errorify404(View): 43 | 44 | def get(self, _): 45 | return HttpResponse() 46 | 47 | 48 | def not_found(_): 49 | raise HTTPExceptions.NOT_FOUND 50 | -------------------------------------------------------------------------------- /django_http_exceptions/middleware.py: -------------------------------------------------------------------------------- 1 | from threading import local 2 | 3 | from django.http import HttpResponse 4 | from django.utils.deprecation import MiddlewareMixin 5 | 6 | from .exceptions import HTTPExceptions 7 | 8 | 9 | class ExceptionHandlerMiddleware(MiddlewareMixin): 10 | @staticmethod 11 | def process_exception(request, exc): 12 | if isinstance(exc, HTTPExceptions.BASE_EXCEPTION): 13 | for handler in exc._error_handlers: 14 | handler(request, exc) 15 | response = getattr(exc, "response", None) 16 | if not response and exc._has_default_view(): 17 | response = exc._get_default_view_response(request) 18 | if not response: 19 | response = HttpResponse(content=exc.description.encode(), status=exc.status) 20 | return response 21 | 22 | 23 | _thread_locals = local() 24 | 25 | 26 | def get_current_request(): 27 | return getattr(_thread_locals, "request", None) 28 | 29 | 30 | class ThreadLocalRequestMiddleware(MiddlewareMixin): 31 | @staticmethod 32 | def process_request(request): 33 | _thread_locals.request = request 34 | 35 | @staticmethod 36 | def process_response(request, response): 37 | if hasattr(_thread_locals, "request"): 38 | del _thread_locals.request 39 | return response 40 | 41 | @staticmethod 42 | def process_exception(request, exception): 43 | if hasattr(_thread_locals, "request"): 44 | del _thread_locals.request 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | db.sqlite3-journal 62 | 63 | # Flask stuff: 64 | instance/ 65 | .webassets-cache 66 | 67 | # Scrapy stuff: 68 | .scrapy 69 | 70 | # Sphinx documentation 71 | docs/_build/ 72 | 73 | # PyBuilder 74 | target/ 75 | 76 | # Jupyter Notebook 77 | .ipynb_checkpoints 78 | 79 | # IPython 80 | profile_default/ 81 | ipython_config.py 82 | 83 | # pyenv 84 | .python-version 85 | 86 | # pipenv 87 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 88 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 89 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 90 | # install all needed dependencies. 91 | #Pipfile.lock 92 | 93 | # celery beat schedule file 94 | celerybeat-schedule 95 | 96 | # SageMath parsed files 97 | *.sage.py 98 | 99 | # Environments 100 | .env 101 | .venv 102 | env/ 103 | venv/ 104 | ENV/ 105 | env.bak/ 106 | venv.bak/ 107 | 108 | # Spyder project settings 109 | .spyderproject 110 | .spyproject 111 | 112 | # Rope project settings 113 | .ropeproject 114 | 115 | # mkdocs documentation 116 | /site 117 | 118 | # mypy 119 | .mypy_cache/ 120 | .dmypy.json 121 | dmypy.json 122 | 123 | # Pycharm 124 | .idea/ 125 | 126 | # Pyre type checker 127 | .pyre/ 128 | -------------------------------------------------------------------------------- /django_http_exceptions/_exceptions.py: -------------------------------------------------------------------------------- 1 | from .exceptions import HTTPExceptions 2 | 3 | CONTINUE = HTTPExceptions.from_status(100) 4 | SWITCHING_PROTOCOLS = HTTPExceptions.from_status(101) 5 | PROCESSING = HTTPExceptions.from_status(102) 6 | OK = HTTPExceptions.from_status(200) 7 | CREATED = HTTPExceptions.from_status(201) 8 | ACCEPTED = HTTPExceptions.from_status(202) 9 | NON_AUTHORITATIVE_INFORMATION = HTTPExceptions.from_status(203) 10 | NO_CONTENT = HTTPExceptions.from_status(204) 11 | RESET_CONTENT = HTTPExceptions.from_status(205) 12 | PARTIAL_CONTENT = HTTPExceptions.from_status(206) 13 | MULTI_STATUS = HTTPExceptions.from_status(207) 14 | ALREADY_REPORTED = HTTPExceptions.from_status(208) 15 | IM_USED = HTTPExceptions.from_status(226) 16 | MULTIPLE_CHOICES = HTTPExceptions.from_status(300) 17 | MOVED_PERMANENTLY = HTTPExceptions.from_status(301) 18 | FOUND = HTTPExceptions.from_status(302) 19 | SEE_OTHER = HTTPExceptions.from_status(303) 20 | NOT_MODIFIED = HTTPExceptions.from_status(304) 21 | USE_PROXY = HTTPExceptions.from_status(305) 22 | TEMPORARY_REDIRECT = HTTPExceptions.from_status(307) 23 | PERMANENT_REDIRECT = HTTPExceptions.from_status(308) 24 | BAD_REQUEST = HTTPExceptions.from_status(400) 25 | UNAUTHORIZED = HTTPExceptions.from_status(401) 26 | PAYMENT_REQUIRED = HTTPExceptions.from_status(402) 27 | FORBIDDEN = HTTPExceptions.from_status(403) 28 | NOT_FOUND = HTTPExceptions.from_status(404) 29 | METHOD_NOT_ALLOWED = HTTPExceptions.from_status(405) 30 | NOT_ACCEPTABLE = HTTPExceptions.from_status(406) 31 | PROXY_AUTHENTICATION_REQUIRED = HTTPExceptions.from_status(407) 32 | REQUEST_TIMEOUT = HTTPExceptions.from_status(408) 33 | CONFLICT = HTTPExceptions.from_status(409) 34 | GONE = HTTPExceptions.from_status(410) 35 | LENGTH_REQUIRED = HTTPExceptions.from_status(411) 36 | PRECONDITION_FAILED = HTTPExceptions.from_status(412) 37 | REQUEST_ENTITY_TOO_LARGE = HTTPExceptions.from_status(413) 38 | REQUEST_URI_TOO_LONG = HTTPExceptions.from_status(414) 39 | UNSUPPORTED_MEDIA_TYPE = HTTPExceptions.from_status(415) 40 | REQUESTED_RANGE_NOT_SATISFIABLE = HTTPExceptions.from_status(416) 41 | EXPECTATION_FAILED = HTTPExceptions.from_status(417) 42 | UNPROCESSABLE_ENTITY = HTTPExceptions.from_status(422) 43 | LOCKED = HTTPExceptions.from_status(423) 44 | FAILED_DEPENDENCY = HTTPExceptions.from_status(424) 45 | UPGRADE_REQUIRED = HTTPExceptions.from_status(426) 46 | PRECONDITION_REQUIRED = HTTPExceptions.from_status(428) 47 | TOO_MANY_REQUESTS = HTTPExceptions.from_status(429) 48 | REQUEST_HEADER_FIELDS_TOO_LARGE = HTTPExceptions.from_status(431) 49 | INTERNAL_SERVER_ERROR = HTTPExceptions.from_status(500) 50 | NOT_IMPLEMENTED = HTTPExceptions.from_status(501) 51 | BAD_GATEWAY = HTTPExceptions.from_status(502) 52 | SERVICE_UNAVAILABLE = HTTPExceptions.from_status(503) 53 | GATEWAY_TIMEOUT = HTTPExceptions.from_status(504) 54 | HTTP_VERSION_NOT_SUPPORTED = HTTPExceptions.from_status(505) 55 | VARIANT_ALSO_NEGOTIATES = HTTPExceptions.from_status(506) 56 | INSUFFICIENT_STORAGE = HTTPExceptions.from_status(507) 57 | LOOP_DETECTED = HTTPExceptions.from_status(508) 58 | NOT_EXTENDED = HTTPExceptions.from_status(510) 59 | NETWORK_AUTHENTICATION_REQUIRED = HTTPExceptions.from_status(511) 60 | -------------------------------------------------------------------------------- /django_http_exceptions/exceptions.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | from django.http import HttpResponse, JsonResponse 4 | 5 | 6 | def _is_dunder(name): 7 | """Returns True if a __dunder__ name, False otherwise.""" 8 | return name[:2] == name[-2:] == "__" and name[2:3] != "_" and name[-3:-2] != "_" and len(name) > 4 9 | 10 | 11 | class transform(type): 12 | """A metaclass to help automatically apply a function to all 3rd party members of a class""" 13 | 14 | def __setattr__(self, key, value): 15 | return super().__setattr__(key, self.__transform__(value)) 16 | 17 | @staticmethod 18 | def __raise_on_new(klass): 19 | def __new__(cls, *a, **kw): 20 | raise TypeError("{} can not be initilazed.".format(klass)) 21 | 22 | return __new__ 23 | 24 | @staticmethod 25 | def __noop_transform(key, value, classdict): 26 | return value 27 | 28 | @staticmethod 29 | def __noop_checks(key, value, classdict): 30 | return True 31 | 32 | def __new__(mcs, cls, bases, classdict): 33 | c = classdict 34 | _transform = c.get("__transform__", mcs.__noop_transform) 35 | _checks = c.get("__checks__", mcs.__noop_checks) 36 | transformed_classdict = {k: _transform(k, v, c) if _checks(k, v, c) else v for k, v in c.items()} 37 | new_classdict = {**transformed_classdict, "__new__": mcs.__raise_on_new(cls)} 38 | return type.__new__(mcs, cls, bases, new_classdict) 39 | 40 | 41 | class HTTPException(Exception): 42 | _error_handlers = [] 43 | 44 | @classmethod 45 | def with_response(cls, response): 46 | exception = cls() 47 | response.status_code = exception.status 48 | exception.response = response 49 | return exception 50 | 51 | @classmethod 52 | def with_content(cls, content): 53 | exception = cls() 54 | exception.response = HttpResponse(content, status=exception.status) 55 | return exception 56 | 57 | @classmethod 58 | def with_json(cls, json_data): 59 | exception = cls() 60 | exception.response = JsonResponse(json_data, status=exception.status) 61 | return exception 62 | 63 | @classmethod 64 | def register_default_view(cls, view): 65 | cls._default_view = staticmethod(view) 66 | 67 | @classmethod 68 | def register_error_handler(cls, handler): 69 | cls._error_handlers.append(handler) 70 | return handler 71 | 72 | @classmethod 73 | def remove_error_handler(cls, handler): 74 | cls._error_handlers.remove(handler) 75 | 76 | @classmethod 77 | def _has_default_view(cls): 78 | return hasattr(cls, "_default_view") 79 | 80 | @classmethod 81 | def _get_default_view_response(cls, request): 82 | response = cls._default_view(request) 83 | response.status_code = cls.status 84 | return response 85 | 86 | def __call__(self, *args): 87 | self.args += args 88 | return self 89 | 90 | 91 | class HTTPExceptions(metaclass=transform): 92 | BASE_EXCEPTION = HTTPException 93 | encapsulated = ["exceptions", "register_base_exception"] 94 | exceptions = [] 95 | 96 | def __transform__(key, value, classdict): 97 | base_exception = classdict.get("BASE_EXCEPTION") or HTTPException 98 | if not issubclass(base_exception, HTTPException): 99 | raise TypeError("BASE_EXCEPTION must be a subclass of HTTPException.") 100 | return type( 101 | value.name, 102 | (base_exception,), 103 | {"__module__": "HTTPExceptions", "status": value.value, "description": value.description}, 104 | ) 105 | 106 | def __checks__(key, value, classdict): 107 | return all( 108 | ( 109 | not _is_dunder(key), 110 | not callable(value), 111 | not isinstance(value, Exception), 112 | not isinstance(value, classmethod), 113 | key != "encapsulated", 114 | key not in classdict.get("encapsulated", []), 115 | ) 116 | ) 117 | 118 | @classmethod 119 | def from_status(cls, code): 120 | """Get the exception from status code""" 121 | return getattr(cls, HTTPStatus(code).name) 122 | 123 | @classmethod 124 | def register_base_exception(cls, new_exception): 125 | for exception in cls.exceptions: 126 | if not issubclass(new_exception, HTTPException): 127 | raise TypeError("New exception must be a subclass of HTTPException.") 128 | getattr(cls, exception).__bases__ = (new_exception,) 129 | 130 | # Add all possible HTTP status codes from http.HTTPStatus 131 | for status in list(HTTPStatus.__members__.values()): 132 | locals()[status.name] = status 133 | exceptions.append(status.name) 134 | -------------------------------------------------------------------------------- /test/tests/tests.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import json 3 | from http import HTTPStatus 4 | from io import StringIO 5 | 6 | from django.test import Client, SimpleTestCase 7 | 8 | from django_http_exceptions import HTTPExceptions 9 | from django_http_exceptions.exceptions import HTTPException 10 | 11 | from . import views 12 | 13 | 14 | class DjangoHTTPExceptionTestCase(SimpleTestCase): 15 | 16 | def setUp(self): 17 | self.client = Client() 18 | 19 | def test_from_status(self): 20 | for status in HTTPStatus: 21 | response = self.client.get("/from_status/%d/" % status.value) 22 | self.assertEqual(status.value, response.status_code) 23 | 24 | def test_from_name(self): 25 | for status in HTTPStatus: 26 | response = self.client.get("/from_name/%s/" % status.name) 27 | self.assertEqual(status.value, response.status_code) 28 | 29 | def test_with_response(self): 30 | response = self.client.get("/with_response/") 31 | self.assertContains(response, "It is indeed not found", status_code=404) 32 | 33 | def test_with_content(self): 34 | response = self.client.get("/with_content/") 35 | self.assertContains(response, "It is indeed not found", status_code=404) 36 | 37 | def test_with_json(self): 38 | response = self.client.get("/with_json/") 39 | response_json = response.json() 40 | self.assertJSONEqual(json.dumps(response_json), {"response_type": "json"}) 41 | 42 | def test_register_default_view(self): 43 | HTTPExceptions.BAD_REQUEST.register_default_view(views.default_view) 44 | response = self.client.get("/from_status/400/") 45 | self.assertContains(response, "I am a default view", status_code=400) 46 | 47 | def test_errorify_function(self): 48 | response = self.client.get("/errorify/403/") 49 | self.assertEqual(403, response.status_code) 50 | 51 | def test_errorify_class(self): 52 | response = self.client.get("/errorify/404/") 53 | self.assertEqual(404, response.status_code) 54 | 55 | def test_does_not_catch_other_exception(self): 56 | with self.assertRaises(Exception): 57 | self.client.get("/exception/") 58 | 59 | def test_exceptions_can_be_subclassed(self): 60 | class CustomHTTPException(HTTPException): 61 | def __init__(self, *a, **kw): 62 | if a: 63 | self.first_arg = a[0] 64 | super() 65 | 66 | @staticmethod 67 | def custom_static_method(): 68 | return "STATIC METHOD" 69 | 70 | @classmethod 71 | def custom_class_method(cls): 72 | return "CLASS METHOD" 73 | 74 | def custom_method(self): 75 | return self.first_arg 76 | 77 | HTTPExceptions.register_base_exception(CustomHTTPException) 78 | 79 | self.assertEqual(HTTPExceptions.BAD_REQUEST.custom_static_method(), "STATIC METHOD") 80 | self.assertEqual(HTTPExceptions.BAD_REQUEST("first argument").custom_static_method(), "STATIC METHOD") 81 | self.assertEqual(HTTPExceptions.BAD_REQUEST.custom_class_method(), "CLASS METHOD") 82 | self.assertEqual(HTTPExceptions.BAD_REQUEST("first argument").custom_method(), "first argument") 83 | 84 | def test_require_HTTPException_as_base_class(self): 85 | class CustomHTTPException: 86 | def my_custom_method(self): 87 | return "YAY" 88 | 89 | with self.assertRaises(TypeError): 90 | HTTPExceptions.register_base_exception(CustomHTTPException) 91 | 92 | def test_error_handlers(self): 93 | 94 | @HTTPExceptions.NOT_FOUND.register_error_handler 95 | def handler(request, exc): 96 | print("404 logged") 97 | 98 | stdout = StringIO() 99 | with contextlib.redirect_stdout(stdout): 100 | self.client.get("/not_found/") 101 | 102 | self.assertEqual(stdout.getvalue().strip(), "404 logged") 103 | HTTPExceptions.NOT_FOUND.remove_error_handler(handler) 104 | 105 | def test_global_error_handler(self): 106 | 107 | @HTTPExceptions.BASE_EXCEPTION.register_error_handler 108 | def handler(request, exc): 109 | print("error logged") 110 | 111 | stdout = StringIO() 112 | with contextlib.redirect_stdout(stdout): 113 | self.client.get("/not_found/") 114 | 115 | self.assertEqual(stdout.getvalue().strip(), "error logged") 116 | HTTPExceptions.NOT_FOUND.remove_error_handler(handler) 117 | 118 | def test_global_and_single_error_handlers_together(self): 119 | 120 | @HTTPExceptions.BASE_EXCEPTION.register_error_handler 121 | def handler_global(request, exc): 122 | print("error logged") 123 | 124 | @HTTPExceptions.NOT_FOUND.register_error_handler 125 | def handler_404(request, exc): 126 | print("404 logged") 127 | 128 | stdout = StringIO() 129 | with contextlib.redirect_stdout(stdout): 130 | self.client.get("/not_found/") 131 | 132 | self.assertEqual(stdout.getvalue().strip(), "error logged\n404 logged") 133 | HTTPExceptions.NOT_FOUND.remove_error_handler(handler_404) 134 | HTTPExceptions.BASE_EXCEPTION.remove_error_handler(handler_global) 135 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://github.com/isik-kaplan/django-http-exceptions/actions/workflows/tests.yml/badge.svg)](https://github.com/isik-kaplan/django-http-exceptions/actions/workflows/tests.yml/badge.svg) 2 | [![Python 3.5+](https://img.shields.io/badge/python-3.5+-brightgreen.svg)](#) 3 | [![Django 2.0+](https://img.shields.io/badge/django-2.0+-brightgreen.svg)](#) 4 | [![PyPI - License](https://img.shields.io/pypi/l/django-http-exceptions.svg)](https://pypi.org/project/django-http-exceptions/) 5 | [![PyPI - Downloads](https://img.shields.io/pypi/dm/django-http-exceptions.svg)](https://pypi.org/project/django-http-exceptions/) 6 | 7 | 8 | ## What is *django-http-exceptions*? 9 | 10 | It is raisable exceptions for your django views. 11 | 12 | 13 | 14 | ## What is it good for? 15 | 16 | It makes this 17 | 18 | ````py 19 | def some_function(): 20 | raise SomeError 21 | 22 | def view(request): 23 | try: 24 | response = some_function() 25 | except SomeError: 26 | response = HttpResponse(status=403) 27 | return response 28 | ```` 29 | into this 30 | ````py 31 | from django_http_exceptions import HTTPExceptions 32 | def some_function(): 33 | raise HTTPExceptions.FORBIDDEN # HTTPExceptions.from_status(403) 34 | 35 | def view(request): 36 | return some_function() 37 | 38 | ```` 39 | 40 | meaning that is saves you from boilerplate code. 41 | 42 | 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. 43 | 44 | 45 | 46 | ## How to use it? 47 | 48 | Just two middlewares, lower the better, and you are done. 49 | 50 | ````python 51 | MIDDLEWARE = [ 52 | ..., 53 | 'django_http_exceptions.middleware.ExceptionHandlerMiddleware', 54 | 'django_http_exceptions.middleware.ThreadLocalRequestMiddleware', 55 | ... 56 | ] 57 | ```` 58 | 59 | And that is it, you are ready to raise your http exceptions. 60 | 61 | 62 | 63 | ## What else? 64 | 65 | 66 | #### `HTTPExceptions` 67 | Base class that provides all the exceptions to be raised. 68 | 69 | 70 | #### `HTTPExceptions.from_status(status)` 71 | In case you don't want to write 72 | `HTTPExceptions.REQUEST_HEADER_FIELDS_TOO_LARGE` 73 | You can just write 74 | `HTTPExceptions.from_status(431)` 75 | 76 | 77 | #### `HTTPExceptions.BASE_EXCEPTON` 78 | The base exception for all http exception 79 | 80 | #### `HTTPExceptions.register_base_exception(exception)` 81 | Given that `exception` is a class that inherits from `HTTPException` you can customize the exceptions. 82 | Keep in mind that `HTTPException` is an `Exception` subclass itself. 83 | 84 | 85 | #### `HTTPExceptions.BASE_EXCEPTION.with_response(response)` 86 | This is the method for raising exceptions with a response. You can put any response in this method while raising your 87 | error. 88 | 89 | Let's say you have a view named `index`, then this example would return what `index` function would return, but with 90 | status code `410` 91 | `HTTPExceptions.GONE.with_response(index(request))` 92 | 93 | 94 | #### `HTTPExceptions.BASE_EXCEPTION.with_content(content)` 95 | This method allow to raise an **HTTPException** with a custom message (can be either `str` or `bytes`). 96 | 97 | For instance, `HTTPExceptions.NOT_FOUND.with_content("The user named 'username' could not be found")` 98 | would return something equivalent to `HttpResponse("The user named 'username' could not be found", status=404)`. 99 | 100 | #### `HTTPExceptions.BASE_EXCEPTION.with_json(json_data)` 101 | This method allow to raise an **HTTPException** with a custom json response, 102 | `json_data` can be anything that `JsonResponse` accepts. 103 | 104 | #### `HTTPExceptions.BASE_EXCEPTION.register_default_view(view)` 105 | `view` is a function that takes only one argument, `request` when you register a default view to an error class with 106 | `HTTPExceptions.NOT_FOUND.register_defaul_view(view)` when `HTTPExceptions.GONE` is raised it returns the view function, 107 | but again, with `404` status code. If the error has been raised with `.with_response`, that is used instead. 108 | 109 | 110 | #### `get_current_request` 111 | 112 | This function gets you the current request anywhere in your django application, making it easier for your dynamic error 113 | responses to be created, like in the `HTTPExceptions.GONE.with_response(index(request))` example. 114 | 115 | 116 | #### `ExceptionHandlerMiddleware` 117 | 118 | Just there for to exception handling to work. 119 | 120 | 121 | #### `ThreadLocalRequestMiddleware` 122 | 123 | Just there for to `get_current_request` to work. 124 | 125 | 126 | #### `errorify(error)` 127 | 128 | Decorator that turns a view (both class and function) into an http error 129 | 130 | ````python 131 | @errorify(HTTPExceptions.PAYMENT_REQUIRED) 132 | class Subscribe(TemplateView): 133 | template = SUBSCRIBE_TEMPLATE 134 | ```` 135 | 136 | 137 | ## Avaliable Exceptions 138 | ```py 139 | HTTPExceptions.CONTINUE # HTTPExceptions.from_status(100) 140 | HTTPExceptions.SWITCHING_PROTOCOLS # HTTPExceptions.from_status(101) 141 | HTTPExceptions.PROCESSING # HTTPExceptions.from_status(102) 142 | HTTPExceptions.OK # HTTPExceptions.from_status(200) 143 | HTTPExceptions.CREATED # HTTPExceptions.from_status(201) 144 | HTTPExceptions.ACCEPTED # HTTPExceptions.from_status(202) 145 | HTTPExceptions.NON_AUTHORITATIVE_INFORMATION # HTTPExceptions.from_status(203) 146 | HTTPExceptions.NO_CONTENT # HTTPExceptions.from_status(204) 147 | HTTPExceptions.RESET_CONTENT # HTTPExceptions.from_status(205) 148 | HTTPExceptions.PARTIAL_CONTENT # HTTPExceptions.from_status(206) 149 | HTTPExceptions.MULTI_STATUS # HTTPExceptions.from_status(207) 150 | HTTPExceptions.ALREADY_REPORTED # HTTPExceptions.from_status(208) 151 | HTTPExceptions.IM_USED # HTTPExceptions.from_status(226) 152 | HTTPExceptions.MULTIPLE_CHOICES # HTTPExceptions.from_status(300) 153 | HTTPExceptions.MOVED_PERMANENTLY # HTTPExceptions.from_status(301) 154 | HTTPExceptions.FOUND # HTTPExceptions.from_status(302) 155 | HTTPExceptions.SEE_OTHER # HTTPExceptions.from_status(303) 156 | HTTPExceptions.NOT_MODIFIED # HTTPExceptions.from_status(304) 157 | HTTPExceptions.USE_PROXY # HTTPExceptions.from_status(305) 158 | HTTPExceptions.TEMPORARY_REDIRECT # HTTPExceptions.from_status(307) 159 | HTTPExceptions.PERMANENT_REDIRECT # HTTPExceptions.from_status(308) 160 | HTTPExceptions.BAD_REQUEST # HTTPExceptions.from_status(400) 161 | HTTPExceptions.UNAUTHORIZED # HTTPExceptions.from_status(401) 162 | HTTPExceptions.PAYMENT_REQUIRED # HTTPExceptions.from_status(402) 163 | HTTPExceptions.FORBIDDEN # HTTPExceptions.from_status(403) 164 | HTTPExceptions.NOT_FOUND # HTTPExceptions.from_status(404) 165 | HTTPExceptions.METHOD_NOT_ALLOWED # HTTPExceptions.from_status(405) 166 | HTTPExceptions.NOT_ACCEPTABLE # HTTPExceptions.from_status(406) 167 | HTTPExceptions.PROXY_AUTHENTICATION_REQUIRED # HTTPExceptions.from_status(407) 168 | HTTPExceptions.REQUEST_TIMEOUT # HTTPExceptions.from_status(408) 169 | HTTPExceptions.CONFLICT # HTTPExceptions.from_status(409) 170 | HTTPExceptions.GONE # HTTPExceptions.from_status(410) 171 | HTTPExceptions.LENGTH_REQUIRED # HTTPExceptions.from_status(411) 172 | HTTPExceptions.PRECONDITION_FAILED # HTTPExceptions.from_status(412) 173 | HTTPExceptions.REQUEST_ENTITY_TOO_LARGE # HTTPExceptions.from_status(413) 174 | HTTPExceptions.REQUEST_URI_TOO_LONG # HTTPExceptions.from_status(414) 175 | HTTPExceptions.UNSUPPORTED_MEDIA_TYPE # HTTPExceptions.from_status(415) 176 | HTTPExceptions.REQUESTED_RANGE_NOT_SATISFIABLE # HTTPExceptions.from_status(416) 177 | HTTPExceptions.EXPECTATION_FAILED # HTTPExceptions.from_status(417) 178 | HTTPExceptions.UNPROCESSABLE_ENTITY # HTTPExceptions.from_status(422) 179 | HTTPExceptions.LOCKED # HTTPExceptions.from_status(423) 180 | HTTPExceptions.FAILED_DEPENDENCY # HTTPExceptions.from_status(424) 181 | HTTPExceptions.UPGRADE_REQUIRED # HTTPExceptions.from_status(426) 182 | HTTPExceptions.PRECONDITION_REQUIRED # HTTPExceptions.from_status(428) 183 | HTTPExceptions.TOO_MANY_REQUESTS # HTTPExceptions.from_status(429) 184 | HTTPExceptions.REQUEST_HEADER_FIELDS_TOO_LARGE # HTTPExceptions.from_status(431) 185 | HTTPExceptions.INTERNAL_SERVER_ERROR # HTTPExceptions.from_status(500) 186 | HTTPExceptions.NOT_IMPLEMENTED # HTTPExceptions.from_status(501) 187 | HTTPExceptions.BAD_GATEWAY # HTTPExceptions.from_status(502) 188 | HTTPExceptions.SERVICE_UNAVAILABLE # HTTPExceptions.from_status(503) 189 | HTTPExceptions.GATEWAY_TIMEOUT # HTTPExceptions.from_status(504) 190 | HTTPExceptions.HTTP_VERSION_NOT_SUPPORTED # HTTPExceptions.from_status(505) 191 | HTTPExceptions.VARIANT_ALSO_NEGOTIATES # HTTPExceptions.from_status(506) 192 | HTTPExceptions.INSUFFICIENT_STORAGE # HTTPExceptions.from_status(507) 193 | HTTPExceptions.LOOP_DETECTED # HTTPExceptions.from_status(508) 194 | HTTPExceptions.NOT_EXTENDED # HTTPExceptions.from_status(510) 195 | HTTPExceptions.NETWORK_AUTHENTICATION_REQUIRED # HTTPExceptions.from_status(511) 196 | ``` 197 | --------------------------------------------------------------------------------