diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..5cb9b06
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,27 @@
+*.py[co]
+.project
+.pydevproject
+*~
+*.db
+*.orig
+*.DS_Store
+.tox
+*.egg/*
+.eggs/
+*.egg-info/*
+build/*
+docs/_build/*
+dist/*
+
+*.log
+nosetests*.xml
+
+.coverage
+coverage.xml
+coverage
+
+test.db
+.venv
+
+*.sublime-workspace
+.python-version
diff --git a/AUTHORS b/AUTHORS
new file mode 100644
index 0000000..476348f
--- /dev/null
+++ b/AUTHORS
@@ -0,0 +1 @@
+Jeffrey Tratner @jtratner
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..8d2b5a9
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2021 Jeff Tratner
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+of the Software, and to permit persons to whom the Software is furnished to do
+so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000..7c53c27
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,5 @@
+include README.rst
+include LICENSE
+recursive-include turtle_shell/static *
+recursive-include turtle_shell/templates *
+recursive-include docs *
diff --git a/README.rst b/README.rst
new file mode 100644
index 0000000..fc91cb8
--- /dev/null
+++ b/README.rst
@@ -0,0 +1,393 @@
+Django Turtle Shell
+===================
+
+NOTE: This is still in active development! Implementations and everything may
+change!
+
+How does it work?
+-----------------
+
+
+This lil' old library converts _your_ function with annotations into a ✨Django Form✨ and a graphql view.
+
+It leverages some neat features of defopt under the hood so that a function like this:
+
+.. code-block:: python
+
+ import turtle_shell
+ from pydantic import BaseModel
+ import enum
+
+ class FileSizeSummary(BaseModel):
+ p25: float
+ p50: float
+ p75: float
+ p95: float
+
+ class AnalysisSummary(BaseModel):
+ apparent_health: str
+ fastq_summary: FileSizeSummary
+
+ class AssayType(enum.Enum):
+ WGS = enum.auto()
+ NGS = enum.auto()
+
+
+ def summarize_analysis_error(
+ analysis_id: str,
+ assay_type: AssayType,
+ check_fastqs: bool=True,
+ ) -> AnalysisSummary:
+ """Summarize what happened with an analysis.
+
+ Args:
+ analysis_id: reference ID for analysis
+ assay_type: WGS or NGS (narrows checks)
+ check_fastqs: if True, look at 'em
+ """
+ ...
+
+ turtle_shell.get_registry().add(summarize_analysis_error)
+
+
+Becomes this awesome form, generated from type annotations!! (note how
+we also use defopt under the hood to assign docstring elements to parameters)
+
+.. image:: docs/images/summarize-analysis-error-form.png
+ :alt: Analysis Error Form generated from types!
+
+Make your output pydantic models (as shown above) and get nicely structured
+GraphQL output AND nice tables of data on the page :)
+
+.. image:: docs/images/summarize-analysis-graphql-example.png
+ :alt: GraphQL structured request/response
+
+And finally even pushes docs into GraphQL schema
+
+.. image:: docs/images/summarize-analysis-grapqhl-doc.png
+ :alt: example of documentation from grapqhl
+
+If you specify pydantic models as output, you'll even get a nice HTML rendering + structured types in GraphQL!
+
+Installation
+------------
+
+First install it:
+
+```
+pip install git@github.com:jtratner/django-turtle-shell.git
+```
+
+Next, you'll need to add some stuff to INSTALLED_APPS::
+
+ INSTALLED_APPS = [
+ ...
+ "turtle_shell"
+ ...
+ ]
+
+ Next run migrations::
+
+ python manage.py migrate
+
+Then in an ``executions.py`` file you can set up your own functions (or
+register external ones)::
+
+ import turtle_shell
+
+ Registry = turtle_shell.get_registry()
+
+ def myfunc(a: str):
+ return 1
+
+ Registry.add(myfunc)
+
+And finally you add it to your urls.py to do something useful.::
+
+ from django.conf.urls import include
+ from django.urls import path
+
+ import turtle_shell
+
+ router = turtle_shell.get_registry().get_router()
+ urlpatterns = [
+ path("/execute", include(router.urls)]
+ ]
+
+
+To add GraphQL (via [`graphene-django`](https://github.com/graphql-python/graphene-django#settings) ) to your app, add the following::
+
+ from django.urls import path
+ from graphene_django.views import GraphQLView
+ import turtle_shell
+
+ urlpatterns = [
+ # ...
+ path("graphql", GraphQLView.as_view(
+ schema=turtle_shell.get_registry().schema,
+ graphiql=True)),
+ ]
+
+And you'll also need to add `graphene_django` to your installed apps as well::
+
+ INSTALLED_APPS = [
+ ...
+ "graphene_django"
+ ...
+ ]
+
+Motivation
+----------
+
+1. You have a bunch of shell scripts lying around to do things.
+2. You don't want to force everyone to install your dependencies or use docker or whatnot.
+3. Your permission model isn't SOOO complicated that it's necessary to have everyone use their own tokens OR you're just doing read-only things.
+4. You want people to have website goodness (deep-linking, record of actions, easy on-boarding, etc)
+5. Composing and/or ability to long-poll endpoints seems cool to you.
+
+REMAINING WORK:
+
+1. Ability to do asynchronous executions (this is basically all set up)
+3. Help graphene-django release a version based on graphql-core so we can use newer graphene-pydantic :P
+
+
+Overall gist
+------------
+
+You register your functions with the library::
+
+ Registry = turtle_shell.get_registry()
+
+ Registry.add(myfunc)
+
+Then in urls.py::
+
+
+ import turtle_shell
+
+ path("execute/", include(turtle_shell.get_registry().get_router().urls)l)
+
+If you want GraphQL, then [install graphene-django](https://docs.graphene-python.org/projects/django/en/latest/installation/)
+and put into installed apps (also django filter), then finally::
+
+ path("api", GraphQLView.as_view(schema=turtle_shell.get_registry().schema, graphiql=False)),
+ path("graphql", GraphQLView.as_view(schema=turtle_shell.get_registry().schema, graphiql=True))
+
+And finally run migrations::
+
+ ...
+
+
+Now you can get list view / form to create / graphql API to create.
+
+Running the Tests
+-----------------
+
+```
+poetry install
+poetry run pytest
+```
+
+
+
+
+Example Implementation
+----------------------
+
+executions.py::
+
+ import turtle_shell
+ from my_util_scripts import find_root_cause, summarize_issue, error_summary
+
+ Registry = turtle_shell.get_registry()
+
+
+ FindRootCause = Registry.add(find_root_cause)
+ SummarizeIssue = Registry.add(summarize_issue)
+ ErrorSummary = Registry.add(error_summary)
+
+
+
+
+You can just stop there if ya like! Woo :)
+
+For convenience, easy_execute provides a router that set ups default list/detail/edit by function.
+
+urls.py::
+
+ from executions import Registry
+ from graphene_django import GraphQLView
+
+ router = Registry.get_router(list_template="list.html", detail_template="detail.html")
+
+ urlpatterns = [
+ path('/api', GraphQLView(schema=Registry.schema, include_graphiql=False)),
+ path('/graphql', GraphQLView(schema=Registry.schema, include_graphiql=True)),
+ # get default list and view together
+ path('/execute', include(router.urls),
+ ]
+
+ # /execute/overview
+ # /execute/find-root-cause
+ # /execute/find-root-cause/create
+ # /execute/find-root-cause/
+ # /execute/summarize-issue
+ # /execute/summarize-issue/create
+ # /execute/summarize-issue/
+
+Of course you can also customize further::
+
+views::
+
+ import turtle_shell
+
+ Registry = turtle_shell.get_registry()
+
+ class FindRootCauseList(Registry.get(find_root_cause).list_view()):
+ template_name = "list-root-cause.html"
+
+ def get_context_data(self):
+ # do some processing here - yay!
+
+ class FindRootCauseDetail(Registry.get(find_root_cause).detail_view()):
+ template_name = "detail-root-cause.html"
+
+These use the generic django views under the hood.
+
+What's missing from this idea
+-----------------------------
+
+- granular permissions (gotta think about nice API for this)
+- separate tables for different objects.
+
+Using the library
+-----------------
+
+
+ExecutionResult:
+ DB attributes:
+ - pk (UUID)
+ - input_json
+ - output_json
+ - func_name # defaults to module.function_name but can be customized
+ - error_json
+
+ Properties:
+ get_formatted_response() -> JSON serializable object
+
+
+ExecutionForm(func)
+
+ExecutionGraphQLView(func)
+
+
+Every function gets a generic output::
+
+ mutation { dxFindRootCause(input: {job_id: ..., project: ...}) {
+ uuid: str
+ result {
+ status: STATUS
+ uuid: UUID!
+ inputJson: String!
+ outputJson: String? # often JSON serializable
+ errorJson: String?
+ }
+ }
+ errors: Optional {
+ type
+ message
+ }
+ }
+
+
+But can also have structured output::
+
+ mutation { dxFindRootCause(input: {job_id: ..., project: ...}) {
+ output {
+ rootCause: ...
+ rootCauseMessage: ...
+ rootCauseLog: ...
+ }
+ }
+ }
+
+Other potential examples::
+
+ mutation { summarizeAnalysis(input: {analysisId: ...}) {
+ output {
+ fastqSizes {
+ name
+ size
+ }
+ undeterminedReads {
+ name
+ size
+ }
+ humanSummary
+ }
+ }
+
+
+Which would look like (JSON as YAML)::
+
+ output:
+ fastqSizes:
+ - name: "s_1.fastq.gz"
+ size: "125MB"
+ - name: "s_2.fastq.gz"
+ size: "125GB"
+ undeterminedReads:
+ humanSummary: "Distribution heavily skewed. 10 barcodes missing. 5 barcodes much higher than rest."
+
+
+
+
+Why is this useful?
+-------------------
+
+I had a bunch of defopt-based CLI tools that I wanted to expose as webapps for folks
+who were not as command line savvy.
+
+1. Python type signatures are quite succinct - reduces form boilerplate
+2. Expose utility functions as forms for users
+
+
+Customizing output
+------------------
+
+Custom widgets or forms
+^^^^^^^^^^^^^^^^^^^^^^^
+
+``Registry.add()`` takes a ``config`` argument with it. You can set the
+``widgets`` key (to map types or parameter names to widgets) or the ``fields``
+key (to map types or parameter names to fields). You might use this to set your
+widget as a text area or use a custom placeholder!
+
+Pydantic classes
+^^^^^^^^^^^^^^^^
+
+If you set a Pydantic class as your output from a function, it'll be rendered nicely! Try it out :)
+
+https://pydantic-docs.helpmanual.io/usage/models/#data-conversion
+
+
+Why not FastAPI?
+----------------
+
+This is a great point! I didn't see it before I started.
+Using Django provides:
+
+0. FRONT END! -> key for non-technical users
+1. Persistence layer is a big deal - pretty easy on-ramp to handling
+2. Easy ability to add in authentication/authorization (granted FastAPI has this)
+3. Literally didn't see it and we know django better
+
+See here for more details - https://github.com/tiangolo/fastapi
+
+
+Why not Django Ninja?
+---------------------
+
+This may actually be a better option - https://github.com/vitalik/django-ninja
+
+
diff --git a/docs/images/summarize-analysis-error-docstring.png b/docs/images/summarize-analysis-error-docstring.png
new file mode 100644
index 0000000..4833d20
Binary files /dev/null and b/docs/images/summarize-analysis-error-docstring.png differ
diff --git a/docs/images/summarize-analysis-error-form.png b/docs/images/summarize-analysis-error-form.png
new file mode 100644
index 0000000..5ff64ed
Binary files /dev/null and b/docs/images/summarize-analysis-error-form.png differ
diff --git a/docs/images/summarize-analysis-graphql-example.png b/docs/images/summarize-analysis-graphql-example.png
new file mode 100644
index 0000000..dd44997
Binary files /dev/null and b/docs/images/summarize-analysis-graphql-example.png differ
diff --git a/docs/images/summarize-analysis-grapqhl-doc.png b/docs/images/summarize-analysis-grapqhl-doc.png
new file mode 100644
index 0000000..65dd340
Binary files /dev/null and b/docs/images/summarize-analysis-grapqhl-doc.png differ
diff --git a/docs/images/summarize-execution-pretty-output.png b/docs/images/summarize-execution-pretty-output.png
new file mode 100644
index 0000000..b3d5b7b
Binary files /dev/null and b/docs/images/summarize-execution-pretty-output.png differ
diff --git a/poetry.lock b/poetry.lock
new file mode 100644
index 0000000..dbad9a5
--- /dev/null
+++ b/poetry.lock
@@ -0,0 +1,2056 @@
+[[package]]
+name = "alabaster"
+version = "0.7.12"
+description = "A configurable sidebar-enabled Sphinx theme"
+category = "dev"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "aniso8601"
+version = "7.0.0"
+description = "A library for parsing ISO 8601 strings."
+category = "main"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "appdirs"
+version = "1.4.4"
+description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
+category = "dev"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "appnope"
+version = "0.1.2"
+description = "Disable App Nap on macOS >= 10.9"
+category = "dev"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "asgiref"
+version = "3.3.4"
+description = "ASGI specs, helper code, and adapters"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.extras]
+tests = ["pytest", "pytest-asyncio", "mypy (>=0.800)"]
+
+[[package]]
+name = "astroid"
+version = "2.5.3"
+description = "An abstract syntax tree for Python with inference support."
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+lazy-object-proxy = ">=1.4.0"
+wrapt = ">=1.11,<1.13"
+
+[[package]]
+name = "atomicwrites"
+version = "1.4.0"
+description = "Atomic file writes."
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
+[[package]]
+name = "attrs"
+version = "20.3.0"
+description = "Classes Without Boilerplate"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
+[package.extras]
+dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "furo", "sphinx", "pre-commit"]
+docs = ["furo", "sphinx", "zope.interface"]
+tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"]
+tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six"]
+
+[[package]]
+name = "babel"
+version = "2.9.0"
+description = "Internationalization utilities"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
+[package.dependencies]
+pytz = ">=2015.7"
+
+[[package]]
+name = "backcall"
+version = "0.2.0"
+description = "Specifications for callback functions passed in to an API"
+category = "dev"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "black"
+version = "20.8b1"
+description = "The uncompromising code formatter."
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+appdirs = "*"
+click = ">=7.1.2"
+mypy-extensions = ">=0.4.3"
+pathspec = ">=0.6,<1"
+regex = ">=2020.1.8"
+toml = ">=0.10.1"
+typed-ast = ">=1.4.0"
+typing-extensions = ">=3.7.4"
+
+[package.extras]
+colorama = ["colorama (>=0.4.3)"]
+d = ["aiohttp (>=3.3.2)", "aiohttp-cors"]
+
+[[package]]
+name = "certifi"
+version = "2020.12.5"
+description = "Python package for providing Mozilla's CA Bundle."
+category = "dev"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "cfgv"
+version = "3.2.0"
+description = "Validate configuration and produce human readable error messages."
+category = "dev"
+optional = false
+python-versions = ">=3.6.1"
+
+[[package]]
+name = "chardet"
+version = "4.0.0"
+description = "Universal encoding detector for Python 2 and 3"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+
+[[package]]
+name = "click"
+version = "7.1.2"
+description = "Composable command line interface toolkit"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+
+[[package]]
+name = "colorama"
+version = "0.4.4"
+description = "Cross-platform colored terminal text."
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+
+[[package]]
+name = "coverage"
+version = "5.5"
+description = "Code coverage measurement for Python"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4"
+
+[package.extras]
+toml = ["toml"]
+
+[[package]]
+name = "decorator"
+version = "5.0.7"
+description = "Decorators for Humans"
+category = "dev"
+optional = false
+python-versions = ">=3.5"
+
+[[package]]
+name = "defopt"
+version = "6.1.0"
+description = "Effortless argument parser"
+category = "main"
+optional = false
+python-versions = ">=3.5"
+
+[package.dependencies]
+colorama = {version = ">=0.3.4", markers = "sys_platform == \"win32\""}
+docutils = ">=0.10"
+sphinxcontrib-napoleon = ">=0.7.0"
+
+[[package]]
+name = "distlib"
+version = "0.3.1"
+description = "Distribution utilities"
+category = "dev"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "django"
+version = "3.2"
+description = "A high-level Python Web framework that encourages rapid development and clean, pragmatic design."
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+asgiref = ">=3.3.2,<4"
+pytz = "*"
+sqlparse = ">=0.2.2"
+
+[package.extras]
+argon2 = ["argon2-cffi (>=19.1.0)"]
+bcrypt = ["bcrypt"]
+
+[[package]]
+name = "django-coverage-plugin"
+version = "1.8.0"
+description = "Django template coverage.py plugin"
+category = "dev"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+coverage = "*"
+six = ">=1.4.0"
+
+[[package]]
+name = "django-crispy-forms"
+version = "1.11.2"
+description = "Best way to have Django DRY forms"
+category = "main"
+optional = false
+python-versions = ">=3.5"
+
+[[package]]
+name = "django-debug-toolbar"
+version = "3.2"
+description = "A configurable set of panels that display various debug information about the current request/response."
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+Django = ">=2.2"
+sqlparse = ">=0.2.0"
+
+[[package]]
+name = "django-extensions"
+version = "3.1.2"
+description = "Extensions for Django"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+Django = ">=2.2"
+
+[[package]]
+name = "django-filter"
+version = "2.4.0"
+description = "Django-filter is a reusable Django application for allowing users to filter querysets dynamically."
+category = "main"
+optional = false
+python-versions = ">=3.5"
+
+[package.dependencies]
+Django = ">=2.2"
+
+[[package]]
+name = "django-stubs"
+version = "1.7.0"
+description = "Mypy stubs for Django"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+django = "*"
+mypy = ">=0.790"
+typing-extensions = "*"
+
+[[package]]
+name = "docutils"
+version = "0.17"
+description = "Docutils -- Python Documentation Utilities"
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+
+[[package]]
+name = "factory-boy"
+version = "3.2.0"
+description = "A versatile test fixtures replacement based on thoughtbot's factory_bot for Ruby."
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+Faker = ">=0.7.0"
+
+[package.extras]
+dev = ["coverage", "django", "flake8", "isort", "pillow", "sqlalchemy", "mongoengine", "wheel (>=0.32.0)", "tox", "zest.releaser"]
+doc = ["sphinx", "sphinx-rtd-theme", "sphinxcontrib-spelling"]
+
+[[package]]
+name = "faker"
+version = "8.1.0"
+description = "Faker is a Python package that generates fake data for you."
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+python-dateutil = ">=2.4"
+text-unidecode = "1.3"
+
+[[package]]
+name = "filelock"
+version = "3.0.12"
+description = "A platform independent file lock."
+category = "dev"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "flake8"
+version = "3.9.0"
+description = "the modular source code checker: pep8 pyflakes and co"
+category = "dev"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
+
+[package.dependencies]
+mccabe = ">=0.6.0,<0.7.0"
+pycodestyle = ">=2.7.0,<2.8.0"
+pyflakes = ">=2.3.0,<2.4.0"
+
+[[package]]
+name = "flake8-isort"
+version = "4.0.0"
+description = "flake8 plugin that integrates isort ."
+category = "dev"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+flake8 = ">=3.2.1,<4"
+isort = ">=4.3.5,<6"
+testfixtures = ">=6.8.0,<7"
+
+[package.extras]
+test = ["pytest (>=4.0.2,<6)", "toml"]
+
+[[package]]
+name = "graphene"
+version = "2.1.8"
+description = "GraphQL Framework for Python"
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+aniso8601 = ">=3,<=7"
+graphql-core = ">=2.1,<3"
+graphql-relay = ">=2,<3"
+six = ">=1.10.0,<2"
+
+[package.extras]
+django = ["graphene-django"]
+sqlalchemy = ["graphene-sqlalchemy"]
+test = ["pytest", "pytest-benchmark", "pytest-cov", "pytest-mock", "snapshottest", "coveralls", "promise", "six", "mock", "pytz", "iso8601"]
+
+[[package]]
+name = "graphene-django"
+version = "2.15.0"
+description = "Graphene Django integration"
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+Django = ">=1.11"
+graphene = ">=2.1.7,<3"
+graphql-core = ">=2.1.0,<3"
+promise = ">=2.1"
+singledispatch = ">=3.4.0.3"
+six = ">=1.10.0"
+text-unidecode = "*"
+
+[package.extras]
+dev = ["black (==19.10b0)", "flake8 (==3.7.9)", "flake8-black (==0.1.1)", "flake8-bugbear (==20.1.4)", "pytest (>=3.6.3)", "pytest-cov", "coveralls", "mock", "pytz", "pytest-django (>=3.3.2)", "djangorestframework (>=3.6.3)", "django-filter (<2)", "django-filter (>=2)"]
+rest_framework = ["djangorestframework (>=3.6.3)"]
+test = ["pytest (>=3.6.3)", "pytest-cov", "coveralls", "mock", "pytz", "pytest-django (>=3.3.2)", "djangorestframework (>=3.6.3)", "django-filter (<2)", "django-filter (>=2)"]
+
+[[package]]
+name = "graphene-pydantic"
+version = "0.0.9"
+description = "Graphene Pydantic integration"
+category = "main"
+optional = false
+python-versions = ">=3.6,<4.0"
+
+[package.dependencies]
+graphene = ">=2.1.3,<3"
+pydantic = ">=0.26,<=1.6"
+
+[[package]]
+name = "graphql-core"
+version = "2.3.2"
+description = "GraphQL implementation for Python"
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+promise = ">=2.3,<3"
+rx = ">=1.6,<2"
+six = ">=1.10.0"
+
+[package.extras]
+gevent = ["gevent (>=1.1)"]
+test = ["six (==1.14.0)", "pyannotate (==1.2.0)", "pytest (==4.6.10)", "pytest-django (==3.9.0)", "pytest-cov (==2.8.1)", "coveralls (==1.11.1)", "cython (==0.29.17)", "gevent (==1.5.0)", "pytest-benchmark (==3.2.3)", "pytest-mock (==2.0.0)"]
+
+[[package]]
+name = "graphql-relay"
+version = "2.0.1"
+description = "Relay implementation for Python"
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+graphql-core = ">=2.2,<3"
+promise = ">=2.2,<3"
+six = ">=1.12"
+
+[[package]]
+name = "identify"
+version = "2.2.3"
+description = "File identification library for Python"
+category = "dev"
+optional = false
+python-versions = ">=3.6.1"
+
+[package.extras]
+license = ["editdistance-s"]
+
+[[package]]
+name = "idna"
+version = "2.10"
+description = "Internationalized Domain Names in Applications (IDNA)"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
+[[package]]
+name = "imagesize"
+version = "1.2.0"
+description = "Getting image size from png/jpeg/jpeg2000/gif file"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
+[[package]]
+name = "iniconfig"
+version = "1.1.1"
+description = "iniconfig: brain-dead simple config-ini parsing"
+category = "dev"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "ipdb"
+version = "0.13.7"
+description = "IPython-enabled pdb"
+category = "dev"
+optional = false
+python-versions = ">=2.7"
+
+[package.dependencies]
+ipython = {version = ">=7.17.0", markers = "python_version > \"3.6\""}
+toml = {version = ">=0.10.2", markers = "python_version > \"3.6\""}
+
+[[package]]
+name = "ipython"
+version = "7.22.0"
+description = "IPython: Productive Interactive Computing"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+
+[package.dependencies]
+appnope = {version = "*", markers = "sys_platform == \"darwin\""}
+backcall = "*"
+colorama = {version = "*", markers = "sys_platform == \"win32\""}
+decorator = "*"
+jedi = ">=0.16"
+pexpect = {version = ">4.3", markers = "sys_platform != \"win32\""}
+pickleshare = "*"
+prompt-toolkit = ">=2.0.0,<3.0.0 || >3.0.0,<3.0.1 || >3.0.1,<3.1.0"
+pygments = "*"
+traitlets = ">=4.2"
+
+[package.extras]
+all = ["Sphinx (>=1.3)", "ipykernel", "ipyparallel", "ipywidgets", "nbconvert", "nbformat", "nose (>=0.10.1)", "notebook", "numpy (>=1.16)", "pygments", "qtconsole", "requests", "testpath"]
+doc = ["Sphinx (>=1.3)"]
+kernel = ["ipykernel"]
+nbconvert = ["nbconvert"]
+nbformat = ["nbformat"]
+notebook = ["notebook", "ipywidgets"]
+parallel = ["ipyparallel"]
+qtconsole = ["qtconsole"]
+test = ["nose (>=0.10.1)", "requests", "testpath", "pygments", "nbformat", "ipykernel", "numpy (>=1.16)"]
+
+[[package]]
+name = "ipython-genutils"
+version = "0.2.0"
+description = "Vestigial utilities from IPython"
+category = "dev"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "isort"
+version = "5.8.0"
+description = "A Python utility / library to sort Python imports."
+category = "dev"
+optional = false
+python-versions = ">=3.6,<4.0"
+
+[package.extras]
+pipfile_deprecated_finder = ["pipreqs", "requirementslib"]
+requirements_deprecated_finder = ["pipreqs", "pip-api"]
+colors = ["colorama (>=0.4.3,<0.5.0)"]
+
+[[package]]
+name = "jedi"
+version = "0.18.0"
+description = "An autocompletion tool for Python that can be used for text editors."
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+parso = ">=0.8.0,<0.9.0"
+
+[package.extras]
+qa = ["flake8 (==3.8.3)", "mypy (==0.782)"]
+testing = ["Django (<3.1)", "colorama", "docopt", "pytest (<6.0.0)"]
+
+[[package]]
+name = "jinja2"
+version = "2.11.3"
+description = "A very fast and expressive template engine."
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+
+[package.dependencies]
+MarkupSafe = ">=0.23"
+
+[package.extras]
+i18n = ["Babel (>=0.8)"]
+
+[[package]]
+name = "lazy-object-proxy"
+version = "1.6.0"
+description = "A fast and thorough lazy object proxy."
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
+
+[[package]]
+name = "livereload"
+version = "2.6.3"
+description = "Python LiveReload is an awesome tool for web developers"
+category = "dev"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+six = "*"
+tornado = {version = "*", markers = "python_version > \"2.7\""}
+
+[[package]]
+name = "markupsafe"
+version = "1.1.1"
+description = "Safely add untrusted strings to HTML/XML markup."
+category = "dev"
+optional = false
+python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*"
+
+[[package]]
+name = "mccabe"
+version = "0.6.1"
+description = "McCabe checker, plugin for flake8"
+category = "dev"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "mypy"
+version = "0.812"
+description = "Optional static typing for Python"
+category = "dev"
+optional = false
+python-versions = ">=3.5"
+
+[package.dependencies]
+mypy-extensions = ">=0.4.3,<0.5.0"
+typed-ast = ">=1.4.0,<1.5.0"
+typing-extensions = ">=3.7.4"
+
+[package.extras]
+dmypy = ["psutil (>=4.0)"]
+
+[[package]]
+name = "mypy-extensions"
+version = "0.4.3"
+description = "Experimental type system extensions for programs checked with the mypy typechecker."
+category = "dev"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "nodeenv"
+version = "1.6.0"
+description = "Node.js virtual environment builder"
+category = "dev"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "packaging"
+version = "20.9"
+description = "Core utilities for Python packages"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
+[package.dependencies]
+pyparsing = ">=2.0.2"
+
+[[package]]
+name = "parso"
+version = "0.8.2"
+description = "A Python Parser"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.extras]
+qa = ["flake8 (==3.8.3)", "mypy (==0.782)"]
+testing = ["docopt", "pytest (<6.0.0)"]
+
+[[package]]
+name = "pathspec"
+version = "0.8.1"
+description = "Utility library for gitignore style pattern matching of file paths."
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+
+[[package]]
+name = "pexpect"
+version = "4.8.0"
+description = "Pexpect allows easy control of interactive console applications."
+category = "dev"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+ptyprocess = ">=0.5"
+
+[[package]]
+name = "pickleshare"
+version = "0.7.5"
+description = "Tiny 'shelve'-like database with concurrency support"
+category = "dev"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "pluggy"
+version = "0.13.1"
+description = "plugin and hook calling mechanisms for python"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
+[package.extras]
+dev = ["pre-commit", "tox"]
+
+[[package]]
+name = "pockets"
+version = "0.9.1"
+description = "A collection of helpful Python tools!"
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+six = ">=1.5.2"
+
+[[package]]
+name = "pre-commit"
+version = "2.12.0"
+description = "A framework for managing and maintaining multi-language pre-commit hooks."
+category = "dev"
+optional = false
+python-versions = ">=3.6.1"
+
+[package.dependencies]
+cfgv = ">=2.0.0"
+identify = ">=1.0.0"
+nodeenv = ">=0.11.1"
+pyyaml = ">=5.1"
+toml = "*"
+virtualenv = ">=20.0.8"
+
+[[package]]
+name = "promise"
+version = "2.3"
+description = "Promises/A+ implementation for Python"
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+six = "*"
+
+[package.extras]
+test = ["pytest (>=2.7.3)", "pytest-cov", "coveralls", "futures", "pytest-benchmark", "mock"]
+
+[[package]]
+name = "prompt-toolkit"
+version = "3.0.18"
+description = "Library for building powerful interactive command lines in Python"
+category = "dev"
+optional = false
+python-versions = ">=3.6.1"
+
+[package.dependencies]
+wcwidth = "*"
+
+[[package]]
+name = "psycopg2-binary"
+version = "2.8.6"
+description = "psycopg2 - Python-PostgreSQL Database Adapter"
+category = "dev"
+optional = false
+python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*"
+
+[[package]]
+name = "ptyprocess"
+version = "0.7.0"
+description = "Run a subprocess in a pseudo terminal"
+category = "dev"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "py"
+version = "1.10.0"
+description = "library with cross-python path, ini-parsing, io, code, log facilities"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
+[[package]]
+name = "pycodestyle"
+version = "2.7.0"
+description = "Python style guide checker"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
+[[package]]
+name = "pydantic"
+version = "1.6"
+description = "Data validation and settings management using python 3.6 type hinting"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.extras]
+dotenv = ["python-dotenv (>=0.10.4)"]
+email = ["email-validator (>=1.0.3)"]
+typing_extensions = ["typing-extensions (>=3.7.2)"]
+
+[[package]]
+name = "pyflakes"
+version = "2.3.1"
+description = "passive checker of Python programs"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
+[[package]]
+name = "pygments"
+version = "2.8.1"
+description = "Pygments is a syntax highlighting package written in Python."
+category = "dev"
+optional = false
+python-versions = ">=3.5"
+
+[[package]]
+name = "pylint"
+version = "2.7.4"
+description = "python code static checker"
+category = "dev"
+optional = false
+python-versions = "~=3.6"
+
+[package.dependencies]
+astroid = ">=2.5.2,<2.7"
+colorama = {version = "*", markers = "sys_platform == \"win32\""}
+isort = ">=4.2.5,<6"
+mccabe = ">=0.6,<0.7"
+toml = ">=0.7.1"
+
+[package.extras]
+docs = ["sphinx (==3.5.1)", "python-docs-theme (==2020.12)"]
+
+[[package]]
+name = "pylint-celery"
+version = "0.3"
+description = "pylint-celery is a Pylint plugin to aid Pylint in recognising and understandingerrors caused when using the Celery library"
+category = "dev"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+astroid = ">=1.0"
+pylint = ">=1.0"
+pylint-plugin-utils = ">=0.2.1"
+
+[[package]]
+name = "pylint-django"
+version = "2.4.3"
+description = "A Pylint plugin to help Pylint understand the Django web framework"
+category = "dev"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+pylint = ">=2.0"
+pylint-plugin-utils = ">=0.5"
+
+[package.extras]
+for_tests = ["django-tables2", "factory-boy", "coverage", "pytest"]
+with_django = ["django"]
+
+[[package]]
+name = "pylint-plugin-utils"
+version = "0.6"
+description = "Utilities and helpers for writing Pylint plugins"
+category = "dev"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+pylint = ">=1.7"
+
+[[package]]
+name = "pyparsing"
+version = "2.4.7"
+description = "Python parsing module"
+category = "dev"
+optional = false
+python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
+
+[[package]]
+name = "pytest"
+version = "6.2.3"
+description = "pytest: simple powerful testing with Python"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""}
+attrs = ">=19.2.0"
+colorama = {version = "*", markers = "sys_platform == \"win32\""}
+iniconfig = "*"
+packaging = "*"
+pluggy = ">=0.12,<1.0.0a1"
+py = ">=1.8.2"
+toml = "*"
+
+[package.extras]
+testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"]
+
+[[package]]
+name = "pytest-django"
+version = "4.2.0"
+description = "A Django plugin for pytest."
+category = "dev"
+optional = false
+python-versions = ">=3.5"
+
+[package.dependencies]
+pytest = ">=5.4.0"
+
+[package.extras]
+docs = ["sphinx", "sphinx-rtd-theme"]
+testing = ["django", "django-configurations (>=2.0)"]
+
+[[package]]
+name = "pytest-sugar"
+version = "0.9.4"
+description = "pytest-sugar is a plugin for pytest that changes the default look and feel of pytest (e.g. progressbar, show tests that fail instantly)."
+category = "dev"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+packaging = ">=14.1"
+pytest = ">=2.9"
+termcolor = ">=1.1.0"
+
+[[package]]
+name = "python-dateutil"
+version = "2.8.1"
+description = "Extensions to the standard Python datetime module"
+category = "dev"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
+
+[package.dependencies]
+six = ">=1.5"
+
+[[package]]
+name = "pytz"
+version = "2021.1"
+description = "World timezone definitions, modern and historical"
+category = "main"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "pyyaml"
+version = "5.4.1"
+description = "YAML parser and emitter for Python"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
+
+[[package]]
+name = "regex"
+version = "2021.4.4"
+description = "Alternative regular expression module, to replace re."
+category = "dev"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "requests"
+version = "2.25.1"
+description = "Python HTTP for Humans."
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+
+[package.dependencies]
+certifi = ">=2017.4.17"
+chardet = ">=3.0.2,<5"
+idna = ">=2.5,<3"
+urllib3 = ">=1.21.1,<1.27"
+
+[package.extras]
+security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"]
+socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"]
+
+[[package]]
+name = "rx"
+version = "1.6.1"
+description = "Reactive Extensions (Rx) for Python"
+category = "main"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "singledispatch"
+version = "3.6.1"
+description = "Backport functools.singledispatch from Python 3.4 to Python 2.6-3.3."
+category = "main"
+optional = false
+python-versions = ">=2.6"
+
+[package.dependencies]
+six = "*"
+
+[package.extras]
+docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"]
+testing = ["pytest (>=4.6)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "pytest-black (>=0.3.7)", "unittest2"]
+
+[[package]]
+name = "six"
+version = "1.15.0"
+description = "Python 2 and 3 compatibility utilities"
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
+
+[[package]]
+name = "snowballstemmer"
+version = "2.1.0"
+description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms."
+category = "dev"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "sphinx"
+version = "3.5.3"
+description = "Python documentation generator"
+category = "dev"
+optional = false
+python-versions = ">=3.5"
+
+[package.dependencies]
+alabaster = ">=0.7,<0.8"
+babel = ">=1.3"
+colorama = {version = ">=0.3.5", markers = "sys_platform == \"win32\""}
+docutils = ">=0.12"
+imagesize = "*"
+Jinja2 = ">=2.3"
+packaging = "*"
+Pygments = ">=2.0"
+requests = ">=2.5.0"
+snowballstemmer = ">=1.1"
+sphinxcontrib-applehelp = "*"
+sphinxcontrib-devhelp = "*"
+sphinxcontrib-htmlhelp = "*"
+sphinxcontrib-jsmath = "*"
+sphinxcontrib-qthelp = "*"
+sphinxcontrib-serializinghtml = "*"
+
+[package.extras]
+docs = ["sphinxcontrib-websupport"]
+lint = ["flake8 (>=3.5.0)", "isort", "mypy (>=0.800)", "docutils-stubs"]
+test = ["pytest", "pytest-cov", "html5lib", "cython", "typed-ast"]
+
+[[package]]
+name = "sphinx-autobuild"
+version = "2021.3.14"
+description = "Rebuild Sphinx documentation on changes, with live-reload in the browser."
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+colorama = "*"
+livereload = "*"
+sphinx = "*"
+
+[package.extras]
+test = ["pytest", "pytest-cov"]
+
+[[package]]
+name = "sphinxcontrib-applehelp"
+version = "1.0.2"
+description = "sphinxcontrib-applehelp is a sphinx extension which outputs Apple help books"
+category = "dev"
+optional = false
+python-versions = ">=3.5"
+
+[package.extras]
+lint = ["flake8", "mypy", "docutils-stubs"]
+test = ["pytest"]
+
+[[package]]
+name = "sphinxcontrib-devhelp"
+version = "1.0.2"
+description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp document."
+category = "dev"
+optional = false
+python-versions = ">=3.5"
+
+[package.extras]
+lint = ["flake8", "mypy", "docutils-stubs"]
+test = ["pytest"]
+
+[[package]]
+name = "sphinxcontrib-htmlhelp"
+version = "1.0.3"
+description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files"
+category = "dev"
+optional = false
+python-versions = ">=3.5"
+
+[package.extras]
+lint = ["flake8", "mypy", "docutils-stubs"]
+test = ["pytest", "html5lib"]
+
+[[package]]
+name = "sphinxcontrib-jsmath"
+version = "1.0.1"
+description = "A sphinx extension which renders display math in HTML via JavaScript"
+category = "dev"
+optional = false
+python-versions = ">=3.5"
+
+[package.extras]
+test = ["pytest", "flake8", "mypy"]
+
+[[package]]
+name = "sphinxcontrib-napoleon"
+version = "0.7"
+description = "Sphinx \"napoleon\" extension."
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+pockets = ">=0.3"
+six = ">=1.5.2"
+
+[[package]]
+name = "sphinxcontrib-qthelp"
+version = "1.0.3"
+description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp document."
+category = "dev"
+optional = false
+python-versions = ">=3.5"
+
+[package.extras]
+lint = ["flake8", "mypy", "docutils-stubs"]
+test = ["pytest"]
+
+[[package]]
+name = "sphinxcontrib-serializinghtml"
+version = "1.1.4"
+description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)."
+category = "dev"
+optional = false
+python-versions = ">=3.5"
+
+[package.extras]
+lint = ["flake8", "mypy", "docutils-stubs"]
+test = ["pytest"]
+
+[[package]]
+name = "sqlparse"
+version = "0.4.1"
+description = "A non-validating SQL parser."
+category = "main"
+optional = false
+python-versions = ">=3.5"
+
+[[package]]
+name = "termcolor"
+version = "1.1.0"
+description = "ANSII Color formatting for output in terminal."
+category = "dev"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "testfixtures"
+version = "6.17.1"
+description = "A collection of helpers and mock objects for unit tests and doc tests."
+category = "dev"
+optional = false
+python-versions = "*"
+
+[package.extras]
+build = ["setuptools-git", "wheel", "twine"]
+docs = ["sphinx", "zope.component", "sybil", "twisted", "mock", "django (<2)", "django"]
+test = ["pytest (>=3.6)", "pytest-cov", "pytest-django", "zope.component", "sybil", "twisted", "mock", "django (<2)", "django"]
+
+[[package]]
+name = "text-unidecode"
+version = "1.3"
+description = "The most basic Text::Unidecode port"
+category = "main"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "toml"
+version = "0.10.2"
+description = "Python Library for Tom's Obvious, Minimal Language"
+category = "dev"
+optional = false
+python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
+
+[[package]]
+name = "tornado"
+version = "6.1"
+description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed."
+category = "dev"
+optional = false
+python-versions = ">= 3.5"
+
+[[package]]
+name = "traitlets"
+version = "5.0.5"
+description = "Traitlets Python configuration system"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+
+[package.dependencies]
+ipython-genutils = "*"
+
+[package.extras]
+test = ["pytest"]
+
+[[package]]
+name = "typed-ast"
+version = "1.4.3"
+description = "a fork of Python 2 and 3 ast modules with type comment support"
+category = "dev"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "typing-extensions"
+version = "3.7.4.3"
+description = "Backported and Experimental Type Hints for Python 3.5+"
+category = "dev"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "urllib3"
+version = "1.26.4"
+description = "HTTP library with thread-safe connection pooling, file post, and more."
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4"
+
+[package.extras]
+secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"]
+socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
+brotli = ["brotlipy (>=0.6.0)"]
+
+[[package]]
+name = "virtualenv"
+version = "20.4.3"
+description = "Virtual Python Environment builder"
+category = "dev"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7"
+
+[package.dependencies]
+appdirs = ">=1.4.3,<2"
+distlib = ">=0.3.1,<1"
+filelock = ">=3.0.0,<4"
+six = ">=1.9.0,<2"
+
+[package.extras]
+docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=19.9.0rc1)"]
+testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)", "xonsh (>=0.9.16)"]
+
+[[package]]
+name = "watchgod"
+version = "0.6"
+description = "Simple, modern file watching and code reload in python."
+category = "dev"
+optional = false
+python-versions = ">=3.5"
+
+[[package]]
+name = "wcwidth"
+version = "0.2.5"
+description = "Measures the displayed width of unicode strings in a terminal"
+category = "dev"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "werkzeug"
+version = "1.0.1"
+description = "The comprehensive WSGI web application library."
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+
+[package.extras]
+dev = ["pytest", "pytest-timeout", "coverage", "tox", "sphinx", "pallets-sphinx-themes", "sphinx-issues"]
+watchdog = ["watchdog"]
+
+[[package]]
+name = "wrapt"
+version = "1.12.1"
+description = "Module for decorators, wrappers and monkey patching."
+category = "dev"
+optional = false
+python-versions = "*"
+
+[metadata]
+lock-version = "1.1"
+python-versions = ">=3.8,<4"
+content-hash = "9f5573c58d4aa392ba5234bad3b4e1afde82b5942210cb670c35f0ab74112196"
+
+[metadata.files]
+alabaster = [
+ {file = "alabaster-0.7.12-py2.py3-none-any.whl", hash = "sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359"},
+ {file = "alabaster-0.7.12.tar.gz", hash = "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02"},
+]
+aniso8601 = [
+ {file = "aniso8601-7.0.0-py2.py3-none-any.whl", hash = "sha256:d10a4bf949f619f719b227ef5386e31f49a2b6d453004b21f02661ccc8670c7b"},
+ {file = "aniso8601-7.0.0.tar.gz", hash = "sha256:513d2b6637b7853806ae79ffaca6f3e8754bdd547048f5ccc1420aec4b714f1e"},
+]
+appdirs = [
+ {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"},
+ {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"},
+]
+appnope = [
+ {file = "appnope-0.1.2-py2.py3-none-any.whl", hash = "sha256:93aa393e9d6c54c5cd570ccadd8edad61ea0c4b9ea7a01409020c9aa019eb442"},
+ {file = "appnope-0.1.2.tar.gz", hash = "sha256:dd83cd4b5b460958838f6eb3000c660b1f9caf2a5b1de4264e941512f603258a"},
+]
+asgiref = [
+ {file = "asgiref-3.3.4-py3-none-any.whl", hash = "sha256:92906c611ce6c967347bbfea733f13d6313901d54dcca88195eaeb52b2a8e8ee"},
+ {file = "asgiref-3.3.4.tar.gz", hash = "sha256:d1216dfbdfb63826470995d31caed36225dcaf34f182e0fa257a4dd9e86f1b78"},
+]
+astroid = [
+ {file = "astroid-2.5.3-py3-none-any.whl", hash = "sha256:bea3f32799fbb8581f58431c12591bc20ce11cbc90ad82e2ea5717d94f2080d5"},
+ {file = "astroid-2.5.3.tar.gz", hash = "sha256:ad63b8552c70939568966811a088ef0bc880f99a24a00834abd0e3681b514f91"},
+]
+atomicwrites = [
+ {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"},
+ {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"},
+]
+attrs = [
+ {file = "attrs-20.3.0-py2.py3-none-any.whl", hash = "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6"},
+ {file = "attrs-20.3.0.tar.gz", hash = "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"},
+]
+babel = [
+ {file = "Babel-2.9.0-py2.py3-none-any.whl", hash = "sha256:9d35c22fcc79893c3ecc85ac4a56cde1ecf3f19c540bba0922308a6c06ca6fa5"},
+ {file = "Babel-2.9.0.tar.gz", hash = "sha256:da031ab54472314f210b0adcff1588ee5d1d1d0ba4dbd07b94dba82bde791e05"},
+]
+backcall = [
+ {file = "backcall-0.2.0-py2.py3-none-any.whl", hash = "sha256:fbbce6a29f263178a1f7915c1940bde0ec2b2a967566fe1c65c1dfb7422bd255"},
+ {file = "backcall-0.2.0.tar.gz", hash = "sha256:5cbdbf27be5e7cfadb448baf0aa95508f91f2bbc6c6437cd9cd06e2a4c215e1e"},
+]
+black = [
+ {file = "black-20.8b1.tar.gz", hash = "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea"},
+]
+certifi = [
+ {file = "certifi-2020.12.5-py2.py3-none-any.whl", hash = "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"},
+ {file = "certifi-2020.12.5.tar.gz", hash = "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c"},
+]
+cfgv = [
+ {file = "cfgv-3.2.0-py2.py3-none-any.whl", hash = "sha256:32e43d604bbe7896fe7c248a9c2276447dbef840feb28fe20494f62af110211d"},
+ {file = "cfgv-3.2.0.tar.gz", hash = "sha256:cf22deb93d4bcf92f345a5c3cd39d3d41d6340adc60c78bbbd6588c384fda6a1"},
+]
+chardet = [
+ {file = "chardet-4.0.0-py2.py3-none-any.whl", hash = "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"},
+ {file = "chardet-4.0.0.tar.gz", hash = "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa"},
+]
+click = [
+ {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"},
+ {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"},
+]
+colorama = [
+ {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"},
+ {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"},
+]
+coverage = [
+ {file = "coverage-5.5-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:b6d534e4b2ab35c9f93f46229363e17f63c53ad01330df9f2d6bd1187e5eaacf"},
+ {file = "coverage-5.5-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:b7895207b4c843c76a25ab8c1e866261bcfe27bfaa20c192de5190121770672b"},
+ {file = "coverage-5.5-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:c2723d347ab06e7ddad1a58b2a821218239249a9e4365eaff6649d31180c1669"},
+ {file = "coverage-5.5-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:900fbf7759501bc7807fd6638c947d7a831fc9fdf742dc10f02956ff7220fa90"},
+ {file = "coverage-5.5-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:004d1880bed2d97151facef49f08e255a20ceb6f9432df75f4eef018fdd5a78c"},
+ {file = "coverage-5.5-cp27-cp27m-win32.whl", hash = "sha256:06191eb60f8d8a5bc046f3799f8a07a2d7aefb9504b0209aff0b47298333302a"},
+ {file = "coverage-5.5-cp27-cp27m-win_amd64.whl", hash = "sha256:7501140f755b725495941b43347ba8a2777407fc7f250d4f5a7d2a1050ba8e82"},
+ {file = "coverage-5.5-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:372da284cfd642d8e08ef606917846fa2ee350f64994bebfbd3afb0040436905"},
+ {file = "coverage-5.5-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:8963a499849a1fc54b35b1c9f162f4108017b2e6db2c46c1bed93a72262ed083"},
+ {file = "coverage-5.5-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:869a64f53488f40fa5b5b9dcb9e9b2962a66a87dab37790f3fcfb5144b996ef5"},
+ {file = "coverage-5.5-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:4a7697d8cb0f27399b0e393c0b90f0f1e40c82023ea4d45d22bce7032a5d7b81"},
+ {file = "coverage-5.5-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:8d0a0725ad7c1a0bcd8d1b437e191107d457e2ec1084b9f190630a4fb1af78e6"},
+ {file = "coverage-5.5-cp310-cp310-manylinux1_x86_64.whl", hash = "sha256:51cb9476a3987c8967ebab3f0fe144819781fca264f57f89760037a2ea191cb0"},
+ {file = "coverage-5.5-cp310-cp310-win_amd64.whl", hash = "sha256:c0891a6a97b09c1f3e073a890514d5012eb256845c451bd48f7968ef939bf4ae"},
+ {file = "coverage-5.5-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:3487286bc29a5aa4b93a072e9592f22254291ce96a9fbc5251f566b6b7343cdb"},
+ {file = "coverage-5.5-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:deee1077aae10d8fa88cb02c845cfba9b62c55e1183f52f6ae6a2df6a2187160"},
+ {file = "coverage-5.5-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:f11642dddbb0253cc8853254301b51390ba0081750a8ac03f20ea8103f0c56b6"},
+ {file = "coverage-5.5-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:6c90e11318f0d3c436a42409f2749ee1a115cd8b067d7f14c148f1ce5574d701"},
+ {file = "coverage-5.5-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:30c77c1dc9f253283e34c27935fded5015f7d1abe83bc7821680ac444eaf7793"},
+ {file = "coverage-5.5-cp35-cp35m-win32.whl", hash = "sha256:9a1ef3b66e38ef8618ce5fdc7bea3d9f45f3624e2a66295eea5e57966c85909e"},
+ {file = "coverage-5.5-cp35-cp35m-win_amd64.whl", hash = "sha256:972c85d205b51e30e59525694670de6a8a89691186012535f9d7dbaa230e42c3"},
+ {file = "coverage-5.5-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:af0e781009aaf59e25c5a678122391cb0f345ac0ec272c7961dc5455e1c40066"},
+ {file = "coverage-5.5-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:74d881fc777ebb11c63736622b60cb9e4aee5cace591ce274fb69e582a12a61a"},
+ {file = "coverage-5.5-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:92b017ce34b68a7d67bd6d117e6d443a9bf63a2ecf8567bb3d8c6c7bc5014465"},
+ {file = "coverage-5.5-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:d636598c8305e1f90b439dbf4f66437de4a5e3c31fdf47ad29542478c8508bbb"},
+ {file = "coverage-5.5-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:41179b8a845742d1eb60449bdb2992196e211341818565abded11cfa90efb821"},
+ {file = "coverage-5.5-cp36-cp36m-win32.whl", hash = "sha256:040af6c32813fa3eae5305d53f18875bedd079960822ef8ec067a66dd8afcd45"},
+ {file = "coverage-5.5-cp36-cp36m-win_amd64.whl", hash = "sha256:5fec2d43a2cc6965edc0bb9e83e1e4b557f76f843a77a2496cbe719583ce8184"},
+ {file = "coverage-5.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:18ba8bbede96a2c3dde7b868de9dcbd55670690af0988713f0603f037848418a"},
+ {file = "coverage-5.5-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:2910f4d36a6a9b4214bb7038d537f015346f413a975d57ca6b43bf23d6563b53"},
+ {file = "coverage-5.5-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:f0b278ce10936db1a37e6954e15a3730bea96a0997c26d7fee88e6c396c2086d"},
+ {file = "coverage-5.5-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:796c9c3c79747146ebd278dbe1e5c5c05dd6b10cc3bcb8389dfdf844f3ead638"},
+ {file = "coverage-5.5-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:53194af30d5bad77fcba80e23a1441c71abfb3e01192034f8246e0d8f99528f3"},
+ {file = "coverage-5.5-cp37-cp37m-win32.whl", hash = "sha256:184a47bbe0aa6400ed2d41d8e9ed868b8205046518c52464fde713ea06e3a74a"},
+ {file = "coverage-5.5-cp37-cp37m-win_amd64.whl", hash = "sha256:2949cad1c5208b8298d5686d5a85b66aae46d73eec2c3e08c817dd3513e5848a"},
+ {file = "coverage-5.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:217658ec7187497e3f3ebd901afdca1af062b42cfe3e0dafea4cced3983739f6"},
+ {file = "coverage-5.5-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1aa846f56c3d49205c952d8318e76ccc2ae23303351d9270ab220004c580cfe2"},
+ {file = "coverage-5.5-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:24d4a7de75446be83244eabbff746d66b9240ae020ced65d060815fac3423759"},
+ {file = "coverage-5.5-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:d1f8bf7b90ba55699b3a5e44930e93ff0189aa27186e96071fac7dd0d06a1873"},
+ {file = "coverage-5.5-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:970284a88b99673ccb2e4e334cfb38a10aab7cd44f7457564d11898a74b62d0a"},
+ {file = "coverage-5.5-cp38-cp38-win32.whl", hash = "sha256:01d84219b5cdbfc8122223b39a954820929497a1cb1422824bb86b07b74594b6"},
+ {file = "coverage-5.5-cp38-cp38-win_amd64.whl", hash = "sha256:2e0d881ad471768bf6e6c2bf905d183543f10098e3b3640fc029509530091502"},
+ {file = "coverage-5.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d1f9ce122f83b2305592c11d64f181b87153fc2c2bbd3bb4a3dde8303cfb1a6b"},
+ {file = "coverage-5.5-cp39-cp39-manylinux1_i686.whl", hash = "sha256:13c4ee887eca0f4c5a247b75398d4114c37882658300e153113dafb1d76de529"},
+ {file = "coverage-5.5-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:52596d3d0e8bdf3af43db3e9ba8dcdaac724ba7b5ca3f6358529d56f7a166f8b"},
+ {file = "coverage-5.5-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:2cafbbb3af0733db200c9b5f798d18953b1a304d3f86a938367de1567f4b5bff"},
+ {file = "coverage-5.5-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:44d654437b8ddd9eee7d1eaee28b7219bec228520ff809af170488fd2fed3e2b"},
+ {file = "coverage-5.5-cp39-cp39-win32.whl", hash = "sha256:d314ed732c25d29775e84a960c3c60808b682c08d86602ec2c3008e1202e3bb6"},
+ {file = "coverage-5.5-cp39-cp39-win_amd64.whl", hash = "sha256:13034c4409db851670bc9acd836243aeee299949bd5673e11844befcb0149f03"},
+ {file = "coverage-5.5-pp36-none-any.whl", hash = "sha256:f030f8873312a16414c0d8e1a1ddff2d3235655a2174e3648b4fa66b3f2f1079"},
+ {file = "coverage-5.5-pp37-none-any.whl", hash = "sha256:2a3859cb82dcbda1cfd3e6f71c27081d18aa251d20a17d87d26d4cd216fb0af4"},
+ {file = "coverage-5.5.tar.gz", hash = "sha256:ebe78fe9a0e874362175b02371bdfbee64d8edc42a044253ddf4ee7d3c15212c"},
+]
+decorator = [
+ {file = "decorator-5.0.7-py3-none-any.whl", hash = "sha256:945d84890bb20cc4a2f4a31fc4311c0c473af65ea318617f13a7257c9a58bc98"},
+ {file = "decorator-5.0.7.tar.gz", hash = "sha256:6f201a6c4dac3d187352661f508b9364ec8091217442c9478f1f83c003a0f060"},
+]
+defopt = [
+ {file = "defopt-6.1.0.tar.gz", hash = "sha256:cfe6ecfb54b1368a5cc45d9d58fb3c125e4b4b789e08fd29b5722404b57e137d"},
+]
+distlib = [
+ {file = "distlib-0.3.1-py2.py3-none-any.whl", hash = "sha256:8c09de2c67b3e7deef7184574fc060ab8a793e7adbb183d942c389c8b13c52fb"},
+ {file = "distlib-0.3.1.zip", hash = "sha256:edf6116872c863e1aa9d5bb7cb5e05a022c519a4594dc703843343a9ddd9bff1"},
+]
+django = [
+ {file = "Django-3.2-py3-none-any.whl", hash = "sha256:0604e84c4fb698a5e53e5857b5aea945b2f19a18f25f10b8748dbdf935788927"},
+ {file = "Django-3.2.tar.gz", hash = "sha256:21f0f9643722675976004eb683c55d33c05486f94506672df3d6a141546f389d"},
+]
+django-coverage-plugin = [
+ {file = "django_coverage_plugin-1.8.0.tar.gz", hash = "sha256:d53cbf3828fd83d6b89ff7292c6805de5274e36411711692043e67bcde25ae0c"},
+]
+django-crispy-forms = [
+ {file = "django-crispy-forms-1.11.2.tar.gz", hash = "sha256:88efa857ce6111bd696cc4f74057539a3456102fe9c3a3ece8868e1e4579e70a"},
+ {file = "django_crispy_forms-1.11.2-py3-none-any.whl", hash = "sha256:3db71ab06d17ec9d0195c086d3ad454da300ac268752ac3a4f63d72f7a490254"},
+]
+django-debug-toolbar = [
+ {file = "django-debug-toolbar-3.2.tar.gz", hash = "sha256:84e2607d900dbd571df0a2acf380b47c088efb787dce9805aefeb407341961d2"},
+ {file = "django_debug_toolbar-3.2-py3-none-any.whl", hash = "sha256:9e5a25d0c965f7e686f6a8ba23613ca9ca30184daa26487706d4829f5cfb697a"},
+]
+django-extensions = [
+ {file = "django-extensions-3.1.2.tar.gz", hash = "sha256:081828e985485662f62a22340c1506e37989d14b927652079a5b7cd84a82368b"},
+ {file = "django_extensions-3.1.2-py3-none-any.whl", hash = "sha256:17f85f4dcdd5eea09b8c4f0bad8f0370bf2db6d03e61b431fa7103fee29888de"},
+]
+django-filter = [
+ {file = "django-filter-2.4.0.tar.gz", hash = "sha256:84e9d5bb93f237e451db814ed422a3a625751cbc9968b484ecc74964a8696b06"},
+ {file = "django_filter-2.4.0-py3-none-any.whl", hash = "sha256:e00d32cebdb3d54273c48f4f878f898dced8d5dfaad009438fe61ebdf535ace1"},
+]
+django-stubs = [
+ {file = "django-stubs-1.7.0.tar.gz", hash = "sha256:ddd190aca5b9adb4d30760d5c64f67cb3658703f5f42c3bb0c2c71ff4d752c39"},
+ {file = "django_stubs-1.7.0-py3-none-any.whl", hash = "sha256:30a7d99c694acf79c5d93d69a5a8e4b54d2a8c11dd672aa869006789e2189fa6"},
+]
+docutils = [
+ {file = "docutils-0.17-py2.py3-none-any.whl", hash = "sha256:a71042bb7207c03d5647f280427f14bfbd1a65c9eb84f4b341d85fafb6bb4bdf"},
+ {file = "docutils-0.17.tar.gz", hash = "sha256:e2ffeea817964356ba4470efba7c2f42b6b0de0b04e66378507e3e2504bbff4c"},
+]
+factory-boy = [
+ {file = "factory_boy-3.2.0-py2.py3-none-any.whl", hash = "sha256:1d3db4b44b8c8c54cdd8b83ae4bdb9aeb121e464400035f1f03ae0e1eade56a4"},
+ {file = "factory_boy-3.2.0.tar.gz", hash = "sha256:401cc00ff339a022f84d64a4339503d1689e8263a4478d876e58a3295b155c5b"},
+]
+faker = [
+ {file = "Faker-8.1.0-py3-none-any.whl", hash = "sha256:44eb060fad3015690ff3fec6564d7171be393021e820ad1851d96cb968fbfcd4"},
+ {file = "Faker-8.1.0.tar.gz", hash = "sha256:26c7c3df8d46f1db595a34962f8967021dd90bbd38cc6e27461a3fb16cd413ae"},
+]
+filelock = [
+ {file = "filelock-3.0.12-py3-none-any.whl", hash = "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"},
+ {file = "filelock-3.0.12.tar.gz", hash = "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59"},
+]
+flake8 = [
+ {file = "flake8-3.9.0-py2.py3-none-any.whl", hash = "sha256:12d05ab02614b6aee8df7c36b97d1a3b2372761222b19b58621355e82acddcff"},
+ {file = "flake8-3.9.0.tar.gz", hash = "sha256:78873e372b12b093da7b5e5ed302e8ad9e988b38b063b61ad937f26ca58fc5f0"},
+]
+flake8-isort = [
+ {file = "flake8-isort-4.0.0.tar.gz", hash = "sha256:2b91300f4f1926b396c2c90185844eb1a3d5ec39ea6138832d119da0a208f4d9"},
+ {file = "flake8_isort-4.0.0-py2.py3-none-any.whl", hash = "sha256:729cd6ef9ba3659512dee337687c05d79c78e1215fdf921ed67e5fe46cce2f3c"},
+]
+graphene = [
+ {file = "graphene-2.1.8-py2.py3-none-any.whl", hash = "sha256:09165f03e1591b76bf57b133482db9be6dac72c74b0a628d3c93182af9c5a896"},
+ {file = "graphene-2.1.8.tar.gz", hash = "sha256:2cbe6d4ef15cfc7b7805e0760a0e5b80747161ce1b0f990dfdc0d2cf497c12f9"},
+]
+graphene-django = [
+ {file = "graphene-django-2.15.0.tar.gz", hash = "sha256:b78c9b05bc899016b9cc5bf13faa1f37fe1faa8c5407552c6ddd1a28f46fc31a"},
+ {file = "graphene_django-2.15.0-py2.py3-none-any.whl", hash = "sha256:02671d195f0c09c8649acff2a8f4ad4f297d0f7d98ea6e6cdf034b81bab92880"},
+]
+graphene-pydantic = [
+ {file = "graphene_pydantic-0.0.9-py3-none-any.whl", hash = "sha256:ee7077143efc58149466afa5ca38e5f96f13f107196a609d062593f70ceedf99"},
+ {file = "graphene_pydantic-0.0.9.tar.gz", hash = "sha256:9395c1b432356b7abc24e27133055c15f4be19bd263aa570d9871ec88f178e08"},
+]
+graphql-core = [
+ {file = "graphql-core-2.3.2.tar.gz", hash = "sha256:aac46a9ac524c9855910c14c48fc5d60474def7f99fd10245e76608eba7af746"},
+ {file = "graphql_core-2.3.2-py2.py3-none-any.whl", hash = "sha256:44c9bac4514e5e30c5a595fac8e3c76c1975cae14db215e8174c7fe995825bad"},
+]
+graphql-relay = [
+ {file = "graphql-relay-2.0.1.tar.gz", hash = "sha256:870b6b5304123a38a0b215a79eace021acce5a466bf40cd39fa18cb8528afabb"},
+ {file = "graphql_relay-2.0.1-py3-none-any.whl", hash = "sha256:ac514cb86db9a43014d7e73511d521137ac12cf0101b2eaa5f0a3da2e10d913d"},
+]
+identify = [
+ {file = "identify-2.2.3-py2.py3-none-any.whl", hash = "sha256:398cb92a7599da0b433c65301a1b62b9b1f4bb8248719b84736af6c0b22289d6"},
+ {file = "identify-2.2.3.tar.gz", hash = "sha256:4537474817e0bbb8cea3e5b7504b7de6d44e3f169a90846cbc6adb0fc8294502"},
+]
+idna = [
+ {file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"},
+ {file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"},
+]
+imagesize = [
+ {file = "imagesize-1.2.0-py2.py3-none-any.whl", hash = "sha256:6965f19a6a2039c7d48bca7dba2473069ff854c36ae6f19d2cde309d998228a1"},
+ {file = "imagesize-1.2.0.tar.gz", hash = "sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1"},
+]
+iniconfig = [
+ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"},
+ {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"},
+]
+ipdb = [
+ {file = "ipdb-0.13.7.tar.gz", hash = "sha256:178c367a61c1039e44e17c56fcc4a6e7dc11b33561261382d419b6ddb4401810"},
+]
+ipython = [
+ {file = "ipython-7.22.0-py3-none-any.whl", hash = "sha256:c0ce02dfaa5f854809ab7413c601c4543846d9da81010258ecdab299b542d199"},
+ {file = "ipython-7.22.0.tar.gz", hash = "sha256:9c900332d4c5a6de534b4befeeb7de44ad0cc42e8327fa41b7685abde58cec74"},
+]
+ipython-genutils = [
+ {file = "ipython_genutils-0.2.0-py2.py3-none-any.whl", hash = "sha256:72dd37233799e619666c9f639a9da83c34013a73e8bbc79a7a6348d93c61fab8"},
+ {file = "ipython_genutils-0.2.0.tar.gz", hash = "sha256:eb2e116e75ecef9d4d228fdc66af54269afa26ab4463042e33785b887c628ba8"},
+]
+isort = [
+ {file = "isort-5.8.0-py3-none-any.whl", hash = "sha256:2bb1680aad211e3c9944dbce1d4ba09a989f04e238296c87fe2139faa26d655d"},
+ {file = "isort-5.8.0.tar.gz", hash = "sha256:0a943902919f65c5684ac4e0154b1ad4fac6dcaa5d9f3426b732f1c8b5419be6"},
+]
+jedi = [
+ {file = "jedi-0.18.0-py2.py3-none-any.whl", hash = "sha256:18456d83f65f400ab0c2d3319e48520420ef43b23a086fdc05dff34132f0fb93"},
+ {file = "jedi-0.18.0.tar.gz", hash = "sha256:92550a404bad8afed881a137ec9a461fed49eca661414be45059329614ed0707"},
+]
+jinja2 = [
+ {file = "Jinja2-2.11.3-py2.py3-none-any.whl", hash = "sha256:03e47ad063331dd6a3f04a43eddca8a966a26ba0c5b7207a9a9e4e08f1b29419"},
+ {file = "Jinja2-2.11.3.tar.gz", hash = "sha256:a6d58433de0ae800347cab1fa3043cebbabe8baa9d29e668f1c768cb87a333c6"},
+]
+lazy-object-proxy = [
+ {file = "lazy-object-proxy-1.6.0.tar.gz", hash = "sha256:489000d368377571c6f982fba6497f2aa13c6d1facc40660963da62f5c379726"},
+ {file = "lazy_object_proxy-1.6.0-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:c6938967f8528b3668622a9ed3b31d145fab161a32f5891ea7b84f6b790be05b"},
+ {file = "lazy_object_proxy-1.6.0-cp27-cp27m-win32.whl", hash = "sha256:ebfd274dcd5133e0afae738e6d9da4323c3eb021b3e13052d8cbd0e457b1256e"},
+ {file = "lazy_object_proxy-1.6.0-cp27-cp27m-win_amd64.whl", hash = "sha256:ed361bb83436f117f9917d282a456f9e5009ea12fd6de8742d1a4752c3017e93"},
+ {file = "lazy_object_proxy-1.6.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:d900d949b707778696fdf01036f58c9876a0d8bfe116e8d220cfd4b15f14e741"},
+ {file = "lazy_object_proxy-1.6.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:5743a5ab42ae40caa8421b320ebf3a998f89c85cdc8376d6b2e00bd12bd1b587"},
+ {file = "lazy_object_proxy-1.6.0-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:bf34e368e8dd976423396555078def5cfc3039ebc6fc06d1ae2c5a65eebbcde4"},
+ {file = "lazy_object_proxy-1.6.0-cp36-cp36m-win32.whl", hash = "sha256:b579f8acbf2bdd9ea200b1d5dea36abd93cabf56cf626ab9c744a432e15c815f"},
+ {file = "lazy_object_proxy-1.6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:4f60460e9f1eb632584c9685bccea152f4ac2130e299784dbaf9fae9f49891b3"},
+ {file = "lazy_object_proxy-1.6.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:d7124f52f3bd259f510651450e18e0fd081ed82f3c08541dffc7b94b883aa981"},
+ {file = "lazy_object_proxy-1.6.0-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:22ddd618cefe54305df49e4c069fa65715be4ad0e78e8d252a33debf00f6ede2"},
+ {file = "lazy_object_proxy-1.6.0-cp37-cp37m-win32.whl", hash = "sha256:9d397bf41caad3f489e10774667310d73cb9c4258e9aed94b9ec734b34b495fd"},
+ {file = "lazy_object_proxy-1.6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:24a5045889cc2729033b3e604d496c2b6f588c754f7a62027ad4437a7ecc4837"},
+ {file = "lazy_object_proxy-1.6.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:17e0967ba374fc24141738c69736da90e94419338fd4c7c7bef01ee26b339653"},
+ {file = "lazy_object_proxy-1.6.0-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:410283732af311b51b837894fa2f24f2c0039aa7f220135192b38fcc42bd43d3"},
+ {file = "lazy_object_proxy-1.6.0-cp38-cp38-win32.whl", hash = "sha256:85fb7608121fd5621cc4377a8961d0b32ccf84a7285b4f1d21988b2eae2868e8"},
+ {file = "lazy_object_proxy-1.6.0-cp38-cp38-win_amd64.whl", hash = "sha256:d1c2676e3d840852a2de7c7d5d76407c772927addff8d742b9808fe0afccebdf"},
+ {file = "lazy_object_proxy-1.6.0-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:b865b01a2e7f96db0c5d12cfea590f98d8c5ba64ad222300d93ce6ff9138bcad"},
+ {file = "lazy_object_proxy-1.6.0-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:4732c765372bd78a2d6b2150a6e99d00a78ec963375f236979c0626b97ed8e43"},
+ {file = "lazy_object_proxy-1.6.0-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:9698110e36e2df951c7c36b6729e96429c9c32b3331989ef19976592c5f3c77a"},
+ {file = "lazy_object_proxy-1.6.0-cp39-cp39-win32.whl", hash = "sha256:1fee665d2638491f4d6e55bd483e15ef21f6c8c2095f235fef72601021e64f61"},
+ {file = "lazy_object_proxy-1.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:f5144c75445ae3ca2057faac03fda5a902eff196702b0a24daf1d6ce0650514b"},
+]
+livereload = [
+ {file = "livereload-2.6.3.tar.gz", hash = "sha256:776f2f865e59fde56490a56bcc6773b6917366bce0c267c60ee8aaf1a0959869"},
+]
+markupsafe = [
+ {file = "MarkupSafe-1.1.1-cp27-cp27m-macosx_10_6_intel.whl", hash = "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161"},
+ {file = "MarkupSafe-1.1.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7"},
+ {file = "MarkupSafe-1.1.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183"},
+ {file = "MarkupSafe-1.1.1-cp27-cp27m-win32.whl", hash = "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b"},
+ {file = "MarkupSafe-1.1.1-cp27-cp27m-win_amd64.whl", hash = "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e"},
+ {file = "MarkupSafe-1.1.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f"},
+ {file = "MarkupSafe-1.1.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1"},
+ {file = "MarkupSafe-1.1.1-cp34-cp34m-macosx_10_6_intel.whl", hash = "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5"},
+ {file = "MarkupSafe-1.1.1-cp34-cp34m-manylinux1_i686.whl", hash = "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1"},
+ {file = "MarkupSafe-1.1.1-cp34-cp34m-manylinux1_x86_64.whl", hash = "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735"},
+ {file = "MarkupSafe-1.1.1-cp34-cp34m-win32.whl", hash = "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21"},
+ {file = "MarkupSafe-1.1.1-cp34-cp34m-win_amd64.whl", hash = "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235"},
+ {file = "MarkupSafe-1.1.1-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b"},
+ {file = "MarkupSafe-1.1.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f"},
+ {file = "MarkupSafe-1.1.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905"},
+ {file = "MarkupSafe-1.1.1-cp35-cp35m-win32.whl", hash = "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1"},
+ {file = "MarkupSafe-1.1.1-cp35-cp35m-win_amd64.whl", hash = "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d"},
+ {file = "MarkupSafe-1.1.1-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff"},
+ {file = "MarkupSafe-1.1.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:d53bc011414228441014aa71dbec320c66468c1030aae3a6e29778a3382d96e5"},
+ {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473"},
+ {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e"},
+ {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:3b8a6499709d29c2e2399569d96719a1b21dcd94410a586a18526b143ec8470f"},
+ {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:84dee80c15f1b560d55bcfe6d47b27d070b4681c699c572af2e3c7cc90a3b8e0"},
+ {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:b1dba4527182c95a0db8b6060cc98ac49b9e2f5e64320e2b56e47cb2831978c7"},
+ {file = "MarkupSafe-1.1.1-cp36-cp36m-win32.whl", hash = "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66"},
+ {file = "MarkupSafe-1.1.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5"},
+ {file = "MarkupSafe-1.1.1-cp37-cp37m-macosx_10_6_intel.whl", hash = "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d"},
+ {file = "MarkupSafe-1.1.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:bf5aa3cbcfdf57fa2ee9cd1822c862ef23037f5c832ad09cfea57fa846dec193"},
+ {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e"},
+ {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6"},
+ {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:6fffc775d90dcc9aed1b89219549b329a9250d918fd0b8fa8d93d154918422e1"},
+ {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:a6a744282b7718a2a62d2ed9d993cad6f5f585605ad352c11de459f4108df0a1"},
+ {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:195d7d2c4fbb0ee8139a6cf67194f3973a6b3042d742ebe0a9ed36d8b6f0c07f"},
+ {file = "MarkupSafe-1.1.1-cp37-cp37m-win32.whl", hash = "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2"},
+ {file = "MarkupSafe-1.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c"},
+ {file = "MarkupSafe-1.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15"},
+ {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2"},
+ {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42"},
+ {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:acf08ac40292838b3cbbb06cfe9b2cb9ec78fce8baca31ddb87aaac2e2dc3bc2"},
+ {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:d9be0ba6c527163cbed5e0857c451fcd092ce83947944d6c14bc95441203f032"},
+ {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:caabedc8323f1e93231b52fc32bdcde6db817623d33e100708d9a68e1f53b26b"},
+ {file = "MarkupSafe-1.1.1-cp38-cp38-win32.whl", hash = "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b"},
+ {file = "MarkupSafe-1.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"},
+ {file = "MarkupSafe-1.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d73a845f227b0bfe8a7455ee623525ee656a9e2e749e4742706d80a6065d5e2c"},
+ {file = "MarkupSafe-1.1.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:98bae9582248d6cf62321dcb52aaf5d9adf0bad3b40582925ef7c7f0ed85fceb"},
+ {file = "MarkupSafe-1.1.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:2beec1e0de6924ea551859edb9e7679da6e4870d32cb766240ce17e0a0ba2014"},
+ {file = "MarkupSafe-1.1.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:7fed13866cf14bba33e7176717346713881f56d9d2bcebab207f7a036f41b850"},
+ {file = "MarkupSafe-1.1.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:6f1e273a344928347c1290119b493a1f0303c52f5a5eae5f16d74f48c15d4a85"},
+ {file = "MarkupSafe-1.1.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:feb7b34d6325451ef96bc0e36e1a6c0c1c64bc1fbec4b854f4529e51887b1621"},
+ {file = "MarkupSafe-1.1.1-cp39-cp39-win32.whl", hash = "sha256:22c178a091fc6630d0d045bdb5992d2dfe14e3259760e713c490da5323866c39"},
+ {file = "MarkupSafe-1.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:b7d644ddb4dbd407d31ffb699f1d140bc35478da613b441c582aeb7c43838dd8"},
+ {file = "MarkupSafe-1.1.1.tar.gz", hash = "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b"},
+]
+mccabe = [
+ {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"},
+ {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"},
+]
+mypy = [
+ {file = "mypy-0.812-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:a26f8ec704e5a7423c8824d425086705e381b4f1dfdef6e3a1edab7ba174ec49"},
+ {file = "mypy-0.812-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:28fb5479c494b1bab244620685e2eb3c3f988d71fd5d64cc753195e8ed53df7c"},
+ {file = "mypy-0.812-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:9743c91088d396c1a5a3c9978354b61b0382b4e3c440ce83cf77994a43e8c521"},
+ {file = "mypy-0.812-cp35-cp35m-win_amd64.whl", hash = "sha256:d7da2e1d5f558c37d6e8c1246f1aec1e7349e4913d8fb3cb289a35de573fe2eb"},
+ {file = "mypy-0.812-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:4eec37370483331d13514c3f55f446fc5248d6373e7029a29ecb7b7494851e7a"},
+ {file = "mypy-0.812-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:d65cc1df038ef55a99e617431f0553cd77763869eebdf9042403e16089fe746c"},
+ {file = "mypy-0.812-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:61a3d5b97955422964be6b3baf05ff2ce7f26f52c85dd88db11d5e03e146a3a6"},
+ {file = "mypy-0.812-cp36-cp36m-win_amd64.whl", hash = "sha256:25adde9b862f8f9aac9d2d11971f226bd4c8fbaa89fb76bdadb267ef22d10064"},
+ {file = "mypy-0.812-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:552a815579aa1e995f39fd05dde6cd378e191b063f031f2acfe73ce9fb7f9e56"},
+ {file = "mypy-0.812-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:499c798053cdebcaa916eef8cd733e5584b5909f789de856b482cd7d069bdad8"},
+ {file = "mypy-0.812-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:5873888fff1c7cf5b71efbe80e0e73153fe9212fafdf8e44adfe4c20ec9f82d7"},
+ {file = "mypy-0.812-cp37-cp37m-win_amd64.whl", hash = "sha256:9f94aac67a2045ec719ffe6111df543bac7874cee01f41928f6969756e030564"},
+ {file = "mypy-0.812-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d23e0ea196702d918b60c8288561e722bf437d82cb7ef2edcd98cfa38905d506"},
+ {file = "mypy-0.812-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:674e822aa665b9fd75130c6c5f5ed9564a38c6cea6a6432ce47eafb68ee578c5"},
+ {file = "mypy-0.812-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:abf7e0c3cf117c44d9285cc6128856106183938c68fd4944763003decdcfeb66"},
+ {file = "mypy-0.812-cp38-cp38-win_amd64.whl", hash = "sha256:0d0a87c0e7e3a9becdfbe936c981d32e5ee0ccda3e0f07e1ef2c3d1a817cf73e"},
+ {file = "mypy-0.812-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7ce3175801d0ae5fdfa79b4f0cfed08807af4d075b402b7e294e6aa72af9aa2a"},
+ {file = "mypy-0.812-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:b09669bcda124e83708f34a94606e01b614fa71931d356c1f1a5297ba11f110a"},
+ {file = "mypy-0.812-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:33f159443db0829d16f0a8d83d94df3109bb6dd801975fe86bacb9bf71628e97"},
+ {file = "mypy-0.812-cp39-cp39-win_amd64.whl", hash = "sha256:3f2aca7f68580dc2508289c729bd49ee929a436208d2b2b6aab15745a70a57df"},
+ {file = "mypy-0.812-py3-none-any.whl", hash = "sha256:2f9b3407c58347a452fc0736861593e105139b905cca7d097e413453a1d650b4"},
+ {file = "mypy-0.812.tar.gz", hash = "sha256:cd07039aa5df222037005b08fbbfd69b3ab0b0bd7a07d7906de75ae52c4e3119"},
+]
+mypy-extensions = [
+ {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"},
+ {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"},
+]
+nodeenv = [
+ {file = "nodeenv-1.6.0-py2.py3-none-any.whl", hash = "sha256:621e6b7076565ddcacd2db0294c0381e01fd28945ab36bcf00f41c5daf63bef7"},
+ {file = "nodeenv-1.6.0.tar.gz", hash = "sha256:3ef13ff90291ba2a4a7a4ff9a979b63ffdd00a464dbe04acf0ea6471517a4c2b"},
+]
+packaging = [
+ {file = "packaging-20.9-py2.py3-none-any.whl", hash = "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"},
+ {file = "packaging-20.9.tar.gz", hash = "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5"},
+]
+parso = [
+ {file = "parso-0.8.2-py2.py3-none-any.whl", hash = "sha256:a8c4922db71e4fdb90e0d0bc6e50f9b273d3397925e5e60a717e719201778d22"},
+ {file = "parso-0.8.2.tar.gz", hash = "sha256:12b83492c6239ce32ff5eed6d3639d6a536170723c6f3f1506869f1ace413398"},
+]
+pathspec = [
+ {file = "pathspec-0.8.1-py2.py3-none-any.whl", hash = "sha256:aa0cb481c4041bf52ffa7b0d8fa6cd3e88a2ca4879c533c9153882ee2556790d"},
+ {file = "pathspec-0.8.1.tar.gz", hash = "sha256:86379d6b86d75816baba717e64b1a3a3469deb93bb76d613c9ce79edc5cb68fd"},
+]
+pexpect = [
+ {file = "pexpect-4.8.0-py2.py3-none-any.whl", hash = "sha256:0b48a55dcb3c05f3329815901ea4fc1537514d6ba867a152b581d69ae3710937"},
+ {file = "pexpect-4.8.0.tar.gz", hash = "sha256:fc65a43959d153d0114afe13997d439c22823a27cefceb5ff35c2178c6784c0c"},
+]
+pickleshare = [
+ {file = "pickleshare-0.7.5-py2.py3-none-any.whl", hash = "sha256:9649af414d74d4df115d5d718f82acb59c9d418196b7b4290ed47a12ce62df56"},
+ {file = "pickleshare-0.7.5.tar.gz", hash = "sha256:87683d47965c1da65cdacaf31c8441d12b8044cdec9aca500cd78fc2c683afca"},
+]
+pluggy = [
+ {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"},
+ {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"},
+]
+pockets = [
+ {file = "pockets-0.9.1-py2.py3-none-any.whl", hash = "sha256:68597934193c08a08eb2bf6a1d85593f627c22f9b065cc727a4f03f669d96d86"},
+ {file = "pockets-0.9.1.tar.gz", hash = "sha256:9320f1a3c6f7a9133fe3b571f283bcf3353cd70249025ae8d618e40e9f7e92b3"},
+]
+pre-commit = [
+ {file = "pre_commit-2.12.0-py2.py3-none-any.whl", hash = "sha256:029d53cb83c241fe7d66eeee1e24db426f42c858f15a38d20bcefd8d8e05c9da"},
+ {file = "pre_commit-2.12.0.tar.gz", hash = "sha256:46b6ffbab37986c47d0a35e40906ae029376deed89a0eb2e446fb6e67b220427"},
+]
+promise = [
+ {file = "promise-2.3.tar.gz", hash = "sha256:dfd18337c523ba4b6a58801c164c1904a9d4d1b1747c7d5dbf45b693a49d93d0"},
+]
+prompt-toolkit = [
+ {file = "prompt_toolkit-3.0.18-py3-none-any.whl", hash = "sha256:bf00f22079f5fadc949f42ae8ff7f05702826a97059ffcc6281036ad40ac6f04"},
+ {file = "prompt_toolkit-3.0.18.tar.gz", hash = "sha256:e1b4f11b9336a28fa11810bc623c357420f69dfdb6d2dac41ca2c21a55c033bc"},
+]
+psycopg2-binary = [
+ {file = "psycopg2-binary-2.8.6.tar.gz", hash = "sha256:11b9c0ebce097180129e422379b824ae21c8f2a6596b159c7659e2e5a00e1aa0"},
+ {file = "psycopg2_binary-2.8.6-cp27-cp27m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:d14b140a4439d816e3b1229a4a525df917d6ea22a0771a2a78332273fd9528a4"},
+ {file = "psycopg2_binary-2.8.6-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:1fabed9ea2acc4efe4671b92c669a213db744d2af8a9fc5d69a8e9bc14b7a9db"},
+ {file = "psycopg2_binary-2.8.6-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:f5ab93a2cb2d8338b1674be43b442a7f544a0971da062a5da774ed40587f18f5"},
+ {file = "psycopg2_binary-2.8.6-cp27-cp27m-win32.whl", hash = "sha256:b4afc542c0ac0db720cf516dd20c0846f71c248d2b3d21013aa0d4ef9c71ca25"},
+ {file = "psycopg2_binary-2.8.6-cp27-cp27m-win_amd64.whl", hash = "sha256:e74a55f6bad0e7d3968399deb50f61f4db1926acf4a6d83beaaa7df986f48b1c"},
+ {file = "psycopg2_binary-2.8.6-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:0deac2af1a587ae12836aa07970f5cb91964f05a7c6cdb69d8425ff4c15d4e2c"},
+ {file = "psycopg2_binary-2.8.6-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:ad20d2eb875aaa1ea6d0f2916949f5c08a19c74d05b16ce6ebf6d24f2c9f75d1"},
+ {file = "psycopg2_binary-2.8.6-cp34-cp34m-win32.whl", hash = "sha256:950bc22bb56ee6ff142a2cb9ee980b571dd0912b0334aa3fe0fe3788d860bea2"},
+ {file = "psycopg2_binary-2.8.6-cp34-cp34m-win_amd64.whl", hash = "sha256:b8a3715b3c4e604bcc94c90a825cd7f5635417453b253499664f784fc4da0152"},
+ {file = "psycopg2_binary-2.8.6-cp35-cp35m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:d1b4ab59e02d9008efe10ceabd0b31e79519da6fb67f7d8e8977118832d0f449"},
+ {file = "psycopg2_binary-2.8.6-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:ac0c682111fbf404525dfc0f18a8b5f11be52657d4f96e9fcb75daf4f3984859"},
+ {file = "psycopg2_binary-2.8.6-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:7d92a09b788cbb1aec325af5fcba9fed7203897bbd9269d5691bb1e3bce29550"},
+ {file = "psycopg2_binary-2.8.6-cp35-cp35m-win32.whl", hash = "sha256:aaa4213c862f0ef00022751161df35804127b78adf4a2755b9f991a507e425fd"},
+ {file = "psycopg2_binary-2.8.6-cp35-cp35m-win_amd64.whl", hash = "sha256:c2507d796fca339c8fb03216364cca68d87e037c1f774977c8fc377627d01c71"},
+ {file = "psycopg2_binary-2.8.6-cp36-cp36m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:ee69dad2c7155756ad114c02db06002f4cded41132cc51378e57aad79cc8e4f4"},
+ {file = "psycopg2_binary-2.8.6-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:e82aba2188b9ba309fd8e271702bd0d0fc9148ae3150532bbb474f4590039ffb"},
+ {file = "psycopg2_binary-2.8.6-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:d5227b229005a696cc67676e24c214740efd90b148de5733419ac9aaba3773da"},
+ {file = "psycopg2_binary-2.8.6-cp36-cp36m-win32.whl", hash = "sha256:a0eb43a07386c3f1f1ebb4dc7aafb13f67188eab896e7397aa1ee95a9c884eb2"},
+ {file = "psycopg2_binary-2.8.6-cp36-cp36m-win_amd64.whl", hash = "sha256:e1f57aa70d3f7cc6947fd88636a481638263ba04a742b4a37dd25c373e41491a"},
+ {file = "psycopg2_binary-2.8.6-cp37-cp37m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:833709a5c66ca52f1d21d41865a637223b368c0ee76ea54ca5bad6f2526c7679"},
+ {file = "psycopg2_binary-2.8.6-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:ba28584e6bca48c59eecbf7efb1576ca214b47f05194646b081717fa628dfddf"},
+ {file = "psycopg2_binary-2.8.6-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:6a32f3a4cb2f6e1a0b15215f448e8ce2da192fd4ff35084d80d5e39da683e79b"},
+ {file = "psycopg2_binary-2.8.6-cp37-cp37m-win32.whl", hash = "sha256:0e4dc3d5996760104746e6cfcdb519d9d2cd27c738296525d5867ea695774e67"},
+ {file = "psycopg2_binary-2.8.6-cp37-cp37m-win_amd64.whl", hash = "sha256:cec7e622ebc545dbb4564e483dd20e4e404da17ae07e06f3e780b2dacd5cee66"},
+ {file = "psycopg2_binary-2.8.6-cp38-cp38-macosx_10_9_x86_64.macosx_10_9_intel.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:ba381aec3a5dc29634f20692349d73f2d21f17653bda1decf0b52b11d694541f"},
+ {file = "psycopg2_binary-2.8.6-cp38-cp38-manylinux1_i686.whl", hash = "sha256:a0c50db33c32594305b0ef9abc0cb7db13de7621d2cadf8392a1d9b3c437ef77"},
+ {file = "psycopg2_binary-2.8.6-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:2dac98e85565d5688e8ab7bdea5446674a83a3945a8f416ad0110018d1501b94"},
+ {file = "psycopg2_binary-2.8.6-cp38-cp38-win32.whl", hash = "sha256:bd1be66dde2b82f80afb9459fc618216753f67109b859a361cf7def5c7968729"},
+ {file = "psycopg2_binary-2.8.6-cp38-cp38-win_amd64.whl", hash = "sha256:8cd0fb36c7412996859cb4606a35969dd01f4ea34d9812a141cd920c3b18be77"},
+ {file = "psycopg2_binary-2.8.6-cp39-cp39-macosx_10_9_x86_64.macosx_10_9_intel.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:89705f45ce07b2dfa806ee84439ec67c5d9a0ef20154e0e475e2b2ed392a5b83"},
+ {file = "psycopg2_binary-2.8.6-cp39-cp39-manylinux1_i686.whl", hash = "sha256:42ec1035841b389e8cc3692277a0bd81cdfe0b65d575a2c8862cec7a80e62e52"},
+ {file = "psycopg2_binary-2.8.6-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:7312e931b90fe14f925729cde58022f5d034241918a5c4f9797cac62f6b3a9dd"},
+ {file = "psycopg2_binary-2.8.6-cp39-cp39-win32.whl", hash = "sha256:6422f2ff0919fd720195f64ffd8f924c1395d30f9a495f31e2392c2efafb5056"},
+ {file = "psycopg2_binary-2.8.6-cp39-cp39-win_amd64.whl", hash = "sha256:15978a1fbd225583dd8cdaf37e67ccc278b5abecb4caf6b2d6b8e2b948e953f6"},
+]
+ptyprocess = [
+ {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"},
+ {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"},
+]
+py = [
+ {file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"},
+ {file = "py-1.10.0.tar.gz", hash = "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3"},
+]
+pycodestyle = [
+ {file = "pycodestyle-2.7.0-py2.py3-none-any.whl", hash = "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068"},
+ {file = "pycodestyle-2.7.0.tar.gz", hash = "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef"},
+]
+pydantic = [
+ {file = "pydantic-1.6-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:a12e2fc2f5529b6aeb956f30a2b5efe46283b69e965888cd683cd1a04d75a1b8"},
+ {file = "pydantic-1.6-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:61e3cde8e7b8517615da5341e0b28cc01de507e7ac9174c3c0e95069482dcbeb"},
+ {file = "pydantic-1.6-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:4aa11a8a65fa891489d9e4e8f05299d80b000c554ce18b03bb1b5f0afe5b73f4"},
+ {file = "pydantic-1.6-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:aedd4df265600889907d2c74dc0432709b0ac91712f85f3ffa605b40a00f2577"},
+ {file = "pydantic-1.6-cp36-cp36m-win_amd64.whl", hash = "sha256:1c97f90056c2811d58a6343f6352820c531e822941303a16ccebb77bbdcd4a98"},
+ {file = "pydantic-1.6-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:d88e55dc77241e2b78139dac33ad5edc3fd78b0c6e635824e4eeba6679371707"},
+ {file = "pydantic-1.6-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:dd78ccb5cfe26ae6bc3d2a1b47b18a5267f88be43cb768aa6bea470c4ac17099"},
+ {file = "pydantic-1.6-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:82242b0458b0a5bad0c15c36d461b2bd99eec2d807c0c5263f802ba30cb856f0"},
+ {file = "pydantic-1.6-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:df5c511b8af11834b3061b33211e0aefb4fb8e2f94b939aa51d844cae22bad5c"},
+ {file = "pydantic-1.6-cp37-cp37m-win_amd64.whl", hash = "sha256:b1c229e595b53d1397435fb7433c841c5bc4032ba81f901156124b9d077b5f6a"},
+ {file = "pydantic-1.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6ea91ec880de48699c4a01aa92ac8c3a71363bb6833bf031cb6aa2b99567d5ab"},
+ {file = "pydantic-1.6-cp38-cp38-manylinux1_i686.whl", hash = "sha256:2b49f9ecefcdee47f6b70fd475160eeda01c28507137d9c1ed41a758d231c060"},
+ {file = "pydantic-1.6-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:8154df22783601d9693712d1cee284c596cdb5d6817f57c1624bcb54e4611eba"},
+ {file = "pydantic-1.6-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:de093dcf6a8c6a2f92f8ac28b713b9f4ad80f1e664bdc9232ad06ac912c559fc"},
+ {file = "pydantic-1.6-cp38-cp38-win_amd64.whl", hash = "sha256:1ab625f56534edd1ecec5871e0777c9eee1c7b38f1963d97c4a78b09daf89526"},
+ {file = "pydantic-1.6-py36.py37.py38-none-any.whl", hash = "sha256:390844ede21e29e762c0017e46b4105edee9dcdc0119ce1aa8ab6fe58448bd2f"},
+ {file = "pydantic-1.6.tar.gz", hash = "sha256:1998e5f783c37853c6dfc1e6ba3a0cc486798b26920822b569ea883b38fd39eb"},
+]
+pyflakes = [
+ {file = "pyflakes-2.3.1-py2.py3-none-any.whl", hash = "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3"},
+ {file = "pyflakes-2.3.1.tar.gz", hash = "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db"},
+]
+pygments = [
+ {file = "Pygments-2.8.1-py3-none-any.whl", hash = "sha256:534ef71d539ae97d4c3a4cf7d6f110f214b0e687e92f9cb9d2a3b0d3101289c8"},
+ {file = "Pygments-2.8.1.tar.gz", hash = "sha256:2656e1a6edcdabf4275f9a3640db59fd5de107d88e8663c5d4e9a0fa62f77f94"},
+]
+pylint = [
+ {file = "pylint-2.7.4-py3-none-any.whl", hash = "sha256:209d712ec870a0182df034ae19f347e725c1e615b2269519ab58a35b3fcbbe7a"},
+ {file = "pylint-2.7.4.tar.gz", hash = "sha256:bd38914c7731cdc518634a8d3c5585951302b6e2b6de60fbb3f7a0220e21eeee"},
+]
+pylint-celery = [
+ {file = "pylint-celery-0.3.tar.gz", hash = "sha256:41e32094e7408d15c044178ea828dd524beedbdbe6f83f712c5e35bde1de4beb"},
+]
+pylint-django = [
+ {file = "pylint-django-2.4.3.tar.gz", hash = "sha256:a5a4515209a6237d1d390a4a307d53f53baaf4f058ecf4bb556c775d208f6b0d"},
+ {file = "pylint_django-2.4.3-py3-none-any.whl", hash = "sha256:dc5ed27bb7662d73444ccd15a0b3964ed6ced6cc2712b85db616102062d2ec35"},
+]
+pylint-plugin-utils = [
+ {file = "pylint-plugin-utils-0.6.tar.gz", hash = "sha256:57625dcca20140f43731311cd8fd879318bf45a8b0fd17020717a8781714a25a"},
+ {file = "pylint_plugin_utils-0.6-py3-none-any.whl", hash = "sha256:2f30510e1c46edf268d3a195b2849bd98a1b9433229bb2ba63b8d776e1fc4d0a"},
+]
+pyparsing = [
+ {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"},
+ {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"},
+]
+pytest = [
+ {file = "pytest-6.2.3-py3-none-any.whl", hash = "sha256:6ad9c7bdf517a808242b998ac20063c41532a570d088d77eec1ee12b0b5574bc"},
+ {file = "pytest-6.2.3.tar.gz", hash = "sha256:671238a46e4df0f3498d1c3270e5deb9b32d25134c99b7d75370a68cfbe9b634"},
+]
+pytest-django = [
+ {file = "pytest-django-4.2.0.tar.gz", hash = "sha256:80f8875226ec4dc0b205f0578072034563879d98d9b1bec143a80b9045716cb0"},
+ {file = "pytest_django-4.2.0-py3-none-any.whl", hash = "sha256:a51150d8962200250e850c6adcab670779b9c2aa07271471059d1fb92a843fa9"},
+]
+pytest-sugar = [
+ {file = "pytest-sugar-0.9.4.tar.gz", hash = "sha256:b1b2186b0a72aada6859bea2a5764145e3aaa2c1cfbb23c3a19b5f7b697563d3"},
+]
+python-dateutil = [
+ {file = "python-dateutil-2.8.1.tar.gz", hash = "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c"},
+ {file = "python_dateutil-2.8.1-py2.py3-none-any.whl", hash = "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"},
+]
+pytz = [
+ {file = "pytz-2021.1-py2.py3-none-any.whl", hash = "sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798"},
+ {file = "pytz-2021.1.tar.gz", hash = "sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da"},
+]
+pyyaml = [
+ {file = "PyYAML-5.4.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922"},
+ {file = "PyYAML-5.4.1-cp27-cp27m-win32.whl", hash = "sha256:129def1b7c1bf22faffd67b8f3724645203b79d8f4cc81f674654d9902cb4393"},
+ {file = "PyYAML-5.4.1-cp27-cp27m-win_amd64.whl", hash = "sha256:4465124ef1b18d9ace298060f4eccc64b0850899ac4ac53294547536533800c8"},
+ {file = "PyYAML-5.4.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:bb4191dfc9306777bc594117aee052446b3fa88737cd13b7188d0e7aa8162185"},
+ {file = "PyYAML-5.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:6c78645d400265a062508ae399b60b8c167bf003db364ecb26dcab2bda048253"},
+ {file = "PyYAML-5.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:4e0583d24c881e14342eaf4ec5fbc97f934b999a6828693a99157fde912540cc"},
+ {file = "PyYAML-5.4.1-cp36-cp36m-win32.whl", hash = "sha256:3bd0e463264cf257d1ffd2e40223b197271046d09dadf73a0fe82b9c1fc385a5"},
+ {file = "PyYAML-5.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df"},
+ {file = "PyYAML-5.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018"},
+ {file = "PyYAML-5.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:e1d4970ea66be07ae37a3c2e48b5ec63f7ba6804bdddfdbd3cfd954d25a82e63"},
+ {file = "PyYAML-5.4.1-cp37-cp37m-win32.whl", hash = "sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b"},
+ {file = "PyYAML-5.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf"},
+ {file = "PyYAML-5.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46"},
+ {file = "PyYAML-5.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb"},
+ {file = "PyYAML-5.4.1-cp38-cp38-win32.whl", hash = "sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc"},
+ {file = "PyYAML-5.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:0f5f5786c0e09baddcd8b4b45f20a7b5d61a7e7e99846e3c799b05c7c53fa696"},
+ {file = "PyYAML-5.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:294db365efa064d00b8d1ef65d8ea2c3426ac366c0c4368d930bf1c5fb497f77"},
+ {file = "PyYAML-5.4.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183"},
+ {file = "PyYAML-5.4.1-cp39-cp39-win32.whl", hash = "sha256:49d4cdd9065b9b6e206d0595fee27a96b5dd22618e7520c33204a4a3239d5b10"},
+ {file = "PyYAML-5.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db"},
+ {file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"},
+]
+regex = [
+ {file = "regex-2021.4.4-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:619d71c59a78b84d7f18891fe914446d07edd48dc8328c8e149cbe0929b4e000"},
+ {file = "regex-2021.4.4-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:47bf5bf60cf04d72bf6055ae5927a0bd9016096bf3d742fa50d9bf9f45aa0711"},
+ {file = "regex-2021.4.4-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:281d2fd05555079448537fe108d79eb031b403dac622621c78944c235f3fcf11"},
+ {file = "regex-2021.4.4-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:bd28bc2e3a772acbb07787c6308e00d9626ff89e3bfcdebe87fa5afbfdedf968"},
+ {file = "regex-2021.4.4-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:7c2a1af393fcc09e898beba5dd59196edaa3116191cc7257f9224beaed3e1aa0"},
+ {file = "regex-2021.4.4-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:c38c71df845e2aabb7fb0b920d11a1b5ac8526005e533a8920aea97efb8ec6a4"},
+ {file = "regex-2021.4.4-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:96fcd1888ab4d03adfc9303a7b3c0bd78c5412b2bfbe76db5b56d9eae004907a"},
+ {file = "regex-2021.4.4-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:ade17eb5d643b7fead300a1641e9f45401c98eee23763e9ed66a43f92f20b4a7"},
+ {file = "regex-2021.4.4-cp36-cp36m-win32.whl", hash = "sha256:e8e5b509d5c2ff12f8418006d5a90e9436766133b564db0abaec92fd27fcee29"},
+ {file = "regex-2021.4.4-cp36-cp36m-win_amd64.whl", hash = "sha256:11d773d75fa650cd36f68d7ca936e3c7afaae41b863b8c387a22aaa78d3c5c79"},
+ {file = "regex-2021.4.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:d3029c340cfbb3ac0a71798100ccc13b97dddf373a4ae56b6a72cf70dfd53bc8"},
+ {file = "regex-2021.4.4-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:18c071c3eb09c30a264879f0d310d37fe5d3a3111662438889ae2eb6fc570c31"},
+ {file = "regex-2021.4.4-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:4c557a7b470908b1712fe27fb1ef20772b78079808c87d20a90d051660b1d69a"},
+ {file = "regex-2021.4.4-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:01afaf2ec48e196ba91b37451aa353cb7eda77efe518e481707e0515025f0cd5"},
+ {file = "regex-2021.4.4-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:3a9cd17e6e5c7eb328517969e0cb0c3d31fd329298dd0c04af99ebf42e904f82"},
+ {file = "regex-2021.4.4-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:90f11ff637fe8798933fb29f5ae1148c978cccb0452005bf4c69e13db951e765"},
+ {file = "regex-2021.4.4-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:919859aa909429fb5aa9cf8807f6045592c85ef56fdd30a9a3747e513db2536e"},
+ {file = "regex-2021.4.4-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:339456e7d8c06dd36a22e451d58ef72cef293112b559010db3d054d5560ef439"},
+ {file = "regex-2021.4.4-cp37-cp37m-win32.whl", hash = "sha256:67bdb9702427ceddc6ef3dc382455e90f785af4c13d495f9626861763ee13f9d"},
+ {file = "regex-2021.4.4-cp37-cp37m-win_amd64.whl", hash = "sha256:32e65442138b7b76dd8173ffa2cf67356b7bc1768851dded39a7a13bf9223da3"},
+ {file = "regex-2021.4.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1e1c20e29358165242928c2de1482fb2cf4ea54a6a6dea2bd7a0e0d8ee321500"},
+ {file = "regex-2021.4.4-cp38-cp38-manylinux1_i686.whl", hash = "sha256:314d66636c494ed9c148a42731b3834496cc9a2c4251b1661e40936814542b14"},
+ {file = "regex-2021.4.4-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:6d1b01031dedf2503631d0903cb563743f397ccaf6607a5e3b19a3d76fc10480"},
+ {file = "regex-2021.4.4-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:741a9647fcf2e45f3a1cf0e24f5e17febf3efe8d4ba1281dcc3aa0459ef424dc"},
+ {file = "regex-2021.4.4-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:4c46e22a0933dd783467cf32b3516299fb98cfebd895817d685130cc50cd1093"},
+ {file = "regex-2021.4.4-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:e512d8ef5ad7b898cdb2d8ee1cb09a8339e4f8be706d27eaa180c2f177248a10"},
+ {file = "regex-2021.4.4-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:980d7be47c84979d9136328d882f67ec5e50008681d94ecc8afa8a65ed1f4a6f"},
+ {file = "regex-2021.4.4-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:ce15b6d103daff8e9fee13cf7f0add05245a05d866e73926c358e871221eae87"},
+ {file = "regex-2021.4.4-cp38-cp38-win32.whl", hash = "sha256:a91aa8619b23b79bcbeb37abe286f2f408d2f2d6f29a17237afda55bb54e7aac"},
+ {file = "regex-2021.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:c0502c0fadef0d23b128605d69b58edb2c681c25d44574fc673b0e52dce71ee2"},
+ {file = "regex-2021.4.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:598585c9f0af8374c28edd609eb291b5726d7cbce16be6a8b95aa074d252ee17"},
+ {file = "regex-2021.4.4-cp39-cp39-manylinux1_i686.whl", hash = "sha256:ee54ff27bf0afaf4c3b3a62bcd016c12c3fdb4ec4f413391a90bd38bc3624605"},
+ {file = "regex-2021.4.4-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:7d9884d86dd4dd489e981d94a65cd30d6f07203d90e98f6f657f05170f6324c9"},
+ {file = "regex-2021.4.4-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:bf5824bfac591ddb2c1f0a5f4ab72da28994548c708d2191e3b87dd207eb3ad7"},
+ {file = "regex-2021.4.4-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:563085e55b0d4fb8f746f6a335893bda5c2cef43b2f0258fe1020ab1dd874df8"},
+ {file = "regex-2021.4.4-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:b9c3db21af35e3b3c05764461b262d6f05bbca08a71a7849fd79d47ba7bc33ed"},
+ {file = "regex-2021.4.4-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:3916d08be28a1149fb97f7728fca1f7c15d309a9f9682d89d79db75d5e52091c"},
+ {file = "regex-2021.4.4-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:fd45ff9293d9274c5008a2054ecef86a9bfe819a67c7be1afb65e69b405b3042"},
+ {file = "regex-2021.4.4-cp39-cp39-win32.whl", hash = "sha256:fa4537fb4a98fe8fde99626e4681cc644bdcf2a795038533f9f711513a862ae6"},
+ {file = "regex-2021.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:97f29f57d5b84e73fbaf99ab3e26134e6687348e95ef6b48cfd2c06807005a07"},
+ {file = "regex-2021.4.4.tar.gz", hash = "sha256:52ba3d3f9b942c49d7e4bc105bb28551c44065f139a65062ab7912bef10c9afb"},
+]
+requests = [
+ {file = "requests-2.25.1-py2.py3-none-any.whl", hash = "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e"},
+ {file = "requests-2.25.1.tar.gz", hash = "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804"},
+]
+rx = [
+ {file = "Rx-1.6.1-py2.py3-none-any.whl", hash = "sha256:7357592bc7e881a95e0c2013b73326f704953301ab551fbc8133a6fadab84105"},
+ {file = "Rx-1.6.1.tar.gz", hash = "sha256:13a1d8d9e252625c173dc795471e614eadfe1cf40ffc684e08b8fff0d9748c23"},
+]
+singledispatch = [
+ {file = "singledispatch-3.6.1-py2.py3-none-any.whl", hash = "sha256:85c97f94c8957fa4e6dab113156c182fb346d56d059af78aad710bced15f16fb"},
+ {file = "singledispatch-3.6.1.tar.gz", hash = "sha256:58b46ce1cc4d43af0aac3ac9a047bdb0f44e05f0b2fa2eec755863331700c865"},
+]
+six = [
+ {file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"},
+ {file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"},
+]
+snowballstemmer = [
+ {file = "snowballstemmer-2.1.0-py2.py3-none-any.whl", hash = "sha256:b51b447bea85f9968c13b650126a888aabd4cb4463fca868ec596826325dedc2"},
+ {file = "snowballstemmer-2.1.0.tar.gz", hash = "sha256:e997baa4f2e9139951b6f4c631bad912dfd3c792467e2f03d7239464af90e914"},
+]
+sphinx = [
+ {file = "Sphinx-3.5.3-py3-none-any.whl", hash = "sha256:3f01732296465648da43dec8fb40dc451ba79eb3e2cc5c6d79005fd98197107d"},
+ {file = "Sphinx-3.5.3.tar.gz", hash = "sha256:ce9c228456131bab09a3d7d10ae58474de562a6f79abb3dc811ae401cf8c1abc"},
+]
+sphinx-autobuild = [
+ {file = "sphinx-autobuild-2021.3.14.tar.gz", hash = "sha256:de1ca3b66e271d2b5b5140c35034c89e47f263f2cd5db302c9217065f7443f05"},
+ {file = "sphinx_autobuild-2021.3.14-py3-none-any.whl", hash = "sha256:8fe8cbfdb75db04475232f05187c776f46f6e9e04cacf1e49ce81bdac649ccac"},
+]
+sphinxcontrib-applehelp = [
+ {file = "sphinxcontrib-applehelp-1.0.2.tar.gz", hash = "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58"},
+ {file = "sphinxcontrib_applehelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:806111e5e962be97c29ec4c1e7fe277bfd19e9652fb1a4392105b43e01af885a"},
+]
+sphinxcontrib-devhelp = [
+ {file = "sphinxcontrib-devhelp-1.0.2.tar.gz", hash = "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4"},
+ {file = "sphinxcontrib_devhelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e"},
+]
+sphinxcontrib-htmlhelp = [
+ {file = "sphinxcontrib-htmlhelp-1.0.3.tar.gz", hash = "sha256:e8f5bb7e31b2dbb25b9cc435c8ab7a79787ebf7f906155729338f3156d93659b"},
+ {file = "sphinxcontrib_htmlhelp-1.0.3-py2.py3-none-any.whl", hash = "sha256:3c0bc24a2c41e340ac37c85ced6dafc879ab485c095b1d65d2461ac2f7cca86f"},
+]
+sphinxcontrib-jsmath = [
+ {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"},
+ {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"},
+]
+sphinxcontrib-napoleon = [
+ {file = "sphinxcontrib-napoleon-0.7.tar.gz", hash = "sha256:407382beed396e9f2d7f3043fad6afda95719204a1e1a231ac865f40abcbfcf8"},
+ {file = "sphinxcontrib_napoleon-0.7-py2.py3-none-any.whl", hash = "sha256:711e41a3974bdf110a484aec4c1a556799eb0b3f3b897521a018ad7e2db13fef"},
+]
+sphinxcontrib-qthelp = [
+ {file = "sphinxcontrib-qthelp-1.0.3.tar.gz", hash = "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72"},
+ {file = "sphinxcontrib_qthelp-1.0.3-py2.py3-none-any.whl", hash = "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6"},
+]
+sphinxcontrib-serializinghtml = [
+ {file = "sphinxcontrib-serializinghtml-1.1.4.tar.gz", hash = "sha256:eaa0eccc86e982a9b939b2b82d12cc5d013385ba5eadcc7e4fed23f4405f77bc"},
+ {file = "sphinxcontrib_serializinghtml-1.1.4-py2.py3-none-any.whl", hash = "sha256:f242a81d423f59617a8e5cf16f5d4d74e28ee9a66f9e5b637a18082991db5a9a"},
+]
+sqlparse = [
+ {file = "sqlparse-0.4.1-py3-none-any.whl", hash = "sha256:017cde379adbd6a1f15a61873f43e8274179378e95ef3fede90b5aa64d304ed0"},
+ {file = "sqlparse-0.4.1.tar.gz", hash = "sha256:0f91fd2e829c44362cbcfab3e9ae12e22badaa8a29ad5ff599f9ec109f0454e8"},
+]
+termcolor = [
+ {file = "termcolor-1.1.0.tar.gz", hash = "sha256:1d6d69ce66211143803fbc56652b41d73b4a400a2891d7bf7a1cdf4c02de613b"},
+]
+testfixtures = [
+ {file = "testfixtures-6.17.1-py2.py3-none-any.whl", hash = "sha256:9ed31e83f59619e2fa17df053b241e16e0608f4580f7b5a9333a0c9bdcc99137"},
+ {file = "testfixtures-6.17.1.tar.gz", hash = "sha256:5ec3a0dd6f71cc4c304fbc024a10cc293d3e0b852c868014b9f233203e149bda"},
+]
+text-unidecode = [
+ {file = "text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93"},
+ {file = "text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8"},
+]
+toml = [
+ {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"},
+ {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
+]
+tornado = [
+ {file = "tornado-6.1-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:d371e811d6b156d82aa5f9a4e08b58debf97c302a35714f6f45e35139c332e32"},
+ {file = "tornado-6.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:0d321a39c36e5f2c4ff12b4ed58d41390460f798422c4504e09eb5678e09998c"},
+ {file = "tornado-6.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:9de9e5188a782be6b1ce866e8a51bc76a0fbaa0e16613823fc38e4fc2556ad05"},
+ {file = "tornado-6.1-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:61b32d06ae8a036a6607805e6720ef00a3c98207038444ba7fd3d169cd998910"},
+ {file = "tornado-6.1-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:3e63498f680547ed24d2c71e6497f24bca791aca2fe116dbc2bd0ac7f191691b"},
+ {file = "tornado-6.1-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:6c77c9937962577a6a76917845d06af6ab9197702a42e1346d8ae2e76b5e3675"},
+ {file = "tornado-6.1-cp35-cp35m-win32.whl", hash = "sha256:6286efab1ed6e74b7028327365cf7346b1d777d63ab30e21a0f4d5b275fc17d5"},
+ {file = "tornado-6.1-cp35-cp35m-win_amd64.whl", hash = "sha256:fa2ba70284fa42c2a5ecb35e322e68823288a4251f9ba9cc77be04ae15eada68"},
+ {file = "tornado-6.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:0a00ff4561e2929a2c37ce706cb8233b7907e0cdc22eab98888aca5dd3775feb"},
+ {file = "tornado-6.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:748290bf9112b581c525e6e6d3820621ff020ed95af6f17fedef416b27ed564c"},
+ {file = "tornado-6.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:e385b637ac3acaae8022e7e47dfa7b83d3620e432e3ecb9a3f7f58f150e50921"},
+ {file = "tornado-6.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:25ad220258349a12ae87ede08a7b04aca51237721f63b1808d39bdb4b2164558"},
+ {file = "tornado-6.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:65d98939f1a2e74b58839f8c4dab3b6b3c1ce84972ae712be02845e65391ac7c"},
+ {file = "tornado-6.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:e519d64089b0876c7b467274468709dadf11e41d65f63bba207e04217f47c085"},
+ {file = "tornado-6.1-cp36-cp36m-win32.whl", hash = "sha256:b87936fd2c317b6ee08a5741ea06b9d11a6074ef4cc42e031bc6403f82a32575"},
+ {file = "tornado-6.1-cp36-cp36m-win_amd64.whl", hash = "sha256:cc0ee35043162abbf717b7df924597ade8e5395e7b66d18270116f8745ceb795"},
+ {file = "tornado-6.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:7250a3fa399f08ec9cb3f7b1b987955d17e044f1ade821b32e5f435130250d7f"},
+ {file = "tornado-6.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:ed3ad863b1b40cd1d4bd21e7498329ccaece75db5a5bf58cd3c9f130843e7102"},
+ {file = "tornado-6.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:dcef026f608f678c118779cd6591c8af6e9b4155c44e0d1bc0c87c036fb8c8c4"},
+ {file = "tornado-6.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:70dec29e8ac485dbf57481baee40781c63e381bebea080991893cd297742b8fd"},
+ {file = "tornado-6.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:d3f7594930c423fd9f5d1a76bee85a2c36fd8b4b16921cae7e965f22575e9c01"},
+ {file = "tornado-6.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:3447475585bae2e77ecb832fc0300c3695516a47d46cefa0528181a34c5b9d3d"},
+ {file = "tornado-6.1-cp37-cp37m-win32.whl", hash = "sha256:e7229e60ac41a1202444497ddde70a48d33909e484f96eb0da9baf8dc68541df"},
+ {file = "tornado-6.1-cp37-cp37m-win_amd64.whl", hash = "sha256:cb5ec8eead331e3bb4ce8066cf06d2dfef1bfb1b2a73082dfe8a161301b76e37"},
+ {file = "tornado-6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:20241b3cb4f425e971cb0a8e4ffc9b0a861530ae3c52f2b0434e6c1b57e9fd95"},
+ {file = "tornado-6.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:c77da1263aa361938476f04c4b6c8916001b90b2c2fdd92d8d535e1af48fba5a"},
+ {file = "tornado-6.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:fba85b6cd9c39be262fcd23865652920832b61583de2a2ca907dbd8e8a8c81e5"},
+ {file = "tornado-6.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:1e8225a1070cd8eec59a996c43229fe8f95689cb16e552d130b9793cb570a288"},
+ {file = "tornado-6.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:d14d30e7f46a0476efb0deb5b61343b1526f73ebb5ed84f23dc794bdb88f9d9f"},
+ {file = "tornado-6.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:8f959b26f2634a091bb42241c3ed8d3cedb506e7c27b8dd5c7b9f745318ddbb6"},
+ {file = "tornado-6.1-cp38-cp38-win32.whl", hash = "sha256:34ca2dac9e4d7afb0bed4677512e36a52f09caa6fded70b4e3e1c89dbd92c326"},
+ {file = "tornado-6.1-cp38-cp38-win_amd64.whl", hash = "sha256:6196a5c39286cc37c024cd78834fb9345e464525d8991c21e908cc046d1cc02c"},
+ {file = "tornado-6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f0ba29bafd8e7e22920567ce0d232c26d4d47c8b5cf4ed7b562b5db39fa199c5"},
+ {file = "tornado-6.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:33892118b165401f291070100d6d09359ca74addda679b60390b09f8ef325ffe"},
+ {file = "tornado-6.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:7da13da6f985aab7f6f28debab00c67ff9cbacd588e8477034c0652ac141feea"},
+ {file = "tornado-6.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:e0791ac58d91ac58f694d8d2957884df8e4e2f6687cdf367ef7eb7497f79eaa2"},
+ {file = "tornado-6.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:66324e4e1beede9ac79e60f88de548da58b1f8ab4b2f1354d8375774f997e6c0"},
+ {file = "tornado-6.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:a48900ecea1cbb71b8c71c620dee15b62f85f7c14189bdeee54966fbd9a0c5bd"},
+ {file = "tornado-6.1-cp39-cp39-win32.whl", hash = "sha256:d3d20ea5782ba63ed13bc2b8c291a053c8d807a8fa927d941bd718468f7b950c"},
+ {file = "tornado-6.1-cp39-cp39-win_amd64.whl", hash = "sha256:548430be2740e327b3fe0201abe471f314741efcb0067ec4f2d7dcfb4825f3e4"},
+ {file = "tornado-6.1.tar.gz", hash = "sha256:33c6e81d7bd55b468d2e793517c909b139960b6c790a60b7991b9b6b76fb9791"},
+]
+traitlets = [
+ {file = "traitlets-5.0.5-py3-none-any.whl", hash = "sha256:69ff3f9d5351f31a7ad80443c2674b7099df13cc41fc5fa6e2f6d3b0330b0426"},
+ {file = "traitlets-5.0.5.tar.gz", hash = "sha256:178f4ce988f69189f7e523337a3e11d91c786ded9360174a3d9ca83e79bc5396"},
+]
+typed-ast = [
+ {file = "typed_ast-1.4.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:2068531575a125b87a41802130fa7e29f26c09a2833fea68d9a40cf33902eba6"},
+ {file = "typed_ast-1.4.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:c907f561b1e83e93fad565bac5ba9c22d96a54e7ea0267c708bffe863cbe4075"},
+ {file = "typed_ast-1.4.3-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:1b3ead4a96c9101bef08f9f7d1217c096f31667617b58de957f690c92378b528"},
+ {file = "typed_ast-1.4.3-cp35-cp35m-win32.whl", hash = "sha256:dde816ca9dac1d9c01dd504ea5967821606f02e510438120091b84e852367428"},
+ {file = "typed_ast-1.4.3-cp35-cp35m-win_amd64.whl", hash = "sha256:777a26c84bea6cd934422ac2e3b78863a37017618b6e5c08f92ef69853e765d3"},
+ {file = "typed_ast-1.4.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f8afcf15cc511ada719a88e013cec87c11aff7b91f019295eb4530f96fe5ef2f"},
+ {file = "typed_ast-1.4.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:52b1eb8c83f178ab787f3a4283f68258525f8d70f778a2f6dd54d3b5e5fb4341"},
+ {file = "typed_ast-1.4.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:01ae5f73431d21eead5015997ab41afa53aa1fbe252f9da060be5dad2c730ace"},
+ {file = "typed_ast-1.4.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:c190f0899e9f9f8b6b7863debfb739abcb21a5c054f911ca3596d12b8a4c4c7f"},
+ {file = "typed_ast-1.4.3-cp36-cp36m-win32.whl", hash = "sha256:398e44cd480f4d2b7ee8d98385ca104e35c81525dd98c519acff1b79bdaac363"},
+ {file = "typed_ast-1.4.3-cp36-cp36m-win_amd64.whl", hash = "sha256:bff6ad71c81b3bba8fa35f0f1921fb24ff4476235a6e94a26ada2e54370e6da7"},
+ {file = "typed_ast-1.4.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0fb71b8c643187d7492c1f8352f2c15b4c4af3f6338f21681d3681b3dc31a266"},
+ {file = "typed_ast-1.4.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:760ad187b1041a154f0e4d0f6aae3e40fdb51d6de16e5c99aedadd9246450e9e"},
+ {file = "typed_ast-1.4.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:5feca99c17af94057417d744607b82dd0a664fd5e4ca98061480fd8b14b18d04"},
+ {file = "typed_ast-1.4.3-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:95431a26309a21874005845c21118c83991c63ea800dd44843e42a916aec5899"},
+ {file = "typed_ast-1.4.3-cp37-cp37m-win32.whl", hash = "sha256:aee0c1256be6c07bd3e1263ff920c325b59849dc95392a05f258bb9b259cf39c"},
+ {file = "typed_ast-1.4.3-cp37-cp37m-win_amd64.whl", hash = "sha256:9ad2c92ec681e02baf81fdfa056fe0d818645efa9af1f1cd5fd6f1bd2bdfd805"},
+ {file = "typed_ast-1.4.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b36b4f3920103a25e1d5d024d155c504080959582b928e91cb608a65c3a49e1a"},
+ {file = "typed_ast-1.4.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:067a74454df670dcaa4e59349a2e5c81e567d8d65458d480a5b3dfecec08c5ff"},
+ {file = "typed_ast-1.4.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7538e495704e2ccda9b234b82423a4038f324f3a10c43bc088a1636180f11a41"},
+ {file = "typed_ast-1.4.3-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:af3d4a73793725138d6b334d9d247ce7e5f084d96284ed23f22ee626a7b88e39"},
+ {file = "typed_ast-1.4.3-cp38-cp38-win32.whl", hash = "sha256:f2362f3cb0f3172c42938946dbc5b7843c2a28aec307c49100c8b38764eb6927"},
+ {file = "typed_ast-1.4.3-cp38-cp38-win_amd64.whl", hash = "sha256:dd4a21253f42b8d2b48410cb31fe501d32f8b9fbeb1f55063ad102fe9c425e40"},
+ {file = "typed_ast-1.4.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f328adcfebed9f11301eaedfa48e15bdece9b519fb27e6a8c01aa52a17ec31b3"},
+ {file = "typed_ast-1.4.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:2c726c276d09fc5c414693a2de063f521052d9ea7c240ce553316f70656c84d4"},
+ {file = "typed_ast-1.4.3-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:cae53c389825d3b46fb37538441f75d6aecc4174f615d048321b716df2757fb0"},
+ {file = "typed_ast-1.4.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:b9574c6f03f685070d859e75c7f9eeca02d6933273b5e69572e5ff9d5e3931c3"},
+ {file = "typed_ast-1.4.3-cp39-cp39-win32.whl", hash = "sha256:209596a4ec71d990d71d5e0d312ac935d86930e6eecff6ccc7007fe54d703808"},
+ {file = "typed_ast-1.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:9c6d1a54552b5330bc657b7ef0eae25d00ba7ffe85d9ea8ae6540d2197a3788c"},
+ {file = "typed_ast-1.4.3.tar.gz", hash = "sha256:fb1bbeac803adea29cedd70781399c99138358c26d05fcbd23c13016b7f5ec65"},
+]
+typing-extensions = [
+ {file = "typing_extensions-3.7.4.3-py2-none-any.whl", hash = "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"},
+ {file = "typing_extensions-3.7.4.3-py3-none-any.whl", hash = "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918"},
+ {file = "typing_extensions-3.7.4.3.tar.gz", hash = "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c"},
+]
+urllib3 = [
+ {file = "urllib3-1.26.4-py2.py3-none-any.whl", hash = "sha256:2f4da4594db7e1e110a944bb1b551fdf4e6c136ad42e4234131391e21eb5b0df"},
+ {file = "urllib3-1.26.4.tar.gz", hash = "sha256:e7b021f7241115872f92f43c6508082facffbd1c048e3c6e2bb9c2a157e28937"},
+]
+virtualenv = [
+ {file = "virtualenv-20.4.3-py2.py3-none-any.whl", hash = "sha256:83f95875d382c7abafe06bd2a4cdd1b363e1bb77e02f155ebe8ac082a916b37c"},
+ {file = "virtualenv-20.4.3.tar.gz", hash = "sha256:49ec4eb4c224c6f7dd81bb6d0a28a09ecae5894f4e593c89b0db0885f565a107"},
+]
+watchgod = [
+ {file = "watchgod-0.6-py35.py36.py37-none-any.whl", hash = "sha256:59700dab7445aa8e6067a5b94f37bae90fc367554549b1ed2e9d0f4f38a90d2a"},
+ {file = "watchgod-0.6.tar.gz", hash = "sha256:e9cca0ab9c63f17fc85df9fd8bd18156ff00aff04ebe5976cee473f4968c6858"},
+]
+wcwidth = [
+ {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"},
+ {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"},
+]
+werkzeug = [
+ {file = "Werkzeug-1.0.1-py2.py3-none-any.whl", hash = "sha256:2de2a5db0baeae7b2d2664949077c2ac63fbd16d98da0ff71837f7d1dea3fd43"},
+ {file = "Werkzeug-1.0.1.tar.gz", hash = "sha256:6c80b1e5ad3665290ea39320b91e1be1e0d5f60652b964a3070216de83d2e47c"},
+]
+wrapt = [
+ {file = "wrapt-1.12.1.tar.gz", hash = "sha256:b62ffa81fb85f4332a4f609cab4ac40709470da05643a082ec1eb88e6d9b97d7"},
+]
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..b5f5d9e
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,44 @@
+[tool.poetry]
+name = "turtle-shell"
+version = "0.0.1"
+description = "Easily wrap up your shell and utlity scripts into nice Django UIs/graphQL APIs"
+authors = ["Jeff Tratner "]
+license = "MIT"
+
+[tool.poetry.dependencies]
+python = ">=3.8,<4"
+pydantic = "*"
+graphene-pydantic = "==0.0.9"
+graphene-django = ">=2.15.0"
+django-crispy-forms = ">=1.11.2"
+defopt = ">=6.1.0"
+django-filter=">=2.4.0"
+
+[tool.poetry.dev-dependencies]
+Werkzeug = "*"
+ipdb = "0.13.7"
+psycopg2-binary = "2.8.6"
+watchgod = "0.6"
+mypy = "0.812"
+django-stubs = "1.7.0"
+pytest = "6.2.3"
+pytest-sugar = "0.9.4"
+Sphinx = "3.5.3"
+sphinx-autobuild = "2021.3.14"
+flake8 = "3.9.0"
+flake8-isort = "4.0.0"
+coverage = "5.5"
+black = "20.8b1"
+pylint-django = "2.4.3"
+pylint-celery = "0.3"
+pre-commit = "2.12.0"
+factory-boy = "3.2.0"
+django-debug-toolbar = "3.2"
+django-extensions = "3.1.2"
+django-coverage-plugin = "1.8.0"
+pytest-django = "4.2.0"
+
+
+[build-system]
+requires = ["poetry-core>=1.0.0"]
+build-backend = "poetry.core.masonry.api"
diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 0000000..083018e
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,4 @@
+[pytest]
+addopts = --reuse-db
+python_files = tests.py test_*.py
+DJANGO_SETTINGS_MODULE = settings_test
diff --git a/settings_test.py b/settings_test.py
new file mode 100644
index 0000000..fc34fb2
--- /dev/null
+++ b/settings_test.py
@@ -0,0 +1,19 @@
+from pathlib import Path
+
+ROOT_DIR = Path(__file__).parent
+DEBUG = True
+DATABASES = {
+ "default": {
+ "ENGINE": "django.db.backends.sqlite3",
+ }
+}
+SECRET_KEY = "whatever"
+INSTALLED_APPS = (
+ "django.contrib.admin",
+ "django.contrib.auth",
+ "django.contrib.contenttypes",
+ "django.contrib.sessions",
+ "django.contrib.sites",
+ "turtle_shell",
+ "graphene_django",
+)
diff --git a/setup.cfg b/setup.cfg
new file mode 100644
index 0000000..59873bd
--- /dev/null
+++ b/setup.cfg
@@ -0,0 +1,29 @@
+[flake8]
+max-line-length = 120
+exclude = .tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules,venv
+
+[pycodestyle]
+max-line-length = 120
+exclude = .tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules,venv
+
+[mypy]
+python_version = 3.9
+check_untyped_defs = True
+ignore_missing_imports = True
+warn_unused_ignores = True
+warn_redundant_casts = True
+warn_unused_configs = True
+plugins = mypy_django_plugin.main
+
+[mypy.plugins.django-stubs]
+django_settings_module = settings_test
+
+[mypy-*.migrations.*]
+# Django migrations should not produce any errors:
+ignore_errors = True
+
+[coverage:run]
+include = turtle_shell_app/*
+omit = *migrations*, *tests*
+plugins =
+ django_coverage_plugin
diff --git a/turtle_shell/__init__.py b/turtle_shell/__init__.py
index e69de29..1a153b3 100644
--- a/turtle_shell/__init__.py
+++ b/turtle_shell/__init__.py
@@ -0,0 +1,97 @@
+from dataclasses import dataclass
+
+from django.views.generic import TemplateView
+
+
+@dataclass
+class _Router:
+ urls: list
+
+
+class _Registry:
+ func_name2func: dict
+ _schema = None
+
+ def __init__(self):
+ self.func_name2func = {}
+
+ @classmethod
+ def get_registry(self):
+ return _RegistrySingleton
+
+ def add(self, func, name=None, config=None):
+ from .function_to_form import _Function
+
+ # TODO: maybe _Function object should have overridden __new__ to keep it immutable?? :-/
+ name = name or func.__name__
+ func_obj = self.get(name)
+ if not func_obj:
+ func_obj = _Function.from_function(func, name=name, config=config)
+ self.func_name2func[func_obj.name] = func_obj
+ else:
+ if func_obj.func is not func:
+ raise ValueError(f"Func {name} already registered. (existing is {func_obj})")
+ return self.get(name)
+
+ def get(self, name):
+ return self.func_name2func.get(name, None)
+
+ def summary_view(self, template_name: str = "turtle_shell/overview.html"):
+ """Create a summary view.
+
+ This uses a class-based view version of Template View just to make
+ """
+ registry = self
+
+ # TODO: Something is super goofy here, not sure how to wrap this up more nicely.
+
+ class SummaryView(TemplateView):
+ def get_context_data(self, **kwargs):
+ ctx = super().get_context_data()
+ ctx["registry"] = registry
+ ctx["functions"] = registry.func_name2func.values()
+ return ctx
+
+ return SummaryView.as_view(template_name=template_name)
+
+ def get_router(
+ self,
+ *,
+ list_template="turtle_shell/executionresult_list.html",
+ detail_template="turtle_shell/executionresult_detail.html",
+ create_template="turtle_shell/executionresult_create.html",
+ overview_template="turtle_shell/overview.html",
+ ):
+ from django.urls import path
+ from . import views
+
+ urls = [path("", self.summary_view(template_name=overview_template), name="overview")]
+ for func in self.func_name2func.values():
+ urls.extend(
+ views.Views.from_function(func, schema=get_registry().schema).urls(
+ list_template=list_template,
+ detail_template=detail_template,
+ create_template=create_template,
+ )
+ )
+ return _Router(urls=(urls, "turtle_shell"))
+
+ def clear(self):
+ self.func_name2func.clear()
+ self._schema = None
+ assert not self.func_name2func
+
+ @property
+ def schema(self):
+ from .graphene_adapter import schema_for_registry
+
+ if not self._schema:
+ self._schema = schema_for_registry(self)
+
+ return self._schema
+
+
+_RegistrySingleton = _Registry()
+get_registry = _Registry.get_registry
+
+from .function_to_form import Text
diff --git a/turtle_shell/apps.py b/turtle_shell/apps.py
index 6123282..81825d6 100644
--- a/turtle_shell/apps.py
+++ b/turtle_shell/apps.py
@@ -2,4 +2,4 @@
class TurtleShellConfig(AppConfig):
- name = 'turtle_shell'
+ name = "turtle_shell"
diff --git a/turtle_shell/fake_pydantic_adpater.py b/turtle_shell/fake_pydantic_adpater.py
new file mode 100644
index 0000000..5f9a15d
--- /dev/null
+++ b/turtle_shell/fake_pydantic_adpater.py
@@ -0,0 +1,6 @@
+def is_pydantic(func):
+ return False
+
+
+def maybe_use_pydantic_mutation(func_object):
+ return None
diff --git a/turtle_shell/function_to_form.py b/turtle_shell/function_to_form.py
new file mode 100644
index 0000000..0646e7d
--- /dev/null
+++ b/turtle_shell/function_to_form.py
@@ -0,0 +1,267 @@
+"""
+Function to a Form
+------------------
+
+Converts function signatures into django forms.
+
+NOTE: with enums it's recommended to use the string version, since the value will be used as the
+representation to the user (and generally numbers aren't that valuable)
+"""
+import enum
+import inspect
+import re
+import typing
+
+from dataclasses import dataclass
+from django import forms
+from django.db.models import TextChoices
+from defopt import Parameter, signature, _parse_docstring
+from typing import Dict, Optional
+from typing import Type
+import pathlib
+
+from . import utils
+
+
+class Text(str):
+ """Wrapper class to be able to handle str types"""
+
+ pass
+
+
+type2field_type = {
+ int: forms.IntegerField,
+ str: forms.CharField,
+ bool: forms.BooleanField,
+ Optional[bool]: forms.NullBooleanField,
+ Text: forms.CharField,
+ pathlib.Path: forms.CharField,
+ dict: forms.JSONField,
+}
+
+type2widget = {Text: forms.Textarea()}
+
+
+@dataclass
+class _Function:
+ func: callable
+ name: str
+ form_class: object
+ doc: str
+
+ @classmethod
+ def from_function(cls, func, *, name, config=None):
+ form_class = function_to_form(func, name=name, config=config)
+ return cls(func=func, name=name, form_class=form_class, doc=form_class.__doc__)
+
+
+def doc_mapping(str) -> Dict[str, str]:
+ return {}
+
+
+def function_to_form(func, *, config: dict = None, name: str = None) -> Type[forms.Form]:
+ """Convert a function to a Django Form.
+
+ Args:
+ func: the function to be changed
+ config: A dictionary with keys ``widgets`` and ``fields`` each mapping types/specific
+ arguments to custom fields
+ """
+ name = name or func.__qualname__
+ sig = signature(func)
+ # i.e., class body for form
+ fields = {}
+ defaults = {}
+ for parameter in sig.parameters.values():
+ field = param_to_field(parameter, config)
+ fields[parameter.name] = field
+ if parameter.default is not Parameter.empty:
+ defaults[parameter.name] = parameter.default
+ if isinstance(field, forms.TypedChoiceField):
+ field._parameter_name = parameter.name
+ field._func_name = name
+ if parameter.default and parameter.default is not Parameter.empty:
+ for potential_default in [parameter.default.name, parameter.default.value]:
+ if any(potential_default == x[0] for x in field.choices):
+ defaults[parameter.name] = potential_default
+ break
+ else:
+ raise ValueError(
+ f"Cannot figure out how to assign default for {parameter.name}: {parameter.default}"
+ )
+ fields["__doc__"] = re.sub("\n+", "\n", _parse_docstring(inspect.getdoc(func)).text)
+ form_name = "".join(part.capitalize() for part in func.__name__.split("_"))
+
+ class BaseForm(forms.Form):
+ _func = func
+ _input_defaults = defaults
+ # use this for ignoring extra args from createview and such
+ def __init__(self, *a, instance=None, user=None, **k):
+ from crispy_forms.helper import FormHelper
+ from crispy_forms.layout import Submit
+
+ super().__init__(*a, **k)
+ self.user = user
+ self.helper = FormHelper(self)
+ self.helper.add_input(Submit("submit", "Execute!"))
+
+ def execute_function(self):
+ # TODO: reconvert back to enum type! :(
+ return func(**self.cleaned_data)
+
+ def save(self):
+ from .models import ExecutionResult
+
+ obj = ExecutionResult(func_name=name, input_json=self.cleaned_data, user=self.user)
+ obj.save()
+ return obj
+
+ return type(form_name, (BaseForm,), fields)
+
+
+def is_optional(annotation):
+ if args := typing.get_args(annotation):
+ return len(args) == 2 and args[-1] == type(None)
+
+
+def get_type_from_annotation(param: Parameter):
+ if is_optional(param.annotation):
+ return typing.get_args(param.annotation)[0]
+ if typing.get_origin(param.annotation):
+ raise ValueError(f"Field {param.name}: type class {param.annotation} not supported")
+ return param.annotation
+
+
+@dataclass
+class Coercer:
+ """Wrapper so that we handle implicit string conversion of enum types :("""
+
+ enum_type: object
+ by_attribute: bool = False
+
+ def __call__(self, value):
+ try:
+ resp = self._call(value)
+ return resp
+ except Exception as e:
+ import traceback
+
+ traceback.print_exc()
+ raise
+
+ def _call(self, value):
+ if value and isinstance(value, self.enum_type):
+ return value
+ if self.by_attribute:
+ return getattr(self.enum_type, value)
+ try:
+ resp = self.enum_type(value)
+ return resp
+ except ValueError as e:
+ import traceback
+
+ traceback.print_exc()
+ try:
+ return self.enum_type(int(value))
+ except ValueError as f:
+ # could not coerce to int :(
+ pass
+ if isinstance(value, str):
+ # fallback to some kind of name thing if necesary
+ try:
+ return getattr(self.enum_type, value)
+ except AttributeError:
+ pass
+ raise e from e
+ assert False, "Should not get here"
+
+
+def param_to_field(param: Parameter, config: dict = None) -> forms.Field:
+ """Convert a specific arg to a django form field.
+
+ See function_to_form for config definition."""
+ config = config or {}
+ all_types = {**type2field_type, **(config.get("fields") or {})}
+ widgets = {**type2widget, **(config.get("widgets") or {})}
+ field_type = None
+ kwargs = {}
+ kind = get_type_from_annotation(param)
+ is_enum_class = False
+ try:
+ is_enum_class = issubclass(kind, enum.Enum)
+ except TypeError:
+ # e.g. stupid generic type stuff
+ pass
+ if is_enum_class:
+ utils.EnumRegistry.register(kind)
+ field_type = forms.TypedChoiceField
+ kwargs.update(make_enum_kwargs(param=param, kind=kind))
+ else:
+ field_type = get_for_param_by_type(all_types, param=param, kind=kind)
+ if not field_type:
+ raise ValueError(f"Field {param.name}: Unknown field type: {param.annotation}")
+ # do not overwrite kwargs if already specified
+ kwargs = {**extra_kwargs(field_type, param), **kwargs}
+ if field_type == forms.BooleanField and param.default is None:
+ field_type = forms.NullBooleanField
+
+ widget = get_for_param_by_type(widgets, param=param, kind=kind)
+ if widget:
+ kwargs["widget"] = widget
+ return field_type(**kwargs)
+
+
+def make_enum_kwargs(kind, param):
+ kwargs = {}
+ if all(isinstance(member.value, int) for member in kind):
+ kwargs["choices"] = TextChoices(
+ f"{kind.__name__}Enum", {member.name: (member.name, member.name) for member in kind}
+ ).choices
+ kwargs["coerce"] = Coercer(kind, by_attribute=True)
+ else:
+ # we set up all the kinds of entries to make it a bit easier to do the names and the
+ # values...
+ kwargs["choices"] = TextChoices(
+ f"{kind.__name__}Enum",
+ dict(
+ [(member.name, (str(member.value), member.name)) for member in kind]
+ + [(str(member.value), (member.name, member.name)) for member in kind]
+ ),
+ ).choices
+ kwargs["coerce"] = Coercer(kind)
+ # coerce back
+ if isinstance(param.default, kind):
+ kwargs["initial"] = param.default.value
+ return kwargs
+
+
+def get_for_param_by_type(dct, *, param, kind):
+ """Grab the appropriate element out of dict based on param type.
+
+ Ordering:
+ 1. param.name (i.e., something custom specified by user)
+ 2. param.annotation
+ 3. underlying type if typing.Optional
+ """
+ if elem := dct.get(param.name, dct.get(param.annotation, dct.get(kind))):
+ return elem
+ for k, v in dct.items():
+ if inspect.isclass(k) and issubclass(kind, k) or k == kind:
+ return v
+
+
+def extra_kwargs(field_type, param):
+ kwargs = {}
+ if param.default is Parameter.empty:
+ kwargs["required"] = True
+ elif param.default is None:
+ kwargs["required"] = False
+ # need this so that empty values get passed through to function correctly!
+ if "empty_value" in inspect.signature(field_type).parameters:
+ kwargs["empty_value"] = None
+ else:
+ kwargs["required"] = False
+ kwargs.setdefault("initial", param.default)
+ if param.doc:
+ kwargs["help_text"] = param.doc
+ return kwargs
diff --git a/turtle_shell/graphene_adapter.py b/turtle_shell/graphene_adapter.py
new file mode 100644
index 0000000..7195fc6
--- /dev/null
+++ b/turtle_shell/graphene_adapter.py
@@ -0,0 +1,160 @@
+import json
+import graphene
+from graphene_django.forms.mutation import DjangoFormMutation
+from graphene_django import DjangoObjectType
+from graphene_django.filter import DjangoFilterConnectionField
+from graphene import relay
+from . import models
+from graphene_django.forms import converter as graphene_django_converter
+from django import forms
+from . import utils
+
+# PATCH IT GOOD!
+import turtle_shell.graphene_adapter_jsonstring
+
+# TODO: (try/except here with pydantic)
+from turtle_shell import pydantic_adapter
+
+
+_seen_names: set = set()
+
+# class FakeJSONModule:
+# @classmethod
+# def loads(self, *a, **k):
+# return json.loads(*a, **k)
+#
+# @classmethod
+# def dumps(self, *a, **k):
+# k['cls'] = utils.EnumAwareEncoder
+# return json.dumps(*a, **k)
+#
+# @classmethod
+# def dump(self, *a, **k):
+# k['cls'] = utils.EnumAwareEncoder
+# return json.dump(*a, **k)
+#
+# import graphene_django.views
+# import graphene.types.json
+#
+# graphene_django.views.json = FakeJSONModule
+# graphene.types.json.json = FakeJSONModule
+
+
+@graphene_django_converter.convert_form_field.register(forms.TypedChoiceField)
+def convert_form_field_to_choice(field):
+ # TODO: this should really be ported back to graphene django
+ from graphene_django.converter import convert_choice_field_to_enum
+
+ name = full_name = f"{field._func_name}{field._parameter_name}"
+ index = 0
+ while full_name in _seen_names:
+ index += 1
+ full_name = f"{name}{index}"
+ EnumCls = convert_choice_field_to_enum(field, name=full_name)
+ converted = EnumCls(description=field.help_text, required=field.required)
+ _seen_names.add(full_name)
+
+ return converted
+
+
+class ExecutionResult(DjangoObjectType):
+ class Meta:
+ model = models.ExecutionResult
+ interfaces = (relay.Node,)
+ filter_fields = {
+ "func_name": ["exact"],
+ "uuid": ["exact"],
+ }
+ fields = [
+ "uuid",
+ "func_name",
+ "status",
+ "input_json",
+ "output_json",
+ "error_json",
+ "created",
+ "modified",
+ # TODO: will need this to be set up better
+ # "user"
+ ]
+
+
+def func_to_graphene_form_mutation(func_object):
+ form_class = func_object.form_class
+ defaults = getattr(func_object.form_class, "_input_defaults", None) or {}
+
+ class Meta:
+ form_class = func_object.form_class
+
+ @classmethod
+ def mutate_and_get_payload(cls, root, info, **input):
+ """Set defaults from function in place!"""
+ input = {**defaults, **input}
+ form = cls.get_form(root, info, **input)
+ if not form.is_valid():
+ print("FORM ERRORS", form.errors)
+ try:
+ return super(DefaultOperationMutation, cls).mutate_and_get_payload(root, info, **input)
+ except Exception as e:
+ import traceback
+
+ traceback.print_exc()
+ raise
+
+ @classmethod
+ def perform_mutate(cls, form, info):
+ obj = form.save()
+ all_results = obj.execute()
+ obj.save()
+ kwargs = {"execution": obj}
+ if hasattr(all_results, "dict"):
+ for k, f in fields.items():
+ if k != "execution":
+ kwargs[k] = all_results
+ # TODO: nicer structure
+ if obj.error_json:
+ message = obj.error_json.get("message") or "Hit error in execution :("
+ errors = [{"message": message, "extensions": obj.error_json}]
+ else:
+ errors = []
+
+ return cls(errors=errors, **kwargs)
+
+ # TODO: figure out if name can be customized in class
+ mutation_name = f"{form_class.__name__}Mutation"
+ fields = {"execution": graphene.Field(ExecutionResult)}
+ pydantic_adapter.maybe_add_pydantic_fields(func_object, fields)
+ DefaultOperationMutation = type(
+ mutation_name,
+ (DjangoFormMutation,),
+ (
+ {
+ **fields,
+ "Meta": Meta,
+ "perform_mutate": perform_mutate,
+ "__doc__": f"Mutation form for {form_class.__name__}.\n{form_class.__doc__}",
+ "mutate_and_get_payload": mutate_and_get_payload,
+ }
+ ),
+ )
+ return DefaultOperationMutation
+
+
+def schema_for_registry(registry):
+ # TODO: make this more flexible!
+ class Query(graphene.ObjectType):
+ execution_results = DjangoFilterConnectionField(ExecutionResult)
+ execution_result = graphene.Field(ExecutionResult, uuid=graphene.String())
+
+ def resolve_execution_result(cls, info, uuid):
+ try:
+ return models.ExecutionResult.objects.get(pk=uuid)
+ except models.ExecutionResult.DoesNotExist:
+ pass
+
+ mutation_fields = {}
+ for func_obj in registry.func_name2func.values():
+ mutation = func_to_graphene_form_mutation(func_obj)
+ mutation_fields[f"execute_{func_obj.name}"] = mutation.Field()
+ Mutation = type("Mutation", (graphene.ObjectType,), mutation_fields)
+ return graphene.Schema(query=Query, mutation=Mutation)
diff --git a/turtle_shell/graphene_adapter_jsonstring.py b/turtle_shell/graphene_adapter_jsonstring.py
new file mode 100644
index 0000000..7416c51
--- /dev/null
+++ b/turtle_shell/graphene_adapter_jsonstring.py
@@ -0,0 +1,36 @@
+"""Patch graphene django's JSONString implementation so we can use a custom encoder"""
+# Port over of graphene's JSON string to allow using a custom encoder...sigh
+import json
+
+from graphql.language import ast
+
+from graphene.types.scalars import Scalar
+
+
+class CustomEncoderJSONString(Scalar):
+ """
+ Allows use of a JSON String for input / output from the GraphQL schema.
+
+ Use of this type is *not recommended* as you lose the benefits of having a defined, static
+ schema (one of the key benefits of GraphQL).
+ """
+
+ @staticmethod
+ def serialize(dt):
+ from turtle_shell import utils
+
+ return json.dumps(dt, cls=utils.EnumAwareEncoder)
+
+ @staticmethod
+ def parse_literal(node):
+ if isinstance(node, ast.StringValue):
+ return json.loads(node.value)
+
+ @staticmethod
+ def parse_value(value):
+ return json.loads(value)
+
+
+from graphene_django import converter
+
+converter.JSONString = CustomEncoderJSONString
diff --git a/turtle_shell/migrations/0001_initial.py b/turtle_shell/migrations/0001_initial.py
new file mode 100644
index 0000000..43e3192
--- /dev/null
+++ b/turtle_shell/migrations/0001_initial.py
@@ -0,0 +1,47 @@
+# Generated by Django 3.1.8 on 2021-04-11 16:41
+
+from django.db import migrations, models
+import uuid
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = []
+
+ operations = [
+ migrations.CreateModel(
+ name="ExecutionResult",
+ fields=[
+ (
+ "uuid",
+ models.UUIDField(
+ default=uuid.uuid4,
+ editable=False,
+ primary_key=True,
+ serialize=False,
+ unique=True,
+ ),
+ ),
+ ("func_name", models.CharField(max_length=512)),
+ ("input_json", models.JSONField()),
+ ("output_json", models.JSONField()),
+ (
+ "status",
+ models.CharField(
+ choices=[
+ ("CREATED", "Created"),
+ ("RUNNING", "Running"),
+ ("DONE", "Done"),
+ ("ERRORED", "Errored"),
+ ],
+ default="CREATED",
+ max_length=10,
+ ),
+ ),
+ ("created", models.DateTimeField(auto_now_add=True)),
+ ("modified", models.DateTimeField(auto_now=True)),
+ ],
+ ),
+ ]
diff --git a/turtle_shell/migrations/0002_auto_20210411_1045.py b/turtle_shell/migrations/0002_auto_20210411_1045.py
new file mode 100644
index 0000000..9fc086a
--- /dev/null
+++ b/turtle_shell/migrations/0002_auto_20210411_1045.py
@@ -0,0 +1,43 @@
+# Generated by Django 3.1.8 on 2021-04-11 17:45
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("turtle_shell", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="executionresult",
+ name="error_json",
+ field=models.JSONField(default=None),
+ ),
+ migrations.AlterField(
+ model_name="executionresult",
+ name="func_name",
+ field=models.CharField(editable=False, max_length=512),
+ ),
+ migrations.AlterField(
+ model_name="executionresult",
+ name="output_json",
+ field=models.JSONField(default=None),
+ ),
+ migrations.AlterField(
+ model_name="executionresult",
+ name="status",
+ field=models.CharField(
+ choices=[
+ ("CREATED", "Created"),
+ ("RUNNING", "Running"),
+ ("DONE", "Done"),
+ ("ERRORED", "Errored"),
+ ("JSON_ERROR", "Result could not be coerced to JSON"),
+ ],
+ default="CREATED",
+ max_length=10,
+ ),
+ ),
+ ]
diff --git a/turtle_shell/migrations/0003_auto_20210411_1104.py b/turtle_shell/migrations/0003_auto_20210411_1104.py
new file mode 100644
index 0000000..943b507
--- /dev/null
+++ b/turtle_shell/migrations/0003_auto_20210411_1104.py
@@ -0,0 +1,33 @@
+# Generated by Django 3.1.8 on 2021-04-11 18:04
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ("turtle_shell", "0002_auto_20210411_1045"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="executionresult",
+ name="user",
+ field=models.ForeignKey(
+ null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL
+ ),
+ ),
+ migrations.AlterField(
+ model_name="executionresult",
+ name="error_json",
+ field=models.JSONField(default=dict),
+ ),
+ migrations.AlterField(
+ model_name="executionresult",
+ name="output_json",
+ field=models.JSONField(default=dict),
+ ),
+ ]
diff --git a/turtle_shell/migrations/0004_auto_20210411_1223.py b/turtle_shell/migrations/0004_auto_20210411_1223.py
new file mode 100644
index 0000000..338fef7
--- /dev/null
+++ b/turtle_shell/migrations/0004_auto_20210411_1223.py
@@ -0,0 +1,23 @@
+# Generated by Django 3.1.8 on 2021-04-11 19:23
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("turtle_shell", "0003_auto_20210411_1104"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="executionresult",
+ name="error_json",
+ field=models.JSONField(default=dict, null=True),
+ ),
+ migrations.AlterField(
+ model_name="executionresult",
+ name="output_json",
+ field=models.JSONField(default=dict, null=True),
+ ),
+ ]
diff --git a/turtle_shell/migrations/0005_auto_20210412_2320.py b/turtle_shell/migrations/0005_auto_20210412_2320.py
new file mode 100644
index 0000000..75a9668
--- /dev/null
+++ b/turtle_shell/migrations/0005_auto_20210412_2320.py
@@ -0,0 +1,42 @@
+# Generated by Django 3.1.8 on 2021-04-13 06:20
+
+from django.db import migrations, models
+import turtle_shell.utils
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("turtle_shell", "0004_auto_20210411_1223"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="executionresult",
+ name="error_json",
+ field=models.JSONField(
+ decoder=turtle_shell.utils.EnumAwareDecoder,
+ default=dict,
+ encoder=turtle_shell.utils.EnumAwareEncoder,
+ null=True,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="executionresult",
+ name="input_json",
+ field=models.JSONField(
+ decoder=turtle_shell.utils.EnumAwareDecoder,
+ encoder=turtle_shell.utils.EnumAwareEncoder,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="executionresult",
+ name="output_json",
+ field=models.JSONField(
+ decoder=turtle_shell.utils.EnumAwareDecoder,
+ default=dict,
+ encoder=turtle_shell.utils.EnumAwareEncoder,
+ null=True,
+ ),
+ ),
+ ]
diff --git a/turtle_shell/migrations/0006_executionresult_traceback.py b/turtle_shell/migrations/0006_executionresult_traceback.py
new file mode 100644
index 0000000..a20f19f
--- /dev/null
+++ b/turtle_shell/migrations/0006_executionresult_traceback.py
@@ -0,0 +1,24 @@
+# Generated by Django 3.1.8 on 2021-04-13 13:23
+
+from django.db import migrations, models
+import turtle_shell.utils
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("turtle_shell", "0005_auto_20210412_2320"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="executionresult",
+ name="traceback",
+ field=models.JSONField(
+ decoder=turtle_shell.utils.EnumAwareDecoder,
+ default=dict,
+ encoder=turtle_shell.utils.EnumAwareEncoder,
+ null=True,
+ ),
+ ),
+ ]
diff --git a/turtle_shell/migrations/0007_auto_20210413_0626.py b/turtle_shell/migrations/0007_auto_20210413_0626.py
new file mode 100644
index 0000000..f305759
--- /dev/null
+++ b/turtle_shell/migrations/0007_auto_20210413_0626.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.1.8 on 2021-04-13 13:26
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("turtle_shell", "0006_executionresult_traceback"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="executionresult",
+ name="traceback",
+ field=models.TextField(default=""),
+ ),
+ ]
diff --git a/turtle_shell/models.py b/turtle_shell/models.py
index 71a8362..317f751 100644
--- a/turtle_shell/models.py
+++ b/turtle_shell/models.py
@@ -1,3 +1,128 @@
-from django.db import models
+from django.db import models, transaction
+from django.urls import reverse
+from django.conf import settings
+from turtle_shell import utils
+import uuid
+import json
+import logging
-# Create your models here.
+logger = logging.getLogger(__name__)
+
+
+class CaughtException(Exception):
+ """An exception that was caught and saved. Generally don't need to rollback transaction with
+ this one :)"""
+
+ def __init__(self, exc, message):
+ self.exc = exc
+ super().__init__(message)
+
+
+class ResultJSONEncodeException(CaughtException):
+ """Exceptions for when we cannot save result as actual JSON field :("""
+
+
+class ExecutionResult(models.Model):
+ FIELDS_TO_SHOW_IN_LIST = [
+ ("func_name", "Function"),
+ ("created", "Created"),
+ ("user", "User"),
+ ("status", "Status"),
+ ]
+ uuid = models.UUIDField(primary_key=True, unique=True, editable=False, default=uuid.uuid4)
+ func_name = models.CharField(max_length=512, editable=False)
+ input_json = models.JSONField(encoder=utils.EnumAwareEncoder, decoder=utils.EnumAwareDecoder)
+ output_json = models.JSONField(
+ default=dict, null=True, encoder=utils.EnumAwareEncoder, decoder=utils.EnumAwareDecoder
+ )
+ error_json = models.JSONField(
+ default=dict, null=True, encoder=utils.EnumAwareEncoder, decoder=utils.EnumAwareDecoder
+ )
+ traceback = models.TextField(default="")
+
+ class ExecutionStatus(models.TextChoices):
+ CREATED = "CREATED", "Created"
+ RUNNING = "RUNNING", "Running"
+ DONE = "DONE", "Done"
+ ERRORED = "ERRORED", "Errored"
+ JSON_ERROR = "JSON_ERROR", "Result could not be coerced to JSON"
+
+ status = models.CharField(
+ max_length=10, choices=ExecutionStatus.choices, default=ExecutionStatus.CREATED
+ )
+
+ created = models.DateTimeField(auto_now_add=True)
+ modified = models.DateTimeField(auto_now=True)
+ user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, null=True)
+
+ def execute(self):
+ """Execute with given input, returning caught exceptions as necessary"""
+ from turtle_shell import pydantic_adapter
+
+ if self.status not in (self.ExecutionStatus.CREATED, self.ExecutionStatus.RUNNING):
+ raise ValueError("Cannot run - execution state isn't complete")
+ func = self.get_function()
+ original_result = None
+ try:
+ # TODO: redo conversion another time!
+ result = original_result = func(**self.input_json)
+ except Exception as e:
+ import traceback
+
+ logger.error(
+ f"Failed to execute {self.func_name} :(: {type(e).__name__}:{e}", exc_info=True
+ )
+ # TODO: catch integrity error separately
+ self.error_json = {"type": type(e).__name__, "message": str(e)}
+ self.traceback = "".join(traceback.format_exc())
+ self.status = self.ExecutionStatus.ERRORED
+ self.save()
+ raise CaughtException(f"Failed on {self.func_name} ({type(e).__name__})", e) from e
+ try:
+ if hasattr(result, "json"):
+ result = json.loads(result.json())
+ # if not isinstance(result, (dict, str, tuple)):
+ # result = cattr.unstructure(result)
+ self.output_json = result
+ self.status = self.ExecutionStatus.DONE
+ # allow ourselves to save again externally
+ with transaction.atomic():
+ self.save()
+ except TypeError as e:
+ self.error_json = {"type": type(e).__name__, "message": str(e)}
+ msg = f"Failed on {self.func_name} ({type(e).__name__})"
+ if "JSON serializable" in str(e):
+ self.status = self.ExecutionStatus.JSON_ERROR
+ # save it as a str so we can at least have something to show
+ self.output_json = str(result)
+ self.save()
+ raise ResultJSONEncodeException(msg, e) from e
+ else:
+ raise e
+ return original_result
+
+ def get_function(self):
+ # TODO: figure this out
+ from . import get_registry
+
+ func_obj = get_registry().get(self.func_name)
+ if not func_obj:
+ raise ValueError(f"No registered function defined for {self.func_name}")
+ return func_obj.func
+
+ def get_absolute_url(self):
+ # TODO: prob better way to do this so that it all redirects right :(
+ return reverse(f"turtle_shell:detail-{self.func_name}", kwargs={"pk": self.pk})
+
+ def __repr__(self):
+ return f"<{type(self).__name__}({self})"
+
+ @property
+ def pydantic_object(self):
+ from turtle_shell import pydantic_adapter
+
+ return pydantic_adapter.get_pydantic_object(self)
+
+ @property
+ def list_entry(self) -> list:
+ return [getattr(self, obj_name) for obj_name, _ in self.FIELDS_TO_SHOW_IN_LIST]
diff --git a/turtle_shell/pydantic_adapter.py b/turtle_shell/pydantic_adapter.py
new file mode 100644
index 0000000..aa8049d
--- /dev/null
+++ b/turtle_shell/pydantic_adapter.py
@@ -0,0 +1,79 @@
+"""
+Pydantic model handling!
+
+This module is designed to be a conditional import with fake pydantic instead used if pydantic not
+available.
+"""
+import typing
+from graphene_pydantic import PydanticObjectType, registry
+from pydantic import BaseModel
+import inspect
+import graphene
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+def is_pydantic(func):
+ ret_type = inspect.signature(func).return_annotation
+ if args := typing.get_args(ret_type):
+ ret_type = args[0]
+ return inspect.isclass(ret_type) and issubclass(ret_type, BaseModel) and ret_type
+
+
+def get_pydantic_models_in_order(model):
+ """Get all nested models in order for definition"""
+ found = []
+ for field in model.__fields__.values():
+ type_ = field.type_
+ if issubclass(type_, BaseModel):
+ found.extend(get_pydantic_models_in_order(type_))
+ found.append(model)
+ seen_classes = set()
+ deduped = []
+ for elem in found:
+ if elem not in seen_classes:
+ deduped.append(elem)
+ seen_classes.add(elem)
+ return deduped
+
+
+def get_object_type(model) -> PydanticObjectType:
+ """Construct object types in order, using caching etc"""
+ reg = registry.get_global_registry()
+ classes = get_pydantic_models_in_order(model)
+ for klass in classes:
+ if reg.get_type_for_model(klass):
+ continue
+
+ pydantic_oject = type(
+ klass.__name__,
+ (PydanticObjectType,),
+ {"Meta": type("Meta", (object,), {"model": klass})},
+ )
+ assert reg.get_type_for_model(klass), klass
+ return reg.get_type_for_model(model)
+
+
+def maybe_add_pydantic_fields(func_object, fields):
+ if not (pydantic_class := is_pydantic(func_object.func)):
+ return
+ obj_name = pydantic_class.__name__
+
+ root_object = get_object_type(pydantic_class)
+ fields[obj_name[0].lower() + obj_name[1:]] = graphene.Field(root_object)
+
+
+def maybe_convert_pydantic_model(result):
+ if isinstance(result, BaseModel):
+ return result.dict()
+ return result
+
+
+def get_pydantic_object(execution_result):
+ func = execution_result.get_function()
+ if ret_type := is_pydantic(func):
+ try:
+ return ret_type.parse_obj(execution_result.output_json)
+ except Exception as e:
+ logger.warn(f"Hit exception unparsing {type(e).__name__}{e}", exc_info=True)
diff --git a/turtle_shell/templates/turtle_shell/executionresult_create.html b/turtle_shell/templates/turtle_shell/executionresult_create.html
new file mode 100644
index 0000000..5ec4b52
--- /dev/null
+++ b/turtle_shell/templates/turtle_shell/executionresult_create.html
@@ -0,0 +1,10 @@
+{% extends 'base.html' %}
+{% load crispy_forms_tags %}
+
+{% block content %}
+New Execution for {{func_name}}
+{{doc}}
+{% crispy form %}
+{% endblock content %}
+
+
diff --git a/turtle_shell/templates/turtle_shell/executionresult_detail.html b/turtle_shell/templates/turtle_shell/executionresult_detail.html
new file mode 100644
index 0000000..e4e4236
--- /dev/null
+++ b/turtle_shell/templates/turtle_shell/executionresult_detail.html
@@ -0,0 +1,34 @@
+{% extends 'base.html' %}
+{% load pydantic_to_table %}
+
+{% block content %}
+
+
Execution for {{func_name}} ({{object.pk}})
+
+
+
State
+
+{{object.status}}
+{% if object.pydantic_object %}
+
+
Results
+{{object.pydantic_object|pydantic_model_to_table}}
+
+{% endif %}
+
+
Original Data
+
+
+{% include "turtle_shell/executionresult_summaryrow.html" with key="Input" data=object.input_json %}
+{% include "turtle_shell/executionresult_summaryrow.html" with key="Output" data=object.output_json %}
+{% include "turtle_shell/executionresult_summaryrow.html" with key="Error" data=object.error_json %}
+{% include "turtle_shell/executionresult_summaryrow.html" with key="Traceback" data=object.traceback skip_pprint=True %}
+{% load tz %}
+{% get_current_timezone as TIME_ZONE %}
+| User | {{object.user}} |
+| Created | {{object.created}} ({{TIME_ZONE}}) |
+| Modified | {{object.modified}} ({{TIME_ZONE}}) |
+
+
+
+{% endblock content %}
diff --git a/turtle_shell/templates/turtle_shell/executionresult_list.html b/turtle_shell/templates/turtle_shell/executionresult_list.html
new file mode 100644
index 0000000..c6dc29c
--- /dev/null
+++ b/turtle_shell/templates/turtle_shell/executionresult_list.html
@@ -0,0 +1,31 @@
+{% extends 'base.html' %}
+
+{% block content %}
+Executions for {{func_name}}
+
+{% if object_list %}
+
+
+ {% for object in object_list|slice:":1" %}
+ {% for _, field in object.FIELDS_TO_SHOW_IN_LIST %}
+ | {{field}} |
+ {% endfor %}
+ UUID |
+ {% endfor %}
+
+
+
+ {% for object in object_list %}
+
+ {% for elem in object.list_entry %}
+ | {{elem}} |
+ {% endfor %}
+ {{ object.uuid }} |
+ {% endfor %}
+
+
+
+{% else %}
+ No executions :(
+{% endif %}
+{% endblock content %}
diff --git a/turtle_shell/templates/turtle_shell/executionresult_summaryrow.html b/turtle_shell/templates/turtle_shell/executionresult_summaryrow.html
new file mode 100644
index 0000000..05be051
--- /dev/null
+++ b/turtle_shell/templates/turtle_shell/executionresult_summaryrow.html
@@ -0,0 +1,11 @@
+{% if data %}
+
+ | {{ key }} |
+
+
+ {{ data|truncatechars:50 }}
+ {%if skip_pprint %}{{ data|escape }}{% else %}{{data|pprint|escape}}{%endif%}
+
+ |
+
+{% endif %}
diff --git a/turtle_shell/templates/turtle_shell/overview.html b/turtle_shell/templates/turtle_shell/overview.html
new file mode 100644
index 0000000..308ac48
--- /dev/null
+++ b/turtle_shell/templates/turtle_shell/overview.html
@@ -0,0 +1,24 @@
+{% extends "base.html" %}
+
+{% block content %}
+
+
+ | Function | | Description |
+
+{% for elem in functions %}
+
+ | {{elem.name}} |
+
+
+ |
+ {% if elem.doc %} {{elem.doc}} {% endif %} |
+
+{% endfor %}
+
+
+
+{% endblock content %}
diff --git a/turtle_shell/templatetags/pydantic_to_table.py b/turtle_shell/templatetags/pydantic_to_table.py
new file mode 100644
index 0000000..fc6562b
--- /dev/null
+++ b/turtle_shell/templatetags/pydantic_to_table.py
@@ -0,0 +1,70 @@
+from django import template
+from django.utils.safestring import mark_safe, SafeString
+from django.utils import html
+from django.template.defaultfilters import urlizetrunc
+import textwrap
+import json
+
+register = template.Library()
+
+
+@register.filter(is_safe=True)
+def pydantic_model_to_table(obj):
+ if not hasattr(obj, "dict"):
+ raise ValueError("Invalid object - must be pydantic type! (got {type(obj).__name__})")
+ if hasattr(obj, "front_end_dict"):
+ raw = obj.front_end_dict()
+ else:
+ raw = json.loads(obj.json())
+ return mark_safe(dict_to_table(raw))
+
+
+def _urlize(value):
+ if isinstance(value, SafeString):
+ return value
+ return urlizetrunc(value, 40)
+
+
+def dict_to_table(dct):
+ rows = []
+ for k, v in dct.items():
+ if isinstance(v, dict):
+ v = dict_to_table(v)
+ elif isinstance(v, (list, tuple)):
+ if v:
+ v_parts = [
+ html.format_html(
+ "{num_elements} elements
", num_elements=len(v)
+ ),
+ '| # | Elem |
',
+ ]
+ v_parts.append("")
+ for i, elem in enumerate(v, 1):
+ if isinstance(elem, dict):
+ elem = dict_to_table(elem)
+ v_parts.append(
+ html.format_html(
+ "| {idx} | {value} |
", idx=i, value=_urlize(elem)
+ )
+ )
+ v_parts.append("
")
+ v = mark_safe("\n".join(v_parts))
+ rows.append(
+ html.format_html(
+ '| {key} | {value} | ', key=k, value=_urlize(v)
+ )
+ )
+ row_data = "\n ".join(rows)
+ return mark_safe(
+ textwrap.dedent(
+ f"""\
+
+
+ | Key |
+ Value |
+
+ {row_data}
+
+
"""
+ )
+ )
diff --git a/turtle_shell/tests.py b/turtle_shell/tests.py
deleted file mode 100644
index 7ce503c..0000000
--- a/turtle_shell/tests.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from django.test import TestCase
-
-# Create your tests here.
diff --git a/turtle_shell/tests/__init__.py b/turtle_shell/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/turtle_shell/tests/conftest.py b/turtle_shell/tests/conftest.py
new file mode 100644
index 0000000..4cf7b3d
--- /dev/null
+++ b/turtle_shell/tests/conftest.py
@@ -0,0 +1,4 @@
+import pytest
+
+# ensure we get pretty pytest-style diffs in this module :)
+pytest.register_assert_rewrite("turtle_shell.tests.utils")
diff --git a/turtle_shell/tests/test_django_cli2ui.py b/turtle_shell/tests/test_django_cli2ui.py
new file mode 100644
index 0000000..526fd29
--- /dev/null
+++ b/turtle_shell/tests/test_django_cli2ui.py
@@ -0,0 +1,288 @@
+import enum
+import json
+
+import pytest
+from django import forms
+
+from .utils import compare_form_field, compare_forms
+from turtle_shell.function_to_form import param_to_field
+from turtle_shell.function_to_form import function_to_form
+from turtle_shell.function_to_form import Coercer
+from turtle_shell import utils
+import turtle_shell
+from defopt import Parameter
+from typing import Optional
+import typing
+
+
+class Color(enum.Enum):
+ red = enum.auto()
+ green = enum.auto()
+ yellow = enum.auto()
+
+
+COLOR_CHOICES = [(e.name, e.name) for e in Color]
+
+
+class Flag(enum.Enum):
+ is_apple = "is_apple"
+ is_banana = "is_banana"
+
+
+FLAG_CHOICES = [(e.value, e.value) for e in Flag]
+
+
+class Text(str):
+ pass
+
+
+def example_func(
+ *,
+ int_arg: int,
+ int_arg_with_default: int = 5,
+ bool_arg_default_true: bool = True,
+ bool_arg_default_false: bool = False,
+ bool_arg_no_default: bool,
+ str_arg: str,
+ str_arg_with_default: str = "whatever",
+ text_arg: Text,
+ text_arg_with_default: Text = Text("something"),
+ enum_auto: Color,
+ enum_auto_not_required: Color = None,
+ enum_auto_with_default: Color = Color.green,
+ enum_str: Flag,
+ enum_str_with_default: Flag = Flag.is_apple,
+ undocumented_arg: str = None,
+):
+ """
+ First line of text content should be there.
+
+ Args:
+ int_arg: Browser native int field
+ int_arg_with_default: Browser native int field with a default
+ bool_arg_default_true: should be checked checkbox
+ bool_arg_default_false: should be unchecked checkbox
+ bool_arg_no_default: Bool arg with a dropdown
+ str_arg: should be small field
+ str_arg_with_default: should be small field with text in it
+ text_arg: should have a big text field
+ text_arg_with_default: should have big text field with something filled in
+ enum_auto: should be choices with key names
+ enum_auto_not_required: choice field not required
+ enum_auto_with_default: choice field with entry selected
+ enum_str: should be choices with string values
+
+ Later lines of content here.
+
+ """
+
+
+class ExpectedFormForExampleFunc(forms.Form):
+ """\nFirst line of text content should be there. \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \nLater lines of content here."""
+
+ # extra whitespace cuz of weird docstring parsing from defopt
+ int_arg = forms.IntegerField(help_text="Browser native int field", required=True)
+ int_arg_with_default = forms.IntegerField(
+ initial=5, help_text="Browser native int field with a default", required=False
+ )
+ bool_arg_default_true = forms.BooleanField(
+ initial=True, help_text="should be checked checkbox", required=False
+ )
+ bool_arg_default_false = forms.BooleanField(
+ initial=False, help_text="should be unchecked checkbox", required=False
+ )
+ bool_arg_no_default = forms.BooleanField(help_text="Bool arg with a dropdown", required=True)
+ str_arg = forms.CharField(help_text="should be small field", required=True)
+ str_arg_with_default = forms.CharField(
+ initial="whatever", help_text="should be small field with text in it", required=False
+ )
+ # TODO: different widget
+ text_arg = forms.CharField(help_text="should have a big text field", required=True)
+ # TODO: different widget
+ text_arg_with_default = forms.CharField(
+ initial="something",
+ help_text="should have big text field with something filled in",
+ required=False,
+ )
+ undocumented_arg = forms.CharField(required=False, empty_value=None)
+ enum_auto = forms.TypedChoiceField(
+ choices=COLOR_CHOICES,
+ required=True,
+ help_text="should be choices with key names",
+ coerce=Coercer(Color, by_attribute=True),
+ )
+ enum_auto_not_required = forms.TypedChoiceField(
+ choices=COLOR_CHOICES,
+ required=False,
+ coerce=Coercer(Color, by_attribute=True),
+ help_text="choice field not required",
+ empty_value=None,
+ )
+ enum_auto_with_default = forms.TypedChoiceField(
+ choices=COLOR_CHOICES,
+ initial=Color.green.value,
+ required=False,
+ coerce=Coercer(Color, by_attribute=True),
+ help_text="choice field with entry selected",
+ )
+ enum_str = forms.TypedChoiceField(
+ choices=[("is_apple", "is_apple"), ("is_banana", "is_banana")],
+ required=True,
+ coerce=Coercer(Flag),
+ help_text="should be choices with string values",
+ )
+ enum_str_with_default = forms.TypedChoiceField(
+ choices=[("is_apple", "is_apple"), ("is_banana", "is_banana")],
+ required=False,
+ initial=Flag.is_apple.value,
+ coerce=Coercer(Flag),
+ )
+
+
+def test_compare_complex_example(db):
+ actual = function_to_form(example_func)
+ compare_forms(actual, ExpectedFormForExampleFunc)
+
+
+def _make_parameter(name, annotation, doc="", **kwargs):
+ """helper for simple params :) """
+ return Parameter(
+ name=name,
+ kind=Parameter.KEYWORD_ONLY,
+ default=kwargs.get("default", Parameter.empty),
+ annotation=annotation,
+ doc=doc,
+ )
+
+
+@pytest.mark.parametrize(
+ "arg,expected",
+ [
+ (_make_parameter("int", int, ""), forms.IntegerField(required=True)),
+ (
+ _make_parameter("int_none_default_not_required", int, default=None),
+ forms.IntegerField(required=False),
+ ),
+ (
+ _make_parameter("int_default", int, default=-1),
+ forms.IntegerField(initial=-1, required=False),
+ ),
+ (
+ _make_parameter("str_doc", str, "some doc"),
+ forms.CharField(help_text="some doc", required=True),
+ ),
+ (
+ _make_parameter("str_doc_not_required", str, "some doc", default="a"),
+ forms.CharField(required=False, initial="a", help_text="some doc"),
+ ),
+ (
+ _make_parameter("str_doc_falsey", str, "some doc", default=""),
+ forms.CharField(initial="", required=False, help_text="some doc"),
+ ),
+ (
+ _make_parameter("bool_default_none", bool, "some doc", default=None),
+ forms.NullBooleanField(required=False, help_text="some doc"),
+ ),
+ (
+ _make_parameter("bool_falsey", bool, "some doc", default=False),
+ forms.BooleanField(required=False, initial=False, help_text="some doc"),
+ ),
+ (
+ _make_parameter("bool_truthy", bool, "some doc", default=True),
+ forms.BooleanField(required=False, initial=True, help_text="some doc"),
+ ),
+ (
+ _make_parameter("bool_required", bool, "some doc"),
+ forms.BooleanField(required=True, help_text="some doc"),
+ ),
+ (
+ _make_parameter("optional_bool", Optional[bool], default=True),
+ forms.NullBooleanField(initial=True, required=False),
+ ),
+ (
+ _make_parameter("optional_bool", Optional[bool], default=None),
+ forms.NullBooleanField(initial=None, required=False),
+ ),
+ ],
+ ids=lambda x: x.name if hasattr(x, "name") else x,
+)
+def test_convert_arg(arg, expected):
+ field = param_to_field(arg)
+ compare_form_field(arg.name, field, expected)
+
+
+@pytest.mark.parametrize(
+ "arg,expected",
+ [
+ (
+ _make_parameter("enum_auto_default", Color, "another doc", default=Color.green),
+ forms.TypedChoiceField(
+ coerce=Coercer(Color, by_attribute=True),
+ initial=Color.green.value,
+ choices=COLOR_CHOICES,
+ required=False,
+ help_text="another doc",
+ ),
+ ),
+ (
+ _make_parameter("enum_str", Flag),
+ forms.TypedChoiceField(coerce=Coercer(Flag), choices=FLAG_CHOICES),
+ ),
+ (
+ _make_parameter("enum_str_default", Flag, default=Flag.is_apple),
+ forms.TypedChoiceField(
+ coerce=Coercer(Flag), initial="is_apple", choices=FLAG_CHOICES, required=False
+ ),
+ ),
+ ],
+ ids=lambda x: x.name if hasattr(x, "name") else x,
+)
+def test_enum_convert_arg(arg, expected):
+ field = param_to_field(arg)
+ compare_form_field(arg.name, field, expected)
+
+
+def test_custom_widgets():
+ param = _make_parameter("str_large_text_field", Text, "some doc", default="")
+ text_input_widget = forms.TextInput(attrs={"size": "80", "autocomplete": "off"})
+ compare_form_field(
+ "str_large_text_field",
+ param_to_field(
+ param,
+ {"widgets": {Text: text_input_widget}, "types": {Text: forms.CharField}},
+ ),
+ forms.CharField(
+ widget=text_input_widget, help_text="some doc", initial="", required=False
+ ),
+ )
+
+
+@pytest.mark.parametrize(
+ "parameter,exception_type,msg_regex",
+ [
+ (
+ _make_parameter("union_type", typing.Union[bool, str]),
+ ValueError,
+ "type class.*not supported",
+ ),
+ ],
+)
+def test_exceptions(parameter, exception_type, msg_regex):
+ with pytest.raises(exception_type, match=msg_regex):
+ param_to_field(parameter)
+
+
+def test_coercer():
+ class AutoEnum(enum.Enum):
+ whatever = enum.auto()
+ another = enum.auto()
+
+ class StringlyIntEnum(enum.Enum):
+ val1 = "1"
+ val2 = "2"
+
+ assert Coercer(AutoEnum)(AutoEnum.whatever.value) == AutoEnum.whatever
+ assert Coercer(AutoEnum)(str(AutoEnum.whatever.value)) == AutoEnum.whatever
+ assert Coercer(StringlyIntEnum)("1") == StringlyIntEnum("1")
+ with pytest.raises(ValueError):
+ Coercer(StringlyIntEnum)(1)
diff --git a/turtle_shell/tests/test_form_comparisons.py b/turtle_shell/tests/test_form_comparisons.py
new file mode 100644
index 0000000..130e9c2
--- /dev/null
+++ b/turtle_shell/tests/test_form_comparisons.py
@@ -0,0 +1,150 @@
+"""
+Confirm that our form comparison tool actually works!
+"""
+import enum
+import pytest
+from django import forms
+from typing import Type
+from .utils import compare_form_field, compare_forms
+
+
+class FormA(forms.Form):
+ myfield = forms.CharField()
+
+
+class DifferentFieldNames(forms.Form):
+ otherfield = forms.CharField()
+
+
+class DifferentFieldTypes(forms.Form):
+ myfield = forms.IntegerField()
+
+
+class DifferentFieldRequired(forms.Form):
+ myfield = forms.CharField(required=False)
+
+
+class DifferentInitial(forms.Form):
+ myfield = forms.CharField(initial="something")
+
+
+class DocForm(forms.Form):
+ """
+ My Doc
+
+ Some new line
+ """
+
+ some = forms.CharField()
+
+
+class DocFormExtraWhitespace(forms.Form):
+ """
+ My Doc
+
+ Some new line
+ """
+
+ some = forms.CharField()
+
+
+class DifferentDoc(forms.Form):
+ """
+ Another doc
+
+ Some new line
+ """
+
+ some = forms.CharField()
+
+
+def test_comparison():
+
+ compare_forms(FormA, FormA)
+ with pytest.raises(AssertionError, match="Found unexpected form fields"):
+ test_different_fields(raiseit=True)
+
+ with pytest.raises(AssertionError, match=r"Field mismatch \(myfield\)"):
+ test_field_required(raiseit=True)
+
+ with pytest.raises(AssertionError, match=r"Field mismatch \(myfield\)"):
+ test_field_initial(raiseit=True)
+
+ with pytest.raises(AssertionError, match=r"Field type mismatch \(myfield\)"):
+ test_different_field_types(raiseit=True)
+
+ with pytest.raises(AssertionError, match=r"Forms have different docstrings"):
+ test_doc_diff(raiseit=True)
+
+ test_different_whitespace()
+
+
+def _wrap_knownfail(f):
+ def wrapper(*args, raiseit=False, **kwargs):
+ try:
+ f(*args, **kwargs)
+ except AssertionError as exc:
+ if raiseit:
+ raise
+ else:
+ print("Known fail: {type(exc)}: {exc}")
+
+ return wrapper
+
+
+@_wrap_knownfail
+def test_different_fields():
+ compare_forms(FormA, DifferentFieldNames)
+
+
+@_wrap_knownfail
+def test_different_field_types():
+ compare_forms(FormA, DifferentFieldTypes)
+
+
+@_wrap_knownfail
+def test_field_required():
+ compare_forms(FormA, DifferentFieldRequired)
+
+
+@_wrap_knownfail
+def test_field_initial():
+ compare_forms(FormA, DifferentInitial)
+
+
+@_wrap_knownfail
+def test_convert_form():
+ pass
+
+
+@_wrap_knownfail
+def test_doc_diff():
+ actual_fields = DocForm.declared_fields
+ expected_fields = DifferentDoc.declared_fields
+ assert actual_fields.keys() == expected_fields.keys(), "Different fields"
+ compare_forms(DocForm, DifferentDoc)
+
+
+def test_different_whitespace():
+ compare_forms(DocForm, DocFormExtraWhitespace)
+
+
+def test_equivalent_forms():
+ class Form1(forms.Form):
+ myfield = forms.CharField(initial="whatever")
+
+ class Form2(forms.Form):
+ myfield = forms.CharField(initial="whatever")
+
+ compare_forms(Form1, Form2)
+
+
+def test_differ_on_doc():
+ class BasicForm(forms.Form):
+ myfield = forms.CharField(initial="whatever", help_text="what")
+
+ class BasicFormDifferentDoc(forms.Form):
+ myfield = forms.CharField(initial="whatever", help_text="different")
+
+ with pytest.raises(AssertionError, match="myfield.*"):
+ compare_forms(BasicForm, BasicFormDifferentDoc)
diff --git a/turtle_shell/tests/test_graphene_adapter.py b/turtle_shell/tests/test_graphene_adapter.py
new file mode 100644
index 0000000..ebd6b61
--- /dev/null
+++ b/turtle_shell/tests/test_graphene_adapter.py
@@ -0,0 +1,80 @@
+from .utils import execute_gql, execute_gql_and_get_input_json
+import turtle_shell
+from turtle_shell import utils
+import enum
+import pytest
+
+
+@pytest.mark.django_db
+class TestDefaultHandling:
+ def test_defaults(db):
+ # defaults should be passed through
+ def myfunc(a: bool = True, b: str = "whatever"):
+ pass
+
+ resp = execute_gql_and_get_input_json(
+ myfunc, "mutation { executeMyfunc(input: {}) { result { inputJson }}}"
+ )
+ assert resp == {"a": True, "b": "whatever"}
+
+ def test_enum_preservation(db):
+ class ReadType(enum.Enum):
+ fastq = enum.auto()
+ bam = enum.auto()
+
+ def func(read_type: ReadType = ReadType.fastq):
+ return read_type
+
+ input_json = execute_gql_and_get_input_json(
+ func, "mutation { executeFunc(input: {readType: BAM}) { result { inputJson }}}"
+ )
+ assert input_json == {"read_type": utils.EnumRegistry.to_json_repr(ReadType.bam)}
+ assert input_json["read_type"]["__enum__"]["name"] == "bam"
+
+ input_json = execute_gql_and_get_input_json(
+ func, "mutation { executeFunc(input: {}) { result { inputJson }}}"
+ )
+ assert input_json == {"read_type": utils.EnumRegistry.to_json_repr(ReadType.fastq)}
+ assert input_json["read_type"]["__enum__"]["name"] == "fastq"
+
+ def test_default_none(db):
+
+ # defaults should be passed through
+ def myfunc(a: bool = None, b: str = None):
+ pass
+
+ resp = execute_gql_and_get_input_json(
+ myfunc, "mutation { executeMyfunc(input: {}) { result { inputJson }}}"
+ )
+ # sadly None's get replaced :(
+ assert resp == {"a": None, "b": None}
+
+ def test_error_with_no_default(db):
+
+ # no default should error
+ def my_func(*, a: bool, b: str):
+ pass
+
+ registry = turtle_shell.get_registry()
+ registry.clear()
+ registry.add(my_func)
+ gql = "mutation { executeMyfunc(input: {}) { result { inputJson }}}"
+ result = registry.schema.execute(gql)
+ assert result.errors
+
+ def test_rendering_enum_with_mixed_type(db):
+ class MiscStringEnum(enum.Enum):
+ whatever = "bobiswhatever"
+ mish = "dish"
+ defa = "default yeah"
+
+ def func(s: MiscStringEnum = MiscStringEnum.defa):
+ return s
+
+ input_json = execute_gql_and_get_input_json(
+ func, "mutation { executeFunc(input: {}) { result { inputJson }}}"
+ )
+ input_json2 = execute_gql_and_get_input_json(
+ func, "mutation { executeFunc(input: {s: DEFAULT_YEAH}) { result { inputJson }}}"
+ )
+ assert input_json == input_json2
diff --git a/turtle_shell/tests/test_pydantic_adapter.py b/turtle_shell/tests/test_pydantic_adapter.py
new file mode 100644
index 0000000..a736337
--- /dev/null
+++ b/turtle_shell/tests/test_pydantic_adapter.py
@@ -0,0 +1,96 @@
+from pydantic import BaseModel
+import enum
+from typing import List
+from turtle_shell import pydantic_adapter
+from .utils import execute_gql, execute_gql_and_get_input_json
+import pytest
+
+
+class Status(enum.Enum):
+ complete = "complete"
+ bad = "bad"
+
+
+class NestedStructure(BaseModel):
+ status: Status
+ thing: str
+
+
+class StructuredOutput(BaseModel):
+ value: str
+ nested_things: List[NestedStructure]
+
+
+def test_get_nested_models():
+ lst = pydantic_adapter.get_pydantic_models_in_order(StructuredOutput)
+ assert lst == [NestedStructure, StructuredOutput]
+ lst = pydantic_adapter.get_pydantic_models_in_order(NestedStructure)
+ assert lst == [NestedStructure]
+
+
+def test_structured_output(db):
+ def myfunc(a: str) -> StructuredOutput:
+ return StructuredOutput(
+ value=a,
+ nested_things=[
+ NestedStructure(status=Status.bad, thing="other"),
+ NestedStructure(status=Status.complete, thing="other2"),
+ ],
+ )
+
+ result = execute_gql(
+ myfunc,
+ 'mutation { executeMyfunc(input:{a: "whatever"}) { structuredOutput { nestedThings { status }}}}',
+ )
+ assert not result.errors
+ nested = result.data["executeMyfunc"]["structuredOutput"]["nestedThings"]
+ assert nested == [{"status": "bad"}, {"status": "complete"}]
+
+
+@pytest.mark.xfail
+def test_duplicate_enum_reference(db):
+ class StructuredDuplicatingStatus(BaseModel):
+ # this extra status causes graphene reducer to complain cuz we don't cache the Enum model
+ # :(
+ status: Status
+ nested_things: List[NestedStructure]
+
+ def myfunc(a: str) -> StructuredDuplicatingStatus:
+ return StructuredDuplicatingStatus(
+ status=Status.complete,
+ value=a,
+ nested_things=[
+ NestedStructure(status=Status.bad, thing="other"),
+ NestedStructure(status=Status.complete, thing="other2"),
+ ],
+ )
+
+ result = execute_gql(
+ myfunc,
+ 'mutation { executeMyfunc(input:{a: "whatever"}) { structuredDuplicatingStatus { nested_things { status }}}}',
+ )
+ assert not result.errors
+ nested = result.data["executeMyfunc"]["output"]["nested_things"]
+ assert list(sorted(nested)) == list(sorted([{"status": "bad"}, {"status": "complete"}]))
+
+
+@pytest.mark.xfail
+def test_structured_input(db):
+ class NestedInput(BaseModel):
+ text: str
+
+ class StructuredInput(BaseModel):
+ a: str
+ b: List[int]
+ nested: List[NestedInput]
+
+ inpt1 = StructuredInput(a="a", b=[1, 2, 3], nested=[NestedInput(text="whatever")])
+
+ def myfunc(s: StructuredInput = inpt1) -> str:
+ return "apples"
+
+ inpt = execute_gql_and_get_input_json(
+ myfunc, "mutation { executeMyfunc(input: {}) { result { inputJson}}}"
+ )
+ actual = StructuredInput.parse_obj(inpt["s"])
+ assert actual == inpt1
diff --git a/turtle_shell/tests/test_utils.py b/turtle_shell/tests/test_utils.py
new file mode 100644
index 0000000..446efab
--- /dev/null
+++ b/turtle_shell/tests/test_utils.py
@@ -0,0 +1,25 @@
+from turtle_shell import utils
+import enum
+import json
+import pytest
+
+
+def test_json_encoder_not_registered():
+ class MyEnum(enum.Enum):
+ a = enum.auto()
+
+ with pytest.raises(TypeError, match="has not been registered"):
+ json.dumps(MyEnum.a, cls=utils.EnumAwareEncoder)
+
+
+def test_json_encoder_registered():
+ class MyOtherEnum(enum.Enum):
+ a = enum.auto()
+
+ utils.EnumRegistry.register(MyOtherEnum)
+
+ original = {"val": MyOtherEnum.a}
+ s = json.dumps(original, cls=utils.EnumAwareEncoder)
+ assert '"__enum__"' in s
+ round_trip = json.loads(s, cls=utils.EnumAwareDecoder)
+ assert round_trip == original
diff --git a/turtle_shell/tests/utils.py b/turtle_shell/tests/utils.py
new file mode 100644
index 0000000..4d0da54
--- /dev/null
+++ b/turtle_shell/tests/utils.py
@@ -0,0 +1,79 @@
+from typing import Type
+import inspect
+import turtle_shell
+
+from django import forms
+import json
+
+
+def compare_form_field(name, actual, expected):
+ """Compare important variables of two form fields"""
+ try:
+ assert type(actual) == type(expected)
+ except AssertionError as e:
+ # bare assert causes pytest rewrite, so we just add a bit around it
+ raise AssertionError(f"Field type mismatch ({name}): {e}") from e
+ actual_vars = vars(actual)
+ expected_vars = vars(expected)
+ for k in ("widget", "_func_name", "_parameter_name"):
+ actual_vars.pop(k, None)
+ expected_vars.pop(k, None)
+ try:
+ assert actual_vars == expected_vars
+ except AssertionError as e:
+ # bare assert causes pytest rewrite, so we just add a bit around it
+ raise AssertionError(f"Field mismatch ({name}): {e}") from e
+
+
+def compare_forms(actual: Type[forms.Form], expected: Type[forms.Form]):
+ """Compare two forms.
+
+ Checks:
+ 1. Shared fields are the same (see compare_form_field)
+ 2. Both forms have the same set of fields
+ 3. Both forms have the same docstring
+ """
+ actual_fields = actual.declared_fields
+ expected_fields = expected.declared_fields
+ excluded_keys = {"_func_name", "_parameter_name"}
+ shared_keys = list(set(actual_fields.keys()) & set(expected_fields.keys()) - excluded_keys)
+ extra_keys = list(set(actual_fields.keys()) - set(expected_fields.keys()) - excluded_keys)
+ missing_keys = list(set(expected_fields.keys()) - set(actual_fields.keys()) - excluded_keys)
+ for name in shared_keys:
+ actual_field = actual_fields[name]
+ expected_field = expected_fields[name]
+ compare_form_field(name, actual_field, expected_field)
+ assert not extra_keys, f"Found unexpected form fields:\n{extra_keys}"
+ assert not missing_keys, f"Expected fields missing:\n{missing_keys}"
+ try:
+ assert actual_fields.keys() == expected_fields.keys()
+ except AssertionError as e:
+ # bare assert causes pytest rewrite, so we just add a bit around it
+ raise AssertionError(f"Forms have different field names: {e}") from e
+ try:
+ assert inspect.getdoc(actual) == inspect.getdoc(expected)
+ except AssertionError as e:
+ print(repr(inspect.getdoc(actual)))
+ print(repr(inspect.getdoc(expected)))
+ # bare assert causes pytest rewrite, so we just add a bit around it
+ raise AssertionError(f"Forms have different docstrings: {e}") from e
+
+
+def execute_gql(func, gql):
+ registry = turtle_shell.get_registry()
+ registry.clear()
+ registry.add(func)
+ result = registry.schema.execute(gql)
+ return result
+
+
+def execute_gql_and_get_input_json(func, gql):
+ """Helper to make it easy to test default setting"""
+ result = execute_gql(func, gql)
+ data = result.data
+ assert not result.errors
+ result_from_response = list(data.values())[0]["result"]
+ assert result_from_response
+ return json.loads(result_from_response["inputJson"])
+ # data = json.loads(result["data"]["result"]["inputJson"])
+ # return data
diff --git a/turtle_shell/utils.py b/turtle_shell/utils.py
new file mode 100644
index 0000000..dd54bff
--- /dev/null
+++ b/turtle_shell/utils.py
@@ -0,0 +1,72 @@
+import json
+import enum
+from collections import defaultdict
+from django.core.serializers.json import DjangoJSONEncoder
+
+
+class EnumRegistry:
+ # URP - global! :(
+ _registered_enums = defaultdict(dict)
+
+ @classmethod
+ def register(cls, enum_class):
+ cls._registered_enums[enum_class.__module__][enum_class.__qualname__] = enum_class
+
+ @classmethod
+ def has_enum(cls, enum_class):
+ try:
+ return cls._registered_enums[enum_class.__module__][enum_class.__qualname__]
+ except KeyError:
+ return None
+
+ @classmethod
+ def to_json_repr(cls, enum_member):
+ if not cls.has_enum(type(enum_member)):
+ raise TypeError(
+ f"Enum type {type(enum_member)} has not been registered and can't be serialized :("
+ )
+ enum_class = type(enum_member)
+ return {
+ "__enum__": {
+ "__type__": [enum_class.__module__, enum_class.__qualname__],
+ "name": enum_member.name,
+ "value": enum_member.value,
+ }
+ }
+
+ @classmethod
+ def from_json_repr(cls, json_obj):
+ if "__enum__" not in json_obj:
+ raise ValueError("Enums must be represented by __enum__ key")
+ try:
+ type_data = json_obj["__enum__"]["__type__"]
+ try:
+ enum_class = cls._registered_enums[type_data[0]][type_data[1]]
+ except KeyError:
+ raise ValueError(f"Looks like enum {type_data} is not registered :(")
+ return enum_class(json_obj["__enum__"]["value"])
+ except (KeyError, ValueError) as e:
+ raise ValueError(f"Invalid enum representation in JSON:: {type(e).__name__}: {e}")
+
+ @classmethod
+ def object_hook(cls, dct):
+ if "__enum__" not in dct:
+ return dct
+ return cls.from_json_repr(dct)
+
+
+class EnumAwareEncoder(DjangoJSONEncoder):
+ def default(self, o, **k):
+ if isinstance(o, enum.Enum):
+ return EnumRegistry.to_json_repr(o)
+ else:
+ super().default(o, **k)
+
+
+class EnumAwareDecoder(json.JSONDecoder):
+ def __init__(self, *a, **k):
+ k.setdefault("object_hook", self.object_hook)
+ super().__init__(*a, **k)
+
+ def object_hook(self, dct):
+ return EnumRegistry.object_hook(dct)
diff --git a/turtle_shell/views.py b/turtle_shell/views.py
index 91ea44a..2b3c245 100644
--- a/turtle_shell/views.py
+++ b/turtle_shell/views.py
@@ -1,3 +1,138 @@
-from django.shortcuts import render
+from django.views.generic import DetailView
+from django.views.generic import ListView
+from django.views.generic import TemplateView
+from django.views.generic.edit import CreateView
+from django.contrib.auth.mixins import LoginRequiredMixin
+from graphene_django.views import GraphQLView
+from .models import ExecutionResult
+from dataclasses import dataclass
+from django.urls import path
+from django.contrib import messages
+from typing import Optional
-# Create your views here.
+
+class ExecutionViewMixin:
+ """Wrapper that auto-filters queryset to include function name"""
+
+ func_name: str = None
+ model = ExecutionResult
+
+ def __init__(self, *a, **k):
+ super().__init__(*a, **k)
+ if not self.func_name:
+ raise ValueError(
+ "Must specify function name for ExecutionClasses classes (class was {type(self).__name__})"
+ )
+
+ def get_queryset(self):
+ qs = super().get_queryset()
+ return qs.filter(func_name=self.func_name)
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context["func_name"] = self.func_name
+ return context
+
+
+class ExecutionDetailView(ExecutionViewMixin, DetailView):
+ pass
+
+
+class ExecutionListView(ExecutionViewMixin, ListView):
+ def get_queryset(self):
+ qs = super().get_queryset()
+ return qs.order_by("-created")
+
+
+class ExecutionCreateView(ExecutionViewMixin, CreateView):
+ def get_form_kwargs(self, *a, **k):
+ kwargs = super().get_form_kwargs(*a, **k)
+ kwargs["user"] = self.request.user
+ return kwargs
+
+ def form_valid(self, form):
+ from .models import CaughtException
+
+ sup = super().form_valid(form)
+ try:
+ self.object.execute()
+ except CaughtException as e:
+ messages.warning(
+ self.request, f"Error in Execution {self.object.pk} ({self.object.func_name}): {e}"
+ )
+ else:
+ messages.info(
+ self.request, f"Completed execution for {self.object.pk} ({self.object.func_name})"
+ )
+ return sup
+
+ def get_context_data(self, *a, **k):
+ ctx = super().get_context_data(*a, **k)
+ ctx["doc"] = self.form_class.__doc__
+ return ctx
+
+
+class LoginRequiredGraphQLView(LoginRequiredMixin, GraphQLView):
+ def handle_no_permission(self):
+ if self.request.user.is_authenticated:
+ raise PermissionDenied("No permission to access this resource.")
+ resp = HttpResponse(json.dumps({"error": "Invalid token"}), status=401)
+ resp["WWW-Authenticate"] = "Bearer"
+ return resp
+
+
+@dataclass
+class Views:
+ detail_view: object
+ list_view: object
+ create_view: object
+ graphql_view: Optional[object]
+ func_name: str
+
+ @classmethod
+ def from_function(
+ cls, func: "turtle_shell._Function", *, require_login: bool = True, schema=None
+ ):
+ bases = (LoginRequiredMixin,) if require_login else tuple()
+ detail_view = type(
+ f"{func.name}DetailView", bases + (ExecutionDetailView,), ({"func_name": func.name})
+ )
+ list_view = type(
+ f"{func.name}ListView", bases + (ExecutionListView,), ({"func_name": func.name})
+ )
+ create_view = type(
+ f"{func.name}CreateView",
+ bases + (ExecutionCreateView,),
+ ({"func_name": func.name, "form_class": func.form_class}),
+ )
+ return cls(
+ detail_view=detail_view,
+ list_view=list_view,
+ create_view=create_view,
+ func_name=func.name,
+ graphql_view=(
+ LoginRequiredGraphQLView.as_view(graphiql=True, schema=schema) if schema else None
+ ),
+ )
+
+ def urls(self, *, list_template, detail_template, create_template):
+ # TODO: namespace this again!
+ ret = [
+ path(
+ f"{self.func_name}/",
+ self.list_view.as_view(template_name=list_template),
+ name=f"list-{self.func_name}",
+ ),
+ path(
+ f"{self.func_name}/create/",
+ self.create_view.as_view(template_name=create_template),
+ name=f"create-{self.func_name}",
+ ),
+ path(
+ f"{self.func_name}//",
+ self.detail_view.as_view(template_name=detail_template),
+ name=f"detail-{self.func_name}",
+ ),
+ ]
+ ret.append(path("graphql", self.graphql_view))
+ return ret
|---|