├── 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 | 
7 | 
8 | 
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 | 
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 |
--------------------------------------------------------------------------------