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

New Execution for {{func_name}}

+
{{doc}}
+{% crispy form %} +{% endblock content %} + + diff --git a/turtle_shell/templates/turtle_shell/executionresult_detail.html b/turtle_shell/templates/turtle_shell/executionresult_detail.html new file mode 100644 index 0000000..e4e4236 --- /dev/null +++ b/turtle_shell/templates/turtle_shell/executionresult_detail.html @@ -0,0 +1,34 @@ +{% extends 'base.html' %} +{% load pydantic_to_table %} + +{% block content %} +
+

Execution for {{func_name}} ({{object.pk}})

+
+
+

State

+
+

{{object.status}}

+{% if object.pydantic_object %} +
+

Results

+{{object.pydantic_object|pydantic_model_to_table}} +
+{% endif %} +
+

Original Data

+ + +{% include "turtle_shell/executionresult_summaryrow.html" with key="Input" data=object.input_json %} +{% include "turtle_shell/executionresult_summaryrow.html" with key="Output" data=object.output_json %} +{% include "turtle_shell/executionresult_summaryrow.html" with key="Error" data=object.error_json %} +{% include "turtle_shell/executionresult_summaryrow.html" with key="Traceback" data=object.traceback skip_pprint=True %} +{% load tz %} +{% get_current_timezone as TIME_ZONE %} + + + + +
User{{object.user}}
Created{{object.created}} ({{TIME_ZONE}})
Modified{{object.modified}} ({{TIME_ZONE}})
+
+{% endblock content %} diff --git a/turtle_shell/templates/turtle_shell/executionresult_list.html b/turtle_shell/templates/turtle_shell/executionresult_list.html new file mode 100644 index 0000000..c6dc29c --- /dev/null +++ b/turtle_shell/templates/turtle_shell/executionresult_list.html @@ -0,0 +1,31 @@ +{% extends 'base.html' %} + +{% block content %} +

Executions for {{func_name}}

+

+{% if object_list %} + + + {% for object in object_list|slice:":1" %} + {% for _, field in object.FIELDS_TO_SHOW_IN_LIST %} + + {% endfor %} + + {% endfor %} + + + + {% for object in object_list %} + + {% for elem in object.list_entry %} + + {% endfor %} + + {% endfor %} + + +
{{field}}UUID
{{elem}}{{ object.uuid }}
+{% else %} +

