From 4672b7d412c64cbee6fa08a8f17e673c7333e6ef Mon Sep 17 00:00:00 2001
From: Jeff Tratner
Date: Sun, 11 Apr 2021 11:45:34 -0400
Subject: [PATCH 01/29] Add some stuff
---
turtle_shell/README.rst | 230 +++++++++++++++++++++++++++++++
turtle_shell/function_to_form.py | 121 ++++++++++++++++
turtle_shell/models.py | 22 ++-
turtle_shell/views.py | 30 +++-
4 files changed, 400 insertions(+), 3 deletions(-)
create mode 100644 turtle_shell/README.rst
create mode 100644 turtle_shell/function_to_form.py
diff --git a/turtle_shell/README.rst b/turtle_shell/README.rst
new file mode 100644
index 0000000..256a614
--- /dev/null
+++ b/turtle_shell/README.rst
@@ -0,0 +1,230 @@
+Function to Form View
+=====================
+
+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.
+
+How does it work?
+-----------------
+
+
+This lil' old library converts _your_ function with annotations into a ✨Django Form✨ (that calls the function on ``form_valid``!)
+
+It leverages some neat features of defopt under the hood so that a function like this:
+
+.. code-block:: python
+
+ def some_action(
+ username: str,
+ url: str,
+ optional_comment: str=None,
+ hits: int = 5,
+ _special_hidden_field: bool=False,
+ ):
+ """Perform action on URL for username.
+
+ Args:
+ username: the user to associate the URL with
+ url: which url to hit
+ optional_comment: why this happened
+ hits: how many hits you saw
+ """
+ pass
+
+
+Becomes this awesome form!
+
+
+
+
+Overall gist
+------------
+
+You register your functions with the library::
+
+ MyFuncWrapper = wrapit.wrap(myfunc)
+
+Then in urls.py::
+
+
+ path("/myfunc", MyFuncWrapper.as_view())
+ path("/api/myfunc", MyFuncWrapper.as_view(graphql=True))
+
+And finally run migrations::
+
+ ...
+
+
+Now you can get list view / form to create / graphql API to create.
+
+Example Implementation
+----------------------
+
+executions.py::
+
+ from easy_execute import ExecutionWrapper
+ from my_util_scripts import find_root_cause, summarize_issue, error_summary
+
+ Registry = ExecutionWrapper()
+
+
+ FindRootCause = Registry.wrap(find_root_cause)
+ SummarizeIssue = Registry.wrap(summarize_issue)
+ ErrorSummary = Registry.wrap(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::
+
+ from . import executions
+
+ class FindRootCauseList(executions.FindRootCause.list_view()):
+ template_name = "list-root-cause.html"
+
+ class FindRootCauseDetail(executions.FindRootCause.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
+
+ 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
+ execution {
+ status: String?
+ exitCode: Int
+ successful: Bool
+ }
+ rawOutput {
+ stderr: String?
+ stdout: String # often JSON serializable
+ }
+ }
+ 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 the forms
+---------------------
+
+First - you can pass a config dictionary to ``function_to_form`` to tell it to
+use particular widgets for fields or how to construct a form field for your custom type (
+as a callable that takes standard field keyword arguments).
+
+You can also subclass the generated form object to add your own ``clean_*`` methods or more complex validation - yay!
diff --git a/turtle_shell/function_to_form.py b/turtle_shell/function_to_form.py
new file mode 100644
index 0000000..9b06db8
--- /dev/null
+++ b/turtle_shell/function_to_form.py
@@ -0,0 +1,121 @@
+"""
+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
+
+from django import forms
+from defopt import Parameter, signature, _parse_docstring
+from typing import Dict
+from typing import Type
+
+
+type2field_type = {int: forms.IntegerField, str: forms.CharField, bool: forms.BooleanField}
+
+
+def doc_mapping(str) -> Dict[str, str]:
+ return {}
+
+
+def function_to_form(func, config: dict = 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
+ """
+ sig = signature(func)
+ # i.e., class body for form
+ fields = {}
+ for parameter in sig.parameters.values():
+ fields[parameter.name] = param_to_field(parameter, config)
+ fields["__doc__"] = re.sub("\n+", "\n", _parse_docstring(inspect.getdoc(func)).text)
+ fields["_func"] = func
+ form_name = "".join(part.capitalize() for part in func.__name__.split("_"))
+
+ def execute_function(self):
+ # TODO: reconvert back to enum type! :(
+ return func(**self.cleaned_data)
+
+ fields["execute_function"] = execute_function
+
+ return type(form_name, (forms.Form,), fields)
+
+
+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 = dict(type2field_type)
+ all_types.update(config.get("types", {}))
+ widgets = config.get("widgets") or {}
+ field_type = None
+ kwargs = {}
+ if issubclass(param.annotation, enum.Enum):
+ field_type = forms.TypedChoiceField
+ kwargs["coerce"] = param.annotation
+ kwargs["choices"] = [(member.value, member.value) for member in param.annotation]
+ # coerce back
+ if isinstance(param.default, param.annotation):
+ kwargs["initial"] = param.default.value
+ else:
+ for k, v in all_types.items():
+ if isinstance(k, str):
+ if param.name == k:
+ field_type = v
+ break
+ continue
+ if issubclass(k, param.annotation):
+ field_type = v
+ break
+ else:
+ raise ValueError(f"Field {param.name}: Unknown field type: {param.annotation}")
+ if param.default is Parameter.empty:
+ kwargs["required"] = True
+ elif param.default is None:
+ kwargs["required"] = False
+ else:
+ kwargs["required"] = False
+ kwargs.setdefault("initial", param.default)
+ if param.doc:
+ kwargs["help_text"] = param.doc
+ for k, v in widgets.items():
+ if isinstance(k, str):
+ if param.name == k:
+ kwargs["widget"] = v
+ break
+ continue
+ if issubclass(k, param.annotation):
+ kwargs["widget"] = v
+ break
+ return field_type(**kwargs)
+
+
+def to_graphene_form_mutation(func):
+ import graphene
+ from graphene_django.forms.mutation import DjangoFormMutation
+
+ form_klass = function_to_form(func)
+
+ class DefaultOperationMutation(DjangoFormMutation):
+ form_output_json = graphene.String()
+ class Meta:
+ form_class = form_klass
+
+ @classmethod
+ def perform_mutate(cls, form, info):
+ return cls(errors=[], from_output_json=json.dumps(form.execute_func()))
+
+
+ DefaultOperationMutation.__doc__ = f'Mutation form for {form_klass.__name__}.\n{form_klass.__doc__}'
+ DefaultOperationMutation.__name__ = f'{form_klass.__name__}Mutation'
+ return DefaultOperationMutation
diff --git a/turtle_shell/models.py b/turtle_shell/models.py
index 71a8362..a54a7bc 100644
--- a/turtle_shell/models.py
+++ b/turtle_shell/models.py
@@ -1,3 +1,23 @@
from django.db import models
+import uuid
+
+
+
+class ExecutionResult(models.Model):
+ uuid = models.UUIDField(unique=True, editable=False, default=uuid.uuid4)
+ func_name = models.CharField(max_length=512)
+ input_json = models.JSONField()
+ output_json = models.JSONField()
+
+ class ExecutionStatus(models.TextChoices):
+ CREATED = 'CREATED', 'Created'
+ RUNNING = 'RUNNING', 'Running'
+ DONE = 'DONE', 'Done'
+ ERRORED = 'ERRORED', 'Errored'
+
+ 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)
-# Create your models here.
diff --git a/turtle_shell/views.py b/turtle_shell/views.py
index 91ea44a..cb764b1 100644
--- a/turtle_shell/views.py
+++ b/turtle_shell/views.py
@@ -1,3 +1,29 @@
-from django.shortcuts import render
+from django.views.generic import DetailView
+from django.views.generic import ListView
+from django.views.generic.edit import CreateView
+from .models import ExecutionResult
-# 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)
+
+
+class ExecutionDetailView(DetailView, ExecutionViewMixin):
+ pass
+
+class ExecutionListView(ListView, ExecutionViewMixin):
+ pass
+
+class ExecutionCreateView(CreateView, ExecutionViewMixin):
+ pass
From 6bebee554b6ca6d5f0927f032068ff17ea04f9ec Mon Sep 17 00:00:00 2001
From: Jeff Tratner
Date: Wed, 14 Apr 2021 22:33:09 -0400
Subject: [PATCH 02/29] License authors and .gitignore
---
.gitignore | 27 +++++++++++++++++++++++++++
AUTHORS | 1 +
LICENSE | 21 +++++++++++++++++++++
3 files changed, 49 insertions(+)
create mode 100644 .gitignore
create mode 100644 AUTHORS
create mode 100644 LICENSE
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.
From eef531e6cf99a36a86f43e628ee0ddead81cd1ec Mon Sep 17 00:00:00 2001
From: Jeff Tratner
Date: Sun, 11 Apr 2021 11:46:08 -0400
Subject: [PATCH 03/29] Set up turtle shell and examples
Mostly working model wooo
Tests passing
---
turtle_shell/README.rst | 25 +-
turtle_shell/__init__.py | 70 +++++
turtle_shell/function_to_form.py | 117 +++++---
turtle_shell/migrations/0001_initial.py | 27 ++
turtle_shell/models.py | 69 ++++-
.../turtle_shell/executionresult_detail.html | 25 ++
.../turtle_shell/executionresult_list.html | 13 +
.../templates/turtle_shell/overview.html | 11 +
turtle_shell/tests.py | 3 -
turtle_shell/tests/__init__.py | 0
turtle_shell/tests/conftest.py | 4 +
turtle_shell/tests/test_django_cli2ui.py | 250 ++++++++++++++++++
turtle_shell/tests/test_form_comparisons.py | 129 +++++++++
turtle_shell/tests/utils.py | 33 +++
turtle_shell/views.py | 61 ++++-
15 files changed, 785 insertions(+), 52 deletions(-)
create mode 100644 turtle_shell/migrations/0001_initial.py
create mode 100644 turtle_shell/templates/turtle_shell/executionresult_detail.html
create mode 100644 turtle_shell/templates/turtle_shell/executionresult_list.html
create mode 100644 turtle_shell/templates/turtle_shell/overview.html
delete mode 100644 turtle_shell/tests.py
create mode 100644 turtle_shell/tests/__init__.py
create mode 100644 turtle_shell/tests/conftest.py
create mode 100644 turtle_shell/tests/test_django_cli2ui.py
create mode 100644 turtle_shell/tests/test_form_comparisons.py
create mode 100644 turtle_shell/tests/utils.py
diff --git a/turtle_shell/README.rst b/turtle_shell/README.rst
index 256a614..f3f5e33 100644
--- a/turtle_shell/README.rst
+++ b/turtle_shell/README.rst
@@ -1,6 +1,16 @@
Function to Form View
=====================
+CURRENTLY BROKEN STUFF:
+
+1. Graphql view does not respect defaults :( (e.g., find_root_cause comes through as false :()
+2. Needs testing
+3. Needs lots of testing
+
+LIMITATIONS:
+
+1. Boolean fields always are passed in as False.
+
Motivation
----------
@@ -48,13 +58,22 @@ Overall gist
You register your functions with the library::
- MyFuncWrapper = wrapit.wrap(myfunc)
+ Registry = turtle_shell.get_registry()
+
+ Registry.add(myfunc)
Then in urls.py::
- path("/myfunc", MyFuncWrapper.as_view())
- path("/api/myfunc", MyFuncWrapper.as_view(graphql=True))
+ 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::
diff --git a/turtle_shell/__init__.py b/turtle_shell/__init__.py
index e69de29..1294d72 100644
--- a/turtle_shell/__init__.py
+++ b/turtle_shell/__init__.py
@@ -0,0 +1,70 @@
+from dataclasses import dataclass
+
+@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):
+ 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)
+ 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, request):
+ from django.template import loader
+ from django.http import HttpResponse
+
+ template = loader.get_template("turtle_shell/overview.html")
+ context = {"registry": self, "functions": self.func_name2func.values()}
+ return HttpResponse(template.render(context))
+
+ 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'):
+ from django.urls import path
+ from . import views
+ urls = [path("", self.summary_view, name="overview")]
+ for func in self.func_name2func.values():
+ urls.extend(views.Views.from_function(func).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
diff --git a/turtle_shell/function_to_form.py b/turtle_shell/function_to_form.py
index 9b06db8..99832a8 100644
--- a/turtle_shell/function_to_form.py
+++ b/turtle_shell/function_to_form.py
@@ -10,21 +10,37 @@
import enum
import inspect
import re
+import typing
+from dataclasses import dataclass
from django import forms
from defopt import Parameter, signature, _parse_docstring
-from typing import Dict
+from typing import Dict, Optional
from typing import Type
+import pathlib
-type2field_type = {int: forms.IntegerField, str: forms.CharField, bool: forms.BooleanField}
+type2field_type = {int: forms.IntegerField, str: forms.CharField, bool: forms.BooleanField,
+ Optional[bool]: forms.NullBooleanField,
+ pathlib.Path: forms.CharField, dict: forms.JSONField}
+
+@dataclass
+class _Function:
+ func: callable
+ name: str
+ form_class: object
+
+ @classmethod
+ def from_function(cls, func, *, name):
+ from turtle_shell.function_to_form import function_to_form
+ return cls(func=func, name=name, form_class=function_to_form(func, name=name))
def doc_mapping(str) -> Dict[str, str]:
return {}
-def function_to_form(func, config: dict = None) -> Type[forms.Form]:
+def function_to_form(func, *, config: dict = None, name: str=None) -> Type[forms.Form]:
"""Convert a function to a Django Form.
Args:
@@ -32,22 +48,59 @@ def function_to_form(func, config: dict = None) -> Type[forms.Form]:
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():
fields[parameter.name] = param_to_field(parameter, config)
+ if parameter.default is not Parameter.empty:
+ defaults[parameter.name] = parameter.default
fields["__doc__"] = re.sub("\n+", "\n", _parse_docstring(inspect.getdoc(func)).text)
- fields["_func"] = func
form_name = "".join(part.capitalize() for part in func.__name__.split("_"))
- def execute_function(self):
- # TODO: reconvert back to enum type! :(
- return func(**self.cleaned_data)
- fields["execute_function"] = execute_function
+ 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)
+
- return type(form_name, (forms.Form,), 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
def param_to_field(param: Parameter, config: dict = None) -> forms.Field:
@@ -60,24 +113,29 @@ def param_to_field(param: Parameter, config: dict = None) -> forms.Field:
widgets = config.get("widgets") or {}
field_type = None
kwargs = {}
- if issubclass(param.annotation, enum.Enum):
+ 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:
field_type = forms.TypedChoiceField
- kwargs["coerce"] = param.annotation
- kwargs["choices"] = [(member.value, member.value) for member in param.annotation]
+ kwargs["coerce"] = kind
+ kwargs["choices"] = [(member.value, member.value) for member in kind]
# coerce back
- if isinstance(param.default, param.annotation):
+ if isinstance(param.default, kind):
kwargs["initial"] = param.default.value
else:
+ field_type = all_types.get(param.annotation, all_types.get(kind))
for k, v in all_types.items():
- if isinstance(k, str):
- if param.name == k:
- field_type = v
- break
- continue
- if issubclass(k, param.annotation):
+ if field_type:
+ break
+ if inspect.isclass(k) and issubclass(kind, k) or k == kind:
field_type = v
break
- else:
+ if not field_type:
raise ValueError(f"Field {param.name}: Unknown field type: {param.annotation}")
if param.default is Parameter.empty:
kwargs["required"] = True
@@ -100,22 +158,3 @@ def param_to_field(param: Parameter, config: dict = None) -> forms.Field:
return field_type(**kwargs)
-def to_graphene_form_mutation(func):
- import graphene
- from graphene_django.forms.mutation import DjangoFormMutation
-
- form_klass = function_to_form(func)
-
- class DefaultOperationMutation(DjangoFormMutation):
- form_output_json = graphene.String()
- class Meta:
- form_class = form_klass
-
- @classmethod
- def perform_mutate(cls, form, info):
- return cls(errors=[], from_output_json=json.dumps(form.execute_func()))
-
-
- DefaultOperationMutation.__doc__ = f'Mutation form for {form_klass.__name__}.\n{form_klass.__doc__}'
- DefaultOperationMutation.__name__ = f'{form_klass.__name__}Mutation'
- return DefaultOperationMutation
diff --git a/turtle_shell/migrations/0001_initial.py b/turtle_shell/migrations/0001_initial.py
new file mode 100644
index 0000000..ba7b2c0
--- /dev/null
+++ b/turtle_shell/migrations/0001_initial.py
@@ -0,0 +1,27 @@
+# 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/models.py b/turtle_shell/models.py
index a54a7bc..dbc2a29 100644
--- a/turtle_shell/models.py
+++ b/turtle_shell/models.py
@@ -1,23 +1,86 @@
from django.db import models
+from django.urls import reverse
+from django.conf import settings
import uuid
+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):
- uuid = models.UUIDField(unique=True, editable=False, default=uuid.uuid4)
- func_name = models.CharField(max_length=512)
+ 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()
- output_json = models.JSONField()
+ output_json = models.JSONField(default=dict, null=True)
+ error_json = models.JSONField(default=dict, null=True)
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"""
+ if self.status not in (self.ExecutionStatus.CREATED, self.ExecutionStatus.RUNNING):
+ raise ValueError("Cannot run - execution state isn't complete")
+ func = self.get_function()
+ try:
+ # TODO: redo conversion another time!
+ result = func(**self.input_json)
+ except Exception as e:
+ # TODO: catch integrity error separately
+ self.error_json = {"type": type(e).__name__, "message": str(e)}
+ self.status = self.ExecutionStatus.ERRORED
+ self.save()
+ raise CaughtException(f"Failed on {self.func_name} ({type(e).__name__})", e) from e
+ try:
+ self.output_json = result
+ self.status = self.ExecutionStatus.DONE
+ 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 self
+
+ 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__}(pk="{self.pk}", func_name="{self.func_name}",'
+ f' created={self.created}, modified={self.modified})')
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..2808ccc
--- /dev/null
+++ b/turtle_shell/templates/turtle_shell/executionresult_detail.html
@@ -0,0 +1,25 @@
+{% extends 'base.html' %}
+
+{% block content %}
+Execution for {{func_name}} ({{object.pk}})
+ State
+{{object.status}}
+ Input
+{{object.input_json|pprint|escape}}
+{% if object.output_json %}
+ Output
+{{object.output_json|pprint|escape}}
+{% endif %}
+{% if object.error_json %}
+ Error
+{{object.error_json|pprint|escape}}
+{% endif %}
+ User
+{{object.user}}
+Created
+{{object.created}}
+Modified
+{{object.modified}}
+{% 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..e742e19
--- /dev/null
+++ b/turtle_shell/templates/turtle_shell/executionresult_list.html
@@ -0,0 +1,13 @@
+{% extends 'base.html' %}
+
+{% block content %}
+Executions for {{func_name}}
+
+
+{% endblock content %}
+
+
diff --git a/turtle_shell/templates/turtle_shell/overview.html b/turtle_shell/templates/turtle_shell/overview.html
new file mode 100644
index 0000000..452577f
--- /dev/null
+++ b/turtle_shell/templates/turtle_shell/overview.html
@@ -0,0 +1,11 @@
+{% extends "base.html" %}
+
+{% block content %}
+
+{% endblock content %}
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..88e73b8
--- /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("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..f03b6ac
--- /dev/null
+++ b/turtle_shell/tests/test_django_cli2ui.py
@@ -0,0 +1,250 @@
+import enum
+import json
+
+import pytest
+from django import forms
+
+from .utils import compare_form_field
+from turtle_shell.function_to_form import param_to_field
+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.value, e.value) 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):
+ """
+ First line of text content should be there.
+
+ Later lines of content here.
+ """
+
+ int_arg = forms.IntegerField()
+ int_arg_with_default = forms.IntegerField(initial=5)
+ bool_arg_default_true = forms.BooleanField(initial=True)
+ bool_arg_default_false = forms.BooleanField(initial=False)
+ bool_arg_no_default = forms.BooleanField()
+ str_arg = forms.CharField()
+ str_arg_with_default = forms.CharField(initial="whatever")
+ # TODO: different widget
+ text_arg = forms.CharField()
+ # TODO: different widget
+ text_arg_with_default = forms.CharField(initial="something")
+ enum_auto = forms.ChoiceField
+
+
+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_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("enum_auto", Color),
+ forms.TypedChoiceField(coerce=Color, choices=COLOR_CHOICES),
+ ),
+ (
+ _make_parameter("enum_auto_default", Color, "another doc", default=Color.green),
+ forms.TypedChoiceField(
+ coerce=Color,
+ initial=Color.green.value,
+ choices=COLOR_CHOICES,
+ required=False,
+ help_text="another doc",
+ ),
+ ),
+ (
+ _make_parameter("enum_str", Flag),
+ forms.TypedChoiceField(coerce=Flag, choices=FLAG_CHOICES),
+ ),
+ (
+ _make_parameter("enum_str_default", Flag, default=Flag.is_apple),
+ forms.TypedChoiceField(
+ coerce=Flag, initial="is_apple", choices=FLAG_CHOICES, required=False
+ ),
+ ),
+ (
+ _make_parameter("optional_bool", Optional[bool], default=True),
+ forms.NullBooleanField(initial=True, 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)
+
+
+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
+ ),
+ )
+
+
+def test_validators():
+ # something about fields failing validation
+ pass
+
+
+def execute_gql_and_get_input_json(func, gql):
+ registry = turtle_shell.get_registry()
+ registry.clear()
+ registry.add(func)
+ result = registry.schema.execute(gql)
+ data = result.data
+ assert not result.errors
+ return json.loads(list(data.values())[0]["result"]["inputJson"])
+ # data = json.loads(result["data"]["result"]["inputJson"])
+ # return data
+
+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"}
+
+@pytest.mark.xfail
+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
+
+
+@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)
diff --git a/turtle_shell/tests/test_form_comparisons.py b/turtle_shell/tests/test_form_comparisons.py
new file mode 100644
index 0000000..96652f4
--- /dev/null
+++ b/turtle_shell/tests/test_form_comparisons.py
@@ -0,0 +1,129 @@
+"""
+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="Forms have different field names"):
+ 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)
diff --git a/turtle_shell/tests/utils.py b/turtle_shell/tests/utils.py
new file mode 100644
index 0000000..1d9f47a
--- /dev/null
+++ b/turtle_shell/tests/utils.py
@@ -0,0 +1,33 @@
+from typing import Type
+import inspect
+
+from django import forms
+
+
+def compare_form_field(name, actual, expected):
+ """Compare important variables of two form fields"""
+ assert type(actual) == type(expected), f"Field type mismatch ({name})"
+ actual_vars = vars(actual)
+ expected_vars = vars(expected)
+ actual_vars.pop("widget", None)
+ expected_vars.pop("widget", None)
+ assert actual_vars == expected_vars, f"Field mismatch ({name})"
+
+
+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
+ shared_keys = list(set(actual_fields.keys()) & set(expected_fields.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 actual_fields.keys() == expected_fields.keys(), "Forms have different field names"
+ assert inspect.getdoc(actual) == inspect.getdoc(expected), "Forms have different docstrings"
diff --git a/turtle_shell/views.py b/turtle_shell/views.py
index cb764b1..34ca291 100644
--- a/turtle_shell/views.py
+++ b/turtle_shell/views.py
@@ -1,7 +1,11 @@
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 .models import ExecutionResult
+from dataclasses import dataclass
+from django.urls import path
+from django.contrib import messages
class ExecutionViewMixin:
@@ -18,12 +22,61 @@ 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(DetailView, ExecutionViewMixin):
- pass
-class ExecutionListView(ListView, ExecutionViewMixin):
+class ExecutionDetailView(ExecutionViewMixin, DetailView):
pass
-class ExecutionCreateView(CreateView, ExecutionViewMixin):
+class ExecutionListView(ExecutionViewMixin, ListView):
pass
+
+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, str(e))
+ else:
+ messages.info(self.request, f"Completed execution for {self.object.pk}")
+ return sup
+
+
+@dataclass
+class Views:
+ detail_view: object
+ list_view: object
+ create_view: object
+ func_name: str
+
+ @classmethod
+ def from_function(cls, func: 'turtle_shell._Function'):
+ detail_view = type(f'{func.name}DetailView', (ExecutionDetailView,), ({"func_name": func.name}))
+ list_view = type(f'{func.name}ListView', (ExecutionListView,), ({"func_name": func.name}))
+ create_view = type(f'{func.name}CreateView', (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)
+
+ def urls(self, *, list_template, detail_template, create_template):
+ # TODO: namespace this again!
+ return [
+ 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}")
+ ]
+
From e55e7405ab4986bea2f0ce66d9f0320bddd23d23 Mon Sep 17 00:00:00 2001
From: Jeff Tratner
Date: Sun, 11 Apr 2021 21:25:47 -0400
Subject: [PATCH 04/29] Do better job with enums
---
turtle_shell/function_to_form.py | 2 +-
turtle_shell/tests/conftest.py | 2 +-
turtle_shell/tests/test_django_cli2ui.py | 51 +++++++++++++--------
turtle_shell/tests/test_form_comparisons.py | 23 +++++++++-
turtle_shell/tests/utils.py | 30 ++++++++++--
5 files changed, 83 insertions(+), 25 deletions(-)
diff --git a/turtle_shell/function_to_form.py b/turtle_shell/function_to_form.py
index 99832a8..f1c773b 100644
--- a/turtle_shell/function_to_form.py
+++ b/turtle_shell/function_to_form.py
@@ -123,7 +123,7 @@ def param_to_field(param: Parameter, config: dict = None) -> forms.Field:
if is_enum_class:
field_type = forms.TypedChoiceField
kwargs["coerce"] = kind
- kwargs["choices"] = [(member.value, member.value) for member in kind]
+ kwargs["choices"] = [(member.name, member.value) for member in kind]
# coerce back
if isinstance(param.default, kind):
kwargs["initial"] = param.default.value
diff --git a/turtle_shell/tests/conftest.py b/turtle_shell/tests/conftest.py
index 88e73b8..4cf7b3d 100644
--- a/turtle_shell/tests/conftest.py
+++ b/turtle_shell/tests/conftest.py
@@ -1,4 +1,4 @@
import pytest
# ensure we get pretty pytest-style diffs in this module :)
-pytest.register_assert_rewrite("tests.utils")
+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
index f03b6ac..bd1f7e4 100644
--- a/turtle_shell/tests/test_django_cli2ui.py
+++ b/turtle_shell/tests/test_django_cli2ui.py
@@ -4,8 +4,9 @@
import pytest
from django import forms
-from .utils import compare_form_field
+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
import turtle_shell
from defopt import Parameter
from typing import Optional
@@ -18,7 +19,7 @@ class Color(enum.Enum):
yellow = enum.auto()
-COLOR_CHOICES = [(e.value, e.value) for e in Color]
+COLOR_CHOICES = [(e.name, e.value) for e in Color]
class Flag(enum.Enum):
@@ -75,24 +76,38 @@ def example_func(
class ExpectedFormForExampleFunc(forms.Form):
- """
- First line of text content should be there.
-
- Later lines of content here.
- """
-
- int_arg = forms.IntegerField()
- int_arg_with_default = forms.IntegerField(initial=5)
- bool_arg_default_true = forms.BooleanField(initial=True)
- bool_arg_default_false = forms.BooleanField(initial=False)
- bool_arg_no_default = forms.BooleanField()
- str_arg = forms.CharField()
- str_arg_with_default = forms.CharField(initial="whatever")
+ """\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()
+ 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")
- enum_auto = forms.ChoiceField
+ 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)
+ enum_auto = forms.TypedChoiceField(choices=COLOR_CHOICES, required=True, help_text="should be choices with key names", coerce=Color)
+ enum_auto_not_required = forms.TypedChoiceField(choices=COLOR_CHOICES, required=False, coerce=Color,
+ help_text="choice field not required")
+ enum_auto_with_default = forms.TypedChoiceField(choices=COLOR_CHOICES,
+ initial=Color.green.value, required=False,
+ coerce=Color, help_text="choice field with entry selected")
+ enum_str = forms.TypedChoiceField(choices=[("is_apple", "is_apple"), ("is_banana",
+ "is_banana")], required=True, coerce=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=Flag)
+
+
+def test_compare_complex_example(db):
+ actual = function_to_form(example_func)
+ compare_forms(actual, ExpectedFormForExampleFunc)
+
+
def _make_parameter(name, annotation, doc="", **kwargs):
diff --git a/turtle_shell/tests/test_form_comparisons.py b/turtle_shell/tests/test_form_comparisons.py
index 96652f4..130e9c2 100644
--- a/turtle_shell/tests/test_form_comparisons.py
+++ b/turtle_shell/tests/test_form_comparisons.py
@@ -61,7 +61,7 @@ class DifferentDoc(forms.Form):
def test_comparison():
compare_forms(FormA, FormA)
- with pytest.raises(AssertionError, match="Forms have different field names"):
+ with pytest.raises(AssertionError, match="Found unexpected form fields"):
test_different_fields(raiseit=True)
with pytest.raises(AssertionError, match=r"Field mismatch \(myfield\)"):
@@ -127,3 +127,24 @@ def test_doc_diff():
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/utils.py b/turtle_shell/tests/utils.py
index 1d9f47a..69ef694 100644
--- a/turtle_shell/tests/utils.py
+++ b/turtle_shell/tests/utils.py
@@ -6,12 +6,20 @@
def compare_form_field(name, actual, expected):
"""Compare important variables of two form fields"""
- assert type(actual) == type(expected), f"Field type mismatch ({name})"
+ 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)
actual_vars.pop("widget", None)
expected_vars.pop("widget", None)
- assert actual_vars == expected_vars, f"Field mismatch ({name})"
+ 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]):
@@ -25,9 +33,23 @@ def compare_forms(actual: Type[forms.Form], expected: Type[forms.Form]):
actual_fields = actual.declared_fields
expected_fields = expected.declared_fields
shared_keys = list(set(actual_fields.keys()) & set(expected_fields.keys()))
+ extra_keys = list(set(actual_fields.keys()) - set(expected_fields.keys()))
+ missing_keys = list(set(expected_fields.keys()) - set(actual_fields.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 actual_fields.keys() == expected_fields.keys(), "Forms have different field names"
- assert inspect.getdoc(actual) == inspect.getdoc(expected), "Forms have different docstrings"
+ 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
From be28cc9bd0d3f55e34960a7dcf1da73193c94009 Mon Sep 17 00:00:00 2001
From: Jeff Tratner
Date: Sun, 11 Apr 2021 21:31:40 -0400
Subject: [PATCH 05/29] Getting empty value working woo
---
turtle_shell/function_to_form.py | 3 +++
turtle_shell/tests/test_django_cli2ui.py | 5 ++---
2 files changed, 5 insertions(+), 3 deletions(-)
diff --git a/turtle_shell/function_to_form.py b/turtle_shell/function_to_form.py
index f1c773b..c1b9d18 100644
--- a/turtle_shell/function_to_form.py
+++ b/turtle_shell/function_to_form.py
@@ -141,6 +141,9 @@ def param_to_field(param: Parameter, config: dict = None) -> forms.Field:
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)
diff --git a/turtle_shell/tests/test_django_cli2ui.py b/turtle_shell/tests/test_django_cli2ui.py
index bd1f7e4..0339f08 100644
--- a/turtle_shell/tests/test_django_cli2ui.py
+++ b/turtle_shell/tests/test_django_cli2ui.py
@@ -89,10 +89,10 @@ class ExpectedFormForExampleFunc(forms.Form):
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)
+ 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=Color)
enum_auto_not_required = forms.TypedChoiceField(choices=COLOR_CHOICES, required=False, coerce=Color,
- help_text="choice field not required")
+ 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=Color, help_text="choice field with entry selected")
@@ -231,7 +231,6 @@ def myfunc(a: bool=True, b: str="whatever"):
resp = execute_gql_and_get_input_json(myfunc, "mutation { executeMyfunc(input: {}) { result { inputJson }}}")
assert resp == {"a": True, "b": "whatever"}
-@pytest.mark.xfail
def test_default_none(db):
# defaults should be passed through
From 31d4f6032ca682ad17b213a44bc184750b60fc0a Mon Sep 17 00:00:00 2001
From: Jeff Tratner
Date: Sun, 11 Apr 2021 21:44:19 -0400
Subject: [PATCH 06/29] Clean ups and such
Clean stuff up a bit more
---
turtle_shell/function_to_form.py | 141 +++++++++++++++++++----
turtle_shell/models.py | 15 ++-
turtle_shell/tests/test_django_cli2ui.py | 104 ++++++++++++++---
turtle_shell/tests/utils.py | 12 +-
4 files changed, 219 insertions(+), 53 deletions(-)
diff --git a/turtle_shell/function_to_form.py b/turtle_shell/function_to_form.py
index c1b9d18..8543e4f 100644
--- a/turtle_shell/function_to_form.py
+++ b/turtle_shell/function_to_form.py
@@ -14,11 +14,14 @@
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
+
type2field_type = {int: forms.IntegerField, str: forms.CharField, bool: forms.BooleanField,
Optional[bool]: forms.NullBooleanField,
@@ -54,9 +57,21 @@ def function_to_form(func, *, config: dict = None, name: str=None) -> Type[forms
fields = {}
defaults = {}
for parameter in sig.parameters.values():
- fields[parameter.name] = param_to_field(parameter, config)
+ 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:
+ print(field.choices)
+ 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("_"))
@@ -103,6 +118,54 @@ def get_type_from_annotation(param: Parameter):
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):
+ print(f"COERCE: {self} {value}")
+ try:
+ resp = self._call(value)
+ print(f"COERCED TO: {self} {value} => {resp}")
+ return resp
+ except Exception as e:
+ import traceback
+ print(f"FAILED TO COERCE {repr(value)}({value})")
+ traceback.print_exc()
+ raise
+ def _call(self, value):
+ if value and isinstance(value, self.enum_type):
+ print("ALREADY INSTANCE")
+ return value
+ if self.by_attribute:
+ print("BY ATTRIBUTE")
+ return getattr(self.enum_type, value)
+ try:
+ print("BY __call__")
+ resp = self.enum_type(value)
+ print(f"RESULT: {resp} ({repr(resp)})")
+ return resp
+ except ValueError as e:
+ import traceback
+ traceback.print_exc()
+ try:
+ print("BY int coerced __call__")
+ 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.
@@ -121,22 +184,59 @@ def param_to_field(param: Parameter, config: dict = None) -> forms.Field:
# e.g. stupid generic type stuff
pass
if is_enum_class:
+ utils.EnumRegistry.register(kind)
field_type = forms.TypedChoiceField
- kwargs["coerce"] = kind
- kwargs["choices"] = [(member.name, member.value) for member in kind]
- # coerce back
- if isinstance(param.default, kind):
- kwargs["initial"] = param.default.value
+ kwargs.update(make_enum_kwargs(param=param, kind=kind))
else:
- field_type = all_types.get(param.annotation, all_types.get(kind))
- for k, v in all_types.items():
- if field_type:
- break
- if inspect.isclass(k) and issubclass(kind, k) or k == kind:
- field_type = v
- break
- if not field_type:
- raise ValueError(f"Field {param.name}: Unknown field type: {param.annotation}")
+ 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:
@@ -149,15 +249,6 @@ def param_to_field(param: Parameter, config: dict = None) -> forms.Field:
kwargs.setdefault("initial", param.default)
if param.doc:
kwargs["help_text"] = param.doc
- for k, v in widgets.items():
- if isinstance(k, str):
- if param.name == k:
- kwargs["widget"] = v
- break
- continue
- if issubclass(k, param.annotation):
- kwargs["widget"] = v
- break
- return field_type(**kwargs)
+ return kwargs
diff --git a/turtle_shell/models.py b/turtle_shell/models.py
index dbc2a29..9578f0d 100644
--- a/turtle_shell/models.py
+++ b/turtle_shell/models.py
@@ -1,6 +1,7 @@
-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
class CaughtException(Exception):
@@ -19,9 +20,11 @@ class ResultJSONEncodeException(CaughtException):
class ExecutionResult(models.Model):
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()
- output_json = models.JSONField(default=dict, null=True)
- error_json = models.JSONField(default=dict, null=True)
+ 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)
class ExecutionStatus(models.TextChoices):
CREATED = 'CREATED', 'Created'
@@ -54,7 +57,9 @@ def execute(self):
try:
self.output_json = result
self.status = self.ExecutionStatus.DONE
- self.save()
+ # 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__})"
diff --git a/turtle_shell/tests/test_django_cli2ui.py b/turtle_shell/tests/test_django_cli2ui.py
index 0339f08..c703d4d 100644
--- a/turtle_shell/tests/test_django_cli2ui.py
+++ b/turtle_shell/tests/test_django_cli2ui.py
@@ -7,6 +7,8 @@
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
@@ -19,7 +21,7 @@ class Color(enum.Enum):
yellow = enum.auto()
-COLOR_CHOICES = [(e.name, e.value) for e in Color]
+COLOR_CHOICES = [(e.name, e.name) for e in Color]
class Flag(enum.Enum):
@@ -90,17 +92,17 @@ class ExpectedFormForExampleFunc(forms.Form):
# 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=Color)
- enum_auto_not_required = forms.TypedChoiceField(choices=COLOR_CHOICES, required=False, coerce=Color,
+ 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=Color, help_text="choice field with entry selected")
+ 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=Flag, help_text="should be choices with string values")
+ "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=Flag)
+ coerce=Coercer(Flag))
def test_compare_complex_example(db):
@@ -145,6 +147,10 @@ def _make_parameter(name, annotation, doc="", **kwargs):
_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"),
@@ -158,13 +164,28 @@ def _make_parameter(name, annotation, doc="", **kwargs):
forms.BooleanField(required=True, help_text="some doc"),
),
(
- _make_parameter("enum_auto", Color),
- forms.TypedChoiceField(coerce=Color, choices=COLOR_CHOICES),
+ _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=Color,
+ coerce=Coercer(Color, by_attribute=True),
initial=Color.green.value,
choices=COLOR_CHOICES,
required=False,
@@ -173,22 +194,18 @@ def _make_parameter(name, annotation, doc="", **kwargs):
),
(
_make_parameter("enum_str", Flag),
- forms.TypedChoiceField(coerce=Flag, choices=FLAG_CHOICES),
+ forms.TypedChoiceField(coerce=Coercer(Flag), choices=FLAG_CHOICES),
),
(
_make_parameter("enum_str_default", Flag, default=Flag.is_apple),
forms.TypedChoiceField(
- coerce=Flag, initial="is_apple", choices=FLAG_CHOICES, required=False
+ coerce=Coercer(Flag), initial="is_apple", choices=FLAG_CHOICES, required=False
),
),
- (
- _make_parameter("optional_bool", Optional[bool], default=True),
- forms.NullBooleanField(initial=True, required=False)
- ),
],
ids=lambda x: x.name if hasattr(x, "name") else x,
)
-def test_convert_arg(arg, expected):
+def test_enum_convert_arg(arg, expected):
field = param_to_field(arg)
compare_form_field(arg.name, field, expected)
@@ -212,14 +229,20 @@ def test_validators():
pass
-def execute_gql_and_get_input_json(func, gql):
+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):
+ result = execute_gql(func, gql)
data = result.data
assert not result.errors
- return json.loads(list(data.values())[0]["result"]["inputJson"])
+ 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
@@ -231,6 +254,22 @@ def myfunc(a: bool=True, b: str="whatever"):
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
@@ -262,3 +301,32 @@ def my_func(*, a: bool, b: str):
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)
+
+
+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/utils.py b/turtle_shell/tests/utils.py
index 69ef694..fd207c4 100644
--- a/turtle_shell/tests/utils.py
+++ b/turtle_shell/tests/utils.py
@@ -13,8 +13,9 @@ def compare_form_field(name, actual, expected):
raise AssertionError(f"Field type mismatch ({name}): {e}") from e
actual_vars = vars(actual)
expected_vars = vars(expected)
- actual_vars.pop("widget", None)
- expected_vars.pop("widget", None)
+ 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:
@@ -32,9 +33,10 @@ def compare_forms(actual: Type[forms.Form], expected: Type[forms.Form]):
"""
actual_fields = actual.declared_fields
expected_fields = expected.declared_fields
- shared_keys = list(set(actual_fields.keys()) & set(expected_fields.keys()))
- extra_keys = list(set(actual_fields.keys()) - set(expected_fields.keys()))
- missing_keys = list(set(expected_fields.keys()) - set(actual_fields.keys()))
+ 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]
From 8af8d8e424df7351c3b10e7869c3ba018ef4c784 Mon Sep 17 00:00:00 2001
From: Jeff Tratner
Date: Mon, 12 Apr 2021 09:17:44 -0400
Subject: [PATCH 07/29] Stuff seems to mostly work yay
Set up
Do stuff
---
turtle_shell/README.rst | 98 ++++++++-----
turtle_shell/function_to_form.py | 6 +-
turtle_shell/graphene_adapter.py | 133 ++++++++++++++++++
turtle_shell/graphene_adapter_jsonstring.py | 35 +++++
.../migrations/0002_auto_20210411_1045.py | 33 +++++
.../migrations/0003_auto_20210411_1104.py | 31 ++++
.../migrations/0004_auto_20210411_1223.py | 23 +++
turtle_shell/models.py | 3 +
.../turtle_shell/executionresult_create.html | 9 ++
.../templates/turtle_shell/overview.html | 1 +
turtle_shell/tests/test_utils.py | 24 ++++
turtle_shell/utils.py | 62 ++++++++
12 files changed, 424 insertions(+), 34 deletions(-)
create mode 100644 turtle_shell/graphene_adapter.py
create mode 100644 turtle_shell/graphene_adapter_jsonstring.py
create mode 100644 turtle_shell/migrations/0002_auto_20210411_1045.py
create mode 100644 turtle_shell/migrations/0003_auto_20210411_1104.py
create mode 100644 turtle_shell/migrations/0004_auto_20210411_1223.py
create mode 100644 turtle_shell/templates/turtle_shell/executionresult_create.html
create mode 100644 turtle_shell/tests/test_utils.py
create mode 100644 turtle_shell/utils.py
diff --git a/turtle_shell/README.rst b/turtle_shell/README.rst
index f3f5e33..9f0d404 100644
--- a/turtle_shell/README.rst
+++ b/turtle_shell/README.rst
@@ -1,15 +1,6 @@
Function to Form View
=====================
-CURRENTLY BROKEN STUFF:
-
-1. Graphql view does not respect defaults :( (e.g., find_root_cause comes through as false :()
-2. Needs testing
-3. Needs lots of testing
-
-LIMITATIONS:
-
-1. Boolean fields always are passed in as False.
Motivation
----------
@@ -20,11 +11,17 @@ Motivation
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)
+2. Better UI on output and/or ability to have structured graphql output for nicer APIs
+ Maybe some kind of output serializer? See end for some ideas.
+
How does it work?
-----------------
-This lil' old library converts _your_ function with annotations into a ✨Django Form✨ (that calls the function on ``form_valid``!)
+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:
@@ -52,6 +49,16 @@ Becomes this awesome form!
+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. Easy ability to add in authentication/authorization (granted FastAPI has this)
+2. Literally didn't see it and we know django better
+
Overall gist
------------
@@ -87,15 +94,15 @@ Example Implementation
executions.py::
- from easy_execute import ExecutionWrapper
+ import turtle_shell
from my_util_scripts import find_root_cause, summarize_issue, error_summary
- Registry = ExecutionWrapper()
+ Registry = turtle_shell.get_registry()
- FindRootCause = Registry.wrap(find_root_cause)
- SummarizeIssue = Registry.wrap(summarize_issue)
- ErrorSummary = Registry.wrap(error_summary)
+ FindRootCause = Registry.add(find_root_cause)
+ SummarizeIssue = Registry.add(summarize_issue)
+ ErrorSummary = Registry.add(error_summary)
@@ -130,12 +137,17 @@ Of course you can also customize further::
views::
- from . import executions
+ import turtle_shell
+
+ Registry = turtle_shell.get_registry()
- class FindRootCauseList(executions.FindRootCause.list_view()):
+ class FindRootCauseList(Registry.get(find_root_cause).list_view()):
template_name = "list-root-cause.html"
- class FindRootCauseDetail(executions.FindRootCause.detail_view()):
+ 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.
@@ -145,6 +157,7 @@ What's missing from this idea
- granular permissions (gotta think about nice API for this)
- separate tables for different objects.
+- some kind of nicer serializer
Using the library
-----------------
@@ -170,14 +183,12 @@ Every function gets a generic output::
mutation { dxFindRootCause(input: {job_id: ..., project: ...}) {
uuid: str
- execution {
- status: String?
- exitCode: Int
- successful: Bool
- }
- rawOutput {
- stderr: String?
- stdout: String # often JSON serializable
+ result {
+ status: STATUS
+ uuid: UUID!
+ inputJson: String!
+ outputJson: String? # often JSON serializable
+ errorJson: String?
}
}
errors: Optional {
@@ -239,11 +250,34 @@ who were not as command line savvy.
2. Expose utility functions as forms for users
-Customizing the forms
----------------------
+How output serializers might look
+---------------------------------
+
+1. One cool idea would just be to automatically convert to and from attrs classes using `cattrs` to customize output. (little more flexible in general)
+2. Could just return django models that get persisted (advantage is a bit easier to see old executions)
+3. Use pydantic to have some nice structure on output types :)
+
+Attrs classes
+^^^^^^^^^^^^^
+
+Concept:
+ 1. attr_to_graphene => convert attrs classes into nested graphene type. Handles resolving those fields from result
+ 2. cattr.structure/cattr.unstructure to marshal to and from JSON
+
+Pros:
+
+* Easy to represent deeply nested contents
+* Do not need to save to DB
+
+Cons:
+
+* Reimplement a lot of the graphene django work :(
+
+
+Pydantic classes
+^^^^^^^^^^^^^^^^
-First - you can pass a config dictionary to ``function_to_form`` to tell it to
-use particular widgets for fields or how to construct a form field for your custom type (
-as a callable that takes standard field keyword arguments).
+Better support for unmarshalling
+works with fast api as well
-You can also subclass the generated form object to add your own ``clean_*`` methods or more complex validation - yay!
+https://pydantic-docs.helpmanual.io/usage/models/#data-conversion
diff --git a/turtle_shell/function_to_form.py b/turtle_shell/function_to_form.py
index 8543e4f..196edb0 100644
--- a/turtle_shell/function_to_form.py
+++ b/turtle_shell/function_to_form.py
@@ -32,11 +32,13 @@ class _Function:
func: callable
name: str
form_class: object
+ doc: str
@classmethod
def from_function(cls, func, *, name):
- from turtle_shell.function_to_form import function_to_form
- return cls(func=func, name=name, form_class=function_to_form(func, name=name))
+ form_class = function_to_form(func, name=name)
+ return cls(func=func, name=name, form_class=form_class,
+ doc=form_class.__doc__)
def doc_mapping(str) -> Dict[str, str]:
diff --git a/turtle_shell/graphene_adapter.py b/turtle_shell/graphene_adapter.py
new file mode 100644
index 0000000..2e4ef6e
--- /dev/null
+++ b/turtle_shell/graphene_adapter.py
@@ -0,0 +1,133 @@
+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
+
+_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)
+ print(EnumCls, getattr(EnumCls, "BAM", None))
+ 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}
+ print(f"MUTATE GET PAYLOAD {input} {repr(input.get('read_type'))} {type(input.get('read_type'))}")
+ form = cls.get_form(root, info, **input)
+ if not form.is_valid():
+ print(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()
+ obj.execute()
+ obj.save()
+ # TODO: make errors show up nicely
+ return cls(errors=[], result=obj)
+
+ # TODO: figure out if name can be customized in class
+ mutation_name = f'{form_class.__name__}Mutation'
+ DefaultOperationMutation = type(mutation_name, (DjangoFormMutation,), ({"Meta": Meta,
+ "perform_mutate": perform_mutate, "result": graphene.Field(ExecutionResult), "__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..6d80e2a
--- /dev/null
+++ b/turtle_shell/graphene_adapter_jsonstring.py
@@ -0,0 +1,35 @@
+"""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/0002_auto_20210411_1045.py b/turtle_shell/migrations/0002_auto_20210411_1045.py
new file mode 100644
index 0000000..c0c30de
--- /dev/null
+++ b/turtle_shell/migrations/0002_auto_20210411_1045.py
@@ -0,0 +1,33 @@
+# 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..2fbcd90
--- /dev/null
+++ b/turtle_shell/migrations/0003_auto_20210411_1104.py
@@ -0,0 +1,31 @@
+# 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..f20328a
--- /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/models.py b/turtle_shell/models.py
index 9578f0d..5f81a64 100644
--- a/turtle_shell/models.py
+++ b/turtle_shell/models.py
@@ -3,6 +3,7 @@
from django.conf import settings
from turtle_shell import utils
import uuid
+import cattr
class CaughtException(Exception):
"""An exception that was caught and saved. Generally don't need to rollback transaction with
@@ -55,6 +56,8 @@ def execute(self):
self.save()
raise CaughtException(f"Failed on {self.func_name} ({type(e).__name__})", e) from e
try:
+ 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
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..4619c35
--- /dev/null
+++ b/turtle_shell/templates/turtle_shell/executionresult_create.html
@@ -0,0 +1,9 @@
+{% extends 'base.html' %}
+{% load crispy_forms_tags %}
+
+{% block content %}
+New Execution for {{func_name}}
+{% crispy form %}
+{% endblock content %}
+
+
diff --git a/turtle_shell/templates/turtle_shell/overview.html b/turtle_shell/templates/turtle_shell/overview.html
index 452577f..af5d18c 100644
--- a/turtle_shell/templates/turtle_shell/overview.html
+++ b/turtle_shell/templates/turtle_shell/overview.html
@@ -6,6 +6,7 @@
{{elem.name}} (
Create New Execution )
+{% if elem.doc %} {{elem.doc}}
{% endif %}
{% endfor %}
{% endblock content %}
diff --git a/turtle_shell/tests/test_utils.py b/turtle_shell/tests/test_utils.py
new file mode 100644
index 0000000..564666a
--- /dev/null
+++ b/turtle_shell/tests/test_utils.py
@@ -0,0 +1,24 @@
+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/utils.py b/turtle_shell/utils.py
new file mode 100644
index 0000000..be6e6e4
--- /dev/null
+++ b/turtle_shell/utils.py
@@ -0,0 +1,62 @@
+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)
From 68471932dc8aca3924680df41ffac406e026c301 Mon Sep 17 00:00:00 2001
From: Jeff Tratner
Date: Mon, 12 Apr 2021 11:32:59 -0400
Subject: [PATCH 08/29] Restructure tests yay
Set up restore form - yay
More set up and tests and such
---
turtle_shell/README.rst | 42 ++++++----
turtle_shell/__init__.py | 6 +-
turtle_shell/fake_pydantic_adpater.py | 5 ++
turtle_shell/function_to_form.py | 19 +++--
turtle_shell/graphene_adapter.py | 19 ++++-
turtle_shell/models.py | 25 +++++-
turtle_shell/pydantic_adapter.py | 78 ++++++++++++++++++
.../turtle_shell/executionresult_create.html | 1 +
.../turtle_shell/executionresult_detail.html | 42 ++++++----
.../turtle_shell/executionresult_list.html | 30 +++++--
.../executionresult_summaryrow.html | 11 +++
.../templatetags/pydantic_to_table.py | 58 +++++++++++++
turtle_shell/tests/test_django_cli2ui.py | 82 -------------------
turtle_shell/tests/test_graphene_adapter.py | 68 +++++++++++++++
turtle_shell/tests/test_pydantic_adapter.py | 80 ++++++++++++++++++
turtle_shell/tests/utils.py | 21 +++++
turtle_shell/views.py | 4 +-
17 files changed, 452 insertions(+), 139 deletions(-)
create mode 100644 turtle_shell/fake_pydantic_adpater.py
create mode 100644 turtle_shell/pydantic_adapter.py
create mode 100644 turtle_shell/templates/turtle_shell/executionresult_summaryrow.html
create mode 100644 turtle_shell/templatetags/pydantic_to_table.py
create mode 100644 turtle_shell/tests/test_graphene_adapter.py
create mode 100644 turtle_shell/tests/test_pydantic_adapter.py
diff --git a/turtle_shell/README.rst b/turtle_shell/README.rst
index 9f0d404..2a714a2 100644
--- a/turtle_shell/README.rst
+++ b/turtle_shell/README.rst
@@ -16,6 +16,7 @@ REMAINING WORK:
1. Ability to do asynchronous executions (this is basically all set up)
2. Better UI on output and/or ability to have structured graphql output for nicer APIs
Maybe some kind of output serializer? See end for some ideas.
+3. Help graphene-django release a version based on graphql-core so we can use newer graphene-pydantic :P
How does it work?
-----------------
@@ -49,16 +50,7 @@ Becomes this awesome form!
-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. Easy ability to add in authentication/authorization (granted FastAPI has this)
-2. Literally didn't see it and we know django better
-
+Make your output pydantic models and get nicely structured GraphQL output AND nice tables of data on the page :)
Overall gist
------------
@@ -157,7 +149,6 @@ What's missing from this idea
- granular permissions (gotta think about nice API for this)
- separate tables for different objects.
-- some kind of nicer serializer
Using the library
-----------------
@@ -250,12 +241,9 @@ who were not as command line savvy.
2. Expose utility functions as forms for users
-How output serializers might look
----------------------------------
+Customizing output
+------------------
-1. One cool idea would just be to automatically convert to and from attrs classes using `cattrs` to customize output. (little more flexible in general)
-2. Could just return django models that get persisted (advantage is a bit easier to see old executions)
-3. Use pydantic to have some nice structure on output types :)
Attrs classes
^^^^^^^^^^^^^
@@ -281,3 +269,25 @@ Better support for unmarshalling
works with fast api as well
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/turtle_shell/__init__.py b/turtle_shell/__init__.py
index 1294d72..e1f50a3 100644
--- a/turtle_shell/__init__.py
+++ b/turtle_shell/__init__.py
@@ -15,14 +15,14 @@ def __init__(self):
def get_registry(self):
return _RegistrySingleton
- def add(self, func, name=None):
+ 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)
+ 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:
@@ -68,3 +68,5 @@ def schema(self):
_RegistrySingleton = _Registry()
get_registry = _Registry.get_registry
+
+from .function_to_form import Text
diff --git a/turtle_shell/fake_pydantic_adpater.py b/turtle_shell/fake_pydantic_adpater.py
new file mode 100644
index 0000000..e10c442
--- /dev/null
+++ b/turtle_shell/fake_pydantic_adpater.py
@@ -0,0 +1,5 @@
+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
index 196edb0..15b7c9a 100644
--- a/turtle_shell/function_to_form.py
+++ b/turtle_shell/function_to_form.py
@@ -22,11 +22,17 @@
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,
+ Optional[bool]: forms.NullBooleanField, Text: forms.CharField,
pathlib.Path: forms.CharField, dict: forms.JSONField}
+type2widget = {Text: forms.Textarea()}
+
@dataclass
class _Function:
func: callable
@@ -35,10 +41,10 @@ class _Function:
doc: str
@classmethod
- def from_function(cls, func, *, name):
- form_class = function_to_form(func, name=name)
+ 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__)
+ doc=form_class.__doc__)
def doc_mapping(str) -> Dict[str, str]:
@@ -173,9 +179,8 @@ def param_to_field(param: Parameter, config: dict = None) -> forms.Field:
See function_to_form for config definition."""
config = config or {}
- all_types = dict(type2field_type)
- all_types.update(config.get("types", {}))
- widgets = config.get("widgets") or {}
+ all_types = {**type2field_type, **(config.get("types") or {})}
+ widgets = {**type2widget, **(config.get("widgets") or {})}
field_type = None
kwargs = {}
kind = get_type_from_annotation(param)
diff --git a/turtle_shell/graphene_adapter.py b/turtle_shell/graphene_adapter.py
index 2e4ef6e..400cd06 100644
--- a/turtle_shell/graphene_adapter.py
+++ b/turtle_shell/graphene_adapter.py
@@ -10,6 +10,9 @@
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()
@@ -100,15 +103,23 @@ def mutate_and_get_payload(cls, root, info, **input):
@classmethod
def perform_mutate(cls, form, info):
obj = form.save()
- obj.execute()
+ all_results = obj.execute()
obj.save()
+ kwargs = {"result": obj}
+ if hasattr(all_results, 'dict'):
+ for k, f in fields.items():
+ if k != 'result':
+ kwargs[k] = all_results
+
# TODO: make errors show up nicely
- return cls(errors=[], result=obj)
+ return cls(errors=[], **kwargs)
# TODO: figure out if name can be customized in class
mutation_name = f'{form_class.__name__}Mutation'
- DefaultOperationMutation = type(mutation_name, (DjangoFormMutation,), ({"Meta": Meta,
- "perform_mutate": perform_mutate, "result": graphene.Field(ExecutionResult), "__doc__":
+ fields = {"result": 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
diff --git a/turtle_shell/models.py b/turtle_shell/models.py
index 5f81a64..acb7eaf 100644
--- a/turtle_shell/models.py
+++ b/turtle_shell/models.py
@@ -4,6 +4,7 @@
from turtle_shell import utils
import uuid
import cattr
+import json
class CaughtException(Exception):
"""An exception that was caught and saved. Generally don't need to rollback transaction with
@@ -19,6 +20,8 @@ class ResultJSONEncodeException(CaughtException):
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)
@@ -43,12 +46,15 @@ class ExecutionStatus(models.TextChoices):
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 = func(**self.input_json)
+ result = original_result = func(**self.input_json)
except Exception as e:
# TODO: catch integrity error separately
self.error_json = {"type": type(e).__name__, "message": str(e)}
@@ -56,6 +62,8 @@ def execute(self):
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
@@ -74,7 +82,7 @@ def execute(self):
raise ResultJSONEncodeException(msg, e) from e
else:
raise e
- return self
+ return original_result
def get_function(self):
# TODO: figure this out
@@ -90,5 +98,14 @@ def get_absolute_url(self):
return reverse(f'turtle_shell:detail-{self.func_name}', kwargs={"pk": self.pk})
def __repr__(self):
- return (f'<{type(self).__name__}(pk="{self.pk}", func_name="{self.func_name}",'
- f' created={self.created}, modified={self.modified})')
+ 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..f3eae82
--- /dev/null
+++ b/turtle_shell/pydantic_adapter.py
@@ -0,0 +1,78 @@
+"""
+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_
+ print(type_)
+ if issubclass(type_, BaseModel):
+ print(f"HIT {type_}")
+ 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)
+ print("FOUND {len(classes)} classes from 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})})
+ print(f"CREATED: {klass.__name__}")
+ 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
+ print("ADDING PYDANTIC FIELDS")
+ obj_name = pydantic_class.__name__
+
+ root_object = get_object_type(pydantic_class)
+ fields[obj_name[0].lower() + obj_name[1:]] = graphene.Field(root_object)
+ print(f"Added field {obj_name}")
+
+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}", stack_info=True)
diff --git a/turtle_shell/templates/turtle_shell/executionresult_create.html b/turtle_shell/templates/turtle_shell/executionresult_create.html
index 4619c35..b981c58 100644
--- a/turtle_shell/templates/turtle_shell/executionresult_create.html
+++ b/turtle_shell/templates/turtle_shell/executionresult_create.html
@@ -3,6 +3,7 @@
{% block content %}
New Execution for {{func_name}}
+{{form.__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
index 2808ccc..47396ff 100644
--- a/turtle_shell/templates/turtle_shell/executionresult_detail.html
+++ b/turtle_shell/templates/turtle_shell/executionresult_detail.html
@@ -1,25 +1,33 @@
{% extends 'base.html' %}
+{% load pydantic_to_table %}
{% block content %}
+
Execution for {{func_name}} ({{object.pk}})
+
+
State
+
{{object.status}}
- Input
-{{object.input_json|pprint|escape}}
-{% if object.output_json %}
- Output
-{{object.output_json|pprint|escape}}
+{% if object.pydantic_object %}
+
+
Results
+{{object.pydantic_object|pydantic_model_to_table}}
+
{% endif %}
-{% if object.error_json %}
- Error
-{{object.error_json|pprint|escape}}
-{% endif %}
- User
-{{object.user}}
-Created
-{{object.created}}
-Modified
-{{object.modified}}
+
+
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 %}
+{% 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
index e742e19..5020880 100644
--- a/turtle_shell/templates/turtle_shell/executionresult_list.html
+++ b/turtle_shell/templates/turtle_shell/executionresult_list.html
@@ -3,11 +3,29 @@
{% block content %}
Executions for {{func_name}}
-
- {% for object in object_list %}
- - {{ object }} ({{object.status}})
- {% endfor %}
+{% 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..44ad893
--- /dev/null
+++ b/turtle_shell/templates/turtle_shell/executionresult_summaryrow.html
@@ -0,0 +1,11 @@
+{% if data %}
+
+ | {{ key }} |
+
+
+ {{ data|truncatechars:50 }}
+ {{ data|pprint|escape }}
+
+ |
+
+{% endif %}
diff --git a/turtle_shell/templatetags/pydantic_to_table.py b/turtle_shell/templatetags/pydantic_to_table.py
new file mode 100644
index 0000000..0e2d309
--- /dev/null
+++ b/turtle_shell/templatetags/pydantic_to_table.py
@@ -0,0 +1,58 @@
+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"):
+ print("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/test_django_cli2ui.py b/turtle_shell/tests/test_django_cli2ui.py
index c703d4d..6b351cc 100644
--- a/turtle_shell/tests/test_django_cli2ui.py
+++ b/turtle_shell/tests/test_django_cli2ui.py
@@ -224,75 +224,6 @@ def test_custom_widgets():
)
-def test_validators():
- # something about fields failing validation
- pass
-
-
-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):
- 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
-
-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
-
-
@pytest.mark.parametrize(
"parameter,exception_type,msg_regex",
[
@@ -317,16 +248,3 @@ class StringlyIntEnum(enum.Enum):
assert Coercer(StringlyIntEnum)('1') == StringlyIntEnum('1')
with pytest.raises(ValueError):
Coercer(StringlyIntEnum)(1)
-
-
-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_graphene_adapter.py b/turtle_shell/tests/test_graphene_adapter.py
new file mode 100644
index 0000000..c762e46
--- /dev/null
+++ b/turtle_shell/tests/test_graphene_adapter.py
@@ -0,0 +1,68 @@
+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..3b46894
--- /dev/null
+++ b/turtle_shell/tests/test_pydantic_adapter.py
@@ -0,0 +1,80 @@
+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 { 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_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/utils.py b/turtle_shell/tests/utils.py
index fd207c4..1574ffb 100644
--- a/turtle_shell/tests/utils.py
+++ b/turtle_shell/tests/utils.py
@@ -1,7 +1,9 @@
from typing import Type
import inspect
+import turtle_shell
from django import forms
+import json
def compare_form_field(name, actual, expected):
@@ -55,3 +57,22 @@ def compare_forms(actual: Type[forms.Form], expected: Type[forms.Form]):
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/views.py b/turtle_shell/views.py
index 34ca291..2e172b1 100644
--- a/turtle_shell/views.py
+++ b/turtle_shell/views.py
@@ -32,7 +32,9 @@ class ExecutionDetailView(ExecutionViewMixin, DetailView):
pass
class ExecutionListView(ExecutionViewMixin, ListView):
- pass
+ def get_queryset(self):
+ qs = super().get_queryset()
+ return qs.order_by("-created")
class ExecutionCreateView(ExecutionViewMixin, CreateView):
From ab6db6c1b91a2def13d625ffd7d5224d7019ef58 Mon Sep 17 00:00:00 2001
From: Jeff Tratner
Date: Mon, 12 Apr 2021 23:20:21 -0400
Subject: [PATCH 09/29] Switch to better config name
---
turtle_shell/README.rst | 25 ++++---------
turtle_shell/__init__.py | 2 +-
turtle_shell/function_to_form.py | 2 +-
.../migrations/0005_auto_20210412_2320.py | 29 +++++++++++++++
.../turtle_shell/executionresult_create.html | 2 +-
.../templates/turtle_shell/overview.html | 20 +++++++---
turtle_shell/views.py | 37 +++++++++++++++----
7 files changed, 84 insertions(+), 33 deletions(-)
create mode 100644 turtle_shell/migrations/0005_auto_20210412_2320.py
diff --git a/turtle_shell/README.rst b/turtle_shell/README.rst
index 2a714a2..35388a8 100644
--- a/turtle_shell/README.rst
+++ b/turtle_shell/README.rst
@@ -244,29 +244,18 @@ who were not as command line savvy.
Customizing output
------------------
+Custom widgets or forms
+^^^^^^^^^^^^^^^^^^^^^^^
-Attrs classes
-^^^^^^^^^^^^^
-
-Concept:
- 1. attr_to_graphene => convert attrs classes into nested graphene type. Handles resolving those fields from result
- 2. cattr.structure/cattr.unstructure to marshal to and from JSON
-
-Pros:
-
-* Easy to represent deeply nested contents
-* Do not need to save to DB
-
-Cons:
-
-* Reimplement a lot of the graphene django work :(
-
+``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
^^^^^^^^^^^^^^^^
-Better support for unmarshalling
-works with fast api as well
+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
diff --git a/turtle_shell/__init__.py b/turtle_shell/__init__.py
index e1f50a3..50f9faf 100644
--- a/turtle_shell/__init__.py
+++ b/turtle_shell/__init__.py
@@ -47,7 +47,7 @@ def get_router(self, *, list_template= 'turtle_shell/executionresult_list.html',
from . import views
urls = [path("", self.summary_view, name="overview")]
for func in self.func_name2func.values():
- urls.extend(views.Views.from_function(func).urls(list_template=list_template,
+ 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"))
diff --git a/turtle_shell/function_to_form.py b/turtle_shell/function_to_form.py
index 15b7c9a..4317c14 100644
--- a/turtle_shell/function_to_form.py
+++ b/turtle_shell/function_to_form.py
@@ -179,7 +179,7 @@ def param_to_field(param: Parameter, config: dict = None) -> forms.Field:
See function_to_form for config definition."""
config = config or {}
- all_types = {**type2field_type, **(config.get("types") or {})}
+ all_types = {**type2field_type, **(config.get("fields") or {})}
widgets = {**type2widget, **(config.get("widgets") or {})}
field_type = None
kwargs = {}
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..e98db3f
--- /dev/null
+++ b/turtle_shell/migrations/0005_auto_20210412_2320.py
@@ -0,0 +1,29 @@
+# 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/templates/turtle_shell/executionresult_create.html b/turtle_shell/templates/turtle_shell/executionresult_create.html
index b981c58..5ec4b52 100644
--- a/turtle_shell/templates/turtle_shell/executionresult_create.html
+++ b/turtle_shell/templates/turtle_shell/executionresult_create.html
@@ -3,7 +3,7 @@
{% block content %}
New Execution for {{func_name}}
-{{form.__doc__}}
+{{doc}}
{% crispy form %}
{% endblock content %}
diff --git a/turtle_shell/templates/turtle_shell/overview.html b/turtle_shell/templates/turtle_shell/overview.html
index af5d18c..e4fe52f 100644
--- a/turtle_shell/templates/turtle_shell/overview.html
+++ b/turtle_shell/templates/turtle_shell/overview.html
@@ -1,12 +1,22 @@
{% extends "base.html" %}
{% block content %}
-
+
+ | Function | | Description |
{% for elem in functions %}
--
-
{{elem.name}} (
- Create New Execution )
-{% if elem.doc %} {{elem.doc}}
{% endif %}
+
+ | {{elem.name}} |
+
+
+ |
+ {% if elem.doc %} {{elem.doc}} {% endif %} |
+
{% endfor %}
{% endblock content %}
diff --git a/turtle_shell/views.py b/turtle_shell/views.py
index 2e172b1..dde4c44 100644
--- a/turtle_shell/views.py
+++ b/turtle_shell/views.py
@@ -2,10 +2,13 @@
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
class ExecutionViewMixin:
@@ -49,31 +52,49 @@ def form_valid(self, form):
try:
self.object.execute()
except CaughtException as e:
- messages.warning(self.request, str(e))
+ messages.warning(self.request, f"Error in Execution {self.object.pk}: {e}")
else:
messages.info(self.request, f"Completed execution for {self.object.pk}")
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'):
- detail_view = type(f'{func.name}DetailView', (ExecutionDetailView,), ({"func_name": func.name}))
- list_view = type(f'{func.name}ListView', (ExecutionListView,), ({"func_name": func.name}))
- create_view = type(f'{func.name}CreateView', (ExecutionCreateView,), ({"func_name":
+ 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)
+ 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!
- return [
+ 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/",
@@ -81,4 +102,6 @@ def urls(self, *, list_template, detail_template, create_template):
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
From cc510aa9dfa8fbff99b2e903ea280947571e0c65 Mon Sep 17 00:00:00 2001
From: Jeff Tratner
Date: Tue, 13 Apr 2021 09:14:47 -0400
Subject: [PATCH 10/29] Blackify
---
turtle_shell/__init__.py | 22 ++++-
turtle_shell/apps.py | 2 +-
turtle_shell/fake_pydantic_adpater.py | 1 +
turtle_shell/function_to_form.py | 63 ++++++++-----
turtle_shell/graphene_adapter.py | 45 ++++++---
turtle_shell/graphene_adapter_jsonstring.py | 3 +-
turtle_shell/migrations/0001_initial.py | 40 ++++++--
.../migrations/0002_auto_20210411_1045.py | 30 ++++--
.../migrations/0003_auto_20210411_1104.py | 18 ++--
.../migrations/0004_auto_20210411_1223.py | 10 +-
.../migrations/0005_auto_20210412_2320.py | 33 +++++--
turtle_shell/models.py | 42 +++++----
turtle_shell/pydantic_adapter.py | 13 ++-
.../templatetags/pydantic_to_table.py | 35 ++++---
turtle_shell/tests/test_django_cli2ui.py | 92 +++++++++++++------
turtle_shell/tests/test_graphene_adapter.py | 44 +++++----
turtle_shell/tests/test_pydantic_adapter.py | 48 ++++++----
turtle_shell/tests/test_utils.py | 3 +-
turtle_shell/tests/utils.py | 3 +-
turtle_shell/utils.py | 18 +++-
turtle_shell/views.py | 69 +++++++++-----
21 files changed, 428 insertions(+), 206 deletions(-)
diff --git a/turtle_shell/__init__.py b/turtle_shell/__init__.py
index 50f9faf..baccfc6 100644
--- a/turtle_shell/__init__.py
+++ b/turtle_shell/__init__.py
@@ -1,9 +1,11 @@
from dataclasses import dataclass
+
@dataclass
class _Router:
urls: list
+
class _Registry:
func_name2func: dict
_schema = None
@@ -40,15 +42,25 @@ def summary_view(self, request):
context = {"registry": self, "functions": self.func_name2func.values()}
return HttpResponse(template.render(context))
- 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'):
+ 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",
+ ):
from django.urls import path
from . import views
+
urls = [path("", self.summary_view, 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))
+ 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):
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
index e10c442..5f9a15d 100644
--- a/turtle_shell/fake_pydantic_adpater.py
+++ b/turtle_shell/fake_pydantic_adpater.py
@@ -1,5 +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
index 4317c14..0dda87e 100644
--- a/turtle_shell/function_to_form.py
+++ b/turtle_shell/function_to_form.py
@@ -22,17 +22,26 @@
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}
+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
@@ -43,15 +52,14 @@ class _Function:
@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__)
+ 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]:
+def function_to_form(func, *, config: dict = None, name: str = None) -> Type[forms.Form]:
"""Convert a function to a Django Form.
Args:
@@ -79,11 +87,12 @@ def function_to_form(func, *, config: dict = None, name: str=None) -> Type[forms
defaults[parameter.name] = potential_default
break
else:
- raise ValueError(f"Cannot figure out how to assign default for {parameter.name}: {parameter.default}")
+ 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
@@ -95,7 +104,7 @@ def __init__(self, *a, instance=None, user=None, **k):
super().__init__(*a, **k)
self.user = user
self.helper = FormHelper(self)
- self.helper.add_input(Submit('submit', 'Execute!'))
+ self.helper.add_input(Submit("submit", "Execute!"))
def execute_function(self):
# TODO: reconvert back to enum type! :(
@@ -103,13 +112,11 @@ def execute_function(self):
def save(self):
from .models import ExecutionResult
- obj = ExecutionResult(func_name=name,
- input_json=self.cleaned_data,
- user=self.user)
+
+ obj = ExecutionResult(func_name=name, input_json=self.cleaned_data, user=self.user)
obj.save()
return obj
-
return type(form_name, (BaseForm,), fields)
@@ -129,6 +136,7 @@ def get_type_from_annotation(param: Parameter):
@dataclass
class Coercer:
"""Wrapper so that we handle implicit string conversion of enum types :("""
+
enum_type: object
by_attribute: bool = False
@@ -140,9 +148,11 @@ def __call__(self, value):
return resp
except Exception as e:
import traceback
+
print(f"FAILED TO COERCE {repr(value)}({value})")
traceback.print_exc()
raise
+
def _call(self, value):
if value and isinstance(value, self.enum_type):
print("ALREADY INSTANCE")
@@ -157,6 +167,7 @@ def _call(self, value):
return resp
except ValueError as e:
import traceback
+
traceback.print_exc()
try:
print("BY int coerced __call__")
@@ -199,28 +210,33 @@ def param_to_field(param: Parameter, config: dict = None) -> forms.Field:
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}
+ 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
+ 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["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["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):
@@ -242,6 +258,7 @@ def get_for_param_by_type(dct, *, param, kind):
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:
@@ -249,13 +266,11 @@ def extra_kwargs(field_type, param):
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
+ 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
index 400cd06..ba1797a 100644
--- a/turtle_shell/graphene_adapter.py
+++ b/turtle_shell/graphene_adapter.py
@@ -8,8 +8,10 @@
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
@@ -42,20 +44,20 @@
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}'
+
+ 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}'
+ full_name = f"{name}{index}"
EnumCls = convert_choice_field_to_enum(field, name=full_name)
print(EnumCls, getattr(EnumCls, "BAM", None))
- converted = EnumCls(
- description=field.help_text, required=field.required
- )
+ converted = EnumCls(description=field.help_text, required=field.required)
_seen_names.add(full_name)
return converted
+
class ExecutionResult(DjangoObjectType):
class Meta:
model = models.ExecutionResult
@@ -89,7 +91,9 @@ class Meta:
def mutate_and_get_payload(cls, root, info, **input):
"""Set defaults from function in place!"""
input = {**defaults, **input}
- print(f"MUTATE GET PAYLOAD {input} {repr(input.get('read_type'))} {type(input.get('read_type'))}")
+ print(
+ f"MUTATE GET PAYLOAD {input} {repr(input.get('read_type'))} {type(input.get('read_type'))}"
+ )
form = cls.get_form(root, info, **input)
if not form.is_valid():
print(form.errors)
@@ -97,6 +101,7 @@ def mutate_and_get_payload(cls, root, info, **input):
return super(DefaultOperationMutation, cls).mutate_and_get_payload(root, info, **input)
except Exception as e:
import traceback
+
traceback.print_exc()
raise
@@ -106,24 +111,34 @@ def perform_mutate(cls, form, info):
all_results = obj.execute()
obj.save()
kwargs = {"result": obj}
- if hasattr(all_results, 'dict'):
+ if hasattr(all_results, "dict"):
for k, f in fields.items():
- if k != 'result':
+ if k != "result":
kwargs[k] = all_results
# TODO: make errors show up nicely
return cls(errors=[], **kwargs)
# TODO: figure out if name can be customized in class
- mutation_name = f'{form_class.__name__}Mutation'
+ mutation_name = f"{form_class.__name__}Mutation"
fields = {"result": 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}))
+ 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):
@@ -135,10 +150,10 @@ def resolve_execution_result(cls, info, uuid):
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_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
index 6d80e2a..7416c51 100644
--- a/turtle_shell/graphene_adapter_jsonstring.py
+++ b/turtle_shell/graphene_adapter_jsonstring.py
@@ -1,5 +1,5 @@
"""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
+# Port over of graphene's JSON string to allow using a custom encoder...sigh
import json
from graphql.language import ast
@@ -18,6 +18,7 @@ class CustomEncoderJSONString(Scalar):
@staticmethod
def serialize(dt):
from turtle_shell import utils
+
return json.dumps(dt, cls=utils.EnumAwareEncoder)
@staticmethod
diff --git a/turtle_shell/migrations/0001_initial.py b/turtle_shell/migrations/0001_initial.py
index ba7b2c0..43e3192 100644
--- a/turtle_shell/migrations/0001_initial.py
+++ b/turtle_shell/migrations/0001_initial.py
@@ -8,20 +8,40 @@ class Migration(migrations.Migration):
initial = True
- dependencies = [
- ]
+ dependencies = []
operations = [
migrations.CreateModel(
- name='ExecutionResult',
+ 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)),
+ (
+ "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
index c0c30de..9fc086a 100644
--- a/turtle_shell/migrations/0002_auto_20210411_1045.py
+++ b/turtle_shell/migrations/0002_auto_20210411_1045.py
@@ -6,28 +6,38 @@
class Migration(migrations.Migration):
dependencies = [
- ('turtle_shell', '0001_initial'),
+ ("turtle_shell", "0001_initial"),
]
operations = [
migrations.AddField(
- model_name='executionresult',
- name='error_json',
+ model_name="executionresult",
+ name="error_json",
field=models.JSONField(default=None),
),
migrations.AlterField(
- model_name='executionresult',
- name='func_name',
+ model_name="executionresult",
+ name="func_name",
field=models.CharField(editable=False, max_length=512),
),
migrations.AlterField(
- model_name='executionresult',
- name='output_json',
+ 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),
+ 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
index 2fbcd90..943b507 100644
--- a/turtle_shell/migrations/0003_auto_20210411_1104.py
+++ b/turtle_shell/migrations/0003_auto_20210411_1104.py
@@ -9,23 +9,25 @@ class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
- ('turtle_shell', '0002_auto_20210411_1045'),
+ ("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),
+ 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',
+ model_name="executionresult",
+ name="error_json",
field=models.JSONField(default=dict),
),
migrations.AlterField(
- model_name='executionresult',
- name='output_json',
+ 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
index f20328a..338fef7 100644
--- a/turtle_shell/migrations/0004_auto_20210411_1223.py
+++ b/turtle_shell/migrations/0004_auto_20210411_1223.py
@@ -6,18 +6,18 @@
class Migration(migrations.Migration):
dependencies = [
- ('turtle_shell', '0003_auto_20210411_1104'),
+ ("turtle_shell", "0003_auto_20210411_1104"),
]
operations = [
migrations.AlterField(
- model_name='executionresult',
- name='error_json',
+ model_name="executionresult",
+ name="error_json",
field=models.JSONField(default=dict, null=True),
),
migrations.AlterField(
- model_name='executionresult',
- name='output_json',
+ 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
index e98db3f..75a9668 100644
--- a/turtle_shell/migrations/0005_auto_20210412_2320.py
+++ b/turtle_shell/migrations/0005_auto_20210412_2320.py
@@ -7,23 +7,36 @@
class Migration(migrations.Migration):
dependencies = [
- ('turtle_shell', '0004_auto_20210411_1223'),
+ ("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),
+ 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),
+ 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),
+ 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/models.py b/turtle_shell/models.py
index acb7eaf..02bc113 100644
--- a/turtle_shell/models.py
+++ b/turtle_shell/models.py
@@ -6,9 +6,11 @@
import cattr
import json
+
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)
@@ -18,27 +20,33 @@ 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")]
+ 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)
+ 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
+ )
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'
+ 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)
+ 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)
@@ -74,7 +82,7 @@ def execute(self):
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):
+ 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)
@@ -95,10 +103,10 @@ def get_function(self):
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})
+ return reverse(f"turtle_shell:detail-{self.func_name}", kwargs={"pk": self.pk})
def __repr__(self):
- return (f'<{type(self).__name__}({self})')
+ return f"<{type(self).__name__}({self})"
@property
def pydantic_object(self):
diff --git a/turtle_shell/pydantic_adapter.py b/turtle_shell/pydantic_adapter.py
index f3eae82..860513b 100644
--- a/turtle_shell/pydantic_adapter.py
+++ b/turtle_shell/pydantic_adapter.py
@@ -13,9 +13,10 @@
logger = logging.getLogger(__name__)
+
def is_pydantic(func):
ret_type = inspect.signature(func).return_annotation
- if args:= typing.get_args(ret_type):
+ if args := typing.get_args(ret_type):
ret_type = args[0]
return inspect.isclass(ret_type) and issubclass(ret_type, BaseModel) and ret_type
@@ -38,6 +39,7 @@ def get_pydantic_models_in_order(model):
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()
@@ -47,12 +49,16 @@ def get_object_type(model) -> PydanticObjectType:
if reg.get_type_for_model(klass):
continue
- pydantic_oject = type(klass.__name__, (PydanticObjectType,), {"Meta": type("Meta", (object,),
- {"model": klass})})
+ pydantic_oject = type(
+ klass.__name__,
+ (PydanticObjectType,),
+ {"Meta": type("Meta", (object,), {"model": klass})},
+ )
print(f"CREATED: {klass.__name__}")
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
@@ -63,6 +69,7 @@ def maybe_add_pydantic_fields(func_object, fields):
fields[obj_name[0].lower() + obj_name[1:]] = graphene.Field(root_object)
print(f"Added field {obj_name}")
+
def maybe_convert_pydantic_model(result):
if isinstance(result, BaseModel):
return result.dict()
diff --git a/turtle_shell/templatetags/pydantic_to_table.py b/turtle_shell/templatetags/pydantic_to_table.py
index 0e2d309..d8bae29 100644
--- a/turtle_shell/templatetags/pydantic_to_table.py
+++ b/turtle_shell/templatetags/pydantic_to_table.py
@@ -7,9 +7,10 @@
register = template.Library()
+
@register.filter(is_safe=True)
def pydantic_model_to_table(obj):
- if not hasattr(obj, 'dict'):
+ if not hasattr(obj, "dict"):
raise ValueError("Invalid object - must be pydantic type! (got {type(obj).__name__})")
if hasattr(obj, "front_end_dict"):
print("FRONT END DICT")
@@ -24,6 +25,7 @@ def _urlize(value):
return value
return urlizetrunc(value, 40)
+
def dict_to_table(dct):
rows = []
for k, v in dct.items():
@@ -32,22 +34,31 @@ def dict_to_table(dct):
elif isinstance(v, (list, tuple)):
if v:
v_parts = [
- html.format_html('{num_elements} elements
',
- num_elements=len(v)),
- '| # | Elem |
'
+ 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(
+ 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"""\
+ rows.append(
+ html.format_html(
+ '
|---|
| {key} | {value} | ', key=k, value=_urlize(v)
+ )
+ )
+ row_data = "\n ".join(rows)
+ return mark_safe(
+ textwrap.dedent(
+ f"""\
| Key |
@@ -55,4 +66,6 @@ def dict_to_table(dct):
{row_data}
-
"""))
+
|---|
"""
+ )
+ )
diff --git a/turtle_shell/tests/test_django_cli2ui.py b/turtle_shell/tests/test_django_cli2ui.py
index 6b351cc..526fd29 100644
--- a/turtle_shell/tests/test_django_cli2ui.py
+++ b/turtle_shell/tests/test_django_cli2ui.py
@@ -79,30 +79,64 @@ def example_func(
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)
+ 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)
+ 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)
+ 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))
+ 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):
@@ -110,8 +144,6 @@ def test_compare_complex_example(db):
compare_forms(actual, ExpectedFormForExampleFunc)
-
-
def _make_parameter(name, annotation, doc="", **kwargs):
"""helper for simple params :) """
return Parameter(
@@ -149,7 +181,7 @@ def _make_parameter(name, annotation, doc="", **kwargs):
),
(
_make_parameter("bool_default_none", bool, "some doc", default=None),
- forms.NullBooleanField(required=False, help_text="some doc")
+ forms.NullBooleanField(required=False, help_text="some doc"),
),
(
_make_parameter("bool_falsey", bool, "some doc", default=False),
@@ -165,11 +197,11 @@ def _make_parameter(name, annotation, doc="", **kwargs):
),
(
_make_parameter("optional_bool", Optional[bool], default=True),
- forms.NullBooleanField(initial=True, required=False)
+ forms.NullBooleanField(initial=True, required=False),
),
(
_make_parameter("optional_bool", Optional[bool], default=None),
- forms.NullBooleanField(initial=None, required=False)
+ forms.NullBooleanField(initial=None, required=False),
),
],
ids=lambda x: x.name if hasattr(x, "name") else x,
@@ -216,7 +248,8 @@ def test_custom_widgets():
compare_form_field(
"str_large_text_field",
param_to_field(
- param, {"widgets": {Text: text_input_widget}, "types": {Text: forms.CharField}},
+ param,
+ {"widgets": {Text: text_input_widget}, "types": {Text: forms.CharField}},
),
forms.CharField(
widget=text_input_widget, help_text="some doc", initial="", required=False
@@ -227,8 +260,13 @@ def test_custom_widgets():
@pytest.mark.parametrize(
"parameter,exception_type,msg_regex",
[
- (_make_parameter("union_type", typing.Union[bool, str]), ValueError, "type class.*not supported"),
- ])
+ (
+ _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)
@@ -240,11 +278,11 @@ class AutoEnum(enum.Enum):
another = enum.auto()
class StringlyIntEnum(enum.Enum):
- val1 = '1'
- val2 = '2'
+ 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')
+ assert Coercer(StringlyIntEnum)("1") == StringlyIntEnum("1")
with pytest.raises(ValueError):
Coercer(StringlyIntEnum)(1)
diff --git a/turtle_shell/tests/test_graphene_adapter.py b/turtle_shell/tests/test_graphene_adapter.py
index c762e46..ebd6b61 100644
--- a/turtle_shell/tests/test_graphene_adapter.py
+++ b/turtle_shell/tests/test_graphene_adapter.py
@@ -4,14 +4,17 @@
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"):
+ def myfunc(a: bool = True, b: str = "whatever"):
pass
- resp = execute_gql_and_get_input_json(myfunc, "mutation { executeMyfunc(input: {}) { result { inputJson }}}")
+ resp = execute_gql_and_get_input_json(
+ myfunc, "mutation { executeMyfunc(input: {}) { result { inputJson }}}"
+ )
assert resp == {"a": True, "b": "whatever"}
def test_enum_preservation(db):
@@ -19,24 +22,30 @@ class ReadType(enum.Enum):
fastq = enum.auto()
bam = enum.auto()
- def func(read_type: ReadType=ReadType.fastq):
+ 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 }}}')
+ 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 }}}")
+ 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):
+ def myfunc(a: bool = None, b: str = None):
pass
- resp = execute_gql_and_get_input_json(myfunc, "mutation { executeMyfunc(input: {}) { result { inputJson }}}")
+ resp = execute_gql_and_get_input_json(
+ myfunc, "mutation { executeMyfunc(input: {}) { result { inputJson }}}"
+ )
# sadly None's get replaced :(
assert resp == {"a": None, "b": None}
@@ -45,6 +54,7 @@ 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)
@@ -52,17 +62,19 @@ def my_func(*, a: bool, b: str):
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'
+ whatever = "bobiswhatever"
+ mish = "dish"
+ defa = "default yeah"
- def func(s: MiscStringEnum=MiscStringEnum.defa):
+ 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
-
+ 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
index 3b46894..f079b21 100644
--- a/turtle_shell/tests/test_pydantic_adapter.py
+++ b/turtle_shell/tests/test_pydantic_adapter.py
@@ -5,14 +5,17 @@
from .utils import execute_gql, execute_gql_and_get_input_json
import pytest
+
class Status(enum.Enum):
- complete = 'complete'
- bad = 'bad'
+ complete = "complete"
+ bad = "bad"
+
class NestedStructure(BaseModel):
status: Status
thing: str
+
class StructuredOutput(BaseModel):
value: str
nested_things: List[NestedStructure]
@@ -24,18 +27,23 @@ def test_get_nested_models():
lst = pydantic_adapter.get_pydantic_models_in_order(NestedStructure)
assert lst == [NestedStructure]
-def test_structured_output(db):
-
+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')]
+ nested_things=[
+ NestedStructure(status=Status.bad, thing="other"),
+ NestedStructure(status=Status.complete, thing="other2"),
+ ],
)
- result = execute_gql(myfunc, 'mutation { executeMyfunc(input:{a: "whatever"}) { structuredOutput { nested_things { status }}}}')
+
+ result = execute_gql(
+ myfunc,
+ 'mutation { executeMyfunc(input:{a: "whatever"}) { structuredOutput { nested_things { status }}}}',
+ )
assert not result.errors
- nested = result.data['executeMyfunc']['output']['nested_things']
+ nested = result.data["executeMyfunc"]["output"]["nested_things"]
assert list(sorted(nested)) == list(sorted([{"status": "bad"}, {"status": "complete"}]))
@@ -51,15 +59,21 @@ 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')]
+ 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 }}}}')
+ result = execute_gql(
+ myfunc,
+ 'mutation { executeMyfunc(input:{a: "whatever"}) { structuredDuplicatingStatus { nested_things { status }}}}',
+ )
assert not result.errors
- nested = result.data['executeMyfunc']['output']['nested_things']
+ 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):
@@ -70,11 +84,13 @@ class StructuredInput(BaseModel):
b: List[int]
nested: List[NestedInput]
- inpt1 = StructuredInput(a="a", b=[1, 2,3],
- nested=[NestedInput(text="whatever")])
- def myfunc(s: StructuredInput=inpt1) -> str:
+ 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}}}")
+ 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
index 564666a..446efab 100644
--- a/turtle_shell/tests/test_utils.py
+++ b/turtle_shell/tests/test_utils.py
@@ -3,6 +3,7 @@
import json
import pytest
+
def test_json_encoder_not_registered():
class MyEnum(enum.Enum):
a = enum.auto()
@@ -10,6 +11,7 @@ class MyEnum(enum.Enum):
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()
@@ -21,4 +23,3 @@ class MyOtherEnum(enum.Enum):
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
index 1574ffb..4d0da54 100644
--- a/turtle_shell/tests/utils.py
+++ b/turtle_shell/tests/utils.py
@@ -35,7 +35,7 @@ def compare_forms(actual: Type[forms.Form], expected: Type[forms.Form]):
"""
actual_fields = actual.declared_fields
expected_fields = expected.declared_fields
- excluded_keys = {'_func_name', '_parameter_name'}
+ 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)
@@ -66,6 +66,7 @@ def execute_gql(func, gql):
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)
diff --git a/turtle_shell/utils.py b/turtle_shell/utils.py
index be6e6e4..dd54bff 100644
--- a/turtle_shell/utils.py
+++ b/turtle_shell/utils.py
@@ -3,6 +3,7 @@
from collections import defaultdict
from django.core.serializers.json import DjangoJSONEncoder
+
class EnumRegistry:
# URP - global! :(
_registered_enums = defaultdict(dict)
@@ -21,9 +22,17 @@ def has_enum(cls, enum_class):
@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 :(")
+ 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}}
+ 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):
@@ -41,7 +50,7 @@ def from_json_repr(cls, json_obj):
@classmethod
def object_hook(cls, dct):
- if '__enum__' not in dct:
+ if "__enum__" not in dct:
return dct
return cls.from_json_repr(dct)
@@ -53,9 +62,10 @@ def default(self, o, **k):
else:
super().default(o, **k)
+
class EnumAwareDecoder(json.JSONDecoder):
def __init__(self, *a, **k):
- k.setdefault('object_hook', self.object_hook)
+ k.setdefault("object_hook", self.object_hook)
super().__init__(*a, **k)
def object_hook(self, dct):
diff --git a/turtle_shell/views.py b/turtle_shell/views.py
index dde4c44..318addd 100644
--- a/turtle_shell/views.py
+++ b/turtle_shell/views.py
@@ -13,13 +13,16 @@
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__})")
+ raise ValueError(
+ "Must specify function name for ExecutionClasses classes (class was {type(self).__name__})"
+ )
def get_queryset(self):
qs = super().get_queryset()
@@ -27,27 +30,29 @@ def get_queryset(self):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
- context['func_name'] = self.func_name
+ 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):
+class ExecutionCreateView(ExecutionViewMixin, CreateView):
def get_form_kwargs(self, *a, **k):
kwargs = super().get_form_kwargs(*a, **k)
- kwargs['user'] = self.request.user
+ 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()
@@ -59,7 +64,7 @@ def form_valid(self, form):
def get_context_data(self, *a, **k):
ctx = super().get_context_data(*a, **k)
- ctx['doc'] = self.form_class.__doc__
+ ctx["doc"] = self.form_class.__doc__
return ctx
@@ -72,7 +77,6 @@ def handle_no_permission(self):
return resp
-
@dataclass
class Views:
detail_view: object
@@ -82,26 +86,49 @@ class Views:
func_name: str
@classmethod
- def from_function(cls, func: 'turtle_shell._Function', *, require_login: bool=True, schema=None):
+ 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))
+ 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}")
+ 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
-
From 7ae73b6e0c40b366ac3f05d01b6263caf96a3c3c Mon Sep 17 00:00:00 2001
From: Jeff Tratner
Date: Tue, 13 Apr 2021 09:49:47 -0400
Subject: [PATCH 11/29] Clean up some messages etc
Migrations
---
turtle_shell/README.rst | 4 ++--
turtle_shell/graphene_adapter.py | 11 ++++++++---
.../0006_executionresult_traceback.py | 19 +++++++++++++++++++
.../migrations/0007_auto_20210413_0626.py | 18 ++++++++++++++++++
turtle_shell/models.py | 8 +++++++-
turtle_shell/pydantic_adapter.py | 2 +-
.../turtle_shell/executionresult_detail.html | 1 +
.../executionresult_summaryrow.html | 2 +-
turtle_shell/views.py | 4 ++--
9 files changed, 59 insertions(+), 10 deletions(-)
create mode 100644 turtle_shell/migrations/0006_executionresult_traceback.py
create mode 100644 turtle_shell/migrations/0007_auto_20210413_0626.py
diff --git a/turtle_shell/README.rst b/turtle_shell/README.rst
index 35388a8..282160c 100644
--- a/turtle_shell/README.rst
+++ b/turtle_shell/README.rst
@@ -14,8 +14,6 @@ Motivation
REMAINING WORK:
1. Ability to do asynchronous executions (this is basically all set up)
-2. Better UI on output and/or ability to have structured graphql output for nicer APIs
- Maybe some kind of output serializer? See end for some ideas.
3. Help graphene-django release a version based on graphql-core so we can use newer graphene-pydantic :P
How does it work?
@@ -52,6 +50,8 @@ Becomes this awesome form!
Make your output pydantic models and get nicely structured GraphQL output AND nice tables of data on the page :)
+If you specify pydantic models as output, you'll even get a nice HTML rendering + structured types in GraphQL!
+
Overall gist
------------
diff --git a/turtle_shell/graphene_adapter.py b/turtle_shell/graphene_adapter.py
index ba1797a..d2d2458 100644
--- a/turtle_shell/graphene_adapter.py
+++ b/turtle_shell/graphene_adapter.py
@@ -115,9 +115,14 @@ def perform_mutate(cls, form, info):
for k, f in fields.items():
if k != "result":
kwargs[k] = all_results
-
- # TODO: make errors show up nicely
- return cls(errors=[], **kwargs)
+ # 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"
diff --git a/turtle_shell/migrations/0006_executionresult_traceback.py b/turtle_shell/migrations/0006_executionresult_traceback.py
new file mode 100644
index 0000000..e34b5b2
--- /dev/null
+++ b/turtle_shell/migrations/0006_executionresult_traceback.py
@@ -0,0 +1,19 @@
+# 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..26e103b
--- /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 02bc113..302116b 100644
--- a/turtle_shell/models.py
+++ b/turtle_shell/models.py
@@ -5,7 +5,8 @@
import uuid
import cattr
import json
-
+import logging
+logger = logging.getLogger(__name__)
class CaughtException(Exception):
"""An exception that was caught and saved. Generally don't need to rollback transaction with
@@ -36,6 +37,7 @@ class ExecutionResult(models.Model):
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"
@@ -64,8 +66,12 @@ def execute(self):
# 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
diff --git a/turtle_shell/pydantic_adapter.py b/turtle_shell/pydantic_adapter.py
index 860513b..404706d 100644
--- a/turtle_shell/pydantic_adapter.py
+++ b/turtle_shell/pydantic_adapter.py
@@ -82,4 +82,4 @@ def get_pydantic_object(execution_result):
try:
return ret_type.parse_obj(execution_result.output_json)
except Exception as e:
- logger.warn(f"Hit exception unparsing {type(e).__name__}{e}", stack_info=True)
+ logger.warn(f"Hit exception unparsing {type(e).__name__}{e}", exc_info=True)
diff --git a/turtle_shell/templates/turtle_shell/executionresult_detail.html b/turtle_shell/templates/turtle_shell/executionresult_detail.html
index 47396ff..e4e4236 100644
--- a/turtle_shell/templates/turtle_shell/executionresult_detail.html
+++ b/turtle_shell/templates/turtle_shell/executionresult_detail.html
@@ -22,6 +22,7 @@ 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}} |
diff --git a/turtle_shell/templates/turtle_shell/executionresult_summaryrow.html b/turtle_shell/templates/turtle_shell/executionresult_summaryrow.html
index 44ad893..1338b0e 100644
--- a/turtle_shell/templates/turtle_shell/executionresult_summaryrow.html
+++ b/turtle_shell/templates/turtle_shell/executionresult_summaryrow.html
@@ -4,7 +4,7 @@
{{ data|truncatechars:50 }}
- {{ data|pprint|escape }}
+ {%if skip_pprint %}{{ data|escape }}{% else %}{{data|pprint|escape}}{%endif%}
|
diff --git a/turtle_shell/views.py b/turtle_shell/views.py
index 318addd..d0473cd 100644
--- a/turtle_shell/views.py
+++ b/turtle_shell/views.py
@@ -57,9 +57,9 @@ def form_valid(self, form):
try:
self.object.execute()
except CaughtException as e:
- messages.warning(self.request, f"Error in Execution {self.object.pk}: {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}")
+ messages.info(self.request, f"Completed execution for {self.object.pk} ({self.object.func_name})")
return sup
def get_context_data(self, *a, **k):
From d3e115251f0c8bfd4b394b7f025a10f562978504 Mon Sep 17 00:00:00 2001
From: Jeff Tratner
Date: Wed, 14 Apr 2021 22:26:06 -0400
Subject: [PATCH 12/29] Formatting updates
---
turtle_shell/README.rst => README.rst | 0
.../migrations/0006_executionresult_traceback.py | 13 +++++++++----
turtle_shell/migrations/0007_auto_20210413_0626.py | 8 ++++----
turtle_shell/models.py | 8 ++++++--
turtle_shell/views.py | 8 ++++++--
5 files changed, 25 insertions(+), 12 deletions(-)
rename turtle_shell/README.rst => README.rst (100%)
diff --git a/turtle_shell/README.rst b/README.rst
similarity index 100%
rename from turtle_shell/README.rst
rename to README.rst
diff --git a/turtle_shell/migrations/0006_executionresult_traceback.py b/turtle_shell/migrations/0006_executionresult_traceback.py
index e34b5b2..a20f19f 100644
--- a/turtle_shell/migrations/0006_executionresult_traceback.py
+++ b/turtle_shell/migrations/0006_executionresult_traceback.py
@@ -7,13 +7,18 @@
class Migration(migrations.Migration):
dependencies = [
- ('turtle_shell', '0005_auto_20210412_2320'),
+ ("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),
+ 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
index 26e103b..f305759 100644
--- a/turtle_shell/migrations/0007_auto_20210413_0626.py
+++ b/turtle_shell/migrations/0007_auto_20210413_0626.py
@@ -6,13 +6,13 @@
class Migration(migrations.Migration):
dependencies = [
- ('turtle_shell', '0006_executionresult_traceback'),
+ ("turtle_shell", "0006_executionresult_traceback"),
]
operations = [
migrations.AlterField(
- model_name='executionresult',
- name='traceback',
- field=models.TextField(default=''),
+ model_name="executionresult",
+ name="traceback",
+ field=models.TextField(default=""),
),
]
diff --git a/turtle_shell/models.py b/turtle_shell/models.py
index 302116b..0fb3d8b 100644
--- a/turtle_shell/models.py
+++ b/turtle_shell/models.py
@@ -6,8 +6,10 @@
import cattr
import json
import logging
+
logger = logging.getLogger(__name__)
+
class CaughtException(Exception):
"""An exception that was caught and saved. Generally don't need to rollback transaction with
this one :)"""
@@ -67,8 +69,10 @@ def execute(self):
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)
+
+ 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())
diff --git a/turtle_shell/views.py b/turtle_shell/views.py
index d0473cd..2b3c245 100644
--- a/turtle_shell/views.py
+++ b/turtle_shell/views.py
@@ -57,9 +57,13 @@ def form_valid(self, 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}")
+ 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})")
+ messages.info(
+ self.request, f"Completed execution for {self.object.pk} ({self.object.func_name})"
+ )
return sup
def get_context_data(self, *a, **k):
From 80fb1a18df0d2be6e797bbbcbab2c91d40666e05 Mon Sep 17 00:00:00 2001
From: Jeff Tratner
Date: Wed, 14 Apr 2021 22:41:43 -0400
Subject: [PATCH 13/29] Basic pyproject
---
pyproject.toml | 18 ++++++++++++++++++
1 file changed, 18 insertions(+)
create mode 100644 pyproject.toml
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..34b45af
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,18 @@
+[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"
+pydantic = "^1.8.1"
+graphene-pydantic = "^0.2.0"
+graphene-django = "^2.15.0"
+
+[tool.poetry.dev-dependencies]
+
+[build-system]
+requires = ["poetry-core>=1.0.0"]
+build-backend = "poetry.core.masonry.api"
From 25bbf01298c06b7bac40635887c73897d2c8165f Mon Sep 17 00:00:00 2001
From: Jeff Tratner
Date: Wed, 14 Apr 2021 23:09:49 -0400
Subject: [PATCH 14/29] Poetry locking
---
poetry.lock | 2056 ++++++++++++++++++++++++++++++++++++++++++++++++
pyproject.toml | 34 +-
2 files changed, 2086 insertions(+), 4 deletions(-)
create mode 100644 poetry.lock
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
index 34b45af..b5f5d9e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,12 +6,38 @@ authors = ["Jeff Tratner "]
license = "MIT"
[tool.poetry.dependencies]
-python = ">=3.8"
-pydantic = "^1.8.1"
-graphene-pydantic = "^0.2.0"
-graphene-django = "^2.15.0"
+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"]
From d7bd8971f525ba4019e03642ccaa58f9d17c34a0 Mon Sep 17 00:00:00 2001
From: Jeff Tratner
Date: Wed, 14 Apr 2021 23:12:54 -0400
Subject: [PATCH 15/29] Set up pytest and setup
---
pytest.ini | 3 +++
setup.cfg | 29 +++++++++++++++++++++++++++++
2 files changed, 32 insertions(+)
create mode 100644 pytest.ini
create mode 100644 setup.cfg
diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 0000000..c2b3a23
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,3 @@
+[pytest]
+addopts = --ds=config.settings.test --reuse-db
+python_files = tests.py test_*.py
diff --git a/setup.cfg b/setup.cfg
new file mode 100644
index 0000000..083a8c3
--- /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 = config.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
From e234a900e12cfa7fa212fed95b374c8ecb5c9a35 Mon Sep 17 00:00:00 2001
From: Jeff Tratner
Date: Wed, 14 Apr 2021 23:15:34 -0400
Subject: [PATCH 16/29] Include manifest file
---
MANIFEST.in | 5 +++++
1 file changed, 5 insertions(+)
create mode 100644 MANIFEST.in
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 *
From 9f40f67c221c56834bee0f87eb3212a8ec09af32 Mon Sep 17 00:00:00 2001
From: Jeff Tratner
Date: Wed, 14 Apr 2021 23:27:14 -0400
Subject: [PATCH 17/29] Cleaning up some remaining testing stuff
---
pytest.ini | 3 ++-
setup.cfg | 2 +-
turtle_shell/models.py | 5 ++---
turtle_shell/tests/test_pydantic_adapter.py | 6 +++---
4 files changed, 8 insertions(+), 8 deletions(-)
diff --git a/pytest.ini b/pytest.ini
index c2b3a23..083018e 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -1,3 +1,4 @@
[pytest]
-addopts = --ds=config.settings.test --reuse-db
+addopts = --reuse-db
python_files = tests.py test_*.py
+DJANGO_SETTINGS_MODULE = settings_test
diff --git a/setup.cfg b/setup.cfg
index 083a8c3..59873bd 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -16,7 +16,7 @@ warn_unused_configs = True
plugins = mypy_django_plugin.main
[mypy.plugins.django-stubs]
-django_settings_module = config.settings.test
+django_settings_module = settings_test
[mypy-*.migrations.*]
# Django migrations should not produce any errors:
diff --git a/turtle_shell/models.py b/turtle_shell/models.py
index 0fb3d8b..317f751 100644
--- a/turtle_shell/models.py
+++ b/turtle_shell/models.py
@@ -3,7 +3,6 @@
from django.conf import settings
from turtle_shell import utils
import uuid
-import cattr
import json
import logging
@@ -82,8 +81,8 @@ def execute(self):
try:
if hasattr(result, "json"):
result = json.loads(result.json())
- if not isinstance(result, (dict, str, tuple)):
- result = cattr.unstructure(result)
+ # 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
diff --git a/turtle_shell/tests/test_pydantic_adapter.py b/turtle_shell/tests/test_pydantic_adapter.py
index f079b21..a736337 100644
--- a/turtle_shell/tests/test_pydantic_adapter.py
+++ b/turtle_shell/tests/test_pydantic_adapter.py
@@ -40,11 +40,11 @@ def myfunc(a: str) -> StructuredOutput:
result = execute_gql(
myfunc,
- 'mutation { executeMyfunc(input:{a: "whatever"}) { structuredOutput { nested_things { status }}}}',
+ 'mutation { executeMyfunc(input:{a: "whatever"}) { structuredOutput { nestedThings { status }}}}',
)
assert not result.errors
- nested = result.data["executeMyfunc"]["output"]["nested_things"]
- assert list(sorted(nested)) == list(sorted([{"status": "bad"}, {"status": "complete"}]))
+ nested = result.data["executeMyfunc"]["structuredOutput"]["nestedThings"]
+ assert nested == [{"status": "bad"}, {"status": "complete"}]
@pytest.mark.xfail
From 177aa48265de19a745f9bc7477cfcf3d6a1125cd Mon Sep 17 00:00:00 2001
From: Jeff Tratner
Date: Wed, 14 Apr 2021 23:55:36 -0400
Subject: [PATCH 18/29] Set up README
---
README.rst | 159 ++++++++++++++----
.../summarize-analysis-error-docstring.png | Bin 0 -> 40105 bytes
docs/images/summarize-analysis-error-form.png | Bin 0 -> 190796 bytes
.../summarize-analysis-graphql-example.png | Bin 0 -> 281277 bytes
.../images/summarize-analysis-grapqhl-doc.png | Bin 0 -> 85843 bytes
.../summarize-execution-pretty-output.png | Bin 0 -> 140316 bytes
settings_test.py | 19 +++
turtle_shell/graphene_adapter.py | 6 +-
8 files changed, 148 insertions(+), 36 deletions(-)
create mode 100644 docs/images/summarize-analysis-error-docstring.png
create mode 100644 docs/images/summarize-analysis-error-form.png
create mode 100644 docs/images/summarize-analysis-graphql-example.png
create mode 100644 docs/images/summarize-analysis-grapqhl-doc.png
create mode 100644 docs/images/summarize-execution-pretty-output.png
create mode 100644 settings_test.py
diff --git a/README.rst b/README.rst
index 282160c..cde4804 100644
--- a/README.rst
+++ b/README.rst
@@ -1,20 +1,8 @@
-Function to Form View
-=====================
+Django Turtle Shell
+===================
-
-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
+NOTE: This is still in active development! Implementations and everything may
+change!
How does it work?
-----------------
@@ -26,32 +14,136 @@ It leverages some neat features of defopt under the hood so that a function like
.. code-block:: python
- def some_action(
- username: str,
- url: str,
- optional_comment: str=None,
- hits: int = 5,
- _special_hidden_field: bool=False,
- ):
- """Perform action on URL for username.
+ 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:
- username: the user to associate the URL with
- url: which url to hit
- optional_comment: why this happened
- hits: how many hits you saw
+ analysis_id: reference ID for analysis
+ assay_type: WGS or NGS (narrows checks)
+ check_fastqs: if True, look at 'em
"""
- pass
+ ...
+
+ 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)
-Becomes this awesome form!
+.. 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 :)
-Make your output pydantic models 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 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)),
+ ]
+
+
+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
------------
@@ -160,6 +252,7 @@ ExecutionResult:
- input_json
- output_json
- func_name # defaults to module.function_name but can be customized
+ - error_json
Properties:
get_formatted_response() -> JSON serializable object
@@ -267,7 +360,7 @@ 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
+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
diff --git a/docs/images/summarize-analysis-error-docstring.png b/docs/images/summarize-analysis-error-docstring.png
new file mode 100644
index 0000000000000000000000000000000000000000..4833d20abf09faafc2fe5776ba2698f886196bd5
GIT binary patch
literal 40105
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
NY3ScB|KoAU{{hEe155w_
literal 0
HcmV?d00001
diff --git a/docs/images/summarize-analysis-error-form.png b/docs/images/summarize-analysis-error-form.png
new file mode 100644
index 0000000000000000000000000000000000000000..5ff64eda3939e11074b0e3d91a01a9925238acf0
GIT binary patch
literal 190796
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
|---|