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 %} +
+
+
+
+ + + + +
+ © {{ now.year }} + {{ config.SITE_NAME }}. All rights reserved. +
+
+ + 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