No executions :(

+{% endif %} +{% endblock content %} diff --git a/turtle_shell/templates/turtle_shell/executionresult_summaryrow.html b/turtle_shell/templates/turtle_shell/executionresult_summaryrow.html new file mode 100644 index 0000000..05be051 --- /dev/null +++ b/turtle_shell/templates/turtle_shell/executionresult_summaryrow.html @@ -0,0 +1,11 @@ +{% if data %} + + {{ key }} + +
+ {{ data|truncatechars:50 }} +
{%if skip_pprint %}{{ data|escape }}{% else %}{{data|pprint|escape}}{%endif%}
+
+ + +{% endif %} diff --git a/turtle_shell/templates/turtle_shell/overview.html b/turtle_shell/templates/turtle_shell/overview.html new file mode 100644 index 0000000..308ac48 --- /dev/null +++ b/turtle_shell/templates/turtle_shell/overview.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} + +{% block content %} +
+ + + +{% for elem in functions %} + + + + + +{% endfor %} + +
FunctionDescription
{{elem.name}} +
+ +
+
{% if elem.doc %}
{{elem.doc}}
{% endif %}
+
+{% endblock content %} diff --git a/turtle_shell/templatetags/pydantic_to_table.py b/turtle_shell/templatetags/pydantic_to_table.py new file mode 100644 index 0000000..fc6562b --- /dev/null +++ b/turtle_shell/templatetags/pydantic_to_table.py @@ -0,0 +1,70 @@ +from django import template +from django.utils.safestring import mark_safe, SafeString +from django.utils import html +from django.template.defaultfilters import urlizetrunc +import textwrap +import json + +register = template.Library() + + +@register.filter(is_safe=True) +def pydantic_model_to_table(obj): + if not hasattr(obj, "dict"): + raise ValueError("Invalid object - must be pydantic type! (got {type(obj).__name__})") + if hasattr(obj, "front_end_dict"): + raw = obj.front_end_dict() + else: + raw = json.loads(obj.json()) + return mark_safe(dict_to_table(raw)) + + +def _urlize(value): + if isinstance(value, SafeString): + return value + return urlizetrunc(value, 40) + + +def dict_to_table(dct): + rows = [] + for k, v in dct.items(): + if isinstance(v, dict): + v = dict_to_table(v) + elif isinstance(v, (list, tuple)): + if v: + v_parts = [ + html.format_html( + "
{num_elements} elements", num_elements=len(v) + ), + '', + ] + 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=i, value=_urlize(elem) + ) + ) + v_parts.append("
#Elem
{idx}{value}
") + 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"""\ + + + + + + {row_data} + +
KeyValue
""" + ) + ) diff --git a/turtle_shell/tests.py b/turtle_shell/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/turtle_shell/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/turtle_shell/tests/__init__.py b/turtle_shell/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/turtle_shell/tests/conftest.py b/turtle_shell/tests/conftest.py new file mode 100644 index 0000000..4cf7b3d --- /dev/null +++ b/turtle_shell/tests/conftest.py @@ -0,0 +1,4 @@ +import pytest + +# ensure we get pretty pytest-style diffs in this module :) +pytest.register_assert_rewrite("turtle_shell.tests.utils") diff --git a/turtle_shell/tests/test_django_cli2ui.py b/turtle_shell/tests/test_django_cli2ui.py new file mode 100644 index 0000000..526fd29 --- /dev/null +++ b/turtle_shell/tests/test_django_cli2ui.py @@ -0,0 +1,288 @@ +import enum +import json + +import pytest +from django import forms + +from .utils import compare_form_field, compare_forms +from turtle_shell.function_to_form import param_to_field +from turtle_shell.function_to_form import function_to_form +from turtle_shell.function_to_form import Coercer +from turtle_shell import utils +import turtle_shell +from defopt import Parameter +from typing import Optional +import typing + + +class Color(enum.Enum): + red = enum.auto() + green = enum.auto() + yellow = enum.auto() + + +COLOR_CHOICES = [(e.name, e.name) for e in Color] + + +class Flag(enum.Enum): + is_apple = "is_apple" + is_banana = "is_banana" + + +FLAG_CHOICES = [(e.value, e.value) for e in Flag] + + +class Text(str): + pass + + +def example_func( + *, + int_arg: int, + int_arg_with_default: int = 5, + bool_arg_default_true: bool = True, + bool_arg_default_false: bool = False, + bool_arg_no_default: bool, + str_arg: str, + str_arg_with_default: str = "whatever", + text_arg: Text, + text_arg_with_default: Text = Text("something"), + enum_auto: Color, + enum_auto_not_required: Color = None, + enum_auto_with_default: Color = Color.green, + enum_str: Flag, + enum_str_with_default: Flag = Flag.is_apple, + undocumented_arg: str = None, +): + """ + First line of text content should be there. + + Args: + int_arg: Browser native int field + int_arg_with_default: Browser native int field with a default + bool_arg_default_true: should be checked checkbox + bool_arg_default_false: should be unchecked checkbox + bool_arg_no_default: Bool arg with a dropdown + str_arg: should be small field + str_arg_with_default: should be small field with text in it + text_arg: should have a big text field + text_arg_with_default: should have big text field with something filled in + enum_auto: should be choices with key names + enum_auto_not_required: choice field not required + enum_auto_with_default: choice field with entry selected + enum_str: should be choices with string values + + Later lines of content here. + + """ + + +class ExpectedFormForExampleFunc(forms.Form): + """\nFirst line of text content should be there. \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \nLater lines of content here.""" + + # extra whitespace cuz of weird docstring parsing from defopt + int_arg = forms.IntegerField(help_text="Browser native int field", required=True) + int_arg_with_default = forms.IntegerField( + initial=5, help_text="Browser native int field with a default", required=False + ) + bool_arg_default_true = forms.BooleanField( + initial=True, help_text="should be checked checkbox", required=False + ) + bool_arg_default_false = forms.BooleanField( + initial=False, help_text="should be unchecked checkbox", required=False + ) + bool_arg_no_default = forms.BooleanField(help_text="Bool arg with a dropdown", required=True) + str_arg = forms.CharField(help_text="should be small field", required=True) + str_arg_with_default = forms.CharField( + initial="whatever", help_text="should be small field with text in it", required=False + ) + # TODO: different widget + text_arg = forms.CharField(help_text="should have a big text field", required=True) + # TODO: different widget + text_arg_with_default = forms.CharField( + initial="something", + help_text="should have big text field with something filled in", + required=False, + ) + undocumented_arg = forms.CharField(required=False, empty_value=None) + enum_auto = forms.TypedChoiceField( + choices=COLOR_CHOICES, + required=True, + help_text="should be choices with key names", + coerce=Coercer(Color, by_attribute=True), + ) + enum_auto_not_required = forms.TypedChoiceField( + choices=COLOR_CHOICES, + required=False, + coerce=Coercer(Color, by_attribute=True), + help_text="choice field not required", + empty_value=None, + ) + enum_auto_with_default = forms.TypedChoiceField( + choices=COLOR_CHOICES, + initial=Color.green.value, + required=False, + coerce=Coercer(Color, by_attribute=True), + help_text="choice field with entry selected", + ) + enum_str = forms.TypedChoiceField( + choices=[("is_apple", "is_apple"), ("is_banana", "is_banana")], + required=True, + coerce=Coercer(Flag), + help_text="should be choices with string values", + ) + enum_str_with_default = forms.TypedChoiceField( + choices=[("is_apple", "is_apple"), ("is_banana", "is_banana")], + required=False, + initial=Flag.is_apple.value, + coerce=Coercer(Flag), + ) + + +def test_compare_complex_example(db): + actual = function_to_form(example_func) + compare_forms(actual, ExpectedFormForExampleFunc) + + +def _make_parameter(name, annotation, doc="", **kwargs): + """helper for simple params :) """ + return Parameter( + name=name, + kind=Parameter.KEYWORD_ONLY, + default=kwargs.get("default", Parameter.empty), + annotation=annotation, + doc=doc, + ) + + +@pytest.mark.parametrize( + "arg,expected", + [ + (_make_parameter("int", int, ""), forms.IntegerField(required=True)), + ( + _make_parameter("int_none_default_not_required", int, default=None), + forms.IntegerField(required=False), + ), + ( + _make_parameter("int_default", int, default=-1), + forms.IntegerField(initial=-1, required=False), + ), + ( + _make_parameter("str_doc", str, "some doc"), + forms.CharField(help_text="some doc", required=True), + ), + ( + _make_parameter("str_doc_not_required", str, "some doc", default="a"), + forms.CharField(required=False, initial="a", help_text="some doc"), + ), + ( + _make_parameter("str_doc_falsey", str, "some doc", default=""), + forms.CharField(initial="", required=False, help_text="some doc"), + ), + ( + _make_parameter("bool_default_none", bool, "some doc", default=None), + forms.NullBooleanField(required=False, help_text="some doc"), + ), + ( + _make_parameter("bool_falsey", bool, "some doc", default=False), + forms.BooleanField(required=False, initial=False, help_text="some doc"), + ), + ( + _make_parameter("bool_truthy", bool, "some doc", default=True), + forms.BooleanField(required=False, initial=True, help_text="some doc"), + ), + ( + _make_parameter("bool_required", bool, "some doc"), + forms.BooleanField(required=True, help_text="some doc"), + ), + ( + _make_parameter("optional_bool", Optional[bool], default=True), + forms.NullBooleanField(initial=True, required=False), + ), + ( + _make_parameter("optional_bool", Optional[bool], default=None), + forms.NullBooleanField(initial=None, required=False), + ), + ], + ids=lambda x: x.name if hasattr(x, "name") else x, +) +def test_convert_arg(arg, expected): + field = param_to_field(arg) + compare_form_field(arg.name, field, expected) + + +@pytest.mark.parametrize( + "arg,expected", + [ + ( + _make_parameter("enum_auto_default", Color, "another doc", default=Color.green), + forms.TypedChoiceField( + coerce=Coercer(Color, by_attribute=True), + initial=Color.green.value, + choices=COLOR_CHOICES, + required=False, + help_text="another doc", + ), + ), + ( + _make_parameter("enum_str", Flag), + forms.TypedChoiceField(coerce=Coercer(Flag), choices=FLAG_CHOICES), + ), + ( + _make_parameter("enum_str_default", Flag, default=Flag.is_apple), + forms.TypedChoiceField( + coerce=Coercer(Flag), initial="is_apple", choices=FLAG_CHOICES, required=False + ), + ), + ], + ids=lambda x: x.name if hasattr(x, "name") else x, +) +def test_enum_convert_arg(arg, expected): + field = param_to_field(arg) + compare_form_field(arg.name, field, expected) + + +def test_custom_widgets(): + param = _make_parameter("str_large_text_field", Text, "some doc", default="") + text_input_widget = forms.TextInput(attrs={"size": "80", "autocomplete": "off"}) + compare_form_field( + "str_large_text_field", + param_to_field( + param, + {"widgets": {Text: text_input_widget}, "types": {Text: forms.CharField}}, + ), + forms.CharField( + widget=text_input_widget, help_text="some doc", initial="", required=False + ), + ) + + +@pytest.mark.parametrize( + "parameter,exception_type,msg_regex", + [ + ( + _make_parameter("union_type", typing.Union[bool, str]), + ValueError, + "type class.*not supported", + ), + ], +) +def test_exceptions(parameter, exception_type, msg_regex): + with pytest.raises(exception_type, match=msg_regex): + param_to_field(parameter) + + +def test_coercer(): + class AutoEnum(enum.Enum): + whatever = enum.auto() + another = enum.auto() + + class StringlyIntEnum(enum.Enum): + val1 = "1" + val2 = "2" + + assert Coercer(AutoEnum)(AutoEnum.whatever.value) == AutoEnum.whatever + assert Coercer(AutoEnum)(str(AutoEnum.whatever.value)) == AutoEnum.whatever + assert Coercer(StringlyIntEnum)("1") == StringlyIntEnum("1") + with pytest.raises(ValueError): + Coercer(StringlyIntEnum)(1) diff --git a/turtle_shell/tests/test_form_comparisons.py b/turtle_shell/tests/test_form_comparisons.py new file mode 100644 index 0000000..130e9c2 --- /dev/null +++ b/turtle_shell/tests/test_form_comparisons.py @@ -0,0 +1,150 @@ +""" +Confirm that our form comparison tool actually works! +""" +import enum +import pytest +from django import forms +from typing import Type +from .utils import compare_form_field, compare_forms + + +class FormA(forms.Form): + myfield = forms.CharField() + + +class DifferentFieldNames(forms.Form): + otherfield = forms.CharField() + + +class DifferentFieldTypes(forms.Form): + myfield = forms.IntegerField() + + +class DifferentFieldRequired(forms.Form): + myfield = forms.CharField(required=False) + + +class DifferentInitial(forms.Form): + myfield = forms.CharField(initial="something") + + +class DocForm(forms.Form): + """ + My Doc + + Some new line + """ + + some = forms.CharField() + + +class DocFormExtraWhitespace(forms.Form): + """ + My Doc + + Some new line + """ + + some = forms.CharField() + + +class DifferentDoc(forms.Form): + """ + Another doc + + Some new line + """ + + some = forms.CharField() + + +def test_comparison(): + + compare_forms(FormA, FormA) + with pytest.raises(AssertionError, match="Found unexpected form fields"): + test_different_fields(raiseit=True) + + with pytest.raises(AssertionError, match=r"Field mismatch \(myfield\)"): + test_field_required(raiseit=True) + + with pytest.raises(AssertionError, match=r"Field mismatch \(myfield\)"): + test_field_initial(raiseit=True) + + with pytest.raises(AssertionError, match=r"Field type mismatch \(myfield\)"): + test_different_field_types(raiseit=True) + + with pytest.raises(AssertionError, match=r"Forms have different docstrings"): + test_doc_diff(raiseit=True) + + test_different_whitespace() + + +def _wrap_knownfail(f): + def wrapper(*args, raiseit=False, **kwargs): + try: + f(*args, **kwargs) + except AssertionError as exc: + if raiseit: + raise + else: + print("Known fail: {type(exc)}: {exc}") + + return wrapper + + +@_wrap_knownfail +def test_different_fields(): + compare_forms(FormA, DifferentFieldNames) + + +@_wrap_knownfail +def test_different_field_types(): + compare_forms(FormA, DifferentFieldTypes) + + +@_wrap_knownfail +def test_field_required(): + compare_forms(FormA, DifferentFieldRequired) + + +@_wrap_knownfail +def test_field_initial(): + compare_forms(FormA, DifferentInitial) + + +@_wrap_knownfail +def test_convert_form(): + pass + + +@_wrap_knownfail +def test_doc_diff(): + actual_fields = DocForm.declared_fields + expected_fields = DifferentDoc.declared_fields + assert actual_fields.keys() == expected_fields.keys(), "Different fields" + compare_forms(DocForm, DifferentDoc) + + +def test_different_whitespace(): + compare_forms(DocForm, DocFormExtraWhitespace) + + +def test_equivalent_forms(): + class Form1(forms.Form): + myfield = forms.CharField(initial="whatever") + + class Form2(forms.Form): + myfield = forms.CharField(initial="whatever") + + compare_forms(Form1, Form2) + + +def test_differ_on_doc(): + class BasicForm(forms.Form): + myfield = forms.CharField(initial="whatever", help_text="what") + + class BasicFormDifferentDoc(forms.Form): + myfield = forms.CharField(initial="whatever", help_text="different") + + with pytest.raises(AssertionError, match="myfield.*"): + compare_forms(BasicForm, BasicFormDifferentDoc) diff --git a/turtle_shell/tests/test_graphene_adapter.py b/turtle_shell/tests/test_graphene_adapter.py new file mode 100644 index 0000000..ebd6b61 --- /dev/null +++ b/turtle_shell/tests/test_graphene_adapter.py @@ -0,0 +1,80 @@ +from .utils import execute_gql, execute_gql_and_get_input_json +import turtle_shell +from turtle_shell import utils +import enum +import pytest + + +@pytest.mark.django_db +class TestDefaultHandling: + def test_defaults(db): + # defaults should be passed through + def myfunc(a: bool = True, b: str = "whatever"): + pass + + resp = execute_gql_and_get_input_json( + myfunc, "mutation { executeMyfunc(input: {}) { result { inputJson }}}" + ) + assert resp == {"a": True, "b": "whatever"} + + def test_enum_preservation(db): + class ReadType(enum.Enum): + fastq = enum.auto() + bam = enum.auto() + + def func(read_type: ReadType = ReadType.fastq): + return read_type + + input_json = execute_gql_and_get_input_json( + func, "mutation { executeFunc(input: {readType: BAM}) { result { inputJson }}}" + ) + assert input_json == {"read_type": utils.EnumRegistry.to_json_repr(ReadType.bam)} + assert input_json["read_type"]["__enum__"]["name"] == "bam" + + input_json = execute_gql_and_get_input_json( + func, "mutation { executeFunc(input: {}) { result { inputJson }}}" + ) + assert input_json == {"read_type": utils.EnumRegistry.to_json_repr(ReadType.fastq)} + assert input_json["read_type"]["__enum__"]["name"] == "fastq" + + def test_default_none(db): + + # defaults should be passed through + def myfunc(a: bool = None, b: str = None): + pass + + resp = execute_gql_and_get_input_json( + myfunc, "mutation { executeMyfunc(input: {}) { result { inputJson }}}" + ) + # sadly None's get replaced :( + assert resp == {"a": None, "b": None} + + def test_error_with_no_default(db): + + # no default should error + def my_func(*, a: bool, b: str): + pass + + registry = turtle_shell.get_registry() + registry.clear() + registry.add(my_func) + gql = "mutation { executeMyfunc(input: {}) { result { inputJson }}}" + result = registry.schema.execute(gql) + assert result.errors + + def test_rendering_enum_with_mixed_type(db): + class MiscStringEnum(enum.Enum): + whatever = "bobiswhatever" + mish = "dish" + defa = "default yeah" + + def func(s: MiscStringEnum = MiscStringEnum.defa): + return s + + input_json = execute_gql_and_get_input_json( + func, "mutation { executeFunc(input: {}) { result { inputJson }}}" + ) + input_json2 = execute_gql_and_get_input_json( + func, "mutation { executeFunc(input: {s: DEFAULT_YEAH}) { result { inputJson }}}" + ) + assert input_json == input_json2 diff --git a/turtle_shell/tests/test_pydantic_adapter.py b/turtle_shell/tests/test_pydantic_adapter.py new file mode 100644 index 0000000..a736337 --- /dev/null +++ b/turtle_shell/tests/test_pydantic_adapter.py @@ -0,0 +1,96 @@ +from pydantic import BaseModel +import enum +from typing import List +from turtle_shell import pydantic_adapter +from .utils import execute_gql, execute_gql_and_get_input_json +import pytest + + +class Status(enum.Enum): + complete = "complete" + bad = "bad" + + +class NestedStructure(BaseModel): + status: Status + thing: str + + +class StructuredOutput(BaseModel): + value: str + nested_things: List[NestedStructure] + + +def test_get_nested_models(): + lst = pydantic_adapter.get_pydantic_models_in_order(StructuredOutput) + assert lst == [NestedStructure, StructuredOutput] + lst = pydantic_adapter.get_pydantic_models_in_order(NestedStructure) + assert lst == [NestedStructure] + + +def test_structured_output(db): + def myfunc(a: str) -> StructuredOutput: + return StructuredOutput( + value=a, + nested_things=[ + NestedStructure(status=Status.bad, thing="other"), + NestedStructure(status=Status.complete, thing="other2"), + ], + ) + + result = execute_gql( + myfunc, + 'mutation { executeMyfunc(input:{a: "whatever"}) { structuredOutput { nestedThings { status }}}}', + ) + assert not result.errors + nested = result.data["executeMyfunc"]["structuredOutput"]["nestedThings"] + assert nested == [{"status": "bad"}, {"status": "complete"}] + + +@pytest.mark.xfail +def test_duplicate_enum_reference(db): + class StructuredDuplicatingStatus(BaseModel): + # this extra status causes graphene reducer to complain cuz we don't cache the Enum model + # :( + status: Status + nested_things: List[NestedStructure] + + def myfunc(a: str) -> StructuredDuplicatingStatus: + return StructuredDuplicatingStatus( + status=Status.complete, + value=a, + nested_things=[ + NestedStructure(status=Status.bad, thing="other"), + NestedStructure(status=Status.complete, thing="other2"), + ], + ) + + result = execute_gql( + myfunc, + 'mutation { executeMyfunc(input:{a: "whatever"}) { structuredDuplicatingStatus { nested_things { status }}}}', + ) + assert not result.errors + nested = result.data["executeMyfunc"]["output"]["nested_things"] + assert list(sorted(nested)) == list(sorted([{"status": "bad"}, {"status": "complete"}])) + + +@pytest.mark.xfail +def test_structured_input(db): + class NestedInput(BaseModel): + text: str + + class StructuredInput(BaseModel): + a: str + b: List[int] + nested: List[NestedInput] + + inpt1 = StructuredInput(a="a", b=[1, 2, 3], nested=[NestedInput(text="whatever")]) + + def myfunc(s: StructuredInput = inpt1) -> str: + return "apples" + + inpt = execute_gql_and_get_input_json( + myfunc, "mutation { executeMyfunc(input: {}) { result { inputJson}}}" + ) + actual = StructuredInput.parse_obj(inpt["s"]) + assert actual == inpt1 diff --git a/turtle_shell/tests/test_utils.py b/turtle_shell/tests/test_utils.py new file mode 100644 index 0000000..446efab --- /dev/null +++ b/turtle_shell/tests/test_utils.py @@ -0,0 +1,25 @@ +from turtle_shell import utils +import enum +import json +import pytest + + +def test_json_encoder_not_registered(): + class MyEnum(enum.Enum): + a = enum.auto() + + with pytest.raises(TypeError, match="has not been registered"): + json.dumps(MyEnum.a, cls=utils.EnumAwareEncoder) + + +def test_json_encoder_registered(): + class MyOtherEnum(enum.Enum): + a = enum.auto() + + utils.EnumRegistry.register(MyOtherEnum) + + original = {"val": MyOtherEnum.a} + s = json.dumps(original, cls=utils.EnumAwareEncoder) + assert '"__enum__"' in s + round_trip = json.loads(s, cls=utils.EnumAwareDecoder) + assert round_trip == original diff --git a/turtle_shell/tests/utils.py b/turtle_shell/tests/utils.py new file mode 100644 index 0000000..4d0da54 --- /dev/null +++ b/turtle_shell/tests/utils.py @@ -0,0 +1,79 @@ +from typing import Type +import inspect +import turtle_shell + +from django import forms +import json + + +def compare_form_field(name, actual, expected): + """Compare important variables of two form fields""" + try: + assert type(actual) == type(expected) + except AssertionError as e: + # bare assert causes pytest rewrite, so we just add a bit around it + raise AssertionError(f"Field type mismatch ({name}): {e}") from e + actual_vars = vars(actual) + expected_vars = vars(expected) + for k in ("widget", "_func_name", "_parameter_name"): + actual_vars.pop(k, None) + expected_vars.pop(k, None) + try: + assert actual_vars == expected_vars + except AssertionError as e: + # bare assert causes pytest rewrite, so we just add a bit around it + raise AssertionError(f"Field mismatch ({name}): {e}") from e + + +def compare_forms(actual: Type[forms.Form], expected: Type[forms.Form]): + """Compare two forms. + + Checks: + 1. Shared fields are the same (see compare_form_field) + 2. Both forms have the same set of fields + 3. Both forms have the same docstring + """ + actual_fields = actual.declared_fields + expected_fields = expected.declared_fields + excluded_keys = {"_func_name", "_parameter_name"} + shared_keys = list(set(actual_fields.keys()) & set(expected_fields.keys()) - excluded_keys) + extra_keys = list(set(actual_fields.keys()) - set(expected_fields.keys()) - excluded_keys) + missing_keys = list(set(expected_fields.keys()) - set(actual_fields.keys()) - excluded_keys) + for name in shared_keys: + actual_field = actual_fields[name] + expected_field = expected_fields[name] + compare_form_field(name, actual_field, expected_field) + assert not extra_keys, f"Found unexpected form fields:\n{extra_keys}" + assert not missing_keys, f"Expected fields missing:\n{missing_keys}" + try: + assert actual_fields.keys() == expected_fields.keys() + except AssertionError as e: + # bare assert causes pytest rewrite, so we just add a bit around it + raise AssertionError(f"Forms have different field names: {e}") from e + try: + assert inspect.getdoc(actual) == inspect.getdoc(expected) + except AssertionError as e: + print(repr(inspect.getdoc(actual))) + print(repr(inspect.getdoc(expected))) + # bare assert causes pytest rewrite, so we just add a bit around it + raise AssertionError(f"Forms have different docstrings: {e}") from e + + +def execute_gql(func, gql): + registry = turtle_shell.get_registry() + registry.clear() + registry.add(func) + result = registry.schema.execute(gql) + return result + + +def execute_gql_and_get_input_json(func, gql): + """Helper to make it easy to test default setting""" + result = execute_gql(func, gql) + data = result.data + assert not result.errors + result_from_response = list(data.values())[0]["result"] + assert result_from_response + return json.loads(result_from_response["inputJson"]) + # data = json.loads(result["data"]["result"]["inputJson"]) + # return data diff --git a/turtle_shell/utils.py b/turtle_shell/utils.py new file mode 100644 index 0000000..dd54bff --- /dev/null +++ b/turtle_shell/utils.py @@ -0,0 +1,72 @@ +import json +import enum +from collections import defaultdict +from django.core.serializers.json import DjangoJSONEncoder + + +class EnumRegistry: + # URP - global! :( + _registered_enums = defaultdict(dict) + + @classmethod + def register(cls, enum_class): + cls._registered_enums[enum_class.__module__][enum_class.__qualname__] = enum_class + + @classmethod + def has_enum(cls, enum_class): + try: + return cls._registered_enums[enum_class.__module__][enum_class.__qualname__] + except KeyError: + return None + + @classmethod + def to_json_repr(cls, enum_member): + if not cls.has_enum(type(enum_member)): + raise TypeError( + f"Enum type {type(enum_member)} has not been registered and can't be serialized :(" + ) + enum_class = type(enum_member) + return { + "__enum__": { + "__type__": [enum_class.__module__, enum_class.__qualname__], + "name": enum_member.name, + "value": enum_member.value, + } + } + + @classmethod + def from_json_repr(cls, json_obj): + if "__enum__" not in json_obj: + raise ValueError("Enums must be represented by __enum__ key") + try: + type_data = json_obj["__enum__"]["__type__"] + try: + enum_class = cls._registered_enums[type_data[0]][type_data[1]] + except KeyError: + raise ValueError(f"Looks like enum {type_data} is not registered :(") + return enum_class(json_obj["__enum__"]["value"]) + except (KeyError, ValueError) as e: + raise ValueError(f"Invalid enum representation in JSON:: {type(e).__name__}: {e}") + + @classmethod + def object_hook(cls, dct): + if "__enum__" not in dct: + return dct + return cls.from_json_repr(dct) + + +class EnumAwareEncoder(DjangoJSONEncoder): + def default(self, o, **k): + if isinstance(o, enum.Enum): + return EnumRegistry.to_json_repr(o) + else: + super().default(o, **k) + + +class EnumAwareDecoder(json.JSONDecoder): + def __init__(self, *a, **k): + k.setdefault("object_hook", self.object_hook) + super().__init__(*a, **k) + + def object_hook(self, dct): + return EnumRegistry.object_hook(dct) diff --git a/turtle_shell/views.py b/turtle_shell/views.py index 91ea44a..2b3c245 100644 --- a/turtle_shell/views.py +++ b/turtle_shell/views.py @@ -1,3 +1,138 @@ -from django.shortcuts import render +from django.views.generic import DetailView +from django.views.generic import ListView +from django.views.generic import TemplateView +from django.views.generic.edit import CreateView +from django.contrib.auth.mixins import LoginRequiredMixin +from graphene_django.views import GraphQLView +from .models import ExecutionResult +from dataclasses import dataclass +from django.urls import path +from django.contrib import messages +from typing import Optional -# Create your views here. + +class ExecutionViewMixin: + """Wrapper that auto-filters queryset to include function name""" + + func_name: str = None + model = ExecutionResult + + def __init__(self, *a, **k): + super().__init__(*a, **k) + if not self.func_name: + raise ValueError( + "Must specify function name for ExecutionClasses classes (class was {type(self).__name__})" + ) + + def get_queryset(self): + qs = super().get_queryset() + return qs.filter(func_name=self.func_name) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["func_name"] = self.func_name + return context + + +class ExecutionDetailView(ExecutionViewMixin, DetailView): + pass + + +class ExecutionListView(ExecutionViewMixin, ListView): + def get_queryset(self): + qs = super().get_queryset() + return qs.order_by("-created") + + +class ExecutionCreateView(ExecutionViewMixin, CreateView): + def get_form_kwargs(self, *a, **k): + kwargs = super().get_form_kwargs(*a, **k) + kwargs["user"] = self.request.user + return kwargs + + def form_valid(self, form): + from .models import CaughtException + + sup = super().form_valid(form) + try: + self.object.execute() + except CaughtException as e: + messages.warning( + self.request, f"Error in Execution {self.object.pk} ({self.object.func_name}): {e}" + ) + else: + messages.info( + self.request, f"Completed execution for {self.object.pk} ({self.object.func_name})" + ) + return sup + + def get_context_data(self, *a, **k): + ctx = super().get_context_data(*a, **k) + ctx["doc"] = self.form_class.__doc__ + return ctx + + +class LoginRequiredGraphQLView(LoginRequiredMixin, GraphQLView): + def handle_no_permission(self): + if self.request.user.is_authenticated: + raise PermissionDenied("No permission to access this resource.") + resp = HttpResponse(json.dumps({"error": "Invalid token"}), status=401) + resp["WWW-Authenticate"] = "Bearer" + return resp + + +@dataclass +class Views: + detail_view: object + list_view: object + create_view: object + graphql_view: Optional[object] + func_name: str + + @classmethod + def from_function( + cls, func: "turtle_shell._Function", *, require_login: bool = True, schema=None + ): + bases = (LoginRequiredMixin,) if require_login else tuple() + detail_view = type( + f"{func.name}DetailView", bases + (ExecutionDetailView,), ({"func_name": func.name}) + ) + list_view = type( + f"{func.name}ListView", bases + (ExecutionListView,), ({"func_name": func.name}) + ) + create_view = type( + f"{func.name}CreateView", + bases + (ExecutionCreateView,), + ({"func_name": func.name, "form_class": func.form_class}), + ) + return cls( + detail_view=detail_view, + list_view=list_view, + create_view=create_view, + func_name=func.name, + graphql_view=( + LoginRequiredGraphQLView.as_view(graphiql=True, schema=schema) if schema else None + ), + ) + + def urls(self, *, list_template, detail_template, create_template): + # TODO: namespace this again! + ret = [ + path( + f"{self.func_name}/", + self.list_view.as_view(template_name=list_template), + name=f"list-{self.func_name}", + ), + path( + f"{self.func_name}/create/", + self.create_view.as_view(template_name=create_template), + name=f"create-{self.func_name}", + ), + path( + f"{self.func_name}//", + self.detail_view.as_view(template_name=detail_template), + name=f"detail-{self.func_name}", + ), + ] + ret.append(path("graphql", self.graphql_view)) + return ret