├── py.typed ├── example ├── www │ ├── __init__.py │ ├── templates │ │ └── www │ │ │ └── index.html │ ├── apps.py │ └── views.py ├── project │ ├── __init__.py │ ├── urls.py │ ├── wsgi.py │ └── settings.py └── manage.py ├── django_rich_logging ├── __init__.py ├── objects.py └── logging.py ├── tests ├── templates │ └── tests │ │ └── index.html ├── urls.py ├── logging │ └── django_request_handler │ │ ├── test_add_column_headers.py │ │ ├── test_init.py │ │ ├── test_get_markup.py │ │ └── test_emit.py └── objects │ └── test_request.py ├── django-rich-logging.gif ├── CHANGELOG.md ├── DEVELOPING.md ├── .readthedocs.yml ├── docs ├── Makefile ├── make.bat └── source │ ├── conf.py │ └── index.md ├── LICENSE ├── conftest.py ├── README.md ├── .gitignore ├── pyproject.toml ├── CODE_OF_CONDUCT.md └── poetry.lock /py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/www/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/project/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_rich_logging/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/templates/tests/index.html: -------------------------------------------------------------------------------- 1 | test -------------------------------------------------------------------------------- /example/www/templates/www/index.html: -------------------------------------------------------------------------------- 1 | index.html -------------------------------------------------------------------------------- /django-rich-logging.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamghill/django-rich-logging/HEAD/django-rich-logging.gif -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 0.1.1 2 | 3 | - Make 4xx status codes be yellow. 4 | 5 | # 0.1.0 6 | 7 | - `logging.DjangoRequestHandler` with initial functionality. 8 | -------------------------------------------------------------------------------- /example/www/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class WwwConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "www" 7 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from django.views.generic import TemplateView 3 | 4 | 5 | urlpatterns = (path("", TemplateView.as_view(template_name="tests/index.html")),) 6 | -------------------------------------------------------------------------------- /example/www/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | 4 | def index(request): 5 | return render(request, template_name="www/index.html", context={}) 6 | 7 | 8 | def error(request): 9 | raise Exception() 10 | -------------------------------------------------------------------------------- /example/project/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from django.views.generic import TemplateView 3 | 4 | from www import views 5 | 6 | 7 | urlpatterns = [ 8 | path("", views.index), 9 | path("error", views.error), 10 | ] 11 | -------------------------------------------------------------------------------- /DEVELOPING.md: -------------------------------------------------------------------------------- 1 | 1. `poetry version major|minor|patch` 2 | 1. `poe sb` 3 | 1. Update CHANGELOG.md 4 | 1. Commit changes 5 | 1. Tag commit with new version 6 | 1. `poe publish` 7 | 1. Update [GitHub Releases](https://github.com/adamghill/django-rich-logging/releases/new) 8 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | sphinx: 4 | configuration: docs/source/conf.py 5 | fail_on_warning: true 6 | builder: dirhtml 7 | 8 | formats: 9 | - pdf 10 | - epub 11 | 12 | python: 13 | version: 3 14 | install: 15 | - method: pip 16 | path: . 17 | extra_requirements: 18 | - docs 19 | -------------------------------------------------------------------------------- /example/project/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for project project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /tests/logging/django_request_handler/test_add_column_headers.py: -------------------------------------------------------------------------------- 1 | from django_rich_logging.logging import DjangoRequestHandler 2 | 3 | 4 | def test_add_column_headers_default(): 5 | handler = DjangoRequestHandler() 6 | assert len(handler.table.columns) == 5 7 | 8 | 9 | def test_add_column_headers_custom(): 10 | # check that init added one column 11 | handler = DjangoRequestHandler(columns=[{"header": "test1"}]) 12 | assert len(handler.table.columns) == 1 13 | 14 | # reset table columns 15 | handler.table.columns = [] 16 | 17 | handler._add_column_headers() 18 | assert len(handler.table.columns) == 1 19 | -------------------------------------------------------------------------------- /tests/logging/django_request_handler/test_init.py: -------------------------------------------------------------------------------- 1 | from rich.console import Console 2 | 3 | from django_rich_logging.logging import DjangoRequestHandler 4 | 5 | 6 | def test_init_with_console(): 7 | console = Console() 8 | handler = DjangoRequestHandler(console=console) 9 | 10 | assert id(console) == id(handler.console) 11 | 12 | 13 | def test_init_with_no_console(): 14 | handler = DjangoRequestHandler() 15 | 16 | assert handler.console is not None 17 | 18 | 19 | def test_init_with_live(): 20 | handler = DjangoRequestHandler() 21 | 22 | original_live_id = id(handler.live) 23 | 24 | handler.__init__() 25 | assert original_live_id == id(handler.live) 26 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /example/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings") 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /tests/objects/test_request.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import pytest 4 | 5 | from django_rich_logging.objects import RequestRecord 6 | 7 | 8 | @pytest.fixture 9 | def log_record(): 10 | return logging.LogRecord( 11 | "django.server", 12 | logging.INFO, 13 | pathname="path", 14 | lineno=1, 15 | msg='"GET /profile HTTP/1.1" 200 1234', 16 | args=("GET /profile HTTP/1.1", "200", "1234"), 17 | exc_info=None, 18 | ) 19 | 20 | 21 | def test_request_record_is_valid(log_record): 22 | request_record = RequestRecord(log_record) 23 | 24 | assert request_record.is_valid 25 | 26 | 27 | def test_request_record_arg0_is_invalid(log_record): 28 | log_record.args = ("blob", "200", "1234") 29 | request_record = RequestRecord(log_record) 30 | 31 | assert not request_record.is_valid 32 | 33 | 34 | def test_request_record_missing_arg_is_invalid(log_record): 35 | log_record.args = ("GET /profile HTTP/1.1", "200") 36 | request_record = RequestRecord(log_record) 37 | 38 | assert not request_record.is_valid 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Adam Hill 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 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | 3 | import toml 4 | 5 | 6 | # -- Project information 7 | 8 | project = "django-rich-logging" 9 | copyright = "2022, Adam Hill" 10 | author = "Adam Hill" 11 | 12 | pyproject = toml.load("../../pyproject.toml") 13 | version = pyproject["tool"]["poetry"]["version"] 14 | release = version 15 | 16 | 17 | # -- General configuration 18 | 19 | extensions = [ 20 | "sphinx.ext.duration", 21 | "sphinx.ext.doctest", 22 | "sphinx.ext.autodoc", 23 | "sphinx.ext.autosummary", 24 | "sphinx.ext.intersphinx", 25 | "myst_parser", 26 | "sphinx_copybutton", 27 | "sphinx.ext.napoleon", 28 | "sphinx.ext.autosectionlabel", 29 | ] 30 | 31 | intersphinx_mapping = { 32 | "python": ("https://docs.python.org/3/", None), 33 | "sphinx": ("https://www.sphinx-doc.org/en/master/", None), 34 | } 35 | intersphinx_disabled_domains = ["std"] 36 | 37 | templates_path = ["_templates"] 38 | 39 | # -- Options for HTML output 40 | 41 | html_theme = "furo" 42 | 43 | # -- Options for EPUB output 44 | epub_show_urls = "footnote" 45 | 46 | autosectionlabel_prefix_document = True 47 | autosectionlabel_maxdepth = 3 48 | 49 | myst_heading_anchors = 3 50 | myst_enable_extensions = ["linkify", "colon_fence"] 51 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from django.conf import settings 4 | 5 | 6 | def pytest_configure(): 7 | base_dir = Path(".") 8 | 9 | settings.configure( 10 | BASE_DIR=base_dir, 11 | SECRET_KEY="this-is-a-secret", 12 | ROOT_URLCONF="tests.urls", 13 | INSTALLED_APPS=[], 14 | TEMPLATES=[ 15 | { 16 | "BACKEND": "django.template.backends.django.DjangoTemplates", 17 | "DIRS": ["tests/templates"], 18 | "OPTIONS": { 19 | "context_processors": [ 20 | "django.template.context_processors.request", 21 | ], 22 | }, 23 | } 24 | ], 25 | CACHES={ 26 | "default": { 27 | "BACKEND": "django.core.cache.backends.dummy.DummyCache", 28 | } 29 | }, 30 | LOGGING={ 31 | "version": 1, 32 | "disable_existing_loggers": False, 33 | "formatters": {}, 34 | "handlers": { 35 | "django_rich_logging": { 36 | "class": "django_rich_logging.logging.DjangoRequestHandler", 37 | "level": "DEBUG", 38 | }, 39 | }, 40 | "loggers": { 41 | "django.server": { 42 | "handlers": ["django_rich_logging"], 43 | "level": "INFO", 44 | }, 45 | }, 46 | }, 47 | ) 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 |

django-rich-logging

3 |

4 |

A prettier way to see Django requests while developing.

