diff --git a/.gitignore b/.gitignore
index b6e47617..f13d7bae 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,7 +14,6 @@ dist/
downloads/
eggs/
.eggs/
-lib/
lib64/
parts/
sdist/
@@ -127,3 +126,6 @@ dmypy.json
# Pyre type checker
.pyre/
+
+# PyCharm
+.idea
diff --git a/reddit-clone/.dockerignore b/reddit-clone/.dockerignore
new file mode 100644
index 00000000..9008115f
--- /dev/null
+++ b/reddit-clone/.dockerignore
@@ -0,0 +1,7 @@
+.git
+__pycache__
+*.pyc
+*.pyo
+*.pyd
+.Python
+env
diff --git a/reddit-clone/.flake8 b/reddit-clone/.flake8
new file mode 100644
index 00000000..812e13d9
--- /dev/null
+++ b/reddit-clone/.flake8
@@ -0,0 +1,7 @@
+[flake8]
+max-line-length = 89
+exclude=.venv,.git
+ignore = W503
+extend-ignore =
+ # See https://github.com/PyCQA/pycodestyle/issues/373
+ E203,
diff --git a/reddit-clone/.projectroot b/reddit-clone/.projectroot
new file mode 100644
index 00000000..e69de29b
diff --git a/reddit-clone/Dockerfile b/reddit-clone/Dockerfile
new file mode 100644
index 00000000..e4eec895
--- /dev/null
+++ b/reddit-clone/Dockerfile
@@ -0,0 +1,15 @@
+FROM python:3.9-slim
+LABEL maintainer="Aryan Iyappan "
+
+ARG APP_HOME=/app/
+WORKDIR ${APP_HOME}
+
+# install poetry
+RUN pip install poetry
+
+# install dependencies
+COPY ./poetry.lock ./pyproject.toml ${APP_HOME}
+RUN poetry install --no-dev
+
+# copy project files
+COPY ./ ${APP_HOME}
diff --git a/reddit-clone/README.md b/reddit-clone/README.md
new file mode 100644
index 00000000..16deb4d3
--- /dev/null
+++ b/reddit-clone/README.md
@@ -0,0 +1,36 @@
+# Reddit GraphQL API
+
+This example shows you how to create a Reddit API clone using GraphQL.
+The goal of this example is not to re-create the entire reddit API, but
+to produce a simpler version that is easier to understand, and implements
+most of the features that Strawberry gives us.
+
+## Tech Stack used:
+
+- [Strawberry GraphQL](https://github.com/strawberry-graphql/strawberry)
+- [Starlette](https://github.com/encode/starlette) web framework
+- [Uvicorn](https://github.com/encode/uvicorn) ASGI server
+- [SQLAlchemy](https://github.com/sqlalchemy/sqlalchemy) core/ mapper (asyncio)
+- [Alembic](https://github.com/sqlalchemy/alembic) migrations
+- [PostgreSQL](https://github.com/postgres/postgres) database server
+- [Marshmallow](https://github.com/marshmallow-code/marshmallow) data validation
+- [Celery](https://github.com/celery/celery) tasks ([Redis](https://github.com/redis/redis) store, [RabbitMQ](https://github.com/rabbitmq/rabbitmq-server) broker)
+
+## Features at a glance
+
+- [ ] Implements the Relay spec
+- [x] data modelling with relations
+- [x] Error modelling within the schema
+- [ ] Authorization with the permissions API
+- [x] Batch loading with dataloaders
+- [x] modular codebase
+
+## How to use
+
+You can use [Docker Compose](https://github.com/docker/compose) to run this example. Make sure you have it installed on your machine!
+
+```text
+docker compose up
+```
+
+You can now explore the GraphQL API here: http://localhost/graphql
diff --git a/reddit-clone/alembic.ini b/reddit-clone/alembic.ini
new file mode 100644
index 00000000..2d4431e1
--- /dev/null
+++ b/reddit-clone/alembic.ini
@@ -0,0 +1,100 @@
+# A generic, single database configuration.
+
+[alembic]
+# path to migration scripts
+script_location = migrations
+
+# template used to generate migration files
+# file_template = %%(rev)s_%%(slug)s
+
+# sys.path path, will be prepended to sys.path if present.
+# defaults to the current working directory.
+prepend_sys_path = .
+
+# timezone to use when rendering the date within the migration file
+# as well as the filename.
+# If specified, requires the python-dateutil library that can be
+# installed by adding `alembic[tz]` to the pip requirements
+# string value is passed to dateutil.tz.gettz()
+# leave blank for localtime
+# timezone =
+
+# max length of characters to apply to the
+# "slug" field
+# truncate_slug_length = 40
+
+# set to 'true' to run the environment during
+# the 'revision' command, regardless of autogenerate
+# revision_environment = false
+
+# set to 'true' to allow .pyc and .pyo files without
+# a source .py file to be detected as revisions in the
+# versions/ directory
+# sourceless = false
+
+# version location specification; This defaults
+# to migrations/versions. When using multiple version
+# directories, initial revisions must be specified with --version-path.
+# The path separator used here should be the separator specified by "version_path_separator"
+# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions
+
+# version path separator; As mentioned above, this is the character used to split
+# version_locations. Valid values are:
+#
+# version_path_separator = :
+# version_path_separator = ;
+# version_path_separator = space
+version_path_separator = os # default: use os.pathsep
+
+# the output encoding used when revision files
+# are written from script.py.mako
+# output_encoding = utf-8
+
+sqlalchemy.url = driver://user:pass@localhost/dbname
+
+
+[post_write_hooks]
+# post_write_hooks defines scripts or Python functions that are run
+# on newly generated revision scripts. See the documentation for further
+# detail and examples
+
+# format using "black" - use the console_scripts runner, against the "black" entrypoint
+# hooks = black
+# black.type = console_scripts
+# black.entrypoint = black
+# black.options = -l 79 REVISION_SCRIPT_FILENAME
+
+# Logging configuration
+[loggers]
+keys = root,sqlalchemy,alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARN
+handlers = console
+qualname =
+
+[logger_sqlalchemy]
+level = WARN
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s
+datefmt = %H:%M:%S
diff --git a/reddit-clone/docker-compose.yml b/reddit-clone/docker-compose.yml
new file mode 100644
index 00000000..bf2f4231
--- /dev/null
+++ b/reddit-clone/docker-compose.yml
@@ -0,0 +1,113 @@
+version: "3.9"
+x-environment: &base-environment
+ DEBUG: "false"
+ CELERY_BROKER: amqp://reddit:reddit@rabbitmq:5672/
+ CELERY_BACKEND: redis://:reddit@localhost:6379/
+ DATABASE_URL: postgresql+asyncpg://reddit:reddit@postgres:5432/reddit/
+ MAIL_HOST: 127.0.0.1
+ MAIL_PORT: 25
+ MAIL_USERNAME:
+ MAIL_PASSWORD:
+ MAIL_SENDER:
+
+services:
+ nginx:
+ image: nginx:1.21-alpine
+ container_name: reddit-nginx
+ restart: always
+ networks:
+ - reddit-proxy
+ ports:
+ - 80:80
+ volumes:
+ - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf
+ depends_on:
+ - app
+
+ app:
+ build: "."
+ image: reddit-app
+ container_name: reddit-app
+ restart: always
+ command: poetry run uvicorn reddit:app --host=0.0.0.0 --port=8080
+ environment: *base-environment
+ networks:
+ - reddit-main
+ - reddit-proxy
+ volumes:
+ - .:/app
+ depends_on:
+ - postgres
+
+ celery:
+ image: reddit-app
+ container_name: reddit-celery
+ restart: always
+ command: poetry run celery -A reddit.tasks worker
+ environment: *base-environment
+ networks:
+ - reddit-main
+ volumes:
+ - .:/app
+ depends_on:
+ - rabbitmq
+ - redis
+ - app
+
+ postgres:
+ image: postgres:14-alpine
+ container_name: reddit-postgres
+ restart: always
+ environment:
+ POSTGRES_USER: reddit
+ POSTGRES_PASSWORD: reddit
+ POSTGRES_DB: reddit
+ networks:
+ - reddit-main
+ ports:
+ - 5432:5432
+ volumes:
+ - ./data/postgres:/var/lib/postgresql/data
+
+ redis:
+ image: redis:6.2-alpine
+ container_name: reddit-redis
+ restart: always
+ environment:
+ REDIS_PASSWORD: reddit
+ networks:
+ - reddit-main
+ ports:
+ - 6379:6379
+ volumes:
+ - ./data/redis:/data
+ healthcheck:
+ test: redis-cli ping
+ interval: 15s
+ retries: 5
+ timeout: 5s
+
+ rabbitmq:
+ image: rabbitmq:3.9-alpine
+ container_name: reddit-rabbitmq
+ restart: always
+ environment:
+ RABBITMQ_DEFAULT_USER: reddit
+ RABBITMQ_DEFAULT_PASS: reddit
+ networks:
+ - reddit-main
+ ports:
+ - 5672:5672
+ volumes:
+ - ./data/rabbitmq:/data
+ healthcheck:
+ test: rabbitmq-diagnostics -q ping
+ interval: 15s
+ retries: 5
+ timeout: 5s
+
+networks:
+ reddit-main:
+ driver: bridge
+ reddit-proxy:
+ driver: bridge
diff --git a/reddit-clone/migrations/README b/reddit-clone/migrations/README
new file mode 100644
index 00000000..a23d4fb5
--- /dev/null
+++ b/reddit-clone/migrations/README
@@ -0,0 +1 @@
+Generic single-database configuration with an async dbapi.
diff --git a/reddit-clone/migrations/env.py b/reddit-clone/migrations/env.py
new file mode 100644
index 00000000..8b43a8a4
--- /dev/null
+++ b/reddit-clone/migrations/env.py
@@ -0,0 +1,96 @@
+import asyncio
+from logging.config import fileConfig
+
+from sqlalchemy import engine_from_config
+from sqlalchemy import pool
+from sqlalchemy.ext.asyncio import AsyncEngine
+
+from alembic import context
+
+from reddit.database import Base
+from reddit.settings import DATABASE_URI
+
+
+# this is the Alembic Config object, which provides
+# access to the values within the .ini file in use.
+config = context.config
+
+config.set_main_option("sqlalchemy.url", DATABASE_URI)
+
+# Interpret the config file for Python logging.
+# This line sets up loggers basically.
+fileConfig(config.config_file_name)
+
+# populate Base.metadata
+from reddit.users.models import User # noqa
+from reddit.subreddits.models import Subreddit # noqa
+from reddit.posts.models import Post # noqa
+from reddit.comments.models import Comment # noqa
+
+# add your model's MetaData object here
+# for 'autogenerate' support
+# from myapp import mymodel
+# target_metadata = mymodel.Base.metadata
+target_metadata = Base.metadata
+
+# other values from the config, defined by the needs of env.py,
+# can be acquired:
+# my_important_option = config.get_main_option("my_important_option")
+# ... etc.
+
+
+def run_migrations_offline():
+ """Run migrations in 'offline' mode.
+
+ This configures the context with just a URL
+ and not an Engine, though an Engine is acceptable
+ here as well. By skipping the Engine creation
+ we don't even need a DBAPI to be available.
+
+ Calls to context.execute() here emit the given string to the
+ script output.
+
+ """
+ url = config.get_main_option("sqlalchemy.url")
+ context.configure(
+ url=url,
+ target_metadata=target_metadata,
+ literal_binds=True,
+ dialect_opts={"paramstyle": "named"},
+ )
+
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+def do_run_migrations(connection):
+ context.configure(connection=connection, target_metadata=target_metadata)
+
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+async def run_migrations_online():
+ """Run migrations in 'online' mode.
+
+ In this scenario we need to create an Engine
+ and associate a connection with the context.
+
+ """
+ connectable = AsyncEngine(
+ engine_from_config(
+ config.get_section(config.config_ini_section),
+ prefix="sqlalchemy.",
+ poolclass=pool.NullPool,
+ future=True,
+ )
+ )
+
+ async with connectable.connect() as connection:
+ await connection.run_sync(do_run_migrations)
+
+
+if context.is_offline_mode():
+ run_migrations_offline()
+else:
+ asyncio.run(run_migrations_online())
diff --git a/reddit-clone/migrations/script.py.mako b/reddit-clone/migrations/script.py.mako
new file mode 100644
index 00000000..2c015630
--- /dev/null
+++ b/reddit-clone/migrations/script.py.mako
@@ -0,0 +1,24 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+
+"""
+from alembic import op
+import sqlalchemy as sa
+${imports if imports else ""}
+
+# revision identifiers, used by Alembic.
+revision = ${repr(up_revision)}
+down_revision = ${repr(down_revision)}
+branch_labels = ${repr(branch_labels)}
+depends_on = ${repr(depends_on)}
+
+
+def upgrade():
+ ${upgrades if upgrades else "pass"}
+
+
+def downgrade():
+ ${downgrades if downgrades else "pass"}
diff --git a/reddit-clone/migrations/versions/fcae25f2abf6_initial.py b/reddit-clone/migrations/versions/fcae25f2abf6_initial.py
new file mode 100644
index 00000000..3fc823e6
--- /dev/null
+++ b/reddit-clone/migrations/versions/fcae25f2abf6_initial.py
@@ -0,0 +1,105 @@
+"""initial
+
+Revision ID: fcae25f2abf6
+Revises:
+Create Date: 2021-10-14 16:05:02.497467
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = "fcae25f2abf6"
+down_revision = None
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ op.create_table(
+ "users",
+ sa.Column("id", sa.Integer(), nullable=False),
+ sa.Column("username", sa.String(length=32), nullable=False),
+ sa.Column("email", sa.String(length=255), nullable=False),
+ sa.Column("password", sa.String(length=255), nullable=False),
+ sa.Column("avatar", sa.String(length=255), nullable=False),
+ sa.Column("is_active", sa.Boolean(), nullable=False),
+ sa.PrimaryKeyConstraint("id"),
+ sa.UniqueConstraint("email"),
+ sa.UniqueConstraint("username"),
+ )
+ op.create_table(
+ "subreddits",
+ sa.Column("id", sa.Integer(), nullable=False),
+ sa.Column("name", sa.String(length=75), nullable=False),
+ sa.Column("description", sa.String(length=255), nullable=True),
+ sa.Column("owner_id", sa.Integer(), nullable=True),
+ sa.Column("icon", sa.String(length=255), nullable=True),
+ sa.ForeignKeyConstraint(
+ ["owner_id"],
+ ["users.id"],
+ ),
+ sa.PrimaryKeyConstraint("id"),
+ sa.UniqueConstraint("name"),
+ )
+ op.create_table(
+ "posts",
+ sa.Column("id", sa.Integer(), nullable=False),
+ sa.Column("title", sa.String(length=150), nullable=True),
+ sa.Column("text", sa.String(length=1024), nullable=True),
+ sa.Column("link", sa.String(length=255), nullable=True),
+ sa.Column("thumbnail", sa.String(length=255), nullable=True),
+ sa.Column("owner_id", sa.Integer(), nullable=True),
+ sa.Column("subreddit_id", sa.Integer(), nullable=True),
+ sa.Column("votes", sa.Integer(), nullable=True),
+ sa.ForeignKeyConstraint(
+ ["owner_id"],
+ ["users.id"],
+ ),
+ sa.ForeignKeyConstraint(
+ ["subreddit_id"],
+ ["subreddits.id"],
+ ),
+ sa.PrimaryKeyConstraint("id"),
+ sa.UniqueConstraint("link"),
+ )
+ op.create_table(
+ "subreddit_users",
+ sa.Column("user_id", sa.Integer(), nullable=False),
+ sa.Column("subreddit_id", sa.Integer(), nullable=False),
+ sa.ForeignKeyConstraint(
+ ["subreddit_id"],
+ ["subreddits.id"],
+ ),
+ sa.ForeignKeyConstraint(
+ ["user_id"],
+ ["users.id"],
+ ),
+ sa.PrimaryKeyConstraint("user_id", "subreddit_id"),
+ )
+ op.create_table(
+ "comments",
+ sa.Column("id", sa.Integer(), nullable=False),
+ sa.Column("content", sa.Text(), nullable=False),
+ sa.Column("votes", sa.Integer(), nullable=True),
+ sa.Column("owner_id", sa.Integer(), nullable=True),
+ sa.Column("post_id", sa.Integer(), nullable=True),
+ sa.ForeignKeyConstraint(
+ ["owner_id"],
+ ["users.id"],
+ ),
+ sa.ForeignKeyConstraint(
+ ["post_id"],
+ ["posts.id"],
+ ),
+ sa.PrimaryKeyConstraint("id"),
+ )
+
+
+def downgrade():
+ op.drop_table("comments")
+ op.drop_table("subreddit_users")
+ op.drop_table("posts")
+ op.drop_table("subreddits")
+ op.drop_table("users")
diff --git a/reddit-clone/nginx/nginx.conf b/reddit-clone/nginx/nginx.conf
new file mode 100644
index 00000000..6c3ca497
--- /dev/null
+++ b/reddit-clone/nginx/nginx.conf
@@ -0,0 +1,7 @@
+server {
+ listen 80;
+
+ location / {
+ proxy_pass http://app:8080;
+ }
+}
diff --git a/reddit-clone/poetry.lock b/reddit-clone/poetry.lock
new file mode 100644
index 00000000..012f68fc
--- /dev/null
+++ b/reddit-clone/poetry.lock
@@ -0,0 +1,1553 @@
+[[package]]
+name = "aiofiles"
+version = "0.7.0"
+description = "File support for asyncio."
+category = "main"
+optional = false
+python-versions = ">=3.6,<4.0"
+
+[[package]]
+name = "alembic"
+version = "1.7.4"
+description = "A database migration tool for SQLAlchemy."
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+importlib-metadata = {version = "*", markers = "python_version < \"3.9\""}
+importlib-resources = {version = "*", markers = "python_version < \"3.9\""}
+Mako = "*"
+SQLAlchemy = ">=1.3.0"
+
+[package.extras]
+tz = ["python-dateutil"]
+
+[[package]]
+name = "amqp"
+version = "5.0.6"
+description = "Low-level AMQP client for Python (fork of amqplib)."
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+vine = "5.0.0"
+
+[[package]]
+name = "anyio"
+version = "3.3.4"
+description = "High level compatibility layer for multiple asynchronous event loop implementations"
+category = "main"
+optional = false
+python-versions = ">=3.6.2"
+
+[package.dependencies]
+idna = ">=2.8"
+sniffio = ">=1.1"
+typing-extensions = {version = "*", markers = "python_version < \"3.8\""}
+
+[package.extras]
+doc = ["sphinx-rtd-theme", "sphinx-autodoc-typehints (>=1.2.0)"]
+test = ["coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "pytest (>=6.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (<0.15)", "mock (>=4)", "uvloop (>=0.15)"]
+trio = ["trio (>=0.16)"]
+
+[[package]]
+name = "argon2-cffi"
+version = "21.1.0"
+description = "The secure Argon2 password hashing algorithm."
+category = "main"
+optional = false
+python-versions = ">=3.5"
+
+[package.dependencies]
+cffi = ">=1.0.0"
+
+[package.extras]
+dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pytest", "sphinx", "furo", "wheel", "pre-commit"]
+docs = ["sphinx", "furo"]
+tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pytest"]
+
+[[package]]
+name = "asgiref"
+version = "3.4.1"
+description = "ASGI specs, helper code, and adapters"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+typing-extensions = {version = "*", markers = "python_version < \"3.8\""}
+
+[package.extras]
+tests = ["pytest", "pytest-asyncio", "mypy (>=0.800)"]
+
+[[package]]
+name = "asyncpg"
+version = "0.24.0"
+description = "An asyncio PostgreSQL driver"
+category = "main"
+optional = false
+python-versions = ">=3.6.0"
+
+[package.dependencies]
+typing-extensions = {version = ">=3.7.4.3", markers = "python_version < \"3.8\""}
+
+[package.extras]
+dev = ["Cython (>=0.29.24,<0.30.0)", "pytest (>=6.0)", "Sphinx (>=4.1.2,<4.2.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)", "pycodestyle (>=2.7.0,<2.8.0)", "flake8 (>=3.9.2,<3.10.0)", "uvloop (>=0.15.3)"]
+docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)"]
+test = ["pycodestyle (>=2.7.0,<2.8.0)", "flake8 (>=3.9.2,<3.10.0)", "uvloop (>=0.15.3)"]
+
+[[package]]
+name = "attrs"
+version = "21.2.0"
+description = "Classes Without Boilerplate"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+
+[package.extras]
+dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit"]
+docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"]
+tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface"]
+tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins"]
+
+[[package]]
+name = "billiard"
+version = "3.6.4.0"
+description = "Python multiprocessing fork with improvements and bugfixes"
+category = "main"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "black"
+version = "21.9b0"
+description = "The uncompromising code formatter."
+category = "dev"
+optional = false
+python-versions = ">=3.6.2"
+
+[package.dependencies]
+click = ">=7.1.2"
+mypy-extensions = ">=0.4.3"
+pathspec = ">=0.9.0,<1"
+platformdirs = ">=2"
+regex = ">=2020.1.8"
+tomli = ">=0.2.6,<2.0.0"
+typed-ast = {version = ">=1.4.2", markers = "python_version < \"3.8\""}
+typing-extensions = [
+ {version = ">=3.10.0.0", markers = "python_version < \"3.10\""},
+ {version = "!=3.10.0.1", markers = "python_version >= \"3.10\""},
+]
+
+[package.extras]
+colorama = ["colorama (>=0.4.3)"]
+d = ["aiohttp (>=3.6.0)", "aiohttp-cors (>=0.4.0)"]
+jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"]
+python2 = ["typed-ast (>=1.4.2)"]
+uvloop = ["uvloop (>=0.15.2)"]
+
+[[package]]
+name = "cached-property"
+version = "1.5.2"
+description = "A decorator for caching properties in classes."
+category = "main"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "celery"
+version = "5.1.2"
+description = "Distributed Task Queue."
+category = "main"
+optional = false
+python-versions = ">=3.6,"
+
+[package.dependencies]
+billiard = ">=3.6.4.0,<4.0"
+click = ">=7.0,<8.0"
+click-didyoumean = ">=0.0.3"
+click-plugins = ">=1.1.1"
+click-repl = ">=0.1.6"
+kombu = ">=5.1.0,<6.0"
+librabbitmq = {version = ">=1.5.0", optional = true, markers = "extra == \"librabbitmq\""}
+pytz = ">0.0-dev"
+redis = {version = ">=3.2.0", optional = true, markers = "extra == \"redis\""}
+vine = ">=5.0.0,<6.0"
+
+[package.extras]
+arangodb = ["pyArango (>=1.3.2)"]
+auth = ["cryptography"]
+azureblockblob = ["azure-storage-blob (==12.6.0)"]
+brotli = ["brotli (>=1.0.0)", "brotlipy (>=0.7.0)"]
+cassandra = ["cassandra-driver (<3.21.0)"]
+consul = ["python-consul2"]
+cosmosdbsql = ["pydocumentdb (==2.3.2)"]
+couchbase = ["couchbase (>=3.0.0)"]
+couchdb = ["pycouchdb"]
+django = ["Django (>=1.11)"]
+dynamodb = ["boto3 (>=1.9.178)"]
+elasticsearch = ["elasticsearch"]
+eventlet = ["eventlet (>=0.26.1)"]
+gevent = ["gevent (>=1.0.0)"]
+librabbitmq = ["librabbitmq (>=1.5.0)"]
+memcache = ["pylibmc"]
+mongodb = ["pymongo[srv] (>=3.3.0)"]
+msgpack = ["msgpack"]
+pymemcache = ["python-memcached"]
+pyro = ["pyro4"]
+pytest = ["pytest-celery"]
+redis = ["redis (>=3.2.0)"]
+s3 = ["boto3 (>=1.9.125)"]
+slmq = ["softlayer-messaging (>=1.0.3)"]
+solar = ["ephem"]
+sqlalchemy = ["sqlalchemy"]
+sqs = ["boto3 (>=1.9.125)", "pycurl (==7.43.0.5)"]
+tblib = ["tblib (>=1.3.0)", "tblib (>=1.5.0)"]
+yaml = ["PyYAML (>=3.10)"]
+zookeeper = ["kazoo (>=1.3.1)"]
+zstd = ["zstandard"]
+
+[[package]]
+name = "cffi"
+version = "1.15.0"
+description = "Foreign Function Interface for Python calling C code."
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+pycparser = "*"
+
+[[package]]
+name = "click"
+version = "7.1.2"
+description = "Composable command line interface toolkit"
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+
+[[package]]
+name = "click-didyoumean"
+version = "0.3.0"
+description = "Enables git-like *did-you-mean* feature in click"
+category = "main"
+optional = false
+python-versions = ">=3.6.2,<4.0.0"
+
+[package.dependencies]
+click = ">=7"
+
+[[package]]
+name = "click-plugins"
+version = "1.1.1"
+description = "An extension module for click to enable registering CLI commands via setuptools entry-points."
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+click = ">=4.0"
+
+[package.extras]
+dev = ["pytest (>=3.6)", "pytest-cov", "wheel", "coveralls"]
+
+[[package]]
+name = "click-repl"
+version = "0.2.0"
+description = "REPL plugin for Click"
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+click = "*"
+prompt-toolkit = "*"
+six = "*"
+
+[[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 = "flake8"
+version = "3.9.2"
+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]
+importlib-metadata = {version = "*", markers = "python_version < \"3.8\""}
+mccabe = ">=0.6.0,<0.7.0"
+pycodestyle = ">=2.7.0,<2.8.0"
+pyflakes = ">=2.3.0,<2.4.0"
+
+[[package]]
+name = "flake8-black"
+version = "0.2.3"
+description = "flake8 plugin to call black as a code style validator"
+category = "dev"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+black = "*"
+flake8 = ">=3.0.0"
+toml = "*"
+
+[[package]]
+name = "flake8-bugbear"
+version = "21.9.2"
+description = "A plugin for flake8 finding likely bugs and design problems in your program. Contains warnings that don't belong in pyflakes and pycodestyle."
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+attrs = ">=19.2.0"
+flake8 = ">=3.0.0"
+
+[package.extras]
+dev = ["coverage", "black", "hypothesis", "hypothesmith"]
+
+[[package]]
+name = "graphql-core"
+version = "3.1.6"
+description = "GraphQL implementation for Python, a port of GraphQL.js, the JavaScript reference implementation for GraphQL."
+category = "main"
+optional = false
+python-versions = ">=3.6,<4"
+
+[[package]]
+name = "graphql-relay"
+version = "3.1.0"
+description = "Relay library for graphql-core"
+category = "main"
+optional = false
+python-versions = ">=3.6,<4"
+
+[package.dependencies]
+graphql-core = ">=3.1"
+typing-extensions = {version = ">=3.7,<4", markers = "python_version < \"3.8\""}
+
+[[package]]
+name = "greenlet"
+version = "1.1.2"
+description = "Lightweight in-process concurrent programming"
+category = "main"
+optional = false
+python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*"
+
+[package.extras]
+docs = ["sphinx"]
+
+[[package]]
+name = "h11"
+version = "0.12.0"
+description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[[package]]
+name = "httptools"
+version = "0.2.0"
+description = "A collection of framework independent HTTP protocol utils."
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.extras]
+test = ["Cython (==0.29.22)"]
+
+[[package]]
+name = "idna"
+version = "3.3"
+description = "Internationalized Domain Names in Applications (IDNA)"
+category = "main"
+optional = false
+python-versions = ">=3.5"
+
+[[package]]
+name = "importlib-metadata"
+version = "4.8.1"
+description = "Read metadata from Python packages"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""}
+zipp = ">=0.5"
+
+[package.extras]
+docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"]
+perf = ["ipython"]
+testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"]
+
+[[package]]
+name = "importlib-resources"
+version = "5.2.2"
+description = "Read resources from Python packages"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""}
+
+[package.extras]
+docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"]
+testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "pytest-black (>=0.3.7)", "pytest-mypy"]
+
+[[package]]
+name = "jinja2"
+version = "3.0.2"
+description = "A very fast and expressive template engine."
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+MarkupSafe = ">=2.0"
+
+[package.extras]
+i18n = ["Babel (>=2.7)"]
+
+[[package]]
+name = "kombu"
+version = "5.1.0"
+description = "Messaging library for Python."
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+amqp = ">=5.0.6,<6.0.0"
+cached-property = {version = "*", markers = "python_version < \"3.8\""}
+importlib-metadata = {version = ">=0.18", markers = "python_version < \"3.8\""}
+vine = "*"
+
+[package.extras]
+azureservicebus = ["azure-servicebus (>=7.0.0)"]
+azurestoragequeues = ["azure-storage-queue"]
+consul = ["python-consul (>=0.6.0)"]
+librabbitmq = ["librabbitmq (>=1.5.2)"]
+mongodb = ["pymongo (>=3.3.0)"]
+msgpack = ["msgpack"]
+pyro = ["pyro4"]
+qpid = ["qpid-python (>=0.26)", "qpid-tools (>=0.26)"]
+redis = ["redis (>=3.3.11)"]
+slmq = ["softlayer-messaging (>=1.0.3)"]
+sqlalchemy = ["sqlalchemy"]
+sqs = ["boto3 (>=1.4.4)", "pycurl (==7.43.0.2)", "urllib3 (<1.26)"]
+yaml = ["PyYAML (>=3.10)"]
+zookeeper = ["kazoo (>=1.3.1)"]
+
+[[package]]
+name = "librabbitmq"
+version = "2.0.0"
+description = "AMQP Client using the rabbitmq-c library."
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+amqp = ">=1.4.6"
+six = ">=1.0.0"
+
+[[package]]
+name = "mako"
+version = "1.1.5"
+description = "A super-fast templating language that borrows the best ideas from the existing templating languages."
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
+[package.dependencies]
+MarkupSafe = ">=0.9.2"
+
+[package.extras]
+babel = ["babel"]
+lingua = ["lingua"]
+
+[[package]]
+name = "markupsafe"
+version = "2.0.1"
+description = "Safely add untrusted strings to HTML/XML markup."
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[[package]]
+name = "marshmallow"
+version = "3.14.0"
+description = "A lightweight library for converting complex datatypes to and from native Python datatypes."
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.extras]
+dev = ["pytest", "pytz", "simplejson", "mypy (==0.910)", "flake8 (==4.0.1)", "flake8-bugbear (==21.9.2)", "pre-commit (>=2.4,<3.0)", "tox"]
+docs = ["sphinx (==4.2.0)", "sphinx-issues (==1.2.0)", "alabaster (==0.7.12)", "sphinx-version-warning (==1.1.2)", "autodocsumm (==0.2.7)"]
+lint = ["mypy (==0.910)", "flake8 (==4.0.1)", "flake8-bugbear (==21.9.2)", "pre-commit (>=2.4,<3.0)"]
+tests = ["pytest", "pytz", "simplejson"]
+
+[[package]]
+name = "mccabe"
+version = "0.6.1"
+description = "McCabe checker, plugin for flake8"
+category = "dev"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "mypy"
+version = "0.910"
+description = "Optional static typing for Python"
+category = "main"
+optional = false
+python-versions = ">=3.5"
+
+[package.dependencies]
+mypy-extensions = ">=0.4.3,<0.5.0"
+toml = "*"
+typed-ast = {version = ">=1.4.0,<1.5.0", markers = "python_version < \"3.8\""}
+typing-extensions = ">=3.7.4"
+
+[package.extras]
+dmypy = ["psutil (>=4.0)"]
+python2 = ["typed-ast (>=1.4.0,<1.5.0)"]
+
+[[package]]
+name = "mypy-extensions"
+version = "0.4.3"
+description = "Experimental type system extensions for programs checked with the mypy typechecker."
+category = "main"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "passlib"
+version = "1.7.4"
+description = "comprehensive password hashing framework supporting over 30 schemes"
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+argon2-cffi = {version = ">=18.2.0", optional = true, markers = "extra == \"argon2\""}
+
+[package.extras]
+argon2 = ["argon2-cffi (>=18.2.0)"]
+bcrypt = ["bcrypt (>=3.1.0)"]
+build_docs = ["sphinx (>=1.6)", "sphinxcontrib-fulltoc (>=1.2.0)", "cloud-sptheme (>=1.10.1)"]
+totp = ["cryptography"]
+
+[[package]]
+name = "pathspec"
+version = "0.9.0"
+description = "Utility library for gitignore style pattern matching of file paths."
+category = "dev"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
+
+[[package]]
+name = "platformdirs"
+version = "2.4.0"
+description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.extras]
+docs = ["Sphinx (>=4)", "furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)"]
+test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)"]
+
+[[package]]
+name = "prompt-toolkit"
+version = "3.0.20"
+description = "Library for building powerful interactive command lines in Python"
+category = "main"
+optional = false
+python-versions = ">=3.6.2"
+
+[package.dependencies]
+wcwidth = "*"
+
+[[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 = "pycparser"
+version = "2.20"
+description = "C parser in Python"
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
+[[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.10.0"
+description = "Pygments is a syntax highlighting package written in Python."
+category = "main"
+optional = false
+python-versions = ">=3.5"
+
+[[package]]
+name = "python-dateutil"
+version = "2.8.2"
+description = "Extensions to the standard Python datetime module"
+category = "main"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
+
+[package.dependencies]
+six = ">=1.5"
+
+[[package]]
+name = "python-dotenv"
+version = "0.19.1"
+description = "Read key-value pairs from a .env file and set them as environment variables"
+category = "main"
+optional = false
+python-versions = ">=3.5"
+
+[package.extras]
+cli = ["click (>=5.0)"]
+
+[[package]]
+name = "python-multipart"
+version = "0.0.5"
+description = "A streaming multipart parser for Python"
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+six = ">=1.4.0"
+
+[[package]]
+name = "pytz"
+version = "2021.3"
+description = "World timezone definitions, modern and historical"
+category = "main"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "pyyaml"
+version = "6.0"
+description = "YAML parser and emitter for Python"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[[package]]
+name = "redis"
+version = "3.5.3"
+description = "Python client for Redis key-value store"
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+
+[package.extras]
+hiredis = ["hiredis (>=0.1.3)"]
+
+[[package]]
+name = "regex"
+version = "2021.10.8"
+description = "Alternative regular expression module, to replace re."
+category = "dev"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "sentinel"
+version = "0.3.0"
+description = "Create sentinel objects, akin to None, NotImplemented, Ellipsis"
+category = "main"
+optional = false
+python-versions = ">=3.6,<4.0"
+
+[package.extras]
+varname = ["varname (>=0.1)"]
+
+[[package]]
+name = "six"
+version = "1.16.0"
+description = "Python 2 and 3 compatibility utilities"
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
+
+[[package]]
+name = "sniffio"
+version = "1.2.0"
+description = "Sniff out which async library your code is running under"
+category = "main"
+optional = false
+python-versions = ">=3.5"
+
+[[package]]
+name = "sqlalchemy"
+version = "1.4.25"
+description = "Database Abstraction Library"
+category = "main"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7"
+
+[package.dependencies]
+greenlet = {version = "!=0.4.17", markers = "python_version >= \"3\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")"}
+importlib-metadata = {version = "*", markers = "python_version < \"3.8\""}
+mypy = {version = ">=0.910", optional = true, markers = "python_version >= \"3\" and extra == \"mypy\""}
+sqlalchemy2-stubs = {version = "*", optional = true, markers = "extra == \"mypy\""}
+
+[package.extras]
+aiomysql = ["greenlet (!=0.4.17)", "aiomysql"]
+aiosqlite = ["typing_extensions (!=3.10.0.1)", "greenlet (!=0.4.17)", "aiosqlite"]
+asyncio = ["greenlet (!=0.4.17)"]
+asyncmy = ["greenlet (!=0.4.17)", "asyncmy (>=0.2.0)"]
+mariadb_connector = ["mariadb (>=1.0.1)"]
+mssql = ["pyodbc"]
+mssql_pymssql = ["pymssql"]
+mssql_pyodbc = ["pyodbc"]
+mypy = ["sqlalchemy2-stubs", "mypy (>=0.910)"]
+mysql = ["mysqlclient (>=1.4.0,<2)", "mysqlclient (>=1.4.0)"]
+mysql_connector = ["mysql-connector-python"]
+oracle = ["cx_oracle (>=7,<8)", "cx_oracle (>=7)"]
+postgresql = ["psycopg2 (>=2.7)"]
+postgresql_asyncpg = ["greenlet (!=0.4.17)", "asyncpg"]
+postgresql_pg8000 = ["pg8000 (>=1.16.6)"]
+postgresql_psycopg2binary = ["psycopg2-binary"]
+postgresql_psycopg2cffi = ["psycopg2cffi"]
+pymysql = ["pymysql (<1)", "pymysql"]
+sqlcipher = ["sqlcipher3-binary"]
+
+[[package]]
+name = "sqlalchemy2-stubs"
+version = "0.0.2a18"
+description = "Typing Stubs for SQLAlchemy 1.4"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+typing-extensions = ">=3.7.4"
+
+[[package]]
+name = "starlette"
+version = "0.16.0"
+description = "The little ASGI library that shines."
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+anyio = ">=3.0.0,<4"
+typing-extensions = {version = "*", markers = "python_version < \"3.8\""}
+
+[package.extras]
+full = ["itsdangerous", "jinja2", "python-multipart", "pyyaml", "requests", "graphene"]
+
+[[package]]
+name = "strawberry-graphql"
+version = "0.84.2"
+description = "A library for creating GraphQL APIs"
+category = "main"
+optional = false
+python-versions = ">=3.7,<4.0"
+
+[package.dependencies]
+cached-property = ">=1.5.2,<2.0.0"
+click = ">=7.0,<9.0"
+graphql-core = ">=3.1.0,<3.2.0"
+pygments = ">=2.3,<3.0"
+python-dateutil = ">=2.7.0,<3.0.0"
+python-multipart = ">=0.0.5,<0.0.6"
+sentinel = ">=0.3.0,<0.4.0"
+starlette = {version = ">=0.13.6,<0.17.0", optional = true, markers = "extra == \"asgi\" or extra == \"debug-server\""}
+typing_extensions = ">=3.7.4,<4.0.0"
+
+[package.extras]
+asgi = ["starlette (>=0.13.6,<0.17.0)"]
+debug-server = ["starlette (>=0.13.6,<0.17.0)", "uvicorn (>=0.11.6,<0.16.0)"]
+django = ["django (>=2,<4)", "asgiref (>=3.2,<4.0)"]
+flask = ["flask (>=1.1,<2.0)"]
+opentelemetry = ["opentelemetry-api (<2)", "opentelemetry-sdk (<2)"]
+pydantic = ["pydantic (<2)"]
+sanic = ["sanic (>=20.12.2,<22.0.0)"]
+aiohttp = ["aiohttp (>=3.7.4.post0,<4.0.0)"]
+fastapi = ["fastapi (>=0.65.2)"]
+
+[[package]]
+name = "toml"
+version = "0.10.2"
+description = "Python Library for Tom's Obvious, Minimal Language"
+category = "main"
+optional = false
+python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
+
+[[package]]
+name = "tomli"
+version = "1.2.1"
+description = "A lil' TOML parser"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[[package]]
+name = "typed-ast"
+version = "1.4.3"
+description = "a fork of Python 2 and 3 ast modules with type comment support"
+category = "main"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "typing-extensions"
+version = "3.10.0.2"
+description = "Backported and Experimental Type Hints for Python 3.5+"
+category = "main"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "uvicorn"
+version = "0.15.0"
+description = "The lightning-fast ASGI server."
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+asgiref = ">=3.4.0"
+click = ">=7.0"
+colorama = {version = ">=0.4", optional = true, markers = "sys_platform == \"win32\" and extra == \"standard\""}
+h11 = ">=0.8"
+httptools = {version = ">=0.2.0,<0.3.0", optional = true, markers = "extra == \"standard\""}
+python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""}
+PyYAML = {version = ">=5.1", optional = true, markers = "extra == \"standard\""}
+typing-extensions = {version = "*", markers = "python_version < \"3.8\""}
+uvloop = {version = ">=0.14.0,<0.15.0 || >0.15.0,<0.15.1 || >0.15.1", optional = true, markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\" and extra == \"standard\""}
+watchgod = {version = ">=0.6", optional = true, markers = "extra == \"standard\""}
+websockets = {version = ">=9.1", optional = true, markers = "extra == \"standard\""}
+
+[package.extras]
+standard = ["websockets (>=9.1)", "httptools (>=0.2.0,<0.3.0)", "watchgod (>=0.6)", "python-dotenv (>=0.13)", "PyYAML (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "colorama (>=0.4)"]
+
+[[package]]
+name = "uvloop"
+version = "0.16.0"
+description = "Fast implementation of asyncio event loop on top of libuv"
+category = "main"
+optional = false
+python-versions = ">=3.7"
+
+[package.extras]
+dev = ["Cython (>=0.29.24,<0.30.0)", "pytest (>=3.6.0)", "Sphinx (>=4.1.2,<4.2.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)", "aiohttp", "flake8 (>=3.9.2,<3.10.0)", "psutil", "pycodestyle (>=2.7.0,<2.8.0)", "pyOpenSSL (>=19.0.0,<19.1.0)", "mypy (>=0.800)"]
+docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)"]
+test = ["aiohttp", "flake8 (>=3.9.2,<3.10.0)", "psutil", "pycodestyle (>=2.7.0,<2.8.0)", "pyOpenSSL (>=19.0.0,<19.1.0)", "mypy (>=0.800)"]
+
+[[package]]
+name = "vine"
+version = "5.0.0"
+description = "Promises, promises, promises."
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[[package]]
+name = "watchgod"
+version = "0.7"
+description = "Simple, modern file watching and code reload in python."
+category = "main"
+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 = "main"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "websockets"
+version = "10.0"
+description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)"
+category = "main"
+optional = false
+python-versions = ">=3.7"
+
+[[package]]
+name = "zipp"
+version = "3.6.0"
+description = "Backport of pathlib-compatible object wrapper for zip files"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.extras]
+docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"]
+testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"]
+
+[metadata]
+lock-version = "1.1"
+python-versions = "^3.7"
+content-hash = "342061d49c9020a1037e202b9786df0be94a2e1febcc0010c875e99e70eecebe"
+
+[metadata.files]
+aiofiles = [
+ {file = "aiofiles-0.7.0-py3-none-any.whl", hash = "sha256:c67a6823b5f23fcab0a2595a289cec7d8c863ffcb4322fb8cd6b90400aedfdbc"},
+ {file = "aiofiles-0.7.0.tar.gz", hash = "sha256:a1c4fc9b2ff81568c83e21392a82f344ea9d23da906e4f6a52662764545e19d4"},
+]
+alembic = [
+ {file = "alembic-1.7.4-py3-none-any.whl", hash = "sha256:e3cab9e59778b3b6726bb2da9ced451c6622d558199fd3ef914f3b1e8f4ef704"},
+ {file = "alembic-1.7.4.tar.gz", hash = "sha256:9d33f3ff1488c4bfab1e1a6dfebbf085e8a8e1a3e047a43ad29ad1f67f012a1d"},
+]
+amqp = [
+ {file = "amqp-5.0.6-py3-none-any.whl", hash = "sha256:493a2ac6788ce270a2f6a765b017299f60c1998f5a8617908ee9be082f7300fb"},
+ {file = "amqp-5.0.6.tar.gz", hash = "sha256:03e16e94f2b34c31f8bf1206d8ddd3ccaa4c315f7f6a1879b7b1210d229568c2"},
+]
+anyio = [
+ {file = "anyio-3.3.4-py3-none-any.whl", hash = "sha256:4fd09a25ab7fa01d34512b7249e366cd10358cdafc95022c7ff8c8f8a5026d66"},
+ {file = "anyio-3.3.4.tar.gz", hash = "sha256:67da67b5b21f96b9d3d65daa6ea99f5d5282cb09f50eb4456f8fb51dffefc3ff"},
+]
+argon2-cffi = [
+ {file = "argon2-cffi-21.1.0.tar.gz", hash = "sha256:f710b61103d1a1f692ca3ecbd1373e28aa5e545ac625ba067ff2feca1b2bb870"},
+ {file = "argon2_cffi-21.1.0-cp35-abi3-macosx_10_14_x86_64.whl", hash = "sha256:217b4f0f853ccbbb5045242946ad2e162e396064575860141b71a85eb47e475a"},
+ {file = "argon2_cffi-21.1.0-cp35-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:fa7e7d1fc22514a32b1761fdfa1882b6baa5c36bb3ef557bdd69e6fc9ba14a41"},
+ {file = "argon2_cffi-21.1.0-cp35-abi3-win32.whl", hash = "sha256:e4d8f0ae1524b7b0372a3e574a2561cbdddb3fdb6c28b70a72868189bda19659"},
+ {file = "argon2_cffi-21.1.0-cp35-abi3-win_amd64.whl", hash = "sha256:65213a9174320a1aee03fe826596e0620783966b49eb636955958b3074e87ff9"},
+ {file = "argon2_cffi-21.1.0-pp36-pypy36_pp73-macosx_10_7_x86_64.whl", hash = "sha256:245f64a203012b144b7b8c8ea6d468cb02b37caa5afee5ba4a10c80599334f6a"},
+ {file = "argon2_cffi-21.1.0-pp36-pypy36_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4ad152c418f7eb640eac41ac815534e6aa61d1624530b8e7779114ecfbf327f8"},
+ {file = "argon2_cffi-21.1.0-pp36-pypy36_pp73-win32.whl", hash = "sha256:bc513db2283c385ea4da31a2cd039c33380701f376f4edd12fe56db118a3b21a"},
+ {file = "argon2_cffi-21.1.0-pp37-pypy37_pp73-macosx_10_7_x86_64.whl", hash = "sha256:c7a7c8cc98ac418002090e4add5bebfff1b915ea1cb459c578cd8206fef10378"},
+ {file = "argon2_cffi-21.1.0-pp37-pypy37_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:165cadae5ac1e26644f5ade3bd9c18d89963be51d9ea8817bd671006d7909057"},
+ {file = "argon2_cffi-21.1.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:566ffb581bbd9db5562327aee71b2eda24a1c15b23a356740abe3c011bbe0dcb"},
+]
+asgiref = [
+ {file = "asgiref-3.4.1-py3-none-any.whl", hash = "sha256:ffc141aa908e6f175673e7b1b3b7af4fdb0ecb738fc5c8b88f69f055c2415214"},
+ {file = "asgiref-3.4.1.tar.gz", hash = "sha256:4ef1ab46b484e3c706329cedeff284a5d40824200638503f5768edb6de7d58e9"},
+]
+asyncpg = [
+ {file = "asyncpg-0.24.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c4fc0205fe4ddd5aeb3dfdc0f7bafd43411181e1f5650189608e5971cceacff1"},
+ {file = "asyncpg-0.24.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a7095890c96ba36f9f668eb552bb020dddb44f8e73e932f8573efc613ee83843"},
+ {file = "asyncpg-0.24.0-cp310-cp310-win_amd64.whl", hash = "sha256:8ff5073d4b654e34bd5eaadc01dc4d68b8a9609084d835acd364cd934190a08d"},
+ {file = "asyncpg-0.24.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e36c6806883786b19551bb70a4882561f31135dc8105a59662e0376cf5b2cbc5"},
+ {file = "asyncpg-0.24.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ddffcb85227bf39cd1bedd4603e0082b243cf3b14ced64dce506a15b05232b83"},
+ {file = "asyncpg-0.24.0-cp37-cp37m-win_amd64.whl", hash = "sha256:41704c561d354bef01353835a7846e5606faabbeb846214dfcf666cf53319f18"},
+ {file = "asyncpg-0.24.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:29ef6ae0a617fc13cc2ac5dc8e9b367bb83cba220614b437af9b67766f4b6b20"},
+ {file = "asyncpg-0.24.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:eed43abc6ccf1dc02e0d0efc06ce46a411362f3358847c6b0ec9a43426f91ece"},
+ {file = "asyncpg-0.24.0-cp38-cp38-win_amd64.whl", hash = "sha256:129d501f3d30616afd51eb8d3142ef51ba05374256bd5834cec3ef4956a9b317"},
+ {file = "asyncpg-0.24.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a458fc69051fbb67d995fdda46d75a012b5d6200f91e17d23d4751482640ed4c"},
+ {file = "asyncpg-0.24.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:556b0e92e2b75dc028b3c4bc9bd5162ddf0053b856437cf1f04c97f9c6837d03"},
+ {file = "asyncpg-0.24.0-cp39-cp39-win_amd64.whl", hash = "sha256:a738f4807c853623d3f93f0fea11f61be6b0e5ca16ea8aeb42c2c7ee742aa853"},
+ {file = "asyncpg-0.24.0.tar.gz", hash = "sha256:dd2fa063c3344823487d9ddccb40802f02622ddf8bf8a6cc53885ee7a2c1c0c6"},
+]
+attrs = [
+ {file = "attrs-21.2.0-py2.py3-none-any.whl", hash = "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1"},
+ {file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"},
+]
+billiard = [
+ {file = "billiard-3.6.4.0-py3-none-any.whl", hash = "sha256:87103ea78fa6ab4d5c751c4909bcff74617d985de7fa8b672cf8618afd5a875b"},
+ {file = "billiard-3.6.4.0.tar.gz", hash = "sha256:299de5a8da28a783d51b197d496bef4f1595dd023a93a4f59dde1886ae905547"},
+]
+black = [
+ {file = "black-21.9b0-py3-none-any.whl", hash = "sha256:380f1b5da05e5a1429225676655dddb96f5ae8c75bdf91e53d798871b902a115"},
+ {file = "black-21.9b0.tar.gz", hash = "sha256:7de4cfc7eb6b710de325712d40125689101d21d25283eed7e9998722cf10eb91"},
+]
+cached-property = [
+ {file = "cached-property-1.5.2.tar.gz", hash = "sha256:9fa5755838eecbb2d234c3aa390bd80fbd3ac6b6869109bfc1b499f7bd89a130"},
+ {file = "cached_property-1.5.2-py2.py3-none-any.whl", hash = "sha256:df4f613cf7ad9a588cc381aaf4a512d26265ecebd5eb9e1ba12f1319eb85a6a0"},
+]
+celery = [
+ {file = "celery-5.1.2-py3-none-any.whl", hash = "sha256:9dab2170b4038f7bf10ef2861dbf486ddf1d20592290a1040f7b7a1259705d42"},
+ {file = "celery-5.1.2.tar.gz", hash = "sha256:8d9a3de9162965e97f8e8cc584c67aad83b3f7a267584fa47701ed11c3e0d4b0"},
+]
+cffi = [
+ {file = "cffi-1.15.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:c2502a1a03b6312837279c8c1bd3ebedf6c12c4228ddbad40912d671ccc8a962"},
+ {file = "cffi-1.15.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:23cfe892bd5dd8941608f93348c0737e369e51c100d03718f108bf1add7bd6d0"},
+ {file = "cffi-1.15.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:41d45de54cd277a7878919867c0f08b0cf817605e4eb94093e7516505d3c8d14"},
+ {file = "cffi-1.15.0-cp27-cp27m-win32.whl", hash = "sha256:4a306fa632e8f0928956a41fa8e1d6243c71e7eb59ffbd165fc0b41e316b2474"},
+ {file = "cffi-1.15.0-cp27-cp27m-win_amd64.whl", hash = "sha256:e7022a66d9b55e93e1a845d8c9eba2a1bebd4966cd8bfc25d9cd07d515b33fa6"},
+ {file = "cffi-1.15.0-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:14cd121ea63ecdae71efa69c15c5543a4b5fbcd0bbe2aad864baca0063cecf27"},
+ {file = "cffi-1.15.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:d4d692a89c5cf08a8557fdeb329b82e7bf609aadfaed6c0d79f5a449a3c7c023"},
+ {file = "cffi-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0104fb5ae2391d46a4cb082abdd5c69ea4eab79d8d44eaaf79f1b1fd806ee4c2"},
+ {file = "cffi-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:91ec59c33514b7c7559a6acda53bbfe1b283949c34fe7440bcf917f96ac0723e"},
+ {file = "cffi-1.15.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f5c7150ad32ba43a07c4479f40241756145a1f03b43480e058cfd862bf5041c7"},
+ {file = "cffi-1.15.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:00c878c90cb53ccfaae6b8bc18ad05d2036553e6d9d1d9dbcf323bbe83854ca3"},
+ {file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:abb9a20a72ac4e0fdb50dae135ba5e77880518e742077ced47eb1499e29a443c"},
+ {file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a5263e363c27b653a90078143adb3d076c1a748ec9ecc78ea2fb916f9b861962"},
+ {file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f54a64f8b0c8ff0b64d18aa76675262e1700f3995182267998c31ae974fbc382"},
+ {file = "cffi-1.15.0-cp310-cp310-win32.whl", hash = "sha256:c21c9e3896c23007803a875460fb786118f0cdd4434359577ea25eb556e34c55"},
+ {file = "cffi-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:5e069f72d497312b24fcc02073d70cb989045d1c91cbd53979366077959933e0"},
+ {file = "cffi-1.15.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:64d4ec9f448dfe041705426000cc13e34e6e5bb13736e9fd62e34a0b0c41566e"},
+ {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2756c88cbb94231c7a147402476be2c4df2f6078099a6f4a480d239a8817ae39"},
+ {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b96a311ac60a3f6be21d2572e46ce67f09abcf4d09344c49274eb9e0bf345fc"},
+ {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75e4024375654472cc27e91cbe9eaa08567f7fbdf822638be2814ce059f58032"},
+ {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:59888172256cac5629e60e72e86598027aca6bf01fa2465bdb676d37636573e8"},
+ {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:27c219baf94952ae9d50ec19651a687b826792055353d07648a5695413e0c605"},
+ {file = "cffi-1.15.0-cp36-cp36m-win32.whl", hash = "sha256:4958391dbd6249d7ad855b9ca88fae690783a6be9e86df65865058ed81fc860e"},
+ {file = "cffi-1.15.0-cp36-cp36m-win_amd64.whl", hash = "sha256:f6f824dc3bce0edab5f427efcfb1d63ee75b6fcb7282900ccaf925be84efb0fc"},
+ {file = "cffi-1.15.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:06c48159c1abed75c2e721b1715c379fa3200c7784271b3c46df01383b593636"},
+ {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c2051981a968d7de9dd2d7b87bcb9c939c74a34626a6e2f8181455dd49ed69e4"},
+ {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:fd8a250edc26254fe5b33be00402e6d287f562b6a5b2152dec302fa15bb3e997"},
+ {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91d77d2a782be4274da750752bb1650a97bfd8f291022b379bb8e01c66b4e96b"},
+ {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:45db3a33139e9c8f7c09234b5784a5e33d31fd6907800b316decad50af323ff2"},
+ {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:263cc3d821c4ab2213cbe8cd8b355a7f72a8324577dc865ef98487c1aeee2bc7"},
+ {file = "cffi-1.15.0-cp37-cp37m-win32.whl", hash = "sha256:17771976e82e9f94976180f76468546834d22a7cc404b17c22df2a2c81db0c66"},
+ {file = "cffi-1.15.0-cp37-cp37m-win_amd64.whl", hash = "sha256:3415c89f9204ee60cd09b235810be700e993e343a408693e80ce7f6a40108029"},
+ {file = "cffi-1.15.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4238e6dab5d6a8ba812de994bbb0a79bddbdf80994e4ce802b6f6f3142fcc880"},
+ {file = "cffi-1.15.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0808014eb713677ec1292301ea4c81ad277b6cdf2fdd90fd540af98c0b101d20"},
+ {file = "cffi-1.15.0-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:57e9ac9ccc3101fac9d6014fba037473e4358ef4e89f8e181f8951a2c0162024"},
+ {file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b6c2ea03845c9f501ed1313e78de148cd3f6cad741a75d43a29b43da27f2e1e"},
+ {file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:10dffb601ccfb65262a27233ac273d552ddc4d8ae1bf93b21c94b8511bffe728"},
+ {file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:786902fb9ba7433aae840e0ed609f45c7bcd4e225ebb9c753aa39725bb3e6ad6"},
+ {file = "cffi-1.15.0-cp38-cp38-win32.whl", hash = "sha256:da5db4e883f1ce37f55c667e5c0de439df76ac4cb55964655906306918e7363c"},
+ {file = "cffi-1.15.0-cp38-cp38-win_amd64.whl", hash = "sha256:181dee03b1170ff1969489acf1c26533710231c58f95534e3edac87fff06c443"},
+ {file = "cffi-1.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:45e8636704eacc432a206ac7345a5d3d2c62d95a507ec70d62f23cd91770482a"},
+ {file = "cffi-1.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:31fb708d9d7c3f49a60f04cf5b119aeefe5644daba1cd2a0fe389b674fd1de37"},
+ {file = "cffi-1.15.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6dc2737a3674b3e344847c8686cf29e500584ccad76204efea14f451d4cc669a"},
+ {file = "cffi-1.15.0-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:74fdfdbfdc48d3f47148976f49fab3251e550a8720bebc99bf1483f5bfb5db3e"},
+ {file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffaa5c925128e29efbde7301d8ecaf35c8c60ffbcd6a1ffd3a552177c8e5e796"},
+ {file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f7d084648d77af029acb79a0ff49a0ad7e9d09057a9bf46596dac9514dc07df"},
+ {file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ef1f279350da2c586a69d32fc8733092fd32cc8ac95139a00377841f59a3f8d8"},
+ {file = "cffi-1.15.0-cp39-cp39-win32.whl", hash = "sha256:2a23af14f408d53d5e6cd4e3d9a24ff9e05906ad574822a10563efcef137979a"},
+ {file = "cffi-1.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:3773c4d81e6e818df2efbc7dd77325ca0dcb688116050fb2b3011218eda36139"},
+ {file = "cffi-1.15.0.tar.gz", hash = "sha256:920f0d66a896c2d99f0adbb391f990a84091179542c205fa53ce5787aff87954"},
+]
+click = [
+ {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"},
+ {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"},
+]
+click-didyoumean = [
+ {file = "click-didyoumean-0.3.0.tar.gz", hash = "sha256:f184f0d851d96b6d29297354ed981b7dd71df7ff500d82fa6d11f0856bee8035"},
+ {file = "click_didyoumean-0.3.0-py3-none-any.whl", hash = "sha256:a0713dc7a1de3f06bc0df5a9567ad19ead2d3d5689b434768a6145bff77c0667"},
+]
+click-plugins = [
+ {file = "click-plugins-1.1.1.tar.gz", hash = "sha256:46ab999744a9d831159c3411bb0c79346d94a444df9a3a3742e9ed63645f264b"},
+ {file = "click_plugins-1.1.1-py2.py3-none-any.whl", hash = "sha256:5d262006d3222f5057fd81e1623d4443e41dcda5dc815c06b442aa3c02889fc8"},
+]
+click-repl = [
+ {file = "click-repl-0.2.0.tar.gz", hash = "sha256:cd12f68d745bf6151210790540b4cb064c7b13e571bc64b6957d98d120dacfd8"},
+ {file = "click_repl-0.2.0-py3-none-any.whl", hash = "sha256:94b3fbbc9406a236f176e0506524b2937e4b23b6f4c0c0b2a0a83f8a64e9194b"},
+]
+colorama = [
+ {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"},
+ {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"},
+]
+flake8 = [
+ {file = "flake8-3.9.2-py2.py3-none-any.whl", hash = "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907"},
+ {file = "flake8-3.9.2.tar.gz", hash = "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b"},
+]
+flake8-black = [
+ {file = "flake8-black-0.2.3.tar.gz", hash = "sha256:c199844bc1b559d91195ebe8620216f21ed67f2cc1ff6884294c91a0d2492684"},
+ {file = "flake8_black-0.2.3-py3-none-any.whl", hash = "sha256:cc080ba5b3773b69ba102b6617a00cc4ecbad8914109690cfda4d565ea435d96"},
+]
+flake8-bugbear = [
+ {file = "flake8-bugbear-21.9.2.tar.gz", hash = "sha256:db9a09893a6c649a197f5350755100bb1dd84f110e60cf532fdfa07e41808ab2"},
+ {file = "flake8_bugbear-21.9.2-py36.py37.py38-none-any.whl", hash = "sha256:4f7eaa6f05b7d7ea4cbbde93f7bcdc5438e79320fa1ec420d860c181af38b769"},
+]
+graphql-core = [
+ {file = "graphql-core-3.1.6.tar.gz", hash = "sha256:e65975b6a13878f9113a1fa5320760585b522d139944e005936b1b8358d0651a"},
+ {file = "graphql_core-3.1.6-py3-none-any.whl", hash = "sha256:c78d09596d347e1cffd266c5384abfedf43ed1eae08729773bebb3d527fe5a14"},
+]
+graphql-relay = [
+ {file = "graphql-relay-3.1.0.tar.gz", hash = "sha256:70d5a7ee5995ea7c2a9a37e51227663b1a464f1f40e98fdde950be5415dfe0b4"},
+ {file = "graphql_relay-3.1.0-py3-none-any.whl", hash = "sha256:2cda0ac0199dd56c28ca4f6e0381cdcf5787809c06d1507df3c2a738f9ad846f"},
+]
+greenlet = [
+ {file = "greenlet-1.1.2-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:58df5c2a0e293bf665a51f8a100d3e9956febfbf1d9aaf8c0677cf70218910c6"},
+ {file = "greenlet-1.1.2-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:aec52725173bd3a7b56fe91bc56eccb26fbdff1386ef123abb63c84c5b43b63a"},
+ {file = "greenlet-1.1.2-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:833e1551925ed51e6b44c800e71e77dacd7e49181fdc9ac9a0bf3714d515785d"},
+ {file = "greenlet-1.1.2-cp27-cp27m-win32.whl", hash = "sha256:aa5b467f15e78b82257319aebc78dd2915e4c1436c3c0d1ad6f53e47ba6e2713"},
+ {file = "greenlet-1.1.2-cp27-cp27m-win_amd64.whl", hash = "sha256:40b951f601af999a8bf2ce8c71e8aaa4e8c6f78ff8afae7b808aae2dc50d4c40"},
+ {file = "greenlet-1.1.2-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:95e69877983ea39b7303570fa6760f81a3eec23d0e3ab2021b7144b94d06202d"},
+ {file = "greenlet-1.1.2-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:356b3576ad078c89a6107caa9c50cc14e98e3a6c4874a37c3e0273e4baf33de8"},
+ {file = "greenlet-1.1.2-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:8639cadfda96737427330a094476d4c7a56ac03de7265622fcf4cfe57c8ae18d"},
+ {file = "greenlet-1.1.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97e5306482182170ade15c4b0d8386ded995a07d7cc2ca8f27958d34d6736497"},
+ {file = "greenlet-1.1.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6a36bb9474218c7a5b27ae476035497a6990e21d04c279884eb10d9b290f1b1"},
+ {file = "greenlet-1.1.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:abb7a75ed8b968f3061327c433a0fbd17b729947b400747c334a9c29a9af6c58"},
+ {file = "greenlet-1.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:14d4f3cd4e8b524ae9b8aa567858beed70c392fdec26dbdb0a8a418392e71708"},
+ {file = "greenlet-1.1.2-cp35-cp35m-macosx_10_14_x86_64.whl", hash = "sha256:17ff94e7a83aa8671a25bf5b59326ec26da379ace2ebc4411d690d80a7fbcf23"},
+ {file = "greenlet-1.1.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:9f3cba480d3deb69f6ee2c1825060177a22c7826431458c697df88e6aeb3caee"},
+ {file = "greenlet-1.1.2-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:fa877ca7f6b48054f847b61d6fa7bed5cebb663ebc55e018fda12db09dcc664c"},
+ {file = "greenlet-1.1.2-cp35-cp35m-win32.whl", hash = "sha256:7cbd7574ce8e138bda9df4efc6bf2ab8572c9aff640d8ecfece1b006b68da963"},
+ {file = "greenlet-1.1.2-cp35-cp35m-win_amd64.whl", hash = "sha256:903bbd302a2378f984aef528f76d4c9b1748f318fe1294961c072bdc7f2ffa3e"},
+ {file = "greenlet-1.1.2-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:049fe7579230e44daef03a259faa24511d10ebfa44f69411d99e6a184fe68073"},
+ {file = "greenlet-1.1.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:dd0b1e9e891f69e7675ba5c92e28b90eaa045f6ab134ffe70b52e948aa175b3c"},
+ {file = "greenlet-1.1.2-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:7418b6bfc7fe3331541b84bb2141c9baf1ec7132a7ecd9f375912eca810e714e"},
+ {file = "greenlet-1.1.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9d29ca8a77117315101425ec7ec2a47a22ccf59f5593378fc4077ac5b754fce"},
+ {file = "greenlet-1.1.2-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:21915eb821a6b3d9d8eefdaf57d6c345b970ad722f856cd71739493ce003ad08"},
+ {file = "greenlet-1.1.2-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eff9d20417ff9dcb0d25e2defc2574d10b491bf2e693b4e491914738b7908168"},
+ {file = "greenlet-1.1.2-cp36-cp36m-win32.whl", hash = "sha256:32ca72bbc673adbcfecb935bb3fb1b74e663d10a4b241aaa2f5a75fe1d1f90aa"},
+ {file = "greenlet-1.1.2-cp36-cp36m-win_amd64.whl", hash = "sha256:f0214eb2a23b85528310dad848ad2ac58e735612929c8072f6093f3585fd342d"},
+ {file = "greenlet-1.1.2-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:b92e29e58bef6d9cfd340c72b04d74c4b4e9f70c9fa7c78b674d1fec18896dc4"},
+ {file = "greenlet-1.1.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:fdcec0b8399108577ec290f55551d926d9a1fa6cad45882093a7a07ac5ec147b"},
+ {file = "greenlet-1.1.2-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:93f81b134a165cc17123626ab8da2e30c0455441d4ab5576eed73a64c025b25c"},
+ {file = "greenlet-1.1.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e12bdc622676ce47ae9abbf455c189e442afdde8818d9da983085df6312e7a1"},
+ {file = "greenlet-1.1.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8c790abda465726cfb8bb08bd4ca9a5d0a7bd77c7ac1ca1b839ad823b948ea28"},
+ {file = "greenlet-1.1.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f276df9830dba7a333544bd41070e8175762a7ac20350786b322b714b0e654f5"},
+ {file = "greenlet-1.1.2-cp37-cp37m-win32.whl", hash = "sha256:64e6175c2e53195278d7388c454e0b30997573f3f4bd63697f88d855f7a6a1fc"},
+ {file = "greenlet-1.1.2-cp37-cp37m-win_amd64.whl", hash = "sha256:b11548073a2213d950c3f671aa88e6f83cda6e2fb97a8b6317b1b5b33d850e06"},
+ {file = "greenlet-1.1.2-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:9633b3034d3d901f0a46b7939f8c4d64427dfba6bbc5a36b1a67364cf148a1b0"},
+ {file = "greenlet-1.1.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:eb6ea6da4c787111adf40f697b4e58732ee0942b5d3bd8f435277643329ba627"},
+ {file = "greenlet-1.1.2-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:f3acda1924472472ddd60c29e5b9db0cec629fbe3c5c5accb74d6d6d14773478"},
+ {file = "greenlet-1.1.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e859fcb4cbe93504ea18008d1df98dee4f7766db66c435e4882ab35cf70cac43"},
+ {file = "greenlet-1.1.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00e44c8afdbe5467e4f7b5851be223be68adb4272f44696ee71fe46b7036a711"},
+ {file = "greenlet-1.1.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec8c433b3ab0419100bd45b47c9c8551248a5aee30ca5e9d399a0b57ac04651b"},
+ {file = "greenlet-1.1.2-cp38-cp38-win32.whl", hash = "sha256:288c6a76705dc54fba69fbcb59904ae4ad768b4c768839b8ca5fdadec6dd8cfd"},
+ {file = "greenlet-1.1.2-cp38-cp38-win_amd64.whl", hash = "sha256:8d2f1fb53a421b410751887eb4ff21386d119ef9cde3797bf5e7ed49fb51a3b3"},
+ {file = "greenlet-1.1.2-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:166eac03e48784a6a6e0e5f041cfebb1ab400b394db188c48b3a84737f505b67"},
+ {file = "greenlet-1.1.2-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:572e1787d1460da79590bf44304abbc0a2da944ea64ec549188fa84d89bba7ab"},
+ {file = "greenlet-1.1.2-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:be5f425ff1f5f4b3c1e33ad64ab994eed12fc284a6ea71c5243fd564502ecbe5"},
+ {file = "greenlet-1.1.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1692f7d6bc45e3200844be0dba153612103db241691088626a33ff1f24a0d88"},
+ {file = "greenlet-1.1.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7227b47e73dedaa513cdebb98469705ef0d66eb5a1250144468e9c3097d6b59b"},
+ {file = "greenlet-1.1.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ff61ff178250f9bb3cd89752df0f1dd0e27316a8bd1465351652b1b4a4cdfd3"},
+ {file = "greenlet-1.1.2-cp39-cp39-win32.whl", hash = "sha256:f70a9e237bb792c7cc7e44c531fd48f5897961701cdaa06cf22fc14965c496cf"},
+ {file = "greenlet-1.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:013d61294b6cd8fe3242932c1c5e36e5d1db2c8afb58606c5a67efce62c1f5fd"},
+ {file = "greenlet-1.1.2.tar.gz", hash = "sha256:e30f5ea4ae2346e62cedde8794a56858a67b878dd79f7df76a0767e356b1744a"},
+]
+h11 = [
+ {file = "h11-0.12.0-py3-none-any.whl", hash = "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6"},
+ {file = "h11-0.12.0.tar.gz", hash = "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042"},
+]
+httptools = [
+ {file = "httptools-0.2.0-cp35-cp35m-macosx_10_14_x86_64.whl", hash = "sha256:79dbc21f3612a78b28384e989b21872e2e3cf3968532601544696e4ed0007ce5"},
+ {file = "httptools-0.2.0-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:78d03dd39b09c99ec917d50189e6743adbfd18c15d5944392d2eabda688bf149"},
+ {file = "httptools-0.2.0-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:a23166e5ae2775709cf4f7ad4c2048755ebfb272767d244e1a96d55ac775cca7"},
+ {file = "httptools-0.2.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:3ab1f390d8867f74b3b5ee2a7ecc9b8d7f53750bd45714bf1cb72a953d7dfa77"},
+ {file = "httptools-0.2.0-cp36-cp36m-win_amd64.whl", hash = "sha256:a7594f9a010cdf1e16a58b3bf26c9da39bbf663e3b8d46d39176999d71816658"},
+ {file = "httptools-0.2.0-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:01b392a166adcc8bc2f526a939a8aabf89fe079243e1543fd0e7dc1b58d737cb"},
+ {file = "httptools-0.2.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:80ffa04fe8c8dfacf6e4cef8277347d35b0442c581f5814f3b0cf41b65c43c6e"},
+ {file = "httptools-0.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d5682eeb10cca0606c4a8286a3391d4c3c5a36f0c448e71b8bd05be4e1694bfb"},
+ {file = "httptools-0.2.0-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:a289c27ccae399a70eacf32df9a44059ca2ba4ac444604b00a19a6c1f0809943"},
+ {file = "httptools-0.2.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:813871f961edea6cb2fe312f2d9b27d12a51ba92545380126f80d0de1917ea15"},
+ {file = "httptools-0.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:cc9be041e428c10f8b6ab358c6b393648f9457094e1dcc11b4906026d43cd380"},
+ {file = "httptools-0.2.0-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:b08d00d889a118f68f37f3c43e359aab24ee29eb2e3fe96d64c6a2ba8b9d6557"},
+ {file = "httptools-0.2.0-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:fd3b8905e21431ad306eeaf56644a68fdd621bf8f3097eff54d0f6bdf7262065"},
+ {file = "httptools-0.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:200fc1cdf733a9ff554c0bb97a4047785cfaad9875307d6087001db3eb2b417f"},
+ {file = "httptools-0.2.0.tar.gz", hash = "sha256:94505026be56652d7a530ab03d89474dc6021019d6b8682281977163b3471ea0"},
+]
+idna = [
+ {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"},
+ {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"},
+]
+importlib-metadata = [
+ {file = "importlib_metadata-4.8.1-py3-none-any.whl", hash = "sha256:b618b6d2d5ffa2f16add5697cf57a46c76a56229b0ed1c438322e4e95645bd15"},
+ {file = "importlib_metadata-4.8.1.tar.gz", hash = "sha256:f284b3e11256ad1e5d03ab86bb2ccd6f5339688ff17a4d797a0fe7df326f23b1"},
+]
+importlib-resources = [
+ {file = "importlib_resources-5.2.2-py3-none-any.whl", hash = "sha256:2480d8e07d1890056cb53c96e3de44fead9c62f2ba949b0f2e4c4345f4afa977"},
+ {file = "importlib_resources-5.2.2.tar.gz", hash = "sha256:a65882a4d0fe5fbf702273456ba2ce74fe44892c25e42e057aca526b702a6d4b"},
+]
+jinja2 = [
+ {file = "Jinja2-3.0.2-py3-none-any.whl", hash = "sha256:8569982d3f0889eed11dd620c706d39b60c36d6d25843961f33f77fb6bc6b20c"},
+ {file = "Jinja2-3.0.2.tar.gz", hash = "sha256:827a0e32839ab1600d4eb1c4c33ec5a8edfbc5cb42dafa13b81f182f97784b45"},
+]
+kombu = [
+ {file = "kombu-5.1.0-py3-none-any.whl", hash = "sha256:e2dedd8a86c9077c350555153825a31e456a0dc20c15d5751f00137ec9c75f0a"},
+ {file = "kombu-5.1.0.tar.gz", hash = "sha256:01481d99f4606f6939cdc9b637264ed353ee9e3e4f62cfb582324142c41a572d"},
+]
+librabbitmq = [
+ {file = "librabbitmq-2.0.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:c2a8113d3c831808d1d940fdf43e4882636a1efe2864df7ab3bb709a45016b37"},
+ {file = "librabbitmq-2.0.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:3116e40c02d4285b8dd69834e4cbcb1a89ea534ca9147e865f11d44e7cc56eea"},
+ {file = "librabbitmq-2.0.0-cp34-cp34m-manylinux1_x86_64.whl", hash = "sha256:cd9cc09343b193d7cf2cff6c6a578061863bd986a4bdf38f922e9dc32e15d944"},
+ {file = "librabbitmq-2.0.0-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:98e355f486964dadae7e8b51c9a60e9aa0653bbe27f6b14542687f305c4c3652"},
+ {file = "librabbitmq-2.0.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:5cdfb473573396d43d54cef9e9b4c74fa3d1516da51d04a7b261f6ef4e0bd8be"},
+ {file = "librabbitmq-2.0.0.tar.gz", hash = "sha256:ffa2363a860ab5dcc3ce3703247e05e940c73d776c03a3f3f9deaf3cf43bb96c"},
+]
+mako = [
+ {file = "Mako-1.1.5-py2.py3-none-any.whl", hash = "sha256:6804ee66a7f6a6416910463b00d76a7b25194cd27f1918500c5bd7be2a088a23"},
+ {file = "Mako-1.1.5.tar.gz", hash = "sha256:169fa52af22a91900d852e937400e79f535496191c63712e3b9fda5a9bed6fc3"},
+]
+markupsafe = [
+ {file = "MarkupSafe-2.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51"},
+ {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff"},
+ {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b"},
+ {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94"},
+ {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872"},
+ {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f"},
+ {file = "MarkupSafe-2.0.1-cp36-cp36m-win32.whl", hash = "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d"},
+ {file = "MarkupSafe-2.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9"},
+ {file = "MarkupSafe-2.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567"},
+ {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18"},
+ {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f"},
+ {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f"},
+ {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2"},
+ {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d"},
+ {file = "MarkupSafe-2.0.1-cp37-cp37m-win32.whl", hash = "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415"},
+ {file = "MarkupSafe-2.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914"},
+ {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066"},
+ {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35"},
+ {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b"},
+ {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298"},
+ {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75"},
+ {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb"},
+ {file = "MarkupSafe-2.0.1-cp38-cp38-win32.whl", hash = "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64"},
+ {file = "MarkupSafe-2.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833"},
+ {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26"},
+ {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3c112550557578c26af18a1ccc9e090bfe03832ae994343cfdacd287db6a6ae7"},
+ {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:53edb4da6925ad13c07b6d26c2a852bd81e364f95301c66e930ab2aef5b5ddd8"},
+ {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:f5653a225f31e113b152e56f154ccbe59eeb1c7487b39b9d9f9cdb58e6c79dc5"},
+ {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135"},
+ {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902"},
+ {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509"},
+ {file = "MarkupSafe-2.0.1-cp39-cp39-win32.whl", hash = "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74"},
+ {file = "MarkupSafe-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8"},
+ {file = "MarkupSafe-2.0.1.tar.gz", hash = "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a"},
+]
+marshmallow = [
+ {file = "marshmallow-3.14.0-py3-none-any.whl", hash = "sha256:6d00e42d6d6289f8cd3e77618a01689d57a078fe324ee579b00fa206d32e9b07"},
+ {file = "marshmallow-3.14.0.tar.gz", hash = "sha256:bba1a940985c052c5cc7849f97da196ebc81f3b85ec10c56ef1f3228aa9cbe74"},
+]
+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.910-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:a155d80ea6cee511a3694b108c4494a39f42de11ee4e61e72bc424c490e46457"},
+ {file = "mypy-0.910-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:b94e4b785e304a04ea0828759172a15add27088520dc7e49ceade7834275bedb"},
+ {file = "mypy-0.910-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:088cd9c7904b4ad80bec811053272986611b84221835e079be5bcad029e79dd9"},
+ {file = "mypy-0.910-cp35-cp35m-win_amd64.whl", hash = "sha256:adaeee09bfde366d2c13fe6093a7df5df83c9a2ba98638c7d76b010694db760e"},
+ {file = "mypy-0.910-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:ecd2c3fe726758037234c93df7e98deb257fd15c24c9180dacf1ef829da5f921"},
+ {file = "mypy-0.910-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:d9dd839eb0dc1bbe866a288ba3c1afc33a202015d2ad83b31e875b5905a079b6"},
+ {file = "mypy-0.910-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:3e382b29f8e0ccf19a2df2b29a167591245df90c0b5a2542249873b5c1d78212"},
+ {file = "mypy-0.910-cp36-cp36m-win_amd64.whl", hash = "sha256:53fd2eb27a8ee2892614370896956af2ff61254c275aaee4c230ae771cadd885"},
+ {file = "mypy-0.910-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b6fb13123aeef4a3abbcfd7e71773ff3ff1526a7d3dc538f3929a49b42be03f0"},
+ {file = "mypy-0.910-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:e4dab234478e3bd3ce83bac4193b2ecd9cf94e720ddd95ce69840273bf44f6de"},
+ {file = "mypy-0.910-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:7df1ead20c81371ccd6091fa3e2878559b5c4d4caadaf1a484cf88d93ca06703"},
+ {file = "mypy-0.910-cp37-cp37m-win_amd64.whl", hash = "sha256:0aadfb2d3935988ec3815952e44058a3100499f5be5b28c34ac9d79f002a4a9a"},
+ {file = "mypy-0.910-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ec4e0cd079db280b6bdabdc807047ff3e199f334050db5cbb91ba3e959a67504"},
+ {file = "mypy-0.910-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:119bed3832d961f3a880787bf621634ba042cb8dc850a7429f643508eeac97b9"},
+ {file = "mypy-0.910-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:866c41f28cee548475f146aa4d39a51cf3b6a84246969f3759cb3e9c742fc072"},
+ {file = "mypy-0.910-cp38-cp38-win_amd64.whl", hash = "sha256:ceb6e0a6e27fb364fb3853389607cf7eb3a126ad335790fa1e14ed02fba50811"},
+ {file = "mypy-0.910-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1a85e280d4d217150ce8cb1a6dddffd14e753a4e0c3cf90baabb32cefa41b59e"},
+ {file = "mypy-0.910-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:42c266ced41b65ed40a282c575705325fa7991af370036d3f134518336636f5b"},
+ {file = "mypy-0.910-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:3c4b8ca36877fc75339253721f69603a9c7fdb5d4d5a95a1a1b899d8b86a4de2"},
+ {file = "mypy-0.910-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:c0df2d30ed496a08de5daed2a9ea807d07c21ae0ab23acf541ab88c24b26ab97"},
+ {file = "mypy-0.910-cp39-cp39-win_amd64.whl", hash = "sha256:c6c2602dffb74867498f86e6129fd52a2770c48b7cd3ece77ada4fa38f94eba8"},
+ {file = "mypy-0.910-py3-none-any.whl", hash = "sha256:ef565033fa5a958e62796867b1df10c40263ea9ded87164d67572834e57a174d"},
+ {file = "mypy-0.910.tar.gz", hash = "sha256:704098302473cb31a218f1775a873b376b30b4c18229421e9e9dc8916fd16150"},
+]
+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"},
+]
+passlib = [
+ {file = "passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1"},
+ {file = "passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04"},
+]
+pathspec = [
+ {file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"},
+ {file = "pathspec-0.9.0.tar.gz", hash = "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"},
+]
+platformdirs = [
+ {file = "platformdirs-2.4.0-py3-none-any.whl", hash = "sha256:8868bbe3c3c80d42f20156f22e7131d2fb321f5bc86a2a345375c6481a67021d"},
+ {file = "platformdirs-2.4.0.tar.gz", hash = "sha256:367a5e80b3d04d2428ffa76d33f124cf11e8fff2acdaa9b43d545f5c7d661ef2"},
+]
+prompt-toolkit = [
+ {file = "prompt_toolkit-3.0.20-py3-none-any.whl", hash = "sha256:6076e46efae19b1e0ca1ec003ed37a933dc94b4d20f486235d436e64771dcd5c"},
+ {file = "prompt_toolkit-3.0.20.tar.gz", hash = "sha256:eb71d5a6b72ce6db177af4a7d4d7085b99756bf656d98ffcc4fecd36850eea6c"},
+]
+pycodestyle = [
+ {file = "pycodestyle-2.7.0-py2.py3-none-any.whl", hash = "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068"},
+ {file = "pycodestyle-2.7.0.tar.gz", hash = "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef"},
+]
+pycparser = [
+ {file = "pycparser-2.20-py2.py3-none-any.whl", hash = "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705"},
+ {file = "pycparser-2.20.tar.gz", hash = "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0"},
+]
+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.10.0-py3-none-any.whl", hash = "sha256:b8e67fe6af78f492b3c4b3e2970c0624cbf08beb1e493b2c99b9fa1b67a20380"},
+ {file = "Pygments-2.10.0.tar.gz", hash = "sha256:f398865f7eb6874156579fdf36bc840a03cab64d1cde9e93d68f46a425ec52c6"},
+]
+python-dateutil = [
+ {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"},
+ {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"},
+]
+python-dotenv = [
+ {file = "python-dotenv-0.19.1.tar.gz", hash = "sha256:14f8185cc8d494662683e6914addcb7e95374771e707601dfc70166946b4c4b8"},
+ {file = "python_dotenv-0.19.1-py2.py3-none-any.whl", hash = "sha256:bbd3da593fc49c249397cbfbcc449cf36cb02e75afc8157fcc6a81df6fb7750a"},
+]
+python-multipart = [
+ {file = "python-multipart-0.0.5.tar.gz", hash = "sha256:f7bb5f611fc600d15fa47b3974c8aa16e93724513b49b5f95c81e6624c83fa43"},
+]
+pytz = [
+ {file = "pytz-2021.3-py2.py3-none-any.whl", hash = "sha256:3672058bc3453457b622aab7a1c3bfd5ab0bdae451512f6cf25f64ed37f5b87c"},
+ {file = "pytz-2021.3.tar.gz", hash = "sha256:acad2d8b20a1af07d4e4c9d2e9285c5ed9104354062f275f3fcd88dcef4f1326"},
+]
+pyyaml = [
+ {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"},
+ {file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"},
+ {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc"},
+ {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b"},
+ {file = "PyYAML-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"},
+ {file = "PyYAML-6.0-cp310-cp310-win32.whl", hash = "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513"},
+ {file = "PyYAML-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a"},
+ {file = "PyYAML-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86"},
+ {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f"},
+ {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92"},
+ {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4"},
+ {file = "PyYAML-6.0-cp36-cp36m-win32.whl", hash = "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293"},
+ {file = "PyYAML-6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57"},
+ {file = "PyYAML-6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c"},
+ {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0"},
+ {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4"},
+ {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9"},
+ {file = "PyYAML-6.0-cp37-cp37m-win32.whl", hash = "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737"},
+ {file = "PyYAML-6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d"},
+ {file = "PyYAML-6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b"},
+ {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba"},
+ {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34"},
+ {file = "PyYAML-6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287"},
+ {file = "PyYAML-6.0-cp38-cp38-win32.whl", hash = "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78"},
+ {file = "PyYAML-6.0-cp38-cp38-win_amd64.whl", hash = "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07"},
+ {file = "PyYAML-6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b"},
+ {file = "PyYAML-6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174"},
+ {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803"},
+ {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3"},
+ {file = "PyYAML-6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0"},
+ {file = "PyYAML-6.0-cp39-cp39-win32.whl", hash = "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb"},
+ {file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"},
+ {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"},
+]
+redis = [
+ {file = "redis-3.5.3-py2.py3-none-any.whl", hash = "sha256:432b788c4530cfe16d8d943a09d40ca6c16149727e4afe8c2c9d5580c59d9f24"},
+ {file = "redis-3.5.3.tar.gz", hash = "sha256:0e7e0cfca8660dea8b7d5cd8c4f6c5e29e11f31158c0b0ae91a397f00e5a05a2"},
+]
+regex = [
+ {file = "regex-2021.10.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:981c786293a3115bc14c103086ae54e5ee50ca57f4c02ce7cf1b60318d1e8072"},
+ {file = "regex-2021.10.8-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:51feefd58ac38eb91a21921b047da8644155e5678e9066af7bcb30ee0dca7361"},
+ {file = "regex-2021.10.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea8de658d7db5987b11097445f2b1f134400e2232cb40e614e5f7b6f5428710e"},
+ {file = "regex-2021.10.8-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1ce02f420a7ec3b2480fe6746d756530f69769292eca363218c2291d0b116a01"},
+ {file = "regex-2021.10.8-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:39079ebf54156be6e6902f5c70c078f453350616cfe7bfd2dd15bdb3eac20ccc"},
+ {file = "regex-2021.10.8-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ff24897f6b2001c38a805d53b6ae72267025878d35ea225aa24675fbff2dba7f"},
+ {file = "regex-2021.10.8-cp310-cp310-win32.whl", hash = "sha256:c6569ba7b948c3d61d27f04e2b08ebee24fec9ff8e9ea154d8d1e975b175bfa7"},
+ {file = "regex-2021.10.8-cp310-cp310-win_amd64.whl", hash = "sha256:45cb0f7ff782ef51bc79e227a87e4e8f24bc68192f8de4f18aae60b1d60bc152"},
+ {file = "regex-2021.10.8-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:fab3ab8aedfb443abb36729410403f0fe7f60ad860c19a979d47fb3eb98ef820"},
+ {file = "regex-2021.10.8-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74e55f8d66f1b41d44bc44c891bcf2c7fad252f8f323ee86fba99d71fd1ad5e3"},
+ {file = "regex-2021.10.8-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d52c5e089edbdb6083391faffbe70329b804652a53c2fdca3533e99ab0580d9"},
+ {file = "regex-2021.10.8-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1abbd95cbe9e2467cac65c77b6abd9223df717c7ae91a628502de67c73bf6838"},
+ {file = "regex-2021.10.8-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9b5c215f3870aa9b011c00daeb7be7e1ae4ecd628e9beb6d7e6107e07d81287"},
+ {file = "regex-2021.10.8-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f540f153c4f5617bc4ba6433534f8916d96366a08797cbbe4132c37b70403e92"},
+ {file = "regex-2021.10.8-cp36-cp36m-win32.whl", hash = "sha256:1f51926db492440e66c89cd2be042f2396cf91e5b05383acd7372b8cb7da373f"},
+ {file = "regex-2021.10.8-cp36-cp36m-win_amd64.whl", hash = "sha256:5f55c4804797ef7381518e683249310f7f9646da271b71cb6b3552416c7894ee"},
+ {file = "regex-2021.10.8-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:fb2baff66b7d2267e07ef71e17d01283b55b3cc51a81b54cc385e721ae172ba4"},
+ {file = "regex-2021.10.8-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e527ab1c4c7cf2643d93406c04e1d289a9d12966529381ce8163c4d2abe4faf"},
+ {file = "regex-2021.10.8-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:36c98b013273e9da5790ff6002ab326e3f81072b4616fd95f06c8fa733d2745f"},
+ {file = "regex-2021.10.8-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:55ef044899706c10bc0aa052f2fc2e58551e2510694d6aae13f37c50f3f6ff61"},
+ {file = "regex-2021.10.8-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0ab3530a279a3b7f50f852f1bab41bc304f098350b03e30a3876b7dd89840e"},
+ {file = "regex-2021.10.8-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a37305eb3199d8f0d8125ec2fb143ba94ff6d6d92554c4b8d4a8435795a6eccd"},
+ {file = "regex-2021.10.8-cp37-cp37m-win32.whl", hash = "sha256:2efd47704bbb016136fe34dfb74c805b1ef5c7313aef3ce6dcb5ff844299f432"},
+ {file = "regex-2021.10.8-cp37-cp37m-win_amd64.whl", hash = "sha256:924079d5590979c0e961681507eb1773a142553564ccae18d36f1de7324e71ca"},
+ {file = "regex-2021.10.8-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b09d3904bf312d11308d9a2867427479d277365b1617e48ad09696fa7dfcdf59"},
+ {file = "regex-2021.10.8-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7f125fce0a0ae4fd5c3388d369d7a7d78f185f904c90dd235f7ecf8fe13fa741"},
+ {file = "regex-2021.10.8-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f199419a81c1016e0560c39773c12f0bd924c37715bffc64b97140d2c314354"},
+ {file = "regex-2021.10.8-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:09e1031e2059abd91177c302da392a7b6859ceda038be9e015b522a182c89e4f"},
+ {file = "regex-2021.10.8-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9c070d5895ac6aeb665bd3cd79f673775caf8d33a0b569e98ac434617ecea57d"},
+ {file = "regex-2021.10.8-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:176796cb7f82a7098b0c436d6daac82f57b9101bb17b8e8119c36eecf06a60a3"},
+ {file = "regex-2021.10.8-cp38-cp38-win32.whl", hash = "sha256:5e5796d2f36d3c48875514c5cd9e4325a1ca172fc6c78b469faa8ddd3d770593"},
+ {file = "regex-2021.10.8-cp38-cp38-win_amd64.whl", hash = "sha256:e4204708fa116dd03436a337e8e84261bc8051d058221ec63535c9403a1582a1"},
+ {file = "regex-2021.10.8-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b8b6ee6555b6fbae578f1468b3f685cdfe7940a65675611365a7ea1f8d724991"},
+ {file = "regex-2021.10.8-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:973499dac63625a5ef9dfa4c791aa33a502ddb7615d992bdc89cf2cc2285daa3"},
+ {file = "regex-2021.10.8-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88dc3c1acd3f0ecfde5f95c32fcb9beda709dbdf5012acdcf66acbc4794468eb"},
+ {file = "regex-2021.10.8-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:4786dae85c1f0624ac77cb3813ed99267c9adb72e59fdc7297e1cf4d6036d493"},
+ {file = "regex-2021.10.8-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fe6ce4f3d3c48f9f402da1ceb571548133d3322003ce01b20d960a82251695d2"},
+ {file = "regex-2021.10.8-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:9e3e2cea8f1993f476a6833ef157f5d9e8c75a59a8d8b0395a9a6887a097243b"},
+ {file = "regex-2021.10.8-cp39-cp39-win32.whl", hash = "sha256:82cfb97a36b1a53de32b642482c6c46b6ce80803854445e19bc49993655ebf3b"},
+ {file = "regex-2021.10.8-cp39-cp39-win_amd64.whl", hash = "sha256:b04e512eb628ea82ed86eb31c0f7fc6842b46bf2601b66b1356a7008327f7700"},
+ {file = "regex-2021.10.8.tar.gz", hash = "sha256:26895d7c9bbda5c52b3635ce5991caa90fbb1ddfac9c9ff1c7ce505e2282fb2a"},
+]
+sentinel = [
+ {file = "sentinel-0.3.0-py3-none-any.whl", hash = "sha256:bd8710dd26752039c668604f6be2aaf741b56f7811c5924a4dcdfd74359244f3"},
+ {file = "sentinel-0.3.0.tar.gz", hash = "sha256:f28143aa4716dbc8f6193f5682176a3c33cd26aaae05d9ecf66c186a9887cc2d"},
+]
+six = [
+ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
+ {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
+]
+sniffio = [
+ {file = "sniffio-1.2.0-py3-none-any.whl", hash = "sha256:471b71698eac1c2112a40ce2752bb2f4a4814c22a54a3eed3676bc0f5ca9f663"},
+ {file = "sniffio-1.2.0.tar.gz", hash = "sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de"},
+]
+sqlalchemy = [
+ {file = "SQLAlchemy-1.4.25-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:a36ea43919e51b0de0c0bc52bcfdad7683f6ea9fb81b340cdabb9df0e045e0f7"},
+ {file = "SQLAlchemy-1.4.25-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:75cd5d48389a7635393ff5a9214b90695c06b3d74912109c3b00ce7392b69c6c"},
+ {file = "SQLAlchemy-1.4.25-cp27-cp27m-win32.whl", hash = "sha256:16ef07e102d2d4f974ba9b0d4ac46345a411ad20ad988b3654d59ff08e553b1c"},
+ {file = "SQLAlchemy-1.4.25-cp27-cp27m-win_amd64.whl", hash = "sha256:a79abdb404d9256afb8aeaa0d3a4bc7d3b6d8b66103d8b0f2f91febd3909976e"},
+ {file = "SQLAlchemy-1.4.25-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:7ad59e2e16578b6c1a2873e4888134112365605b08a6067dd91e899e026efa1c"},
+ {file = "SQLAlchemy-1.4.25-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:a505ecc0642f52e7c65afb02cc6181377d833b7df0994ecde15943b18d0fa89c"},
+ {file = "SQLAlchemy-1.4.25-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a28fe28c359835f3be20c89efd517b35e8f97dbb2ca09c6cf0d9ac07f62d7ef6"},
+ {file = "SQLAlchemy-1.4.25-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:41a916d815a3a23cb7fff8d11ad0c9b93369ac074e91e428075e088fe57d5358"},
+ {file = "SQLAlchemy-1.4.25-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:842c49dd584aedd75c2ee05f6c950730c3ffcddd21c5824ed0f820808387e1e3"},
+ {file = "SQLAlchemy-1.4.25-cp36-cp36m-win32.whl", hash = "sha256:6b602e3351f59f3999e9fb8b87e5b95cb2faab6a6ecdb482382ac6fdfbee5266"},
+ {file = "SQLAlchemy-1.4.25-cp36-cp36m-win_amd64.whl", hash = "sha256:6400b22e4e41cc27623a9a75630b7719579cd9a3a2027bcf16ad5aaa9a7806c0"},
+ {file = "SQLAlchemy-1.4.25-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:dd4ed12a775f2cde4519f4267d3601990a97d8ecde5c944ab06bfd6e8e8ea177"},
+ {file = "SQLAlchemy-1.4.25-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b7778a205f956755e05721eebf9f11a6ac18b2409bff5db53ce5fe7ede79831"},
+ {file = "SQLAlchemy-1.4.25-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:08d9396a2a38e672133266b31ed39b2b1f2b5ec712b5bff5e08033970563316a"},
+ {file = "SQLAlchemy-1.4.25-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e93978993a2ad0af43f132be3ea8805f56b2f2cd223403ec28d3e7d5c6d39ed1"},
+ {file = "SQLAlchemy-1.4.25-cp37-cp37m-win32.whl", hash = "sha256:0566a6e90951590c0307c75f9176597c88ef4be2724958ca1d28e8ae05ec8822"},
+ {file = "SQLAlchemy-1.4.25-cp37-cp37m-win_amd64.whl", hash = "sha256:0b08a53e40b34205acfeb5328b832f44437956d673a6c09fce55c66ab0e54916"},
+ {file = "SQLAlchemy-1.4.25-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:33a1e86abad782e90976de36150d910748b58e02cd7d35680d441f9a76806c18"},
+ {file = "SQLAlchemy-1.4.25-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ed67aae8cde4d32aacbdba4f7f38183d14443b714498eada5e5a7a37769c0b7"},
+ {file = "SQLAlchemy-1.4.25-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1ebd69365717becaa1b618220a3df97f7c08aa68e759491de516d1c3667bba54"},
+ {file = "SQLAlchemy-1.4.25-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26b0cd2d5c7ea96d3230cb20acac3d89de3b593339c1447b4d64bfcf4eac1110"},
+ {file = "SQLAlchemy-1.4.25-cp38-cp38-win32.whl", hash = "sha256:c211e8ec81522ce87b0b39f0cf0712c998d4305a030459a0e115a2b3dc71598f"},
+ {file = "SQLAlchemy-1.4.25-cp38-cp38-win_amd64.whl", hash = "sha256:9a1df8c93a0dd9cef0839917f0c6c49f46c75810cf8852be49884da4a7de3c59"},
+ {file = "SQLAlchemy-1.4.25-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:1b38db2417b9f7005d6ceba7ce2a526bf10e3f6f635c0f163e6ed6a42b5b62b2"},
+ {file = "SQLAlchemy-1.4.25-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e37621b37c73b034997b5116678862f38ee70e5a054821c7b19d0e55df270dec"},
+ {file = "SQLAlchemy-1.4.25-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:91cd87d1de0111eaca11ccc3d31af441c753fa2bc22df72e5009cfb0a1af5b03"},
+ {file = "SQLAlchemy-1.4.25-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90fe429285b171bcc252e21515703bdc2a4721008d1f13aa5b7150336f8a8493"},
+ {file = "SQLAlchemy-1.4.25-cp39-cp39-win32.whl", hash = "sha256:6003771ea597346ab1e97f2f58405c6cacbf6a308af3d28a9201a643c0ac7bb3"},
+ {file = "SQLAlchemy-1.4.25-cp39-cp39-win_amd64.whl", hash = "sha256:9ebe49c3960aa2219292ea2e5df6acdc425fc828f2f3d50b4cfae1692bcb5f02"},
+ {file = "SQLAlchemy-1.4.25.tar.gz", hash = "sha256:1adf3d25e2e33afbcd48cfad8076f9378793be43e7fec3e4334306cac6bec138"},
+]
+sqlalchemy2-stubs = [
+ {file = "sqlalchemy2-stubs-0.0.2a18.tar.gz", hash = "sha256:513f8f504e7a869e6a584b9cbfa65b7d817d017ff01af61855f62087735561a9"},
+ {file = "sqlalchemy2_stubs-0.0.2a18-py3-none-any.whl", hash = "sha256:75ec8ce53db5a85884adcb9f249751bc3aefb4c24fd2b5bd62860113eea4b37a"},
+]
+starlette = [
+ {file = "starlette-0.16.0-py3-none-any.whl", hash = "sha256:38eb24bf705a2c317e15868e384c1b8a12ca396e5a3c3a003db7e667c43f939f"},
+ {file = "starlette-0.16.0.tar.gz", hash = "sha256:e1904b5d0007aee24bdd3c43994be9b3b729f4f58e740200de1d623f8c3a8870"},
+]
+strawberry-graphql = [
+ {file = "strawberry-graphql-0.84.2.tar.gz", hash = "sha256:20dd45ce98b2e1d0a03186c1beda0faaade95c8bdde0f77cdda2ebc0ba22e8cf"},
+ {file = "strawberry_graphql-0.84.2-py3-none-any.whl", hash = "sha256:b1f80788e783f0f4213a17055fd26107c34b5c2731bcdee2b97634c43a391a4a"},
+]
+toml = [
+ {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"},
+ {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
+]
+tomli = [
+ {file = "tomli-1.2.1-py3-none-any.whl", hash = "sha256:8dd0e9524d6f386271a36b41dbf6c57d8e32fd96fd22b6584679dc569d20899f"},
+ {file = "tomli-1.2.1.tar.gz", hash = "sha256:a5b75cb6f3968abb47af1b40c1819dc519ea82bcc065776a866e8d74c5ca9442"},
+]
+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.10.0.2-py2-none-any.whl", hash = "sha256:d8226d10bc02a29bcc81df19a26e56a9647f8b0a6d4a83924139f4a8b01f17b7"},
+ {file = "typing_extensions-3.10.0.2-py3-none-any.whl", hash = "sha256:f1d25edafde516b146ecd0613dabcc61409817af4766fbbcfb8d1ad4ec441a34"},
+ {file = "typing_extensions-3.10.0.2.tar.gz", hash = "sha256:49f75d16ff11f1cd258e1b988ccff82a3ca5570217d7ad8c5f48205dd99a677e"},
+]
+uvicorn = [
+ {file = "uvicorn-0.15.0-py3-none-any.whl", hash = "sha256:17f898c64c71a2640514d4089da2689e5db1ce5d4086c2d53699bf99513421c1"},
+ {file = "uvicorn-0.15.0.tar.gz", hash = "sha256:d9a3c0dd1ca86728d3e235182683b4cf94cd53a867c288eaeca80ee781b2caff"},
+]
+uvloop = [
+ {file = "uvloop-0.16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6224f1401025b748ffecb7a6e2652b17768f30b1a6a3f7b44660e5b5b690b12d"},
+ {file = "uvloop-0.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:30ba9dcbd0965f5c812b7c2112a1ddf60cf904c1c160f398e7eed3a6b82dcd9c"},
+ {file = "uvloop-0.16.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:bd53f7f5db562f37cd64a3af5012df8cac2c464c97e732ed556800129505bd64"},
+ {file = "uvloop-0.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:772206116b9b57cd625c8a88f2413df2fcfd0b496eb188b82a43bed7af2c2ec9"},
+ {file = "uvloop-0.16.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b572256409f194521a9895aef274cea88731d14732343da3ecdb175228881638"},
+ {file = "uvloop-0.16.0-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:04ff57aa137230d8cc968f03481176041ae789308b4d5079118331ab01112450"},
+ {file = "uvloop-0.16.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3a19828c4f15687675ea912cc28bbcb48e9bb907c801873bd1519b96b04fb805"},
+ {file = "uvloop-0.16.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e814ac2c6f9daf4c36eb8e85266859f42174a4ff0d71b99405ed559257750382"},
+ {file = "uvloop-0.16.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bd8f42ea1ea8f4e84d265769089964ddda95eb2bb38b5cbe26712b0616c3edee"},
+ {file = "uvloop-0.16.0-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:647e481940379eebd314c00440314c81ea547aa636056f554d491e40503c8464"},
+ {file = "uvloop-0.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e0d26fa5875d43ddbb0d9d79a447d2ace4180d9e3239788208527c4784f7cab"},
+ {file = "uvloop-0.16.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:6ccd57ae8db17d677e9e06192e9c9ec4bd2066b77790f9aa7dede2cc4008ee8f"},
+ {file = "uvloop-0.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:089b4834fd299d82d83a25e3335372f12117a7d38525217c2258e9b9f4578897"},
+ {file = "uvloop-0.16.0-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98d117332cc9e5ea8dfdc2b28b0a23f60370d02e1395f88f40d1effd2cb86c4f"},
+ {file = "uvloop-0.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e5f2e2ff51aefe6c19ee98af12b4ae61f5be456cd24396953244a30880ad861"},
+ {file = "uvloop-0.16.0.tar.gz", hash = "sha256:f74bc20c7b67d1c27c72601c78cf95be99d5c2cdd4514502b4f3eb0933ff1228"},
+]
+vine = [
+ {file = "vine-5.0.0-py2.py3-none-any.whl", hash = "sha256:4c9dceab6f76ed92105027c49c823800dd33cacce13bdedc5b914e3514b7fb30"},
+ {file = "vine-5.0.0.tar.gz", hash = "sha256:7d3b1624a953da82ef63462013bbd271d3eb75751489f9807598e8f340bd637e"},
+]
+watchgod = [
+ {file = "watchgod-0.7-py3-none-any.whl", hash = "sha256:d6c1ea21df37847ac0537ca0d6c2f4cdf513562e95f77bb93abbcf05573407b7"},
+ {file = "watchgod-0.7.tar.gz", hash = "sha256:48140d62b0ebe9dd9cf8381337f06351e1f2e70b2203fa9c6eff4e572ca84f29"},
+]
+wcwidth = [
+ {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"},
+ {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"},
+]
+websockets = [
+ {file = "websockets-10.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cd8c6f2ec24aedace251017bc7a414525171d4e6578f914acab9349362def4da"},
+ {file = "websockets-10.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:1f6b814cff6aadc4288297cb3a248614829c6e4ff5556593c44a115e9dd49939"},
+ {file = "websockets-10.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:01db0ecd1a0ca6702d02a5ed40413e18b7d22f94afb3bbe0d323bac86c42c1c8"},
+ {file = "websockets-10.0-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:82b17524b1ce6ae7f7dd93e4d18e9b9474071e28b65dbf1dfe9b5767778db379"},
+ {file = "websockets-10.0-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:8bbf8660c3f833ddc8b1afab90213f2e672a9ddac6eecb3cde968e6b2807c1c7"},
+ {file = "websockets-10.0-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:b8176deb6be540a46695960a765a77c28ac8b2e3ef2ec95d50a4f5df901edb1c"},
+ {file = "websockets-10.0-cp37-cp37m-win32.whl", hash = "sha256:706e200fc7f03bed99ad0574cd1ea8b0951477dd18cc978ccb190683c69dba76"},
+ {file = "websockets-10.0-cp37-cp37m-win_amd64.whl", hash = "sha256:5b2600e01c7ca6f840c42c747ffbe0254f319594ed108db847eb3d75f4aacb80"},
+ {file = "websockets-10.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:085bb8a6e780d30eaa1ba48ac7f3a6707f925edea787cfb761ce5a39e77ac09b"},
+ {file = "websockets-10.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:9a4d889162bd48588e80950e07fa5e039eee9deb76a58092e8c3ece96d7ef537"},
+ {file = "websockets-10.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:b4ade7569b6fd17912452f9c3757d96f8e4044016b6d22b3b8391e641ca50456"},
+ {file = "websockets-10.0-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:2a43072e434c041a99f2e1eb9b692df0232a38c37c61d00e9f24db79474329e4"},
+ {file = "websockets-10.0-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:7f79f02c7f9a8320aff7d3321cd1c7e3a7dbc15d922ac996cca827301ee75238"},
+ {file = "websockets-10.0-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:1ac35426fe3e7d3d0fac3d63c8965c76ed67a8fd713937be072bf0ce22808539"},
+ {file = "websockets-10.0-cp38-cp38-win32.whl", hash = "sha256:ff59c6bdb87b31f7e2d596f09353d5a38c8c8ff571b0e2238e8ee2d55ad68465"},
+ {file = "websockets-10.0-cp38-cp38-win_amd64.whl", hash = "sha256:d67646ddd17a86117ae21c27005d83c1895c0cef5d7be548b7549646372f868a"},
+ {file = "websockets-10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:82bd921885231f4a30d9bc550552495b3fc36b1235add6d374e7c65c3babd805"},
+ {file = "websockets-10.0-cp39-cp39-manylinux1_i686.whl", hash = "sha256:7d2e12e4f901f1bc062dfdf91831712c4106ed18a9a4cdb65e2e5f502124ca37"},
+ {file = "websockets-10.0-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:71358c7816e2762f3e4af3adf0040f268e219f5a38cb3487a9d0fc2e554fef6a"},
+ {file = "websockets-10.0-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:fe83b3ec9ef34063d86dfe1029160a85f24a5a94271036e5714a57acfdd089a1"},
+ {file = "websockets-10.0-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:eb282127e9c136f860c6068a4fba5756eb25e755baffb5940b6f1eae071928b2"},
+ {file = "websockets-10.0-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:62160772314920397f9d219147f958b33fa27a12c662d4455c9ccbba9a07e474"},
+ {file = "websockets-10.0-cp39-cp39-win32.whl", hash = "sha256:e42a1f1e03437b017af341e9bbfdc09252cd48ef32a8c3c3ead769eab3b17368"},
+ {file = "websockets-10.0-cp39-cp39-win_amd64.whl", hash = "sha256:c5880442f5fc268f1ef6d37b2c152c114deccca73f48e3a8c48004d2f16f4567"},
+ {file = "websockets-10.0.tar.gz", hash = "sha256:c4fc9a1d242317892590abe5b61a9127f1a61740477bfb121743f290b8054002"},
+]
+zipp = [
+ {file = "zipp-3.6.0-py3-none-any.whl", hash = "sha256:9fe5ea21568a0a70e50f273397638d39b03353731e6cbbb3fd8502a33fec40bc"},
+ {file = "zipp-3.6.0.tar.gz", hash = "sha256:71c644c5369f4a6e07636f0aa966270449561fcea2e3d6747b8d23efaa9d7832"},
+]
diff --git a/reddit-clone/pyproject.toml b/reddit-clone/pyproject.toml
new file mode 100644
index 00000000..df2abfca
--- /dev/null
+++ b/reddit-clone/pyproject.toml
@@ -0,0 +1,32 @@
+[tool.poetry]
+name = "reddit-graphql"
+version = "0.1.0"
+description = "A Reddit API clone built with GraphQL."
+authors = ["Aryan Iyappan "]
+
+[tool.poetry.dependencies]
+python = "^3.7"
+uvicorn = {extras = ["standard"], version = "^0.15"}
+SQLAlchemy = {extras = ["mypy"], version = "^1.4"}
+strawberry-graphql = {extras = ["asgi"], version = "^0.84"}
+passlib = {extras = ["argon2"], version = "^1.7"}
+celery = {extras = ["redis", "librabbitmq"], version = "^5.1"}
+alembic = "^1.7"
+asyncpg = "^0.24"
+graphql-relay = "^3.1"
+jinja2 = "^3.0"
+aiofiles = "^0.7"
+starlette = "^0.16"
+marshmallow = "^3.14.0"
+
+
+[tool.poetry.dev-dependencies]
+black = "^21.8b0"
+flake8 = "^3.9.2"
+flake8-black = "^0.2.3"
+flake8-bugbear = "^21.4.3"
+mypy = "^0.910"
+
+[build-system]
+requires = ["poetry-core>=1.0.0"]
+build-backend = "poetry.core.masonry.api"
diff --git a/reddit-clone/reddit/__init__.py b/reddit-clone/reddit/__init__.py
new file mode 100644
index 00000000..21bd1d35
--- /dev/null
+++ b/reddit-clone/reddit/__init__.py
@@ -0,0 +1,51 @@
+from typing import Union, Optional, Any
+
+from starlette.applications import Starlette
+from starlette.requests import Request
+from starlette.responses import Response
+from starlette.websockets import WebSocket
+from strawberry.dataloader import DataLoader
+from strawberry.asgi import GraphQL
+
+from reddit import settings
+from reddit.schema import schema
+from reddit.users.loaders import load_users_by_id, load_users_by_username
+from reddit.subreddits.loaders import load_subreddits_by_id
+from reddit.posts.loaders import load_posts_by_id
+from reddit.comments.loaders import load_comments_by_id
+
+__all__ = ("app",)
+
+
+class MyGraphQL(GraphQL):
+ async def get_context(
+ self, request: Union[Request, WebSocket], response: Optional[Response] = None
+ ) -> Optional[Any]:
+ context = await super().get_context(request, response=response)
+ context.update(
+ user_id_loader=DataLoader(load_fn=load_users_by_id),
+ user_username_loader=DataLoader(load_fn=load_users_by_username),
+ subreddit_id_loader=DataLoader(load_fn=load_subreddits_by_id),
+ post_id_loader=DataLoader(load_fn=load_posts_by_id),
+ comment_id_loader=DataLoader(load_fn=load_comments_by_id),
+ )
+ return context
+
+
+def create_application() -> Starlette:
+ """
+ Creates an application instance.
+
+ :return: The created application.
+ """
+ app = Starlette(debug=settings.DEBUG)
+ graphql_app = MyGraphQL(schema=schema, graphiql=True, debug=settings.DEBUG)
+ app.add_route(
+ path="/graphql",
+ route=graphql_app,
+ )
+
+ return app
+
+
+app = create_application()
diff --git a/reddit-clone/reddit/base/__init__.py b/reddit-clone/reddit/base/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/reddit-clone/reddit/base/queries.py b/reddit-clone/reddit/base/queries.py
new file mode 100644
index 00000000..3baeb032
--- /dev/null
+++ b/reddit-clone/reddit/base/queries.py
@@ -0,0 +1,40 @@
+from typing import Optional
+
+import strawberry
+from strawberry.tools import create_type
+from strawberry.types import Info
+from graphql_relay import from_global_id
+
+from reddit.base.types import NodeType
+
+
+def resolve_node(info: Info, id: strawberry.ID) -> Optional[NodeType]:
+ try:
+ type_name, _id = from_global_id(id)
+ except Exception:
+ raise Exception(f'Unable to parse global ID "{id}".')
+
+ schema_type = info.schema.get_type_by_name(type_name)
+ if schema_type is None:
+ raise Exception(f'Relay Node "{type_name}" not found in schema')
+
+ # We make sure the ObjectType implements the "Node" interface
+ if NodeType not in schema_type._type_definition.interfaces:
+ raise Exception(
+ f'ObjectType "{type_name}" does not implement the "{NodeType}" interface.'
+ )
+
+ resolver = getattr(schema_type, "resolve_node", None)
+ if resolver is not None:
+ return resolver(info, _id)
+
+
+node = strawberry.field(
+ resolver=resolve_node,
+ description="""
+ Fetches an object given its ID.
+ """,
+)
+
+
+BaseQuery = create_type(name="BaseQuery", fields=(node,))
diff --git a/reddit-clone/reddit/base/types.py b/reddit-clone/reddit/base/types.py
new file mode 100644
index 00000000..f9323022
--- /dev/null
+++ b/reddit-clone/reddit/base/types.py
@@ -0,0 +1,93 @@
+from __future__ import annotations
+
+from typing import Generic, List, Optional, TypeVar
+
+import strawberry
+
+
+@strawberry.interface(name="Node", description="An object with an ID.")
+class NodeType:
+ # TODO: need to make custom ID resolver
+ id: strawberry.ID = strawberry.field(
+ description="""
+ ID of the object.
+ """
+ )
+
+
+Node = TypeVar(name="Node")
+
+Cursor = TypeVar(name="Cursor")
+
+Edge = TypeVar(name="Edge")
+
+
+# TODO: make an abstract type
+@strawberry.type(name="Edge")
+class EdgeType(Generic[Node, Cursor]):
+ cursor: Cursor = strawberry.field(
+ description="""
+ A cursor for use in pagination.
+ """
+ )
+
+ node: NodeType = strawberry.field(
+ description="""
+ The item at the end of the edge.
+ """
+ )
+
+
+@strawberry.type(name="PageInfo")
+class PageInfoType(Generic[Cursor]):
+ end_cursor: Optional[Cursor] = strawberry.field(
+ description="""
+ When paginating forwards, the cursor to continue.
+ """
+ )
+
+ has_next_page: bool = strawberry.field(
+ description="""
+ When paginating forwards, are there more items?
+ """
+ )
+
+ has_previous_page: bool = strawberry.field(
+ description="""
+ When paginating backwards, are there more items?
+ """
+ )
+
+ start_cursor: Optional[Cursor] = strawberry.field(
+ description="""
+ When paginating backwards, the cursor to continue.
+ """
+ )
+
+
+# TODO: make an abstract type
+@strawberry.type(name="Connection")
+class ConnectionType(Generic[Node, Edge]):
+ edges: List[Edge] = strawberry.field(
+ description="""
+ Contains the edges in the connection.
+ """
+ )
+
+ nodes: List[Node] = strawberry.field(
+ description="""
+ Contains the nodes in the connection.
+ """
+ )
+
+ page_info: PageInfoType = strawberry.field(
+ description="""
+ Information to aid in pagination.
+ """
+ )
+
+ total_count: int = strawberry.field(
+ description="""
+ Identifies the total count of items in the connection.
+ """
+ )
diff --git a/reddit-clone/reddit/comments/__init__.py b/reddit-clone/reddit/comments/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/reddit-clone/reddit/comments/loaders.py b/reddit-clone/reddit/comments/loaders.py
new file mode 100644
index 00000000..b69be7c3
--- /dev/null
+++ b/reddit-clone/reddit/comments/loaders.py
@@ -0,0 +1,20 @@
+from typing import List, Optional, Dict
+
+from sqlalchemy import select
+from sqlalchemy.engine import Result
+
+from reddit.database import get_session
+from reddit.comments.models import Comment
+
+
+async def load_comments_by_id(comment_ids: List[int]) -> List[Optional[Comment]]:
+ """
+ Batch-loads comments by their IDs.
+ """
+ query = select(Comment).filter(Comment.id.in_(comment_ids))
+ async with get_session() as session:
+ result: Result = await session.execute(query)
+ comment_map: Dict[int, Comment] = {
+ comment.id: comment for comment in result.scalars()
+ }
+ return [comment_map.get(comment_id) for comment_id in comment_ids]
diff --git a/reddit-clone/reddit/comments/models.py b/reddit-clone/reddit/comments/models.py
new file mode 100644
index 00000000..3f8582e2
--- /dev/null
+++ b/reddit-clone/reddit/comments/models.py
@@ -0,0 +1,33 @@
+from __future__ import annotations
+
+from typing import Optional, List
+
+from sqlalchemy import Column, Integer, Text, ForeignKey
+from sqlalchemy.orm import relationship
+
+from ..database import Base
+
+
+class Comment(Base):
+ """
+ Represents a Comment on a post.
+ """
+
+ __tablename__ = "comments"
+
+ id: int = Column(Integer, primary_key=True, nullable=False)
+
+ content: str = Column(Text, nullable=False)
+
+ votes: int = Column(Integer, default=1)
+
+ owner_id: Optional[int] = Column(Integer, ForeignKey("users.id"))
+
+ post_id: int = Column(Integer, ForeignKey("posts.id"))
+
+ replies: List[Comment] = relationship(
+ "Comment", back_populates="parent", lazy="dynamic"
+ )
+
+ def __repr__(self) -> str:
+ return f""
diff --git a/reddit-clone/reddit/comments/mutations/__init__.py b/reddit-clone/reddit/comments/mutations/__init__.py
new file mode 100644
index 00000000..a37863d9
--- /dev/null
+++ b/reddit-clone/reddit/comments/mutations/__init__.py
@@ -0,0 +1,11 @@
+from strawberry.tools import create_type
+
+from .comment_create import comment_create
+from .comment_delete import comment_delete
+from .comment_update import comment_update
+from .comment_vote import comment_vote
+
+CommentMutation = create_type(
+ name="CommentMutation",
+ fields=(comment_create, comment_delete, comment_update, comment_vote),
+)
diff --git a/reddit-clone/reddit/comments/mutations/comment_create.py b/reddit-clone/reddit/comments/mutations/comment_create.py
new file mode 100644
index 00000000..3419e0cc
--- /dev/null
+++ b/reddit-clone/reddit/comments/mutations/comment_create.py
@@ -0,0 +1,34 @@
+from typing import Union
+
+import strawberry
+from strawberry.types import Info
+
+from reddit.comments.types import CommentType
+
+
+__all__ = ("comment_create",)
+
+
+@strawberry.input
+class CommentCreateInput:
+ content: str
+ subreddit_id: strawberry.ID
+ post_id: strawberry.ID
+
+
+@strawberry.type
+class CommentCreateSuccess:
+ comment: CommentType
+
+
+@strawberry.type
+class CommentCreateError:
+ error: str
+
+
+CommentCreateResult = Union[CommentCreateSuccess, CommentCreateError]
+
+
+@strawberry.field(description="Creates a new comment on a post.")
+async def comment_create(info: Info, input: CommentCreateInput) -> CommentCreateResult:
+ pass
diff --git a/reddit-clone/reddit/comments/mutations/comment_delete.py b/reddit-clone/reddit/comments/mutations/comment_delete.py
new file mode 100644
index 00000000..ba3c1451
--- /dev/null
+++ b/reddit-clone/reddit/comments/mutations/comment_delete.py
@@ -0,0 +1,34 @@
+from typing import Union
+
+import strawberry
+from strawberry.types import Info
+
+from reddit.comments.types import CommentType
+
+
+__all__ = ("comment_delete",)
+
+
+@strawberry.input
+class CommentDeleteInput:
+ comment_id: strawberry.ID
+ subreddit_id: strawberry.ID
+ post_id: strawberry.ID
+
+
+@strawberry.type
+class CommentDeleteSuccess:
+ comment: CommentType
+
+
+@strawberry.type
+class CommentDeleteError:
+ error: str
+
+
+CommentDeleteResult = Union[CommentDeleteSuccess, CommentDeleteError]
+
+
+@strawberry.field(description="Deletes a comment on a post.")
+async def comment_delete(info: Info, input: CommentDeleteInput) -> CommentDeleteResult:
+ pass
diff --git a/reddit-clone/reddit/comments/mutations/comment_update.py b/reddit-clone/reddit/comments/mutations/comment_update.py
new file mode 100644
index 00000000..6a80c2c3
--- /dev/null
+++ b/reddit-clone/reddit/comments/mutations/comment_update.py
@@ -0,0 +1,35 @@
+from typing import Union, Optional
+
+import strawberry
+from strawberry.types import Info
+
+from reddit.comments.types import CommentType
+
+
+__all__ = ("comment_update",)
+
+
+@strawberry.input
+class CommentUpdateInput:
+ comment: Optional[str]
+ comment_id: strawberry.ID
+ subreddit_id: strawberry.ID
+ post_id: strawberry.ID
+
+
+@strawberry.type
+class CommentUpdateSuccess:
+ comment: CommentType
+
+
+@strawberry.type
+class CommentUpdateError:
+ error: str
+
+
+CommentUpdateResult = Union[CommentUpdateSuccess, CommentUpdateError]
+
+
+@strawberry.field(description="Updates a comment on a post.")
+async def comment_update(info: Info, input: CommentUpdateInput) -> CommentUpdateResult:
+ pass
diff --git a/reddit-clone/reddit/comments/mutations/comment_vote.py b/reddit-clone/reddit/comments/mutations/comment_vote.py
new file mode 100644
index 00000000..6ff8213e
--- /dev/null
+++ b/reddit-clone/reddit/comments/mutations/comment_vote.py
@@ -0,0 +1,34 @@
+from typing import Union
+
+import strawberry
+from strawberry.types import Info
+
+from reddit.comments.types import CommentType
+
+
+__all__ = ("comment_vote",)
+
+
+@strawberry.input
+class CommentVoteInput:
+ comment_id: strawberry.ID
+ subreddit_id: strawberry.ID
+ post_id: strawberry.ID
+
+
+@strawberry.type
+class CommentVoteSuccess:
+ comment: CommentType
+
+
+@strawberry.type
+class CommentVoteError:
+ error: str
+
+
+CommentVoteResult = Union[CommentVoteSuccess, CommentVoteError]
+
+
+@strawberry.field(description="Creates a vote on a comment.")
+async def comment_vote(info: Info, input: CommentVoteInput) -> CommentVoteResult:
+ pass
diff --git a/reddit-clone/reddit/comments/services.py b/reddit-clone/reddit/comments/services.py
new file mode 100644
index 00000000..29a2935f
--- /dev/null
+++ b/reddit-clone/reddit/comments/services.py
@@ -0,0 +1,38 @@
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from reddit.users.models import User
+from reddit.comments.models import Comment
+
+
+async def create_comment(
+ session: AsyncSession, content: str, owner_id: int, post_id: int
+) -> Comment:
+ """
+ Creates a new comment instance.
+ """
+ comment = Comment(content=content, owner_id=owner_id, post_id=post_id)
+ # TODO: validate input data here.
+ session.add(instance=comment)
+ await session.commit()
+ await session.refresh(instance=comment)
+ return comment
+
+
+async def vote_comment(session: AsyncSession, comment: Comment, user: User) -> Comment:
+ """
+ Creates a vote on the given comment.
+ """
+
+
+async def update_comment(session: AsyncSession, comment: Comment) -> Comment:
+ """
+ Updates the given comment instance.
+ """
+
+
+async def delete_comment(
+ session: AsyncSession, comment: Comment, user: User
+) -> Comment:
+ """
+ Deletes the given comment instance.
+ """
diff --git a/reddit-clone/reddit/comments/types.py b/reddit-clone/reddit/comments/types.py
new file mode 100644
index 00000000..5947161f
--- /dev/null
+++ b/reddit-clone/reddit/comments/types.py
@@ -0,0 +1,71 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, List, Optional, cast
+
+import strawberry
+from strawberry.types import Info
+from strawberry.lazy_type import LazyType
+
+from reddit.base.types import NodeType
+
+if TYPE_CHECKING:
+ from reddit.users.types import UserType
+ from reddit.posts.types import PostType
+
+
+@strawberry.type(name="Comment")
+class CommentType(NodeType):
+ content: str = strawberry.field(
+ description="""
+ The content of the comment.
+ """
+ )
+
+ votes: int = strawberry.field(
+ description="""
+ The votes the comment has.
+ """
+ )
+
+ owner_id: Optional[int] = strawberry.field(
+ description="""
+ The owner ID of the comment.
+ """
+ )
+
+ post_id: int = strawberry.field(
+ description="""
+ The post ID of the comment.
+ """
+ )
+
+ replies: List[CommentType] = strawberry.field(
+ description="""
+ The replies for the comment.
+ """
+ )
+
+ @strawberry.field(description="The owner of the comment.")
+ async def owner(
+ self, info: Info
+ ) -> LazyType["UserType", "reddit.users.types"]: # noqa: F821
+ loader = info.context.get("user_id_loader")
+ user = await loader.load(self.owner_id)
+ return cast(UserType, user)
+
+ @strawberry.field(description="The post of the comment.")
+ async def post(
+ self, info: Info
+ ) -> LazyType["PostType", "reddit.posts.types"]: # noqa: F821
+ loader = info.context.get("post_id_loader")
+ post = await loader.load(self.post_id)
+ return cast(PostType, post)
+
+ @classmethod
+ async def resolve_node(cls, info: Info, comment_id: str) -> Optional[CommentType]:
+ """
+ Gets a comment with the given ID.
+ """
+ loader = info.context.get("comment_id_loader")
+ comment = await loader.load(comment_id)
+ return cast(CommentType, comment)
diff --git a/reddit-clone/reddit/database.py b/reddit-clone/reddit/database.py
new file mode 100644
index 00000000..cadd3fb8
--- /dev/null
+++ b/reddit-clone/reddit/database.py
@@ -0,0 +1,35 @@
+from contextlib import asynccontextmanager
+from typing import AsyncGenerator
+
+from sqlalchemy.orm import sessionmaker
+from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
+from sqlalchemy.ext.declarative import declarative_base
+
+from reddit import settings
+
+engine = create_async_engine(settings.DATABASE_URL, future=True)
+
+session_factory = sessionmaker(bind=engine, class_=AsyncSession)
+
+__all__ = ("get_session", "Base")
+
+
+@asynccontextmanager
+async def get_session() -> AsyncGenerator[AsyncSession, None]:
+ """
+ Gets a session instance.
+
+ :return: the obtained session.
+ """
+ session = session_factory()
+ try:
+ yield session
+ await session.commit()
+ except Exception as err:
+ await session.rollback()
+ raise err
+ finally:
+ await session.close()
+
+
+Base = declarative_base()
diff --git a/reddit-clone/reddit/posts/__init__.py b/reddit-clone/reddit/posts/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/reddit-clone/reddit/posts/loaders.py b/reddit-clone/reddit/posts/loaders.py
new file mode 100644
index 00000000..66dade6d
--- /dev/null
+++ b/reddit-clone/reddit/posts/loaders.py
@@ -0,0 +1,18 @@
+from typing import List, Optional, Dict
+
+from sqlalchemy import select
+from sqlalchemy.engine import Result
+
+from reddit.database import get_session
+from reddit.posts.models import Post
+
+
+async def load_posts_by_id(post_ids: List[int]) -> List[Optional[Post]]:
+ """
+ Batch-loads posts by their IDs.
+ """
+ query = select(Post).filter(Post.id.in_(post_ids))
+ async with get_session() as session:
+ result: Result = await session.execute(query)
+ post_map: Dict[int, Post] = {post.id: post for post in result.scalars()}
+ return [post_map.get(post_id) for post_id in post_ids]
diff --git a/reddit-clone/reddit/posts/models.py b/reddit-clone/reddit/posts/models.py
new file mode 100644
index 00000000..85dcda92
--- /dev/null
+++ b/reddit-clone/reddit/posts/models.py
@@ -0,0 +1,38 @@
+from typing import Optional, List
+
+from sqlalchemy import Column, Integer, String, ForeignKey
+from sqlalchemy.orm import relationship
+
+from ..database import Base
+from ..comments.models import Comment
+
+
+class Post(Base):
+ """
+ Represents a post in a Subreddit.
+ """
+
+ __tablename__ = "posts"
+
+ id: int = Column(Integer, primary_key=True, nullable=False)
+
+ title: str = Column(String(150))
+
+ text: Optional[str] = Column(String(1024), default=None)
+
+ link: Optional[str] = Column(String(255), default=None, unique=True)
+
+ thumbnail: Optional[str] = Column(String(255), default=None)
+
+ owner_id: int = Column(Integer, ForeignKey("users.id"))
+
+ subreddit_id: int = Column(Integer, ForeignKey("subreddits.id"))
+
+ votes: int = Column(Integer, default=1)
+
+ comments: List[Comment] = relationship(
+ "Comment", back_populates="post", lazy="dynamic"
+ )
+
+ def __repr__(self) -> str:
+ return f""
diff --git a/reddit-clone/reddit/posts/mutations/__init__.py b/reddit-clone/reddit/posts/mutations/__init__.py
new file mode 100644
index 00000000..06457192
--- /dev/null
+++ b/reddit-clone/reddit/posts/mutations/__init__.py
@@ -0,0 +1,10 @@
+from strawberry.tools import create_type
+
+from .post_create import post_create
+from .post_delete import post_delete
+from .post_update import post_update
+from .post_vote import post_vote
+
+PostMutation = create_type(
+ name="PostMutation", fields=(post_create, post_delete, post_update, post_vote)
+)
diff --git a/reddit-clone/reddit/posts/mutations/post_create.py b/reddit-clone/reddit/posts/mutations/post_create.py
new file mode 100644
index 00000000..5fcdace5
--- /dev/null
+++ b/reddit-clone/reddit/posts/mutations/post_create.py
@@ -0,0 +1,34 @@
+from typing import Union, Optional
+
+import strawberry
+from strawberry.types import Info
+
+from reddit.posts.types import PostType
+
+
+__all__ = ("post_create",)
+
+
+@strawberry.input
+class PostCreateInput:
+ title: str
+ subreddit_id: strawberry.ID
+ text: Optional[str]
+
+
+@strawberry.type
+class PostCreateSuccess:
+ post: PostType
+
+
+@strawberry.type
+class PostCreateError:
+ error: str
+
+
+PostCreateResult = Union[PostCreateSuccess, PostCreateError]
+
+
+@strawberry.mutation(description="Creates a new post in a subreddit.")
+async def post_create(info: Info, input: PostCreateInput) -> PostCreateResult:
+ pass
diff --git a/reddit-clone/reddit/posts/mutations/post_delete.py b/reddit-clone/reddit/posts/mutations/post_delete.py
new file mode 100644
index 00000000..31c578f9
--- /dev/null
+++ b/reddit-clone/reddit/posts/mutations/post_delete.py
@@ -0,0 +1,33 @@
+from typing import Union
+
+import strawberry
+from strawberry.types import Info
+
+from reddit.posts.types import PostType
+
+
+__all__ = ("post_delete",)
+
+
+@strawberry.input
+class PostDeleteInput:
+ post_id: strawberry.ID
+ subreddit_id: strawberry.ID
+
+
+@strawberry.type
+class PostDeleteSuccess:
+ post: PostType
+
+
+@strawberry.type
+class PostDeleteError:
+ error: str
+
+
+PostDeleteResult = Union[PostDeleteSuccess, PostDeleteError]
+
+
+@strawberry.mutation(description="Deletes a post in a subreddit.")
+async def post_delete(info: Info, input: PostDeleteInput) -> PostDeleteResult:
+ pass
diff --git a/reddit-clone/reddit/posts/mutations/post_update.py b/reddit-clone/reddit/posts/mutations/post_update.py
new file mode 100644
index 00000000..c0d0df86
--- /dev/null
+++ b/reddit-clone/reddit/posts/mutations/post_update.py
@@ -0,0 +1,35 @@
+from typing import Union, Optional
+
+import strawberry
+from strawberry.types import Info
+
+from reddit.posts.types import PostType
+
+
+__all__ = ("post_update",)
+
+
+@strawberry.input
+class PostUpdateInput:
+ title: Optional[str]
+ text: Optional[str]
+ post_id: strawberry.ID
+ subreddit_id: strawberry.ID
+
+
+@strawberry.type
+class PostUpdateSuccess:
+ post: PostType
+
+
+@strawberry.type
+class PostUpdateError:
+ error: str
+
+
+PostUpdateResult = Union[PostUpdateSuccess, PostUpdateError]
+
+
+@strawberry.mutation(description="Updates a post in a subreddit.")
+async def post_update(info: Info, input: PostUpdateInput) -> PostUpdateResult:
+ pass
diff --git a/reddit-clone/reddit/posts/mutations/post_vote.py b/reddit-clone/reddit/posts/mutations/post_vote.py
new file mode 100644
index 00000000..4e434a33
--- /dev/null
+++ b/reddit-clone/reddit/posts/mutations/post_vote.py
@@ -0,0 +1,33 @@
+from typing import Union
+
+import strawberry
+from strawberry.types import Info
+
+from reddit.posts.types import PostType
+
+
+__all__ = ("post_vote",)
+
+
+@strawberry.input
+class PostVoteInput:
+ post_id: strawberry.ID
+ subreddit_id: strawberry.ID
+
+
+@strawberry.type
+class PostVoteSuccess:
+ post: PostType
+
+
+@strawberry.type
+class PostVoteError:
+ error: str
+
+
+PostVoteResult = Union[PostVoteSuccess, PostVoteError]
+
+
+@strawberry.mutation(description="Creates a vote on a post.")
+async def post_vote(info: Info, input: PostVoteInput) -> PostVoteResult:
+ pass
diff --git a/reddit-clone/reddit/posts/services.py b/reddit-clone/reddit/posts/services.py
new file mode 100644
index 00000000..8e2db89a
--- /dev/null
+++ b/reddit-clone/reddit/posts/services.py
@@ -0,0 +1,45 @@
+from typing import Optional
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from reddit.users.models import User
+from reddit.posts.models import Post
+
+
+async def create_post(
+ session: AsyncSession,
+ title: str,
+ owner_id: int,
+ subreddit_id: int,
+ link: Optional[str] = None,
+ text: Optional[str] = None,
+) -> Post:
+ """
+ Creates a new post instance.
+ """
+ post = Post(
+ title=title, text=text, link=link, owner_id=owner_id, subreddit_id=subreddit_id
+ )
+ # TODO: validate input data here.
+ session.add(instance=post)
+ await session.commit()
+ await session.refresh(instance=post)
+ return post
+
+
+async def vote_post(session: AsyncSession, post: Post, user: User) -> Post:
+ """
+ Creates a vote on the given post.
+ """
+
+
+async def update_post(session: AsyncSession, post: Post) -> Post:
+ """
+ Updates the given post instance.
+ """
+
+
+async def delete_post(session: AsyncSession, post: Post, user: User) -> Post:
+ """
+ Deletes the given post instance.
+ """
diff --git a/reddit-clone/reddit/posts/types.py b/reddit-clone/reddit/posts/types.py
new file mode 100644
index 00000000..14a7665d
--- /dev/null
+++ b/reddit-clone/reddit/posts/types.py
@@ -0,0 +1,90 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, List, Optional, cast
+
+import strawberry
+from strawberry.types import Info
+from strawberry.lazy_type import LazyType
+
+from reddit.base.types import NodeType
+from reddit.comments.types import CommentType
+
+if TYPE_CHECKING:
+ from reddit.subreddits.types import SubredditType
+ from reddit.users.types import UserType
+
+
+@strawberry.type(name="Post")
+class PostType(NodeType):
+ title: str = strawberry.field(
+ description="""
+ The title of the post.
+ """
+ )
+
+ text: Optional[str] = strawberry.field(
+ description="""
+ The text for the post.
+ """
+ )
+
+ link: Optional[str] = strawberry.field(
+ description="""
+ The link of the post.
+ """
+ )
+
+ thumbnail: Optional[str] = strawberry.field(
+ description="""
+ The thumbnail URL of the post.
+ """
+ )
+
+ owner_id: int = strawberry.field(
+ description="""
+ The owner ID of the post.
+ """
+ )
+
+ subreddit_id: int = strawberry.field(
+ description="""
+ The subreddit ID of the post.
+ """
+ )
+
+ votes: int = strawberry.field(
+ description="""
+ The votes the post has.
+ """
+ )
+
+ comments: List[CommentType] = strawberry.field(
+ description="""
+ The comments for the post.
+ """
+ )
+
+ @strawberry.field(description="The owner of the post.")
+ async def owner(
+ self, info: Info
+ ) -> LazyType["UserType", "reddit.users.types"]: # noqa: F821
+ loader = info.context.get("user_id_loader")
+ user = await loader.load(self.owner_id)
+ return cast(UserType, user)
+
+ @strawberry.field(description="The Subreddit of the post.")
+ async def subreddit(
+ self, info: Info
+ ) -> LazyType["SubredditType", "reddit.subreddits.types"]: # noqa: F821
+ loader = info.context.get("subreddit_id_loader")
+ subreddit = await loader.load(self.subreddit_id)
+ return cast(SubredditType, subreddit)
+
+ @classmethod
+ async def resolve_node(cls, info: Info, post_id: str) -> Optional[PostType]:
+ """
+ Gets a post with the given ID.
+ """
+ loader = info.context.get("post_id_loader")
+ post = await loader.load(post_id)
+ return cast(PostType, post)
diff --git a/reddit-clone/reddit/schema.py b/reddit-clone/reddit/schema.py
new file mode 100644
index 00000000..d6185f2d
--- /dev/null
+++ b/reddit-clone/reddit/schema.py
@@ -0,0 +1,21 @@
+from strawberry import Schema
+from strawberry.tools import merge_types
+
+from reddit.base.queries import BaseQuery
+from reddit.subreddits.queries import SubredditQuery
+from reddit.users.queries import UserQuery
+from reddit.comments.mutations import CommentMutation
+from reddit.posts.mutations import PostMutation
+from reddit.subreddits.mutations import SubredditMutation
+from reddit.users.mutations import UserMutation
+
+__all__ = ("schema",)
+
+
+schema = Schema(
+ query=merge_types(name="Query", types=(BaseQuery, UserQuery, SubredditQuery)),
+ mutation=merge_types(
+ name="Mutation",
+ types=(CommentMutation, PostMutation, UserMutation, SubredditMutation),
+ ),
+)
diff --git a/reddit-clone/reddit/settings.py b/reddit-clone/reddit/settings.py
new file mode 100644
index 00000000..8a23f94a
--- /dev/null
+++ b/reddit-clone/reddit/settings.py
@@ -0,0 +1,34 @@
+from typing import Optional
+
+from starlette.config import Config
+from starlette.datastructures import Secret
+
+
+config = Config(env_file=".env")
+
+# whether the application is in development mode.
+DEBUG: bool = config("DEBUG", cast=bool, default=False)
+
+# SQLAlchemy database URL.
+DATABASE_URL: str = config("DATABASE_URL", cast=str)
+
+# mail client host name.
+MAIL_HOST: str = config("MAIL_HOST", cast=str)
+
+# mail client port.
+MAIL_PORT: int = config("MAIL_PORT", cast=int)
+
+# mail client auth username.
+MAIL_USERNAME: Optional[str] = config("MAIL_USERNAME", cast=str, default=None)
+
+# mail client auth password.
+MAIL_PASSWORD: Optional[Secret] = config("MAIL_PASSWORD", cast=Secret, default=None)
+
+# mail client sender address.
+MAIL_SENDER: Optional[str] = config("MAIL_SENDER", cast=str, default=None)
+
+# celery broker URL.
+CELERY_BROKER: str = config("CELERY_BROKER", cast=str)
+
+# celery result backend URL.
+CELERY_BACKEND: str = config("CELERY_BACKEND", cast=str)
diff --git a/reddit-clone/reddit/subreddits/__init__.py b/reddit-clone/reddit/subreddits/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/reddit-clone/reddit/subreddits/loaders.py b/reddit-clone/reddit/subreddits/loaders.py
new file mode 100644
index 00000000..c1978b33
--- /dev/null
+++ b/reddit-clone/reddit/subreddits/loaders.py
@@ -0,0 +1,20 @@
+from typing import List, Optional, Dict
+
+from sqlalchemy import select
+from sqlalchemy.engine import Result
+
+from reddit.database import get_session
+from reddit.subreddits.models import Subreddit
+
+
+async def load_subreddits_by_id(subreddit_ids: List[int]) -> List[Optional[Subreddit]]:
+ """
+ Batch-loads subreddits by their IDs.
+ """
+ query = select(Subreddit).filter(Subreddit.id.in_(subreddit_ids))
+ async with get_session() as session:
+ result: Result = await session.execute(query)
+ subreddit_map: Dict[int, Subreddit] = {
+ subreddit.id: subreddit for subreddit in result.scalars()
+ }
+ return [subreddit_map.get(subreddit_id) for subreddit_id in subreddit_ids]
diff --git a/reddit-clone/reddit/subreddits/models.py b/reddit-clone/reddit/subreddits/models.py
new file mode 100644
index 00000000..0546f25b
--- /dev/null
+++ b/reddit-clone/reddit/subreddits/models.py
@@ -0,0 +1,30 @@
+from typing import Optional, List
+
+from sqlalchemy import Column, ForeignKey, Integer, String
+from sqlalchemy.orm import relationship
+
+from ..database import Base
+from ..posts.models import Post
+
+
+class Subreddit(Base):
+ """
+ Represents a Subreddit.
+ """
+
+ __tablename__ = "subreddits"
+
+ id: int = Column(Integer, primary_key=True, nullable=False)
+
+ name: str = Column(String(75), unique=True, nullable=False)
+
+ description: Optional[str] = Column(String(255), default=None)
+
+ owner_id: int = Column(Integer, ForeignKey("users.id"))
+
+ icon: Optional[str] = Column(String(255), default=None)
+
+ posts: List[Post] = relationship("Post", back_populates="subreddit", lazy="dynamic")
+
+ def __repr__(self) -> str:
+ return f""
diff --git a/reddit-clone/reddit/subreddits/mutations/__init__.py b/reddit-clone/reddit/subreddits/mutations/__init__.py
new file mode 100644
index 00000000..83a4ad10
--- /dev/null
+++ b/reddit-clone/reddit/subreddits/mutations/__init__.py
@@ -0,0 +1,18 @@
+from strawberry.tools import create_type
+
+from .subreddit_create import subreddit_create
+from .subreddit_delete import subreddit_delete
+from .subreddit_join import subreddit_join
+from .subreddit_leave import subreddit_leave
+from .subreddit_update import subreddit_update
+
+SubredditMutation = create_type(
+ name="SubredditMutation",
+ fields=(
+ subreddit_create,
+ subreddit_delete,
+ subreddit_join,
+ subreddit_leave,
+ subreddit_update,
+ ),
+)
diff --git a/reddit-clone/reddit/subreddits/mutations/subreddit_create.py b/reddit-clone/reddit/subreddits/mutations/subreddit_create.py
new file mode 100644
index 00000000..8fd4e0f2
--- /dev/null
+++ b/reddit-clone/reddit/subreddits/mutations/subreddit_create.py
@@ -0,0 +1,35 @@
+from typing import Optional, Union
+
+import strawberry
+from strawberry.types import Info
+
+from reddit.subreddits.types import SubredditType
+
+
+__all__ = ("subreddit_create",)
+
+
+@strawberry.input
+class SubredditCreateInput:
+ name: str
+ description: Optional[str]
+
+
+@strawberry.type
+class SubredditCreateSuccess:
+ subreddit: SubredditType
+
+
+@strawberry.type
+class SubredditCreateError:
+ error: str
+
+
+SubredditCreateResult = Union[SubredditCreateSuccess, SubredditCreateError]
+
+
+@strawberry.mutation(description="Creates a new subreddit.")
+async def subreddit_create(
+ info: Info, input: SubredditCreateInput
+) -> SubredditCreateResult:
+ pass
diff --git a/reddit-clone/reddit/subreddits/mutations/subreddit_delete.py b/reddit-clone/reddit/subreddits/mutations/subreddit_delete.py
new file mode 100644
index 00000000..39bd8250
--- /dev/null
+++ b/reddit-clone/reddit/subreddits/mutations/subreddit_delete.py
@@ -0,0 +1,34 @@
+from typing import Union
+
+import strawberry
+from strawberry.types import Info
+
+from reddit.subreddits.types import SubredditType
+
+
+__all__ = ("subreddit_delete",)
+
+
+@strawberry.input
+class SubredditDeleteInput:
+ subreddit_id: strawberry.ID
+
+
+@strawberry.type
+class SubredditDeleteSuccess:
+ subreddit: SubredditType
+
+
+@strawberry.type
+class SubredditDeleteError:
+ error: str
+
+
+SubredditDeleteResult = Union[SubredditDeleteSuccess, SubredditDeleteError]
+
+
+@strawberry.mutation(description="Deletes a subreddit.")
+async def subreddit_delete(
+ info: Info, input: SubredditDeleteInput
+) -> SubredditDeleteResult:
+ pass
diff --git a/reddit-clone/reddit/subreddits/mutations/subreddit_join.py b/reddit-clone/reddit/subreddits/mutations/subreddit_join.py
new file mode 100644
index 00000000..1bbe9939
--- /dev/null
+++ b/reddit-clone/reddit/subreddits/mutations/subreddit_join.py
@@ -0,0 +1,36 @@
+from typing import Union
+
+import strawberry
+from strawberry.types import Info
+
+from reddit.subreddits.types import SubredditType
+
+
+__all__ = ("subreddit_join",)
+
+
+@strawberry.input
+class SubredditJoinInput:
+ subreddit_id: strawberry.ID
+
+
+@strawberry.type
+class SubredditJoinSuccess:
+ subreddit: SubredditType
+
+
+@strawberry.type
+class SubredditJoinError:
+ error: str
+
+
+SubredditJoinResult = Union[SubredditJoinSuccess, SubredditJoinError]
+
+
+@strawberry.mutation(
+ description="""
+ Creates a new subreddit-user relationship.
+ """
+)
+async def subreddit_join(info: Info, input: SubredditJoinInput) -> SubredditJoinResult:
+ pass
diff --git a/reddit-clone/reddit/subreddits/mutations/subreddit_leave.py b/reddit-clone/reddit/subreddits/mutations/subreddit_leave.py
new file mode 100644
index 00000000..7ca43834
--- /dev/null
+++ b/reddit-clone/reddit/subreddits/mutations/subreddit_leave.py
@@ -0,0 +1,38 @@
+from typing import Union
+
+import strawberry
+from strawberry.types import Info
+
+from reddit.subreddits.types import SubredditType
+
+
+__all__ = ("subreddit_leave",)
+
+
+@strawberry.input
+class SubredditLeaveInput:
+ subreddit_id: strawberry.ID
+
+
+@strawberry.type
+class SubredditLeaveSuccess:
+ subreddit: SubredditType
+
+
+@strawberry.type
+class SubredditLeaveError:
+ error: str
+
+
+SubredditLeaveResult = Union[SubredditLeaveSuccess, SubredditLeaveError]
+
+
+@strawberry.field(
+ description="""
+ Deletes a subreddit-user relationship.
+ """
+)
+async def subreddit_leave(
+ info: Info, input: SubredditLeaveInput
+) -> SubredditLeaveResult:
+ pass
diff --git a/reddit-clone/reddit/subreddits/mutations/subreddit_update.py b/reddit-clone/reddit/subreddits/mutations/subreddit_update.py
new file mode 100644
index 00000000..ed4d3633
--- /dev/null
+++ b/reddit-clone/reddit/subreddits/mutations/subreddit_update.py
@@ -0,0 +1,36 @@
+from typing import Optional, Union
+
+import strawberry
+from strawberry.types import Info
+
+from reddit.subreddits.types import SubredditType
+
+
+__all__ = ("subreddit_update",)
+
+
+@strawberry.input
+class SubredditUpdateInput:
+ name: Optional[str]
+ description: Optional[str]
+ subreddit_id: strawberry.ID
+
+
+@strawberry.type
+class SubredditUpdateSuccess:
+ subreddit: SubredditType
+
+
+@strawberry.type
+class SubredditUpdateError:
+ error: str
+
+
+SubredditUpdateResult = Union[SubredditUpdateSuccess, SubredditUpdateError]
+
+
+@strawberry.mutation(description="Updates a subreddit.")
+async def subreddit_update(
+ info: Info, input: SubredditUpdateInput
+) -> SubredditUpdateResult:
+ pass
diff --git a/reddit-clone/reddit/subreddits/queries.py b/reddit-clone/reddit/subreddits/queries.py
new file mode 100644
index 00000000..6b1ad2b4
--- /dev/null
+++ b/reddit-clone/reddit/subreddits/queries.py
@@ -0,0 +1,15 @@
+from typing import List
+
+import strawberry
+from strawberry.tools import create_type
+from strawberry.types import Info
+
+from reddit.subreddits.types import SubredditType
+
+
+@strawberry.field(description="Gets the available subreddits.")
+async def subreddits(info: Info) -> List[SubredditType]:
+ pass
+
+
+SubredditQuery = create_type(name="SubredditQuery", fields=(subreddits,))
diff --git a/reddit-clone/reddit/subreddits/services.py b/reddit-clone/reddit/subreddits/services.py
new file mode 100644
index 00000000..0a9d5a8f
--- /dev/null
+++ b/reddit-clone/reddit/subreddits/services.py
@@ -0,0 +1,34 @@
+from typing import Optional
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from reddit.users.models import User
+from reddit.subreddits.models import Subreddit
+
+
+async def create_subreddit(
+ session: AsyncSession, owner_id: int, name: str, description: Optional[str] = None
+) -> Subreddit:
+ """
+ Creates a new subreddit instance.
+ """
+ subreddit = Subreddit(name=name, description=description, owner_id=owner_id)
+ # TODO: validate input data here.
+ session.add(instance=subreddit)
+ await session.commit()
+ await session.refresh(instance=subreddit)
+ return subreddit
+
+
+async def update_subreddit(session: AsyncSession, subreddit: Subreddit) -> Subreddit:
+ """
+ Updates the given subreddit instance.
+ """
+
+
+async def delete_subreddit(
+ session: AsyncSession, subreddit: Subreddit, user: User
+) -> Subreddit:
+ """
+ Deletes the given subreddit instance.
+ """
diff --git a/reddit-clone/reddit/subreddits/types.py b/reddit-clone/reddit/subreddits/types.py
new file mode 100644
index 00000000..873fa26f
--- /dev/null
+++ b/reddit-clone/reddit/subreddits/types.py
@@ -0,0 +1,72 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, List, Optional, cast
+
+import strawberry
+from strawberry.types import Info
+from strawberry.lazy_type import LazyType
+
+from reddit.base.types import NodeType
+from reddit.posts.types import PostType
+
+if TYPE_CHECKING:
+ from reddit.users.types import UserType
+
+
+@strawberry.type(name="Subreddit")
+class SubredditType(NodeType):
+ name: str = strawberry.field(
+ description="""
+ The name of the Subreddit.
+ """
+ )
+
+ description: str = strawberry.field(
+ description="""
+ The description of the Subreddit.
+ """
+ )
+
+ owner_id: int = strawberry.field(
+ description="""
+ The owner ID of the Subreddit.
+ """
+ )
+
+ submit_text: str = strawberry.field(
+ description="""
+ The text set by the Subreddit moderators, intended
+ to be displayed on the submission form.
+ """
+ )
+
+ icon: str = strawberry.field(
+ description="""
+ The icon URL of the Subreddit.
+ """
+ )
+
+ posts: List[PostType] = strawberry.field(
+ description="""
+ The posts for the Subreddit.
+ """
+ )
+
+ @strawberry.field(description="The owner of the Subreddit.")
+ async def owner(
+ self, info: Info
+ ) -> LazyType["UserType", "reddit.users.types"]: # noqa: F821
+ loader = info.context.get("user_id_loader")
+ user = await loader.load(self.owner_id)
+ return cast(UserType, user)
+
+ @classmethod
+ async def resolve_node(
+ cls, info: Info, subreddit_id: str
+ ) -> Optional[SubredditType]:
+ """
+ Gets a Subreddit with the given ID.
+ """
+ loader = info.context.get("subreddit_id_loader")
+ subreddit = await loader.load(subreddit_id)
+ return cast(SubredditType, subreddit)
diff --git a/reddit-clone/reddit/tasks.py b/reddit-clone/reddit/tasks.py
new file mode 100644
index 00000000..60d5b489
--- /dev/null
+++ b/reddit-clone/reddit/tasks.py
@@ -0,0 +1,9 @@
+from celery import Celery
+
+from reddit import settings
+
+__all__ = ("celery",)
+
+celery = Celery(__name__)
+celery.conf.result_backend = settings.CELERY_BACKEND
+celery.conf.broker_url = settings.CELERY_BROKER
diff --git a/reddit-clone/reddit/templates/emails/activate_email.html b/reddit-clone/reddit/templates/emails/activate_email.html
new file mode 100644
index 00000000..727af27c
--- /dev/null
+++ b/reddit-clone/reddit/templates/emails/activate_email.html
@@ -0,0 +1,11 @@
+{% extends "emails/base_email.html" %} {% block title %} Confirm Email {%
+endblock %} {% block content %}
+
+ Someone tried to sign up for a {{ config.SITE_NAME }} account
+ with {{ email }}. If this was you,
+ enter this confirmation code in the app:
+
+{{ code }}
+{% endblock %} {% block content_more %}
+If this wasn't you, this email can be safely ignored.
+{% endblock %}
diff --git a/reddit-clone/reddit/templates/emails/base_email.html b/reddit-clone/reddit/templates/emails/base_email.html
new file mode 100644
index 00000000..31530f06
--- /dev/null
+++ b/reddit-clone/reddit/templates/emails/base_email.html
@@ -0,0 +1,233 @@
+
+
+ {% block title %}{% endblock %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% block content %}{% endblock %}
+ |
+
+
+ |
+
+
+
+
+
+
+ {% block content_more %}{% endblock %}
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+
+
+
+ |
+
+
+
+
diff --git a/reddit-clone/reddit/templates/emails/change_email.html b/reddit-clone/reddit/templates/emails/change_email.html
new file mode 100644
index 00000000..266d02ee
--- /dev/null
+++ b/reddit-clone/reddit/templates/emails/change_email.html
@@ -0,0 +1,11 @@
+{% extends "emails/base_email.html" %} {% block title %} Confirm Email {%
+endblock %} {% block content %}
+
+ Someone tried to change their {{ config.SITE_NAME }} account
+ email to {{ email }}. If this was you,
+ enter this confirmation code in the app:
+
+{{ code }}
+{% endblock %} {% block content_more %}
+If this wasn't you, this email can be safely ignored.
+{% endblock %}
diff --git a/reddit-clone/reddit/templates/emails/password_reset.html b/reddit-clone/reddit/templates/emails/password_reset.html
new file mode 100644
index 00000000..5a759fe8
--- /dev/null
+++ b/reddit-clone/reddit/templates/emails/password_reset.html
@@ -0,0 +1,14 @@
+{% extends "emails/base_email.html" %} {% block title %} Password Reset {%
+endblock %} {% block content %}
+Hey {{ user.username }}!
+
+ We heard that you lost your password. Sorry about that!
+ But don’t worry! Enter the following code to reset your password.
+
+
{{ code }}
+
+{% endblock %} {% block content_more %}
+
+ If this wasn't you, this email can be safely ignored.
+
+{% endblock %}
diff --git a/reddit-clone/reddit/users/__init__.py b/reddit-clone/reddit/users/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/reddit-clone/reddit/users/loaders.py b/reddit-clone/reddit/users/loaders.py
new file mode 100644
index 00000000..b9e801bf
--- /dev/null
+++ b/reddit-clone/reddit/users/loaders.py
@@ -0,0 +1,29 @@
+from typing import List, Optional, Dict
+
+from sqlalchemy import select
+from sqlalchemy.engine import Result
+
+from reddit.database import get_session
+from reddit.users.models import User
+
+
+async def load_users_by_id(user_ids: List[int]) -> List[Optional[User]]:
+ """
+ Batch-loads users by their IDs.
+ """
+ query = select(User).filter(User.id.in_(user_ids))
+ async with get_session() as session:
+ result: Result = await session.execute(query)
+ user_map: Dict[int, User] = {user.id: user for user in result.scalars()}
+ return [user_map.get(user_id) for user_id in user_ids]
+
+
+async def load_users_by_username(usernames: List[str]) -> List[Optional[User]]:
+ """
+ Batch-loads users by their usernames.
+ """
+ query = select(User).filter(User.username.in_(usernames))
+ async with get_session() as session:
+ result: Result = await session.execute(query)
+ user_map: Dict[str, User] = {user.username: user for user in result.scalars()}
+ return [user_map.get(username) for username in usernames]
diff --git a/reddit-clone/reddit/users/models.py b/reddit-clone/reddit/users/models.py
new file mode 100644
index 00000000..8e23be22
--- /dev/null
+++ b/reddit-clone/reddit/users/models.py
@@ -0,0 +1,75 @@
+from __future__ import annotations
+
+from typing import List
+
+from passlib.hash import argon2
+from sqlalchemy import Column, String, Integer, ForeignKey
+from sqlalchemy.orm import relationship
+from sqlalchemy.sql.sqltypes import Boolean
+
+from ..database import Base
+from ..comments.models import Comment
+from ..subreddits.models import Subreddit
+from ..posts.models import Post
+
+
+class User(Base):
+ """
+ Represents an individual user account.
+ """
+
+ __tablename__ = "users"
+
+ id: int = Column(Integer, primary_key=True, nullable=False)
+
+ username: str = Column(String(32), nullable=False, unique=True)
+
+ email: str = Column(String(255), nullable=False, unique=True)
+
+ password: str = Column(String(255), nullable=False)
+
+ avatar: str = Column(String(255), nullable=False, default="default.jpg")
+
+ is_active: bool = Column(Boolean, nullable=False, default=False)
+
+ posts: List[Post] = relationship("Post", back_populates="user", lazy="dynamic")
+
+ subreddits: List[Subreddit] = relationship(
+ "Subreddit", back_populates="user", secondary="subreddit_users", lazy="dynamic"
+ )
+
+ comments: List[Comment] = relationship(
+ "Comment", back_populates="user", lazy="dynamic"
+ )
+
+ def __repr__(self) -> str:
+ return f""
+
+ def set_password(self, password: str) -> None:
+ """
+ Sets a hashed version of the provided
+ password on the user instance.
+ """
+ self.password = argon2.hash(password)
+
+ def check_password(self, password: str) -> bool:
+ """
+ Checks whether the provided password
+ matches the user's password hash.
+ """
+ return argon2.verify(password, self.password)
+
+
+class SubredditUser(Base):
+ """
+ Represents a Subreddit-user relationship.
+ """
+
+ __tablename__ = "subreddit_users"
+
+ user_id: int = Column(Integer, ForeignKey("users.id"), primary_key=True)
+
+ subreddit_id: int = Column(Integer, ForeignKey("subreddits.id"), primary_key=True)
+
+ def __repr__(self) -> str:
+ return f""
diff --git a/reddit-clone/reddit/users/mutations/__init__.py b/reddit-clone/reddit/users/mutations/__init__.py
new file mode 100644
index 00000000..91432cc5
--- /dev/null
+++ b/reddit-clone/reddit/users/mutations/__init__.py
@@ -0,0 +1,26 @@
+from strawberry.tools import create_type
+
+from .authenticate import authenticate
+from .avatar_remove import avatar_remove
+from .email_change_request import email_change_request
+from .email_change import email_change
+from .password_reset_request import password_reset_request
+from .password_reset import password_reset
+from .user_create import user_create
+from .user_deactivate import user_deactivate
+from .user_update import user_update
+
+UserMutation = create_type(
+ name="UserMutation",
+ fields=(
+ authenticate,
+ avatar_remove,
+ email_change_request,
+ email_change,
+ password_reset_request,
+ password_reset,
+ user_create,
+ user_deactivate,
+ user_update,
+ ),
+)
diff --git a/reddit-clone/reddit/users/mutations/authenticate.py b/reddit-clone/reddit/users/mutations/authenticate.py
new file mode 100644
index 00000000..4d9a34d5
--- /dev/null
+++ b/reddit-clone/reddit/users/mutations/authenticate.py
@@ -0,0 +1,33 @@
+from typing import Union
+
+import strawberry
+from strawberry.types import Info
+
+from reddit.users.types import UserType
+
+
+__all__ = ("authenticate",)
+
+
+@strawberry.input
+class AuthenticateInput:
+ username: str
+ password: str
+
+
+@strawberry.type
+class AuthenticateSuccess:
+ user: UserType
+
+
+@strawberry.type
+class AuthenticateError:
+ error: str
+
+
+AuthenticateResult = Union[AuthenticateSuccess, AuthenticateError]
+
+
+@strawberry.mutation(description="Logs the current user in.")
+async def authenticate(info: Info, input: AuthenticateInput) -> AuthenticateResult:
+ pass
diff --git a/reddit-clone/reddit/users/mutations/avatar_remove.py b/reddit-clone/reddit/users/mutations/avatar_remove.py
new file mode 100644
index 00000000..5d4f64f7
--- /dev/null
+++ b/reddit-clone/reddit/users/mutations/avatar_remove.py
@@ -0,0 +1,26 @@
+from typing import Union
+
+import strawberry
+from strawberry.types import Info
+
+from reddit.users.types import UserType
+
+__all__ = ("avatar_remove",)
+
+
+@strawberry.type
+class AvatarRemoveSuccess:
+ user: UserType
+
+
+@strawberry.type
+class AvatarRemoveError:
+ error: str
+
+
+AvatarRemoveResult = Union[AvatarRemoveSuccess, AvatarRemoveError]
+
+
+@strawberry.mutation(description="Removes the current user's avatar.")
+async def avatar_remove(info: Info) -> AvatarRemoveResult:
+ pass
diff --git a/reddit-clone/reddit/users/mutations/email_change.py b/reddit-clone/reddit/users/mutations/email_change.py
new file mode 100644
index 00000000..4648e5d4
--- /dev/null
+++ b/reddit-clone/reddit/users/mutations/email_change.py
@@ -0,0 +1,38 @@
+from typing import Union
+
+import strawberry
+from strawberry.types import Info
+
+from reddit.users.types import UserType
+
+__all__ = ("email_change",)
+
+
+@strawberry.input
+class EmailChangeInput:
+ email: str
+ change_code: str
+ password: str
+
+
+@strawberry.type
+class EmailChangeSuccess:
+ user: UserType
+
+
+@strawberry.type
+class EmailChangeError:
+ error: str
+
+
+EmailChangeResult = Union[EmailChangeSuccess, EmailChangeError]
+
+
+@strawberry.mutation(
+ description="""
+ Changes the email for the user account
+ associated with the given email.
+ """
+)
+async def email_change(info: Info, input: EmailChangeInput) -> EmailChangeResult:
+ pass
diff --git a/reddit-clone/reddit/users/mutations/email_change_request.py b/reddit-clone/reddit/users/mutations/email_change_request.py
new file mode 100644
index 00000000..e530da4c
--- /dev/null
+++ b/reddit-clone/reddit/users/mutations/email_change_request.py
@@ -0,0 +1,40 @@
+from typing import Union
+
+import strawberry
+from strawberry.types import Info
+
+from reddit.users.types import UserType
+
+
+__all__ = ("email_change_request",)
+
+
+@strawberry.input
+class EmailChangeRequestInput:
+ email: str
+ password: str
+
+
+@strawberry.type
+class EmailChangeRequestSuccess:
+ user: UserType
+
+
+@strawberry.type
+class EmailChangeRequestError:
+ error: str
+
+
+EmailChangeRequestResult = Union[EmailChangeRequestSuccess, EmailChangeRequestError]
+
+
+@strawberry.mutation(
+ description="""
+ Sends an email change code to
+ the given email address.
+ """
+)
+async def email_change_request(
+ info: Info, input: EmailChangeRequestInput
+) -> EmailChangeRequestResult:
+ pass
diff --git a/reddit-clone/reddit/users/mutations/password_reset.py b/reddit-clone/reddit/users/mutations/password_reset.py
new file mode 100644
index 00000000..7b2f68c0
--- /dev/null
+++ b/reddit-clone/reddit/users/mutations/password_reset.py
@@ -0,0 +1,38 @@
+from typing import Union
+
+import strawberry
+from strawberry.types import Info
+
+from reddit.users.types import UserType
+
+__all__ = ("password_reset",)
+
+
+@strawberry.input
+class PasswordResetInput:
+ password: str
+ reset_code: str
+ email: str
+
+
+@strawberry.type
+class PasswordResetSuccess:
+ user: UserType
+
+
+@strawberry.type
+class PasswordResetError:
+ error: str
+
+
+PasswordResetResult = Union[PasswordResetSuccess, PasswordResetError]
+
+
+@strawberry.mutation(
+ description="""
+ Resets the password for the user account
+ associated with the given email.
+ """
+)
+async def password_reset(info: Info, input: PasswordResetInput) -> PasswordResetResult:
+ pass
diff --git a/reddit-clone/reddit/users/mutations/password_reset_request.py b/reddit-clone/reddit/users/mutations/password_reset_request.py
new file mode 100644
index 00000000..d23af8f9
--- /dev/null
+++ b/reddit-clone/reddit/users/mutations/password_reset_request.py
@@ -0,0 +1,40 @@
+from typing import Union
+
+import strawberry
+from strawberry.types import Info
+
+from reddit.users.types import UserType
+
+__all__ = ("password_reset_request",)
+
+
+@strawberry.input
+class PasswordResetRequestInput:
+ email: str
+
+
+@strawberry.type
+class PasswordResetRequestSuccess:
+ user: UserType
+
+
+@strawberry.type
+class PasswordResetRequestError:
+ error: str
+
+
+PasswordResetRequestResult = Union[
+ PasswordResetRequestSuccess, PasswordResetRequestError
+]
+
+
+@strawberry.mutation(
+ description="""
+ Sends a password reset code to the
+ provided email, if it actually exists.
+ """
+)
+async def password_reset_request(
+ info: Info, input: PasswordResetRequestInput
+) -> PasswordResetRequestResult:
+ pass
diff --git a/reddit-clone/reddit/users/mutations/user_create.py b/reddit-clone/reddit/users/mutations/user_create.py
new file mode 100644
index 00000000..0b8d0fff
--- /dev/null
+++ b/reddit-clone/reddit/users/mutations/user_create.py
@@ -0,0 +1,62 @@
+from typing import Union, cast
+from marshmallow.exceptions import ValidationError
+
+import strawberry
+from strawberry.types import Info
+
+from reddit.database import get_session
+from reddit.users.types import UserType
+from reddit.users.serializers import user_schema
+from reddit.users.services import user_by_email, user_by_username, create_user
+
+__all__ = ("user_create",)
+
+
+@strawberry.input
+class UserCreateInput:
+ email: str
+ username: str
+ password: str
+
+
+@strawberry.type
+class UserCreateSuccess:
+ user: UserType
+
+
+@strawberry.type
+class UserCreateError:
+ error: str
+
+
+UserCreateResult = Union[UserCreateSuccess, UserCreateError]
+
+
+@strawberry.mutation(description="Creates a new user.")
+async def user_create(info: Info, input: UserCreateInput) -> UserCreateResult:
+ try:
+ data = user_schema.load(
+ {
+ "username": input.username,
+ "password": input.password,
+ "email": input.email,
+ }
+ )
+ except ValidationError as err:
+ # TODO: handle validation errors here.
+ print(err)
+ return UserCreateError(error=str(err))
+
+ async with get_session() as session:
+ if await user_by_email(session=session, email=data.get("email")):
+ return UserCreateError(error="Email already exists.")
+ if await user_by_username(session=session, username=data.get("username")):
+ return UserCreateError(error="Username already exists.")
+
+ user = await create_user(
+ session=session,
+ email=data.get("email"),
+ username=data.get("username"),
+ password=data.get("password"),
+ )
+ return UserCreateSuccess(user=cast(UserType, user))
diff --git a/reddit-clone/reddit/users/mutations/user_deactivate.py b/reddit-clone/reddit/users/mutations/user_deactivate.py
new file mode 100644
index 00000000..d2825ab7
--- /dev/null
+++ b/reddit-clone/reddit/users/mutations/user_deactivate.py
@@ -0,0 +1,33 @@
+from typing import Union
+
+import strawberry
+from strawberry.types import Info
+
+from reddit.users.types import UserType
+
+__all__ = ("user_deactivate",)
+
+
+@strawberry.input
+class UserDeactivateInput:
+ password: str
+
+
+@strawberry.type
+class UserDeactivateSuccess:
+ user: UserType
+
+
+@strawberry.type
+class UserDeactivateError:
+ error: str
+
+
+UserDeactivateResult = Union[UserDeactivateSuccess, UserDeactivateError]
+
+
+@strawberry.mutation(description="Deactivates the current user.")
+async def user_deactivate(
+ info: Info, input: UserDeactivateInput
+) -> UserDeactivateResult:
+ pass
diff --git a/reddit-clone/reddit/users/mutations/user_update.py b/reddit-clone/reddit/users/mutations/user_update.py
new file mode 100644
index 00000000..8f2fea98
--- /dev/null
+++ b/reddit-clone/reddit/users/mutations/user_update.py
@@ -0,0 +1,33 @@
+from typing import Union
+
+import strawberry
+from strawberry.file_uploads import Upload
+from strawberry.types import Info
+
+from reddit.users.types import UserType
+
+__all__ = ("user_update",)
+
+
+@strawberry.input
+class UserUpdateInput:
+ username: str
+ avatar: Upload
+
+
+@strawberry.type
+class UserUpdateSuccess:
+ user: UserType
+
+
+@strawberry.type
+class UserUpdateError:
+ error: str
+
+
+UserUpdateResult = Union[UserUpdateSuccess, UserUpdateError]
+
+
+@strawberry.mutation(description="Updates the current user.")
+async def user_update(info: Info, input: UserUpdateInput) -> UserUpdateResult:
+ pass
diff --git a/reddit-clone/reddit/users/queries.py b/reddit-clone/reddit/users/queries.py
new file mode 100644
index 00000000..129e5978
--- /dev/null
+++ b/reddit-clone/reddit/users/queries.py
@@ -0,0 +1,22 @@
+from typing import Optional, cast
+
+import strawberry
+from strawberry.tools import create_type
+from strawberry.types import Info
+
+from reddit.users.types import UserType
+
+
+@strawberry.field(description="Gets an user by username.")
+async def user(info: Info, username: str) -> Optional[UserType]:
+ loader = info.context.get("user_username_loader")
+ user = await loader.load(username)
+ return cast(UserType, user)
+
+
+@strawberry.field(description="Gets the current user.")
+async def current_user(info: Info) -> Optional[UserType]:
+ pass
+
+
+UserQuery = create_type(name="UserQuery", fields=(user, current_user))
diff --git a/reddit-clone/reddit/users/serializers.py b/reddit-clone/reddit/users/serializers.py
new file mode 100644
index 00000000..1796fbb7
--- /dev/null
+++ b/reddit-clone/reddit/users/serializers.py
@@ -0,0 +1,37 @@
+from marshmallow import Schema, pre_load
+from marshmallow.fields import String, Integer, Boolean
+from marshmallow.validate import Email, Length
+
+
+__all__ = ("user_schema",)
+
+
+class UserSchema(Schema):
+ id = Integer(dump_only=True)
+ email = String(
+ required=True,
+ validate=Email(
+ error="Not a valid email address.",
+ ),
+ )
+ username = String(
+ required=True,
+ validate=(Length(min=2, max=32),),
+ )
+ password = String(
+ required=True,
+ load_only=True,
+ validate=(Length(min=8),),
+ )
+ avatar = String(dump_only=True)
+ is_active = Boolean(dump_only=True)
+
+ def process_input(self, data, **kwargs):
+ # clean user emails before storing them.
+ data["email"] = data["email"].lower().strip()
+ return data
+
+ pre_load(process_input)
+
+
+user_schema = UserSchema()
diff --git a/reddit-clone/reddit/users/services.py b/reddit-clone/reddit/users/services.py
new file mode 100644
index 00000000..425d17d9
--- /dev/null
+++ b/reddit-clone/reddit/users/services.py
@@ -0,0 +1,126 @@
+from typing import Optional
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from reddit.users.models import User
+
+
+async def user_by_email(session: AsyncSession, email: str) -> Optional[User]:
+ """
+ Gets an user by their email.
+ """
+ query = select(User).filter(User.email == email)
+ return (await session.execute(query)).scalar_one()
+
+
+async def user_by_username(session: AsyncSession, username: str) -> Optional[User]:
+ """
+ Gets an user by their username.
+ """
+ query = select(User).filter(User.username == username)
+ return (await session.execute(query)).scalar_one()
+
+
+async def authenticate(
+ session: AsyncSession, username: str, password: str
+) -> Optional[User]:
+ """
+ Checks if the provided user credentials are valid.
+ """
+ user = await user_by_username(session=session, username=username)
+ if user is None or not user.check_password(password=password):
+ # TODO: handle exception here.
+ pass
+ return user
+
+
+async def create_user(
+ session: AsyncSession, email: str, username: str, password: str
+) -> User:
+ """
+ Creates a new user instance.
+ """
+ user = User(email=email, username=username)
+ user.set_password(password=password)
+ # TODO: validate input data here.
+ session.add(instance=user)
+ await session.commit()
+ await session.refresh(instance=user)
+ return user
+
+
+async def update_user(session: AsyncSession, user: User) -> User:
+ """
+ Updates the given user instance.
+ """
+
+
+async def remove_user_avatar(session: AsyncSession, user: User) -> User:
+ """
+ Removes the avatar for the given user instance.
+ """
+ user.avatar = None
+ session.add(instance=user)
+ await session.commit()
+ await session.refresh(instance=user)
+ return user
+
+
+async def request_change_email(
+ session: AsyncSession, email: str, password: str, user: User
+):
+ """
+ Sends an email change code to the user's new email.
+ """
+ if not user.check_password(password=password):
+ # TODO: handle exception here.
+ pass
+ user = await user_by_email(session=session, email=email)
+ if user is not None:
+ # TODO: handle exception here.
+ pass
+ # TODO: store email change code and send
+ # change request email.
+
+
+async def change_email(
+ session: AsyncSession, email: str, change_code: str, password: str
+):
+ """
+ Changes the email for the given user instance.
+ """
+
+
+async def request_reset_password(session: AsyncSession, email: str):
+ """
+ Sends a password reset code to the given email, if it
+ actually exists.
+ """
+ user = await user_by_email(session=session, email=email)
+ if user is not None:
+ # TODO: store password reset code and
+ # send password reset email here.
+ pass
+
+
+async def reset_password(
+ session: AsyncSession, password: str, reset_code: str, email: str
+):
+ """
+ Resets the password for the given user instance.
+ """
+
+
+async def deactivate_user(session: AsyncSession, password: str, user: User) -> User:
+ """
+ Deactivates the given user instance.
+ """
+ if not user.check_password(password=password):
+ # TODO: handle exception here.
+ pass
+ user.is_active = False
+ session.add(instance=user)
+ await session.commit()
+ await session.refresh(instance=user)
+ return user
diff --git a/reddit-clone/reddit/users/types.py b/reddit-clone/reddit/users/types.py
new file mode 100644
index 00000000..ba68aa05
--- /dev/null
+++ b/reddit-clone/reddit/users/types.py
@@ -0,0 +1,53 @@
+from __future__ import annotations
+
+from typing import List, Optional, cast
+
+import strawberry
+from strawberry.types import Info
+
+from reddit.base.types import NodeType
+from reddit.posts.types import PostType
+from reddit.subreddits.types import SubredditType
+from reddit.comments.types import CommentType
+
+
+@strawberry.type(name="User")
+class UserType(NodeType):
+ username: str = strawberry.field(
+ description="""
+ The username of the user.
+ """
+ )
+
+ avatar: str = strawberry.field(
+ description="""
+ The avatar URL of the user.
+ """
+ )
+
+ posts: List[PostType] = strawberry.field(
+ description="""
+ The posts for the user.
+ """
+ )
+
+ subreddits: List[SubredditType] = strawberry.field(
+ description="""
+ The subreddits the user is in.
+ """
+ )
+
+ comments: List[CommentType] = strawberry.field(
+ description="""
+ The comments for the user.
+ """
+ )
+
+ @classmethod
+ async def resolve_node(cls, info: Info, user_id: str) -> Optional[UserType]:
+ """
+ Gets an user with the given ID.
+ """
+ loader = info.context.get("user_id_loader")
+ user = await loader.load(user_id)
+ return cast(UserType, user)
diff --git a/reddit-clone/reddit/utils/__init__.py b/reddit-clone/reddit/utils/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/reddit-clone/reddit/utils/emails.py b/reddit-clone/reddit/utils/emails.py
new file mode 100644
index 00000000..31d490ad
--- /dev/null
+++ b/reddit-clone/reddit/utils/emails.py
@@ -0,0 +1,35 @@
+from email.mime.multipart import MIMEMultipart
+from email.mime.text import MIMEText
+from smtplib import SMTP
+from typing import Optional
+
+from reddit import settings
+from reddit.tasks import celery
+
+__all__ = ("send_mail",)
+
+
+@celery.task(name="send_email")
+def send_mail(
+ recipient: str, subject: str, content: str, html_content: Optional[str] = None
+) -> None:
+ """
+ Sends a multipart email to the provided recipient.
+ """
+ message = MIMEMultipart()
+ message["From"] = settings.MAIL_SENDER
+ message["To"] = recipient
+ message["Subject"] = subject
+
+ plain_text = MIMEText(content)
+ message.attach(plain_text)
+
+ if html_content is not None:
+ html_text = MIMEText(html_content, "html")
+ message.attach(html_text)
+
+ server = SMTP(host=settings.MAIL_HOST, port=settings.MAIL_PORT)
+ server.login(user=settings.MAIL_USERNAME, password=settings.MAIL_PASSWORD)
+ server.starttls()
+ server.send_message(message)
+ server.quit()
diff --git a/reddit-clone/reddit/utils/storage.py b/reddit-clone/reddit/utils/storage.py
new file mode 100644
index 00000000..d7ca389c
--- /dev/null
+++ b/reddit-clone/reddit/utils/storage.py
@@ -0,0 +1,22 @@
+from pathlib import Path
+from hashlib import sha256
+
+import aiofiles
+
+__all__ = ("save_file",)
+
+
+def generate_file_name(file_name: str) -> str:
+ """
+ Generates a secure file name for the given file.
+ """
+ return sha256(file_name.encode("utf-8")).hexdigest()
+
+
+async def save_file(file, path: Path) -> None:
+ """
+ Stores the file at the given file-path.
+ """
+ async with aiofiles.open(path, "w") as out:
+ await out.write(file)
+ await out.flush()
diff --git a/reddit-clone/reddit/utils/templates.py b/reddit-clone/reddit/utils/templates.py
new file mode 100644
index 00000000..567e1793
--- /dev/null
+++ b/reddit-clone/reddit/utils/templates.py
@@ -0,0 +1,16 @@
+from jinja2 import Environment, FileSystemLoader, select_autoescape
+
+__all__ = ("render_template",)
+
+environment = Environment(
+ loader=FileSystemLoader(searchpath="./templates"),
+ autoescape=select_autoescape(("html", "xml")),
+)
+
+
+def render_template(template_name: str, *args, **kwargs):
+ """
+ Renders the HTML template with the given variables.
+ """
+ template = environment.get_template(name=template_name)
+ return template.render(*args, **kwargs)
diff --git a/reddit-clone/setup.cfg b/reddit-clone/setup.cfg
new file mode 100644
index 00000000..a4fec64b
--- /dev/null
+++ b/reddit-clone/setup.cfg
@@ -0,0 +1,2 @@
+[mypy]
+plugins = sqlalchemy.ext.mypy.plugin,strawberry.ext.mypy_plugin