5 | 6 | ![PyPI](https://img.shields.io/pypi/v/django-rich-logging?color=blue&style=flat-square) 7 | ![PyPI - Downloads](https://img.shields.io/pypi/dm/django-rich-logging?color=blue&style=flat-square) 8 | ![GitHub Sponsors](https://img.shields.io/github/sponsors/adamghill?color=blue&style=flat-square) 9 | 10 | 📖 Complete documentation: https://django-rich-logging.readthedocs.io 11 | 12 | 📦 Package located at https://pypi.org/project/django-rich-logging/ 13 | 14 | ## ⭐ Features 15 | 16 | - live-updating table of all requests while developing 17 | 18 | ![demo of django-rich-logging](https://raw.githubusercontent.com/adamghill/django-rich-logging/main/django-rich-logging.gif) 19 | 20 | ## Installation 21 | 22 | `poetry add django-rich-logging` OR `pip install django-rich-logging` 23 | 24 | ### Configuration 25 | 26 | ```python 27 | # settings.py 28 | 29 | # other settings here 30 | 31 | LOGGING = { 32 | "version": 1, 33 | "disable_existing_loggers": False, 34 | "handlers": { 35 | "django_rich_logging": { 36 | "class": "django_rich_logging.logging.DjangoRequestHandler", 37 | "level": "INFO", 38 | }, 39 | }, 40 | "loggers": { 41 | "django.server": {"handlers": ["django_rich_logging"], "level": "INFO"}, 42 | "django.request": {"level": "CRITICAL"}, 43 | }, 44 | } 45 | 46 | # other settings here 47 | ``` 48 | 49 | Read all of the documentation at https://django-rich-logging.readthedocs.io. 50 | -------------------------------------------------------------------------------- /.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 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv* 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # vscode 107 | .vscode/ 108 | .DS_Store 109 | pip-wheel-metadata 110 | TODO.md 111 | 112 | node_modules/ 113 | -------------------------------------------------------------------------------- /django_rich_logging/objects.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | from dataclasses import dataclass 4 | from datetime import datetime 5 | 6 | from django.conf import settings 7 | 8 | 9 | REQUEST_REGEX = ( 10 | r"(GET|POST|PATCH|UPDATE|HEAD|PUT|DELETE|CONNECT|OPTIONS|TRACE)\s+([\S]+)\s+([\S]+)" 11 | ) 12 | 13 | 14 | @dataclass 15 | class RequestRecord: 16 | """ 17 | Handles parsing the `logging.LogRecord` into a `RequestRecord` object. 18 | """ 19 | 20 | method: str 21 | path: str 22 | status_code: str 23 | size: str 24 | created: datetime 25 | record: logging.LogRecord 26 | text_style: str = None 27 | is_valid: bool = False 28 | 29 | def __init__(self, record: logging.LogRecord): 30 | # Ignore any message from `django.server` that doesn't have 3 args. 31 | # TODO: Find a less hacky way to deal with this. 32 | if record.name != "django.server" or len(record.args) != 3: 33 | return 34 | 35 | self.record = record 36 | self.created = datetime.fromtimestamp(record.created) 37 | 38 | unparsed_request = record.args[0] 39 | 40 | # Example: GET /profile HTTP/1.1 41 | matches = re.match(REQUEST_REGEX, unparsed_request) 42 | 43 | if matches: 44 | self.is_valid = True 45 | 46 | self.method = matches.group(1) 47 | self.path = matches.group(2) 48 | 49 | self.status_code = record.args[1] 50 | self.size = record.args[2] 51 | 52 | self.text_style = self.get_text_style() 53 | 54 | def get_text_style(self): 55 | if self.status_code.startswith("2"): 56 | return "green" 57 | elif self.status_code.startswith("3"): 58 | return "yellow" 59 | elif self.status_code.startswith("4"): 60 | return "yellow" 61 | elif self.status_code.startswith("5"): 62 | return "red" 63 | 64 | @property 65 | def is_loggable(self): 66 | if settings.STATIC_URL and self.path.startswith(settings.STATIC_URL): 67 | return False 68 | 69 | if self.path == "/favicon.ico": 70 | return False 71 | 72 | return True 73 | 74 | def get_dict(self, date_format: str = None): 75 | created_str = str(self.created) 76 | 77 | if date_format: 78 | created_str = self.created.strftime(date_format) 79 | 80 | return { 81 | "created": created_str, 82 | "method": self.method, 83 | "path": self.path, 84 | "status_code": self.status_code, 85 | "size": self.size, 86 | } 87 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "django-rich-logging" 3 | version = "0.2.0" 4 | description = "A prettier way to see Django requests while developing." 5 | authors = ["adamghill "] 6 | license = "MIT" 7 | readme = "README.md" 8 | keywords = ["django", "python", "static", "markdown"] 9 | repository = "https://github.com/adamghill/django-rich-logging/" 10 | homepage = "https://github.com/adamghill/django-rich-logging/" 11 | documentation = "https://django-rich-logging.readthedocs.io/" 12 | 13 | [tool.poetry.urls] 14 | "Funding" = "https://github.com/sponsors/adamghill" 15 | 16 | [tool.poetry.dependencies] 17 | python = ">=3.7,<4.0" 18 | Django = ">3.0" 19 | rich = "^11.2.0" 20 | 21 | # docs extras 22 | Sphinx = { version = "^4.3.2", optional = true } 23 | linkify-it-py = { version = "^1.0.3", optional = true } 24 | myst-parser = { version = "^0.16.1", optional = true } 25 | furo = { version = "^2021.11.23", optional = true } 26 | sphinx-copybutton = { version = "^0.4.0", optional = true } 27 | sphinx-autobuild = { version = "^2021.3.14", optional = true } 28 | toml = { version = "*", optional = true } 29 | attrs = { version = "^21.4.0", optional = true } 30 | 31 | [tool.poetry.extras] 32 | docs = ["Sphinx", "linkify-it-py", "myst-parser", "furo", "sphinx-copybutton", "sphinx-autobuild", "toml", "attrs"] 33 | 34 | [tool.poetry.dev-dependencies] 35 | poethepoet = "^0" 36 | black = "^22" 37 | flake9 = "^3" 38 | isort = "^5" 39 | pytest = "^6" 40 | pytest-django = "^4" 41 | pywatchman = "^1" 42 | django-stubs = "^1" 43 | coverage = {extras = ["toml"], version = "^6"} 44 | pytest-cov = "^3" 45 | 46 | [tool.isort] 47 | default_section = "THIRDPARTY" 48 | known_first_party = ["static_site", "example"] 49 | known_django = "django" 50 | sections = ["FUTURE", "STDLIB", "DJANGO", "THIRDPARTY", "FIRSTPARTY", "LOCALFOLDER"] 51 | lines_after_imports = 2 52 | multi_line_output = 3 53 | include_trailing_comma = true 54 | skip_glob = "*/migrations/*.py" 55 | profile = "black" 56 | 57 | [tool.pytest.ini_options] 58 | addopts = "--quiet --failed-first --reuse-db --nomigrations -p no:warnings" 59 | testpaths = [ 60 | "tests" 61 | ] 62 | markers = [ 63 | "slow: marks tests as slow", 64 | ] 65 | 66 | [tool.flake8] 67 | ignore = "E203,E266,H106,H904" 68 | max-line-length = 88 69 | 70 | [tool.coverage.run] 71 | branch = true 72 | parallel = true 73 | 74 | [tool.coverage.report] 75 | show_missing = true 76 | skip_covered = true 77 | skip_empty = true 78 | sort = "cover" 79 | 80 | [tool.mypy] 81 | python_version = "3.8" 82 | warn_return_any = true 83 | warn_unused_configs = true 84 | 85 | [tool.poe.tasks] 86 | r = { cmd = "example/manage.py runserver 0:8046", help = "Start dev server" } 87 | t = { cmd = "pytest -m 'not slow'", help = "Run tests" } 88 | tc = { cmd = "pytest --cov=django_rich_logging", help = "Run tests with coverage" } 89 | cr = { cmd = "coverage report", help = "Show coverage report" } 90 | my = { cmd = "mypy .", help = "Run mypy" } 91 | b = { cmd = "black . --check --quiet", help = "Run black" } 92 | i = { cmd = "isort . --check --quiet", help = "Run isort" } 93 | tm = ["b", "i", "tc", "my"] 94 | sa = { cmd = "sphinx-autobuild -W docs/source docs/build", help = "Sphinx autobuild" } 95 | sb = { cmd = "sphinx-build -W docs/source docs/build", help = "Build documentation" } 96 | publish = { shell = "poetry publish --build -r test && poetry publish" } 97 | 98 | [build-system] 99 | requires = ["poetry-core>=1.0.0"] 100 | build-backend = "poetry.core.masonry.api" 101 | -------------------------------------------------------------------------------- /tests/logging/django_request_handler/test_get_markup.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import datetime 3 | 4 | import pytest 5 | 6 | from django_rich_logging.logging import DjangoRequestHandler 7 | from django_rich_logging.objects import RequestRecord 8 | 9 | 10 | @pytest.fixture 11 | def handler(): 12 | handler = DjangoRequestHandler() 13 | 14 | assert handler.live is not None 15 | assert handler.table is not None 16 | assert len(handler.table.rows) == 0 17 | 18 | return handler 19 | 20 | 21 | @pytest.fixture 22 | def request_record(): 23 | log_record = logging.LogRecord( 24 | "django.server", 25 | logging.INFO, 26 | pathname="path", 27 | lineno=1, 28 | msg='"GET /profile HTTP/1.1" 200 1234', 29 | args=("GET /profile HTTP/1.1", "200", "1234"), 30 | exc_info=None, 31 | ) 32 | 33 | request_record = RequestRecord(log_record) 34 | 35 | setattr(request_record, "created", datetime(2022, 8, 14, 19, 8, 1, 2)) 36 | 37 | return request_record 38 | 39 | 40 | def test_get_markup_no_style(handler, request_record): 41 | column = {"format": "[white]%(method)s"} 42 | 43 | expected = "[white]GET" 44 | actual = handler._get_markup(request_record, column) 45 | 46 | assert expected == actual 47 | 48 | 49 | def test_get_markup_percentage_style(handler, request_record): 50 | column = {"format": "[white]%(method)s", "style": "%"} 51 | 52 | expected = "[white]GET" 53 | actual = handler._get_markup(request_record, column) 54 | 55 | assert expected == actual 56 | 57 | 58 | def test_get_markup_bracket_curly_style(handler, request_record): 59 | column = {"format": "[white]{method}", "style": "{"} 60 | 61 | expected = "[white]GET" 62 | actual = handler._get_markup(request_record, column) 63 | 64 | assert expected == actual 65 | 66 | 67 | def test_get_markup_dollar_style(handler, request_record): 68 | column = {"format": "[white]$method", "style": "$"} 69 | 70 | expected = "[white]GET" 71 | actual = handler._get_markup(request_record, column) 72 | 73 | assert expected == actual 74 | 75 | 76 | def test_get_markup_method(handler, request_record): 77 | column = {"format": "{method}", "style": "{"} 78 | 79 | expected = "GET" 80 | actual = handler._get_markup(request_record, column) 81 | 82 | assert expected == actual 83 | 84 | 85 | def test_get_markup_status_code(handler, request_record): 86 | column = {"format": "{status_code}", "style": "{"} 87 | 88 | expected = "200" 89 | actual = handler._get_markup(request_record, column) 90 | 91 | assert expected == actual 92 | 93 | 94 | def test_get_markup_path(handler, request_record): 95 | column = {"format": "{path}", "style": "{"} 96 | 97 | expected = "/profile" 98 | actual = handler._get_markup(request_record, column) 99 | 100 | assert expected == actual 101 | 102 | 103 | def test_get_markup_created(handler, request_record): 104 | column = {"format": "{created}", "style": "{"} 105 | 106 | expected = "2022-08-14 19:08:01.000002" 107 | actual = handler._get_markup(request_record, column) 108 | 109 | assert expected == actual 110 | 111 | 112 | def test_get_markup_size(handler, request_record): 113 | column = {"format": "{size}", "style": "{"} 114 | 115 | expected = "1234" 116 | actual = handler._get_markup(request_record, column) 117 | 118 | assert expected == actual 119 | 120 | 121 | def test_get_markup_invalid_style(handler, request_record): 122 | column = {"format": "{size}", "style": "x"} 123 | 124 | with pytest.raises(Exception): 125 | handler._get_markup(request_record, column) 126 | -------------------------------------------------------------------------------- /django_rich_logging/logging.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from string import Template 3 | from typing import Dict 4 | 5 | from rich.console import Console 6 | from rich.live import Live 7 | from rich.table import Table 8 | from rich.text import Text 9 | 10 | from .objects import RequestRecord 11 | 12 | 13 | DEFAULT_COLUMNS = [ 14 | {"header": "Method", "format": "[white]{method}", "style": "{"}, 15 | {"header": "Path", "format": "[white bold]{path}", "style": "{"}, 16 | {"header": "Status", "format": "{status_code}", "style": "{"}, 17 | {"header": "Size", "format": "[white]{size}", "style": "{"}, 18 | { 19 | "header": "Time", 20 | "format": "[white]{created}", 21 | "style": "{", 22 | "datefmt": "%H:%M:%S", 23 | }, 24 | ] 25 | 26 | 27 | class DjangoRequestHandler(logging.StreamHandler): 28 | """ 29 | A logging handler that prints Django requests in a live-updating table. 30 | """ 31 | 32 | live = None 33 | table = None 34 | console = None 35 | columns = None 36 | 37 | def __init__(self, *args, **kwargs): 38 | super().__init__() 39 | 40 | if "columns" in kwargs: 41 | self.columns = kwargs["columns"] 42 | else: 43 | self.columns = DEFAULT_COLUMNS 44 | 45 | if "console" in kwargs: 46 | self.console = kwargs["console"] 47 | 48 | if self.console is None: 49 | self.console = Console() 50 | 51 | if self.live is None: 52 | self.table = Table() 53 | self._add_column_headers() 54 | 55 | self.live = Live(self.table, auto_refresh=False) 56 | 57 | def _add_column_headers(self): 58 | """ 59 | Add column headers for each column. 60 | """ 61 | 62 | for column in self.columns: 63 | self.table.add_column(column["header"]) 64 | 65 | def _add_row(self, request_record: RequestRecord) -> None: 66 | """ 67 | Add a row to the live updating table that includes rendered markup for each column. 68 | """ 69 | 70 | row_columns = [] 71 | 72 | for column in self.columns: 73 | markup = self._get_markup(request_record, column) 74 | text = Text.from_markup(markup, style=request_record.text_style) 75 | 76 | row_columns.append(text) 77 | 78 | self.table.add_row(*row_columns) 79 | 80 | def _get_markup(self, request_record: RequestRecord, column: Dict) -> str: 81 | """ 82 | Get markup based on the column's `format` and the `RequestRecord` object. 83 | """ 84 | 85 | format = column.get("format", "") 86 | style = column.get("style", "%") 87 | date_format = column.get("datefmt") 88 | 89 | request_record_data = request_record.get_dict(date_format=date_format) 90 | 91 | if style == "%": 92 | return format % request_record_data 93 | elif style == "{": 94 | return format.format(**request_record_data) 95 | elif style == "$": 96 | template = Template(format) 97 | 98 | return template.substitute(**request_record_data) 99 | else: 100 | raise Exception(f"Unknown style: {style}") 101 | 102 | def emit(self, record: logging.LogRecord) -> None: 103 | """ 104 | Writes the logging record. If it's a request, add the record to a live, 105 | updating table. If not, emit it like normal. 106 | """ 107 | 108 | try: 109 | request_record = RequestRecord(record) 110 | 111 | if request_record.is_valid: 112 | if not request_record.is_loggable: 113 | return 114 | 115 | self._add_row(request_record) 116 | 117 | self.live.start() 118 | self.live.refresh() 119 | 120 | self.flush() 121 | else: 122 | super().emit(record) 123 | except (KeyboardInterrupt, SystemExit): 124 | raise 125 | except Exception as ex: 126 | # self.handleError(record) 127 | print(ex) 128 | -------------------------------------------------------------------------------- /tests/logging/django_request_handler/test_emit.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import pytest 4 | 5 | from django_rich_logging.logging import DjangoRequestHandler 6 | 7 | 8 | @pytest.fixture 9 | def log_record(): 10 | return logging.LogRecord( 11 | "django.server", 12 | logging.INFO, 13 | pathname="path", 14 | lineno=1, 15 | msg='"GET /profile HTTP/1.1" 200 1234', 16 | args=("GET /profile HTTP/1.1", "200", "1234"), 17 | exc_info=None, 18 | ) 19 | 20 | 21 | @pytest.fixture 22 | def handler(): 23 | handler = DjangoRequestHandler() 24 | 25 | assert handler.live is not None 26 | assert handler.table is not None 27 | assert len(handler.table.rows) == 0 28 | 29 | return handler 30 | 31 | 32 | def _get_cell(handler, column_idx, row_idx): 33 | return list( 34 | handler.table._get_cells(handler.console, 0, handler.table.columns[column_idx]) 35 | )[row_idx] 36 | 37 | 38 | def test_emit(handler, log_record): 39 | handler.emit(log_record) 40 | 41 | assert len(handler.table.rows) == 1 42 | 43 | 44 | def test_emit_non_django_server_log_record(handler, log_record): 45 | log_record.name = "not-django.server" 46 | 47 | handler.emit(log_record) 48 | 49 | assert len(handler.table.rows) == 0 50 | 51 | 52 | def test_emit_log_record_with_not_3_args(handler, log_record): 53 | log_record.args = ("127.0.0.1", "1234") 54 | 55 | handler.emit(log_record) 56 | 57 | assert len(handler.table.rows) == 0 58 | 59 | 60 | def test_emit_200(handler, log_record): 61 | handler.emit(log_record) 62 | 63 | assert len(handler.table.rows) == 1 64 | 65 | cell = _get_cell(handler, column_idx=0, row_idx=1) 66 | text = cell.renderable.renderable 67 | 68 | assert str(text) == "GET" 69 | assert text.style == "green" 70 | 71 | 72 | def test_emit_301(handler, log_record): 73 | log_record.args = ("GET /profile HTTP/1.1", "301", "1234") 74 | 75 | handler.emit(log_record) 76 | 77 | assert len(handler.table.rows) == 1 78 | 79 | cell = _get_cell(handler, column_idx=0, row_idx=1) 80 | text = cell.renderable.renderable 81 | 82 | assert str(text) == "GET" 83 | assert text.style == "yellow" 84 | 85 | 86 | def test_emit_404(handler, log_record): 87 | log_record.args = ("GET /profile HTTP/1.1", "404", "1234") 88 | 89 | handler.emit(log_record) 90 | 91 | assert len(handler.table.rows) == 1 92 | 93 | cell = _get_cell(handler, column_idx=0, row_idx=1) 94 | text = cell.renderable.renderable 95 | 96 | assert str(text) == "GET" 97 | assert text.style == "yellow" 98 | 99 | 100 | def test_emit_500(handler, log_record): 101 | log_record.args = ("GET /profile HTTP/1.1", "500", "1234") 102 | 103 | handler.emit(log_record) 104 | 105 | assert len(handler.table.rows) == 1 106 | 107 | cell = _get_cell(handler, column_idx=0, row_idx=1) 108 | text = cell.renderable.renderable 109 | 110 | assert str(text) == "GET" 111 | assert text.style == "red" 112 | 113 | 114 | def test_emit_skip_favicon(handler, log_record): 115 | log_record.args = ("GET /favicon.ico HTTP/1.1", "200", "1234") 116 | 117 | handler.emit(log_record) 118 | 119 | assert len(handler.table.rows) == 0 120 | 121 | 122 | def test_emit_no_matches(handler, log_record): 123 | log_record.args = ("this will not match the regex", "500", "1234") 124 | 125 | handler.emit(log_record) 126 | 127 | assert len(handler.table.rows) == 0 128 | 129 | 130 | def test_emit_static_url(settings, handler, log_record): 131 | settings.STATIC_URL = "/static/" 132 | log_record.args = ("GET /static/vue.js HTTP/1.1", "200", "1234") 133 | 134 | handler.emit(log_record) 135 | 136 | assert len(handler.table.rows) == 0 137 | 138 | 139 | def test_emit_static_url_setting_is_different(settings, handler, log_record): 140 | settings.STATIC_URL = "/static2/" 141 | log_record.args = ("GET /static/vue.js HTTP/1.1", "200", "1234") 142 | 143 | handler.emit(log_record) 144 | 145 | assert len(handler.table.rows) == 1 146 | 147 | 148 | def test_emit_missing_static_setting(settings, handler, log_record): 149 | settings.STATIC_URL = None 150 | log_record.args = ("GET /static/vue.js HTTP/1.1", "200", "1234") 151 | 152 | handler.emit(log_record) 153 | 154 | assert len(handler.table.rows) == 1 155 | -------------------------------------------------------------------------------- /example/project/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for project project. 3 | 4 | Generated by 'django-admin startproject' using Django 3.2.12. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/3.2/ref/settings/ 11 | """ 12 | 13 | from pathlib import Path 14 | 15 | 16 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 17 | BASE_DIR = Path(__file__).resolve().parent.parent 18 | 19 | 20 | # Quick-start development settings - unsuitable for production 21 | # See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/ 22 | 23 | # SECURITY WARNING: keep the secret key used in production secret! 24 | SECRET_KEY = "django-insecure-llfliwnyf*nj&uybcu^y*q=^xpaa5+h+_loa%q0)db7skzgnyp" 25 | 26 | # SECURITY WARNING: don't run with debug turned on in production! 27 | DEBUG = True 28 | 29 | ALLOWED_HOSTS = [] 30 | 31 | 32 | # Application definition 33 | 34 | INSTALLED_APPS = [ 35 | "django.contrib.admin", 36 | "django.contrib.auth", 37 | "django.contrib.contenttypes", 38 | "django.contrib.sessions", 39 | "django.contrib.messages", 40 | "django.contrib.staticfiles", 41 | "www", 42 | ] 43 | 44 | MIDDLEWARE = [ 45 | "django.middleware.security.SecurityMiddleware", 46 | "django.contrib.sessions.middleware.SessionMiddleware", 47 | "django.middleware.common.CommonMiddleware", 48 | "django.middleware.csrf.CsrfViewMiddleware", 49 | "django.contrib.auth.middleware.AuthenticationMiddleware", 50 | "django.contrib.messages.middleware.MessageMiddleware", 51 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 52 | ] 53 | 54 | ROOT_URLCONF = "project.urls" 55 | 56 | TEMPLATES = [ 57 | { 58 | "BACKEND": "django.template.backends.django.DjangoTemplates", 59 | "DIRS": [], 60 | "APP_DIRS": True, 61 | "OPTIONS": { 62 | "context_processors": [ 63 | "django.template.context_processors.debug", 64 | "django.template.context_processors.request", 65 | "django.contrib.auth.context_processors.auth", 66 | "django.contrib.messages.context_processors.messages", 67 | ], 68 | }, 69 | }, 70 | ] 71 | 72 | WSGI_APPLICATION = "project.wsgi.application" 73 | 74 | 75 | # Database 76 | # https://docs.djangoproject.com/en/3.2/ref/settings/#databases 77 | 78 | DATABASES = { 79 | "default": { 80 | "ENGINE": "django.db.backends.sqlite3", 81 | "NAME": BASE_DIR / "db.sqlite3", 82 | } 83 | } 84 | 85 | 86 | # Password validation 87 | # https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators 88 | 89 | AUTH_PASSWORD_VALIDATORS = [ 90 | { 91 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 92 | }, 93 | { 94 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 95 | }, 96 | { 97 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 98 | }, 99 | { 100 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 101 | }, 102 | ] 103 | 104 | 105 | # Internationalization 106 | # https://docs.djangoproject.com/en/3.2/topics/i18n/ 107 | 108 | LANGUAGE_CODE = "en-us" 109 | 110 | TIME_ZONE = "UTC" 111 | 112 | USE_I18N = True 113 | 114 | USE_L10N = True 115 | 116 | USE_TZ = True 117 | 118 | 119 | # Static files (CSS, JavaScript, Images) 120 | # https://docs.djangoproject.com/en/3.2/howto/static-files/ 121 | 122 | STATIC_URL = "/static/" 123 | 124 | # Default primary key field type 125 | # https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field 126 | 127 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 128 | 129 | LOGGING = { 130 | "version": 1, 131 | "disable_existing_loggers": False, 132 | "filters": { 133 | "require_debug_true": { 134 | "()": "django.utils.log.RequireDebugTrue", 135 | }, 136 | }, 137 | "handlers": { 138 | "django_rich_logging": { 139 | "class": "django_rich_logging.logging.DjangoRequestHandler", 140 | "filters": ["require_debug_true"], 141 | "columns": [ 142 | {"header": "Method", "format": "[white]{method}", "style": "{"}, 143 | {"header": "Path", "format": "[white bold]{path}", "style": "{"}, 144 | {"header": "Status", "format": "{status_code}", "style": "{"}, 145 | {"header": "Size", "format": "[white]{size}", "style": "{"}, 146 | { 147 | "header": "Time", 148 | "format": "[white]{created}", 149 | "style": "{", 150 | "datefmt": "%H:%M:%S", 151 | }, 152 | ], 153 | }, 154 | }, 155 | "loggers": { 156 | "django.server": {"handlers": ["django_rich_logging"]}, 157 | "django.request": {"level": "CRITICAL"}, 158 | }, 159 | } 160 | -------------------------------------------------------------------------------- /docs/source/index.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | `django-rich-logging` outputs the current Django request in a live updating table for easy parsing. 4 | 5 | ## Installation 6 | 7 | `poetry add django-rich-logging` OR `pip install django-rich-logging` 8 | 9 | ### Configuration 10 | 11 | `django-rich-logging` uses the log records emitted by the `django.server` logger to do its magic. However, configuring logging in Django (and in Python in general) can sometimes be a little obtuse. 12 | 13 | #### Minimal configuration 14 | 15 | ```python 16 | # settings.py 17 | 18 | # other settings here 19 | 20 | LOGGING = { 21 | "version": 1, 22 | "disable_existing_loggers": False, 23 | "handlers": { 24 | "django_rich_logging": { 25 | "class": "django_rich_logging.logging.DjangoRequestHandler", 26 | }, 27 | }, 28 | "loggers": { 29 | "django.server": {"handlers": ["django_rich_logging"]}, 30 | "django.request": {"level": "CRITICAL"}, 31 | }, 32 | } 33 | 34 | # other settings here 35 | ``` 36 | 37 | - `DjangoRequestHandler` handles log messages from the `django.server` logger 38 | - the level must be `INFO` or below to get all requests (which it is by default) 39 | - there must be a handler which uses `django_rich_logging.logging.DjangoRequestHandler` 40 | - `django.request` should be set to `CRITICAL` otherwise you will see 4xx and 5xx status codes getting logged twice 41 | 42 | #### Only logging when debug 43 | 44 | Most of the time, you will only want this type of logging when in local development (i.e. `DEBUG = True`). The `require_debug_true` logging filter can be used for this purpose. 45 | 46 | ```python 47 | LOGGING = { 48 | "version": 1, 49 | "disable_existing_loggers": False, 50 | "filters": { 51 | "require_debug_true": { 52 | "()": "django.utils.log.RequireDebugTrue", 53 | }, 54 | }, 55 | "handlers": { 56 | "django_rich_logging": { 57 | "class": "django_rich_logging.logging.DjangoRequestHandler", 58 | "filters": ["require_debug_true"], 59 | }, 60 | }, 61 | "loggers": { 62 | "django.server": {"handlers": ["django_rich_logging"]}, 63 | "django.request": {"level": "CRITICAL"}, 64 | }, 65 | } 66 | ``` 67 | 68 | #### Column configuration 69 | 70 | The columns that are logged are configurable via the `columns` key in the `django_rich_logging` handler. The default column configuration is as follows. 71 | 72 | ```python 73 | ... 74 | "handlers": { 75 | "django_rich_logging": { 76 | "class": "django_rich_logging.logging.DjangoRequestHandler", 77 | "columns": [ 78 | {"header": "Method", "format": "[white]{method}", "style": "{"}, 79 | {"header": "Path", "format": "[white bold]{path}", "style": "{"}, 80 | {"header": "Status", "format": "{status_code}", "style": "{"}, 81 | {"header": "Size", "format": "[white]{size}", "style": "{"}, 82 | { 83 | "header": "Time", 84 | "format": "[white]{created}", 85 | "style": "{", 86 | "datefmt": "%H:%M:%S", 87 | }, 88 | ], 89 | }, 90 | }, 91 | ... 92 | ``` 93 | 94 | - `header` is the name of the column header 95 | - `format` follows the same conventions as a normal [Python logging formatter](https://docs.python.org/3/howto/logging.html#formatters) which uses string interpolation to insert data from the current request 96 | - [Similar to a logging formatter](https://docs.python.org/3/howto/logging-cookbook.html#use-of-alternative-formatting-styles), `style` can be specified for the type of string interpolation to use (e.g. `%`, `{`, or `$`); to follow legacy Python conventions, `style` defaults to `%` 97 | 98 | The available information that be specified in `format`: 99 | - `method`: HTTP method, e.g. _GET_, _POST_ 100 | - `path`: The path of the request, e.g. _/index_ 101 | - `status_code`: Status code of the request, e.g. _200_, _404_, _500_ 102 | - `size`: Length of the content 103 | - `created`: `datetime` of when the log was generated; can be additionally formatted into a string with `datefmt` 104 | 105 | Formatted output can be colored or styled with the use of `rich` markup, e.g. `[white bold]something here[\white bold]` 106 | - [`rich` markup syntax](https://rich.readthedocs.io/en/stable/markup.html#syntax) 107 | - [Style attributes](https://rich.readthedocs.io/en/stable/style.html#styles), e.g. _bold_, _italic_ 108 | - [Available colors](https://rich.readthedocs.io/en/stable/appendix/colors.html), e.g. _red_, _blue_ 109 | - [Emojis](https://rich.readthedocs.io/en/stable/markup.html#emoji), e.g. _:warning:_ 110 | 111 | ## More information about Django logging 112 | 113 | - Information about logging `django.server`: https://docs.djangoproject.com/en/stable/ref/logging/#django-server 114 | - Generic information about logging with Django: https://docs.djangoproject.com/en/stable/topics/logging/ 115 | 116 | ## Other logging approaches 117 | 118 | - https://www.willmcgugan.com/blog/tech/post/richer-django-logging/ 119 | - [Django logging with Rich gist](https://gist.github.com/adamchainz/efd465f267ad048b04cdd2056058c4bd) 120 | 121 | ## Inspiration and thanks 122 | 123 | - https://twitter.com/marcelpociot/status/1491771828091695111 for the initial inspiration and my reaction: https://twitter.com/adamghill/status/1491780864447033348 124 | 125 | ### Dependencies 126 | 127 | - https://github.com/Textualize/rich for making terminal output beautiful 128 | 129 | ```{toctree} 130 | :maxdepth: 2 131 | :hidden: 132 | 133 | self 134 | ``` 135 | 136 | ```{toctree} 137 | :maxdepth: 2 138 | :hidden: 139 | 140 | GitHub 141 | Sponsor 142 | ``` 143 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | - Demonstrating empathy and kindness toward other people 21 | - Being respectful of differing opinions, viewpoints, and experiences 22 | - Giving and gracefully accepting constructive feedback 23 | - Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | - Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | - The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | - Trolling, insulting or derogatory comments, and personal or political attacks 33 | - Public or private harassment 34 | - Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | - Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at conduct@adamghill.com. 63 | All complaints will be reviewed and investigated promptly and fairly. 64 | 65 | All community leaders are obligated to respect the privacy and security of the 66 | reporter of any incident. 67 | 68 | ## Enforcement Guidelines 69 | 70 | Community leaders will follow these Community Impact Guidelines in determining 71 | the consequences for any action they deem in violation of this Code of Conduct: 72 | 73 | ### 1. Correction 74 | 75 | **Community Impact**: Use of inappropriate language or other behavior deemed 76 | unprofessional or unwelcome in the community. 77 | 78 | **Consequence**: A private, written warning from community leaders, providing 79 | clarity around the nature of the violation and an explanation of why the 80 | behavior was inappropriate. A public apology may be requested. 81 | 82 | ### 2. Warning 83 | 84 | **Community Impact**: A violation through a single incident or series 85 | of actions. 86 | 87 | **Consequence**: A warning with consequences for continued behavior. No 88 | interaction with the people involved, including unsolicited interaction with 89 | those enforcing the Code of Conduct, for a specified period of time. This 90 | includes avoiding interactions in community spaces as well as external channels 91 | like social media. Violating these terms may lead to a temporary or 92 | permanent ban. 93 | 94 | ### 3. Temporary Ban 95 | 96 | **Community Impact**: A serious violation of community standards, including 97 | sustained inappropriate behavior. 98 | 99 | **Consequence**: A temporary ban from any sort of interaction or public 100 | communication with the community for a specified period of time. No public or 101 | private interaction with the people involved, including unsolicited interaction 102 | with those enforcing the Code of Conduct, is allowed during this period. 103 | Violating these terms may lead to a permanent ban. 104 | 105 | ### 4. Permanent Ban 106 | 107 | **Community Impact**: Demonstrating a pattern of violation of community 108 | standards, including sustained inappropriate behavior, harassment of an 109 | individual, or aggression toward or disparagement of classes of individuals. 110 | 111 | **Consequence**: A permanent ban from any sort of public interaction within 112 | the community. 113 | 114 | ## Attribution 115 | 116 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 117 | version 2.0, available at 118 | [https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0]. 119 | 120 | Community Impact Guidelines were inspired by 121 | [Mozilla's code of conduct enforcement ladder][mozilla coc]. 122 | 123 | For answers to common questions about this code of conduct, see the FAQ at 124 | [https://www.contributor-covenant.org/faq][faq]. Translations are available 125 | at [https://www.contributor-covenant.org/translations][translations]. 126 | 127 | [homepage]: https://www.contributor-covenant.org 128 | [v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html 129 | [mozilla coc]: https://github.com/mozilla/diversity 130 | [faq]: https://www.contributor-covenant.org/faq 131 | [translations]: https://www.contributor-covenant.org/translations 132 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | name = "alabaster" 3 | version = "0.7.12" 4 | description = "A configurable sidebar-enabled Sphinx theme" 5 | category = "main" 6 | optional = true 7 | python-versions = "*" 8 | 9 | [[package]] 10 | name = "asgiref" 11 | version = "3.5.2" 12 | description = "ASGI specs, helper code, and adapters" 13 | category = "main" 14 | optional = false 15 | python-versions = ">=3.7" 16 | 17 | [package.dependencies] 18 | typing-extensions = {version = "*", markers = "python_version < \"3.8\""} 19 | 20 | [package.extras] 21 | tests = ["pytest", "pytest-asyncio", "mypy (>=0.800)"] 22 | 23 | [[package]] 24 | name = "atomicwrites" 25 | version = "1.4.1" 26 | description = "Atomic file writes." 27 | category = "dev" 28 | optional = false 29 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 30 | 31 | [[package]] 32 | name = "attrs" 33 | version = "21.4.0" 34 | description = "Classes Without Boilerplate" 35 | category = "main" 36 | optional = false 37 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 38 | 39 | [package.extras] 40 | dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"] 41 | docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] 42 | tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"] 43 | tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"] 44 | 45 | [[package]] 46 | name = "babel" 47 | version = "2.10.3" 48 | description = "Internationalization utilities" 49 | category = "main" 50 | optional = true 51 | python-versions = ">=3.6" 52 | 53 | [package.dependencies] 54 | pytz = ">=2015.7" 55 | 56 | [[package]] 57 | name = "beautifulsoup4" 58 | version = "4.11.1" 59 | description = "Screen-scraping library" 60 | category = "main" 61 | optional = true 62 | python-versions = ">=3.6.0" 63 | 64 | [package.dependencies] 65 | soupsieve = ">1.2" 66 | 67 | [package.extras] 68 | html5lib = ["html5lib"] 69 | lxml = ["lxml"] 70 | 71 | [[package]] 72 | name = "black" 73 | version = "22.6.0" 74 | description = "The uncompromising code formatter." 75 | category = "dev" 76 | optional = false 77 | python-versions = ">=3.6.2" 78 | 79 | [package.dependencies] 80 | click = ">=8.0.0" 81 | mypy-extensions = ">=0.4.3" 82 | pathspec = ">=0.9.0" 83 | platformdirs = ">=2" 84 | tomli = {version = ">=1.1.0", markers = "python_full_version < \"3.11.0a7\""} 85 | typed-ast = {version = ">=1.4.2", markers = "python_version < \"3.8\" and implementation_name == \"cpython\""} 86 | typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} 87 | 88 | [package.extras] 89 | colorama = ["colorama (>=0.4.3)"] 90 | d = ["aiohttp (>=3.7.4)"] 91 | jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] 92 | uvloop = ["uvloop (>=0.15.2)"] 93 | 94 | [[package]] 95 | name = "certifi" 96 | version = "2022.6.15" 97 | description = "Python package for providing Mozilla's CA Bundle." 98 | category = "main" 99 | optional = true 100 | python-versions = ">=3.6" 101 | 102 | [[package]] 103 | name = "charset-normalizer" 104 | version = "2.1.0" 105 | description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." 106 | category = "main" 107 | optional = true 108 | python-versions = ">=3.6.0" 109 | 110 | [package.extras] 111 | unicode_backport = ["unicodedata2"] 112 | 113 | [[package]] 114 | name = "click" 115 | version = "8.1.3" 116 | description = "Composable command line interface toolkit" 117 | category = "dev" 118 | optional = false 119 | python-versions = ">=3.7" 120 | 121 | [package.dependencies] 122 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 123 | importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} 124 | 125 | [[package]] 126 | name = "colorama" 127 | version = "0.4.5" 128 | description = "Cross-platform colored terminal text." 129 | category = "main" 130 | optional = false 131 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 132 | 133 | [[package]] 134 | name = "commonmark" 135 | version = "0.9.1" 136 | description = "Python parser for the CommonMark Markdown spec" 137 | category = "main" 138 | optional = false 139 | python-versions = "*" 140 | 141 | [package.extras] 142 | test = ["hypothesis (==3.55.3)", "flake8 (==3.7.8)"] 143 | 144 | [[package]] 145 | name = "coverage" 146 | version = "6.4.3" 147 | description = "Code coverage measurement for Python" 148 | category = "dev" 149 | optional = false 150 | python-versions = ">=3.7" 151 | 152 | [package.dependencies] 153 | tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} 154 | 155 | [package.extras] 156 | toml = ["tomli"] 157 | 158 | [[package]] 159 | name = "django" 160 | version = "3.2.15" 161 | description = "A high-level Python Web framework that encourages rapid development and clean, pragmatic design." 162 | category = "main" 163 | optional = false 164 | python-versions = ">=3.6" 165 | 166 | [package.dependencies] 167 | asgiref = ">=3.3.2,<4" 168 | pytz = "*" 169 | sqlparse = ">=0.2.2" 170 | 171 | [package.extras] 172 | argon2 = ["argon2-cffi (>=19.1.0)"] 173 | bcrypt = ["bcrypt"] 174 | 175 | [[package]] 176 | name = "django-stubs" 177 | version = "1.12.0" 178 | description = "Mypy stubs for Django" 179 | category = "dev" 180 | optional = false 181 | python-versions = ">=3.7" 182 | 183 | [package.dependencies] 184 | django = "*" 185 | django-stubs-ext = ">=0.4.0" 186 | mypy = ">=0.930" 187 | tomli = "*" 188 | types-pytz = "*" 189 | types-PyYAML = "*" 190 | typing-extensions = "*" 191 | 192 | [package.extras] 193 | compatible-mypy = ["mypy (>=0.930,<0.970)"] 194 | 195 | [[package]] 196 | name = "django-stubs-ext" 197 | version = "0.5.0" 198 | description = "Monkey-patching and extensions for django-stubs" 199 | category = "dev" 200 | optional = false 201 | python-versions = ">=3.6" 202 | 203 | [package.dependencies] 204 | django = "*" 205 | typing-extensions = "*" 206 | 207 | [[package]] 208 | name = "docutils" 209 | version = "0.17.1" 210 | description = "Docutils -- Python Documentation Utilities" 211 | category = "main" 212 | optional = true 213 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 214 | 215 | [[package]] 216 | name = "flake9" 217 | version = "3.8.3.post2" 218 | description = "the modular source code checker: pep8 pyflakes and co" 219 | category = "dev" 220 | optional = false 221 | python-versions = ">=3.4" 222 | 223 | [package.dependencies] 224 | importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} 225 | mccabe = ">=0.6.0,<0.7.0" 226 | pycodestyle = ">=2.6.0a1,<2.7.0" 227 | pyflakes = ">=2.2.0,<2.3.0" 228 | 229 | [[package]] 230 | name = "furo" 231 | version = "2021.11.23" 232 | description = "A clean customisable Sphinx documentation theme." 233 | category = "main" 234 | optional = true 235 | python-versions = ">=3.6" 236 | 237 | [package.dependencies] 238 | beautifulsoup4 = "*" 239 | pygments = ">=2.7,<3.0" 240 | sphinx = ">=4.0,<5.0" 241 | 242 | [package.extras] 243 | test = ["pytest", "pytest-cov", "pytest-xdist"] 244 | doc = ["myst-parser", "sphinx-copybutton", "sphinx-design", "sphinx-inline-tabs"] 245 | 246 | [[package]] 247 | name = "idna" 248 | version = "3.3" 249 | description = "Internationalized Domain Names in Applications (IDNA)" 250 | category = "main" 251 | optional = true 252 | python-versions = ">=3.5" 253 | 254 | [[package]] 255 | name = "imagesize" 256 | version = "1.4.1" 257 | description = "Getting image size from png/jpeg/jpeg2000/gif file" 258 | category = "main" 259 | optional = true 260 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 261 | 262 | [[package]] 263 | name = "importlib-metadata" 264 | version = "4.12.0" 265 | description = "Read metadata from Python packages" 266 | category = "main" 267 | optional = false 268 | python-versions = ">=3.7" 269 | 270 | [package.dependencies] 271 | typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} 272 | zipp = ">=0.5" 273 | 274 | [package.extras] 275 | docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"] 276 | perf = ["ipython"] 277 | testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.3)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "importlib-resources (>=1.3)"] 278 | 279 | [[package]] 280 | name = "iniconfig" 281 | version = "1.1.1" 282 | description = "iniconfig: brain-dead simple config-ini parsing" 283 | category = "dev" 284 | optional = false 285 | python-versions = "*" 286 | 287 | [[package]] 288 | name = "isort" 289 | version = "5.10.1" 290 | description = "A Python utility / library to sort Python imports." 291 | category = "dev" 292 | optional = false 293 | python-versions = ">=3.6.1,<4.0" 294 | 295 | [package.extras] 296 | pipfile_deprecated_finder = ["pipreqs", "requirementslib"] 297 | requirements_deprecated_finder = ["pipreqs", "pip-api"] 298 | colors = ["colorama (>=0.4.3,<0.5.0)"] 299 | plugins = ["setuptools"] 300 | 301 | [[package]] 302 | name = "jinja2" 303 | version = "3.1.2" 304 | description = "A very fast and expressive template engine." 305 | category = "main" 306 | optional = true 307 | python-versions = ">=3.7" 308 | 309 | [package.dependencies] 310 | MarkupSafe = ">=2.0" 311 | 312 | [package.extras] 313 | i18n = ["Babel (>=2.7)"] 314 | 315 | [[package]] 316 | name = "linkify-it-py" 317 | version = "1.0.3" 318 | description = "Links recognition library with FULL unicode support." 319 | category = "main" 320 | optional = true 321 | python-versions = ">=3.6" 322 | 323 | [package.dependencies] 324 | uc-micro-py = "*" 325 | 326 | [package.extras] 327 | test = ["pytest-cov", "pytest", "coverage"] 328 | doc = ["myst-parser", "sphinx-book-theme", "sphinx"] 329 | dev = ["black", "flake8", "isort", "pre-commit"] 330 | benchmark = ["pytest-benchmark", "pytest"] 331 | 332 | [[package]] 333 | name = "livereload" 334 | version = "2.6.3" 335 | description = "Python LiveReload is an awesome tool for web developers" 336 | category = "main" 337 | optional = true 338 | python-versions = "*" 339 | 340 | [package.dependencies] 341 | six = "*" 342 | tornado = {version = "*", markers = "python_version > \"2.7\""} 343 | 344 | [[package]] 345 | name = "markdown-it-py" 346 | version = "2.1.0" 347 | description = "Python port of markdown-it. Markdown parsing, done right!" 348 | category = "main" 349 | optional = true 350 | python-versions = ">=3.7" 351 | 352 | [package.dependencies] 353 | mdurl = ">=0.1,<1.0" 354 | typing_extensions = {version = ">=3.7.4", markers = "python_version < \"3.8\""} 355 | 356 | [package.extras] 357 | testing = ["pytest-regressions", "pytest-cov", "pytest", "coverage"] 358 | rtd = ["sphinx-book-theme", "sphinx-design", "sphinx-copybutton", "sphinx", "pyyaml", "myst-parser", "attrs"] 359 | profiling = ["gprof2dot"] 360 | plugins = ["mdit-py-plugins"] 361 | linkify = ["linkify-it-py (>=1.0,<2.0)"] 362 | compare = ["panflute (>=2.1.3,<2.2.0)", "mistune (>=2.0.2,<2.1.0)", "mistletoe (>=0.8.1,<0.9.0)", "markdown (>=3.3.6,<3.4.0)", "commonmark (>=0.9.1,<0.10.0)"] 363 | code_style = ["pre-commit (==2.6)"] 364 | benchmarking = ["pytest-benchmark (>=3.2,<4.0)", "pytest", "psutil"] 365 | 366 | [[package]] 367 | name = "markupsafe" 368 | version = "2.1.1" 369 | description = "Safely add untrusted strings to HTML/XML markup." 370 | category = "main" 371 | optional = true 372 | python-versions = ">=3.7" 373 | 374 | [[package]] 375 | name = "mccabe" 376 | version = "0.6.1" 377 | description = "McCabe checker, plugin for flake8" 378 | category = "dev" 379 | optional = false 380 | python-versions = "*" 381 | 382 | [[package]] 383 | name = "mdit-py-plugins" 384 | version = "0.3.0" 385 | description = "Collection of plugins for markdown-it-py" 386 | category = "main" 387 | optional = true 388 | python-versions = "~=3.6" 389 | 390 | [package.dependencies] 391 | markdown-it-py = ">=1.0.0,<3.0.0" 392 | 393 | [package.extras] 394 | testing = ["pytest-regressions", "pytest-cov", "pytest (>=3.6,<4)", "coverage"] 395 | rtd = ["sphinx-book-theme (>=0.1.0,<0.2.0)", "myst-parser (>=0.14.0,<0.15.0)"] 396 | code_style = ["pre-commit (==2.6)"] 397 | 398 | [[package]] 399 | name = "mdurl" 400 | version = "0.1.1" 401 | description = "Markdown URL utilities" 402 | category = "main" 403 | optional = true 404 | python-versions = ">=3.7" 405 | 406 | [[package]] 407 | name = "mypy" 408 | version = "0.971" 409 | description = "Optional static typing for Python" 410 | category = "dev" 411 | optional = false 412 | python-versions = ">=3.6" 413 | 414 | [package.dependencies] 415 | mypy-extensions = ">=0.4.3" 416 | tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} 417 | typed-ast = {version = ">=1.4.0,<2", markers = "python_version < \"3.8\""} 418 | typing-extensions = ">=3.10" 419 | 420 | [package.extras] 421 | reports = ["lxml"] 422 | python2 = ["typed-ast (>=1.4.0,<2)"] 423 | dmypy = ["psutil (>=4.0)"] 424 | 425 | [[package]] 426 | name = "mypy-extensions" 427 | version = "0.4.3" 428 | description = "Experimental type system extensions for programs checked with the mypy typechecker." 429 | category = "dev" 430 | optional = false 431 | python-versions = "*" 432 | 433 | [[package]] 434 | name = "myst-parser" 435 | version = "0.16.1" 436 | description = "An extended commonmark compliant parser, with bridges to docutils & sphinx." 437 | category = "main" 438 | optional = true 439 | python-versions = ">=3.6" 440 | 441 | [package.dependencies] 442 | docutils = ">=0.15,<0.18" 443 | jinja2 = "*" 444 | markdown-it-py = ">=1.0.0,<3.0.0" 445 | mdit-py-plugins = ">=0.3.0,<0.4.0" 446 | pyyaml = "*" 447 | sphinx = ">=3.1,<5" 448 | 449 | [package.extras] 450 | code_style = ["pre-commit (>=2.12,<3.0)"] 451 | linkify = ["linkify-it-py (>=1.0,<2.0)"] 452 | rtd = ["ipython", "sphinx-book-theme (>=0.1.0,<0.2.0)", "sphinx-panels (>=0.5.2,<0.6.0)", "sphinxcontrib-bibtex (>=2.1,<3.0)", "sphinxext-rediraffe (>=0.2,<1.0)", "sphinxcontrib.mermaid (>=0.6.3,<0.7.0)", "sphinxext-opengraph (>=0.4.2,<0.5.0)"] 453 | testing = ["beautifulsoup4", "coverage", "docutils (>=0.17.0,<0.18.0)", "pytest (>=3.6,<4)", "pytest-cov", "pytest-regressions"] 454 | 455 | [[package]] 456 | name = "packaging" 457 | version = "21.3" 458 | description = "Core utilities for Python packages" 459 | category = "main" 460 | optional = false 461 | python-versions = ">=3.6" 462 | 463 | [package.dependencies] 464 | pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" 465 | 466 | [[package]] 467 | name = "pastel" 468 | version = "0.2.1" 469 | description = "Bring colors to your terminal." 470 | category = "dev" 471 | optional = false 472 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 473 | 474 | [[package]] 475 | name = "pathspec" 476 | version = "0.9.0" 477 | description = "Utility library for gitignore style pattern matching of file paths." 478 | category = "dev" 479 | optional = false 480 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" 481 | 482 | [[package]] 483 | name = "platformdirs" 484 | version = "2.5.2" 485 | description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 486 | category = "dev" 487 | optional = false 488 | python-versions = ">=3.7" 489 | 490 | [package.extras] 491 | docs = ["furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)", "sphinx (>=4)"] 492 | test = ["appdirs (==1.4.4)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)", "pytest (>=6)"] 493 | 494 | [[package]] 495 | name = "pluggy" 496 | version = "1.0.0" 497 | description = "plugin and hook calling mechanisms for python" 498 | category = "dev" 499 | optional = false 500 | python-versions = ">=3.6" 501 | 502 | [package.dependencies] 503 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 504 | 505 | [package.extras] 506 | testing = ["pytest-benchmark", "pytest"] 507 | dev = ["tox", "pre-commit"] 508 | 509 | [[package]] 510 | name = "poethepoet" 511 | version = "0.16.0" 512 | description = "A task runner that works well with poetry." 513 | category = "dev" 514 | optional = false 515 | python-versions = ">=3.7" 516 | 517 | [package.dependencies] 518 | pastel = ">=0.2.1,<0.3.0" 519 | tomli = ">=1.2.2" 520 | 521 | [package.extras] 522 | poetry_plugin = ["poetry (>=1.0,<2.0)"] 523 | 524 | [[package]] 525 | name = "py" 526 | version = "1.11.0" 527 | description = "library with cross-python path, ini-parsing, io, code, log facilities" 528 | category = "dev" 529 | optional = false 530 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 531 | 532 | [[package]] 533 | name = "pycodestyle" 534 | version = "2.6.0" 535 | description = "Python style guide checker" 536 | category = "dev" 537 | optional = false 538 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 539 | 540 | [[package]] 541 | name = "pyflakes" 542 | version = "2.2.0" 543 | description = "passive checker of Python programs" 544 | category = "dev" 545 | optional = false 546 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 547 | 548 | [[package]] 549 | name = "pygments" 550 | version = "2.12.0" 551 | description = "Pygments is a syntax highlighting package written in Python." 552 | category = "main" 553 | optional = false 554 | python-versions = ">=3.6" 555 | 556 | [[package]] 557 | name = "pyparsing" 558 | version = "3.0.9" 559 | description = "pyparsing module - Classes and methods to define and execute parsing grammars" 560 | category = "main" 561 | optional = false 562 | python-versions = ">=3.6.8" 563 | 564 | [package.extras] 565 | diagrams = ["railroad-diagrams", "jinja2"] 566 | 567 | [[package]] 568 | name = "pytest" 569 | version = "6.2.5" 570 | description = "pytest: simple powerful testing with Python" 571 | category = "dev" 572 | optional = false 573 | python-versions = ">=3.6" 574 | 575 | [package.dependencies] 576 | atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} 577 | attrs = ">=19.2.0" 578 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 579 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 580 | iniconfig = "*" 581 | packaging = "*" 582 | pluggy = ">=0.12,<2.0" 583 | py = ">=1.8.2" 584 | toml = "*" 585 | 586 | [package.extras] 587 | testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] 588 | 589 | [[package]] 590 | name = "pytest-cov" 591 | version = "3.0.0" 592 | description = "Pytest plugin for measuring coverage." 593 | category = "dev" 594 | optional = false 595 | python-versions = ">=3.6" 596 | 597 | [package.dependencies] 598 | coverage = {version = ">=5.2.1", extras = ["toml"]} 599 | pytest = ">=4.6" 600 | 601 | [package.extras] 602 | testing = ["virtualenv", "pytest-xdist", "six", "process-tests", "hunter", "fields"] 603 | 604 | [[package]] 605 | name = "pytest-django" 606 | version = "4.5.2" 607 | description = "A Django plugin for pytest." 608 | category = "dev" 609 | optional = false 610 | python-versions = ">=3.5" 611 | 612 | [package.dependencies] 613 | pytest = ">=5.4.0" 614 | 615 | [package.extras] 616 | testing = ["django-configurations (>=2.0)", "django"] 617 | docs = ["sphinx-rtd-theme", "sphinx"] 618 | 619 | [[package]] 620 | name = "pytz" 621 | version = "2022.2.1" 622 | description = "World timezone definitions, modern and historical" 623 | category = "main" 624 | optional = false 625 | python-versions = "*" 626 | 627 | [[package]] 628 | name = "pywatchman" 629 | version = "1.4.1" 630 | description = "Watchman client for python" 631 | category = "dev" 632 | optional = false 633 | python-versions = "*" 634 | 635 | [[package]] 636 | name = "pyyaml" 637 | version = "6.0" 638 | description = "YAML parser and emitter for Python" 639 | category = "main" 640 | optional = true 641 | python-versions = ">=3.6" 642 | 643 | [[package]] 644 | name = "requests" 645 | version = "2.28.1" 646 | description = "Python HTTP for Humans." 647 | category = "main" 648 | optional = true 649 | python-versions = ">=3.7, <4" 650 | 651 | [package.dependencies] 652 | certifi = ">=2017.4.17" 653 | charset-normalizer = ">=2,<3" 654 | idna = ">=2.5,<4" 655 | urllib3 = ">=1.21.1,<1.27" 656 | 657 | [package.extras] 658 | socks = ["PySocks (>=1.5.6,!=1.5.7)"] 659 | use_chardet_on_py3 = ["chardet (>=3.0.2,<6)"] 660 | 661 | [[package]] 662 | name = "rich" 663 | version = "11.2.0" 664 | description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" 665 | category = "main" 666 | optional = false 667 | python-versions = ">=3.6.2,<4.0.0" 668 | 669 | [package.dependencies] 670 | colorama = ">=0.4.0,<0.5.0" 671 | commonmark = ">=0.9.0,<0.10.0" 672 | pygments = ">=2.6.0,<3.0.0" 673 | typing-extensions = {version = ">=3.7.4,<5.0", markers = "python_version < \"3.8\""} 674 | 675 | [package.extras] 676 | jupyter = ["ipywidgets (>=7.5.1,<8.0.0)"] 677 | 678 | [[package]] 679 | name = "six" 680 | version = "1.16.0" 681 | description = "Python 2 and 3 compatibility utilities" 682 | category = "main" 683 | optional = true 684 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 685 | 686 | [[package]] 687 | name = "snowballstemmer" 688 | version = "2.2.0" 689 | description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." 690 | category = "main" 691 | optional = true 692 | python-versions = "*" 693 | 694 | [[package]] 695 | name = "soupsieve" 696 | version = "2.3.2.post1" 697 | description = "A modern CSS selector implementation for Beautiful Soup." 698 | category = "main" 699 | optional = true 700 | python-versions = ">=3.6" 701 | 702 | [[package]] 703 | name = "sphinx" 704 | version = "4.5.0" 705 | description = "Python documentation generator" 706 | category = "main" 707 | optional = true 708 | python-versions = ">=3.6" 709 | 710 | [package.dependencies] 711 | alabaster = ">=0.7,<0.8" 712 | babel = ">=1.3" 713 | colorama = {version = ">=0.3.5", markers = "sys_platform == \"win32\""} 714 | docutils = ">=0.14,<0.18" 715 | imagesize = "*" 716 | importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} 717 | Jinja2 = ">=2.3" 718 | packaging = "*" 719 | Pygments = ">=2.0" 720 | requests = ">=2.5.0" 721 | snowballstemmer = ">=1.1" 722 | sphinxcontrib-applehelp = "*" 723 | sphinxcontrib-devhelp = "*" 724 | sphinxcontrib-htmlhelp = ">=2.0.0" 725 | sphinxcontrib-jsmath = "*" 726 | sphinxcontrib-qthelp = "*" 727 | sphinxcontrib-serializinghtml = ">=1.1.5" 728 | 729 | [package.extras] 730 | docs = ["sphinxcontrib-websupport"] 731 | lint = ["flake8 (>=3.5.0)", "isort", "mypy (>=0.931)", "docutils-stubs", "types-typed-ast", "types-requests"] 732 | test = ["pytest", "pytest-cov", "html5lib", "cython", "typed-ast"] 733 | 734 | [[package]] 735 | name = "sphinx-autobuild" 736 | version = "2021.3.14" 737 | description = "Rebuild Sphinx documentation on changes, with live-reload in the browser." 738 | category = "main" 739 | optional = true 740 | python-versions = ">=3.6" 741 | 742 | [package.dependencies] 743 | colorama = "*" 744 | livereload = "*" 745 | sphinx = "*" 746 | 747 | [package.extras] 748 | test = ["pytest", "pytest-cov"] 749 | 750 | [[package]] 751 | name = "sphinx-copybutton" 752 | version = "0.4.0" 753 | description = "Add a copy button to each of your code cells." 754 | category = "main" 755 | optional = true 756 | python-versions = ">=3.6" 757 | 758 | [package.dependencies] 759 | sphinx = ">=1.8" 760 | 761 | [package.extras] 762 | rtd = ["sphinx-book-theme", "ipython", "sphinx"] 763 | code_style = ["pre-commit (==2.12.1)"] 764 | 765 | [[package]] 766 | name = "sphinxcontrib-applehelp" 767 | version = "1.0.2" 768 | description = "sphinxcontrib-applehelp is a sphinx extension which outputs Apple help books" 769 | category = "main" 770 | optional = true 771 | python-versions = ">=3.5" 772 | 773 | [package.extras] 774 | test = ["pytest"] 775 | lint = ["docutils-stubs", "mypy", "flake8"] 776 | 777 | [[package]] 778 | name = "sphinxcontrib-devhelp" 779 | version = "1.0.2" 780 | description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp document." 781 | category = "main" 782 | optional = true 783 | python-versions = ">=3.5" 784 | 785 | [package.extras] 786 | test = ["pytest"] 787 | lint = ["docutils-stubs", "mypy", "flake8"] 788 | 789 | [[package]] 790 | name = "sphinxcontrib-htmlhelp" 791 | version = "2.0.0" 792 | description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" 793 | category = "main" 794 | optional = true 795 | python-versions = ">=3.6" 796 | 797 | [package.extras] 798 | test = ["html5lib", "pytest"] 799 | lint = ["docutils-stubs", "mypy", "flake8"] 800 | 801 | [[package]] 802 | name = "sphinxcontrib-jsmath" 803 | version = "1.0.1" 804 | description = "A sphinx extension which renders display math in HTML via JavaScript" 805 | category = "main" 806 | optional = true 807 | python-versions = ">=3.5" 808 | 809 | [package.extras] 810 | test = ["mypy", "flake8", "pytest"] 811 | 812 | [[package]] 813 | name = "sphinxcontrib-qthelp" 814 | version = "1.0.3" 815 | description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp document." 816 | category = "main" 817 | optional = true 818 | python-versions = ">=3.5" 819 | 820 | [package.extras] 821 | test = ["pytest"] 822 | lint = ["docutils-stubs", "mypy", "flake8"] 823 | 824 | [[package]] 825 | name = "sphinxcontrib-serializinghtml" 826 | version = "1.1.5" 827 | description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)." 828 | category = "main" 829 | optional = true 830 | python-versions = ">=3.5" 831 | 832 | [package.extras] 833 | lint = ["flake8", "mypy", "docutils-stubs"] 834 | test = ["pytest"] 835 | 836 | [[package]] 837 | name = "sqlparse" 838 | version = "0.4.2" 839 | description = "A non-validating SQL parser." 840 | category = "main" 841 | optional = false 842 | python-versions = ">=3.5" 843 | 844 | [[package]] 845 | name = "toml" 846 | version = "0.10.2" 847 | description = "Python Library for Tom's Obvious, Minimal Language" 848 | category = "main" 849 | optional = false 850 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 851 | 852 | [[package]] 853 | name = "tomli" 854 | version = "2.0.1" 855 | description = "A lil' TOML parser" 856 | category = "dev" 857 | optional = false 858 | python-versions = ">=3.7" 859 | 860 | [[package]] 861 | name = "tornado" 862 | version = "6.2" 863 | description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed." 864 | category = "main" 865 | optional = true 866 | python-versions = ">= 3.7" 867 | 868 | [[package]] 869 | name = "typed-ast" 870 | version = "1.5.4" 871 | description = "a fork of Python 2 and 3 ast modules with type comment support" 872 | category = "dev" 873 | optional = false 874 | python-versions = ">=3.6" 875 | 876 | [[package]] 877 | name = "types-pytz" 878 | version = "2022.1.2" 879 | description = "Typing stubs for pytz" 880 | category = "dev" 881 | optional = false 882 | python-versions = "*" 883 | 884 | [[package]] 885 | name = "types-pyyaml" 886 | version = "6.0.11" 887 | description = "Typing stubs for PyYAML" 888 | category = "dev" 889 | optional = false 890 | python-versions = "*" 891 | 892 | [[package]] 893 | name = "typing-extensions" 894 | version = "4.3.0" 895 | description = "Backported and Experimental Type Hints for Python 3.7+" 896 | category = "main" 897 | optional = false 898 | python-versions = ">=3.7" 899 | 900 | [[package]] 901 | name = "uc-micro-py" 902 | version = "1.0.1" 903 | description = "Micro subset of unicode data files for linkify-it-py projects." 904 | category = "main" 905 | optional = true 906 | python-versions = ">=3.6" 907 | 908 | [package.extras] 909 | test = ["pytest-cov", "pytest", "coverage"] 910 | 911 | [[package]] 912 | name = "urllib3" 913 | version = "1.26.11" 914 | description = "HTTP library with thread-safe connection pooling, file post, and more." 915 | category = "main" 916 | optional = true 917 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, <4" 918 | 919 | [package.extras] 920 | brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"] 921 | secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] 922 | socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] 923 | 924 | [[package]] 925 | name = "zipp" 926 | version = "3.8.1" 927 | description = "Backport of pathlib-compatible object wrapper for zip files" 928 | category = "main" 929 | optional = false 930 | python-versions = ">=3.7" 931 | 932 | [package.extras] 933 | docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "jaraco.tidelift (>=1.4)"] 934 | testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.3)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"] 935 | 936 | [extras] 937 | docs = ["Sphinx", "linkify-it-py", "myst-parser", "furo", "sphinx-copybutton", "sphinx-autobuild", "toml", "attrs"] 938 | 939 | [metadata] 940 | lock-version = "1.1" 941 | python-versions = ">=3.7,<4.0" 942 | content-hash = "2c3c5f3d085c6d44372d51dcc2dbf754fc9237155c5bea13592e889cdacd44ef" 943 | 944 | [metadata.files] 945 | alabaster = [] 946 | asgiref = [] 947 | atomicwrites = [] 948 | attrs = [] 949 | babel = [] 950 | beautifulsoup4 = [] 951 | black = [] 952 | certifi = [] 953 | charset-normalizer = [] 954 | click = [] 955 | colorama = [] 956 | commonmark = [] 957 | coverage = [] 958 | django = [] 959 | django-stubs = [] 960 | django-stubs-ext = [] 961 | docutils = [] 962 | flake9 = [] 963 | furo = [] 964 | idna = [] 965 | imagesize = [] 966 | importlib-metadata = [] 967 | iniconfig = [] 968 | isort = [] 969 | jinja2 = [] 970 | linkify-it-py = [] 971 | livereload = [] 972 | markdown-it-py = [] 973 | markupsafe = [] 974 | mccabe = [] 975 | mdit-py-plugins = [] 976 | mdurl = [] 977 | mypy = [] 978 | mypy-extensions = [] 979 | myst-parser = [] 980 | packaging = [] 981 | pastel = [] 982 | pathspec = [] 983 | platformdirs = [] 984 | pluggy = [] 985 | poethepoet = [] 986 | py = [] 987 | pycodestyle = [] 988 | pyflakes = [] 989 | pygments = [] 990 | pyparsing = [] 991 | pytest = [] 992 | pytest-cov = [] 993 | pytest-django = [] 994 | pytz = [] 995 | pywatchman = [] 996 | pyyaml = [] 997 | requests = [] 998 | rich = [] 999 | six = [] 1000 | snowballstemmer = [] 1001 | soupsieve = [] 1002 | sphinx = [] 1003 | sphinx-autobuild = [] 1004 | sphinx-copybutton = [] 1005 | sphinxcontrib-applehelp = [] 1006 | sphinxcontrib-devhelp = [] 1007 | sphinxcontrib-htmlhelp = [] 1008 | sphinxcontrib-jsmath = [] 1009 | sphinxcontrib-qthelp = [] 1010 | sphinxcontrib-serializinghtml = [] 1011 | sqlparse = [] 1012 | toml = [] 1013 | tomli = [] 1014 | tornado = [] 1015 | typed-ast = [] 1016 | types-pytz = [] 1017 | types-pyyaml = [] 1018 | typing-extensions = [] 1019 | uc-micro-py = [] 1020 | urllib3 = [] 1021 | zipp = [] 1022 | --------------------------------------------------------------------------------