├── tests ├── __init__.py ├── unit │ ├── __init__.py │ ├── core │ │ ├── __init__.py │ │ ├── todo │ │ │ ├── __init__.py │ │ │ ├── entities │ │ │ │ ├── __init__.py │ │ │ │ └── todo_item │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── update_todo_item_dto_test.py │ │ │ │ │ ├── create_todo_item_dto_test.py │ │ │ │ │ └── todo_item_test.py │ │ │ ├── conftest.py │ │ │ └── services │ │ │ │ └── todo_item_service_test.py │ │ ├── accounts │ │ │ ├── __init__.py │ │ │ ├── entities │ │ │ │ ├── __init__.py │ │ │ │ ├── user_registry_test.py │ │ │ │ ├── credentials_test.py │ │ │ │ └── user_test.py │ │ │ ├── services │ │ │ │ ├── __init__.py │ │ │ │ ├── hash_service_test.py │ │ │ │ ├── exceptions_test.py │ │ │ │ └── user_service_test.py │ │ │ └── conftest.py │ │ └── conftest.py │ ├── config │ │ ├── __init__.py │ │ └── environment_test.py │ └── conftest.py ├── utils │ ├── __init__.py │ ├── asserts.py │ ├── database.py │ └── auth.py ├── factories │ ├── __init__.py │ ├── utils.py │ ├── providers.py │ ├── model_factories.py │ └── entity_factories.py └── integration │ ├── __init__.py │ ├── api │ ├── __init__.py │ ├── routers │ │ ├── __init__.py │ │ ├── todo │ │ │ ├── __init__.py │ │ │ ├── conftest.py │ │ │ └── todo_item_test.py │ │ ├── account │ │ │ ├── __init__.py │ │ │ ├── conftest.py │ │ │ ├── user_test.py │ │ │ └── auth_test.py │ │ └── root_test.py │ └── conftest.py │ └── infra │ ├── __init__.py │ └── database │ ├── __init__.py │ └── repositories │ ├── __init__.py │ ├── conftest.py │ ├── user_repository_test.py │ └── todo_item_repository_test.py ├── todolist ├── config │ ├── __init__.py │ └── environment.py ├── core │ ├── __init__.py │ ├── accounts │ │ ├── __init__.py │ │ ├── entities │ │ │ ├── __init__.py │ │ │ └── user.py │ │ ├── services │ │ │ ├── __init__.py │ │ │ ├── hash_service.py │ │ │ ├── exceptions.py │ │ │ └── user_service.py │ │ └── protocols │ │ │ ├── __init__.py │ │ │ └── user_repo.py │ └── todo │ │ ├── entities │ │ ├── __init__.py │ │ └── todo_item.py │ │ ├── services │ │ ├── __init__.py │ │ └── todo_item_service.py │ │ └── protocols │ │ ├── __init__.py │ │ └── todo_item_repo.py ├── infra │ ├── __init__.py │ └── database │ │ ├── __init__.py │ │ ├── alembic │ │ ├── __init__.py │ │ ├── versions │ │ │ ├── __init__.py │ │ │ ├── 64f9e3f72798_todoitem_relationship_with_user.py │ │ │ ├── 5c48022e325d_create_user_table.py │ │ │ └── b6dab5e5cdac_initial_migration.py │ │ ├── README │ │ ├── script.py.mako │ │ └── env.py │ │ ├── repositories │ │ ├── __init__.py │ │ ├── user_repository.py │ │ └── todo_item_repository.py │ │ ├── models │ │ ├── __init__.py │ │ ├── user.py │ │ └── todo_item.py │ │ ├── sqlalchemy.py │ │ └── seeds.py ├── __main__.py ├── api │ ├── __init__.py │ ├── routers │ │ ├── todo │ │ │ ├── __init__.py │ │ │ └── todo_item.py │ │ ├── __init__.py │ │ ├── account │ │ │ ├── __init__.py │ │ │ ├── user.py │ │ │ └── auth.py │ │ └── root.py │ ├── container.py │ └── app.py └── __init__.py ├── scripts ├── pgsql-db │ └── init_db.sql └── __init__.py ├── .coveragerc ├── .flake8 ├── pytest.ini ├── python-fastapi-hex-todo.code-workspace ├── Dockerfile ├── LICENSE ├── mypy.ini ├── pyproject.toml ├── alembic.ini ├── docker-compose.yml ├── .gitignore ├── README.md └── poetry.lock /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/factories/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /todolist/config/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /todolist/core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /todolist/infra/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/integration/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/integration/api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/config/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/core/todo/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/integration/infra/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/core/accounts/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /todolist/core/accounts/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /todolist/infra/database/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/integration/api/routers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/core/todo/entities/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /todolist/core/accounts/entities/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /todolist/core/accounts/services/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /todolist/core/todo/entities/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /todolist/core/todo/services/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /todolist/infra/database/alembic/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/integration/api/routers/todo/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/integration/infra/database/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/core/accounts/entities/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/core/accounts/services/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /todolist/infra/database/repositories/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/integration/api/routers/account/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/core/todo/entities/todo_item/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /todolist/infra/database/alembic/versions/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/integration/infra/database/repositories/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /todolist/infra/database/alembic/README: -------------------------------------------------------------------------------- 1 | PostgreSQL database revisions. 2 | -------------------------------------------------------------------------------- /todolist/__main__.py: -------------------------------------------------------------------------------- 1 | from todolist import start_web_server 2 | 3 | 4 | start_web_server() 5 | -------------------------------------------------------------------------------- /todolist/api/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = ("init_app",) 2 | 3 | 4 | from todolist.api.app import init_app 5 | -------------------------------------------------------------------------------- /tests/factories/utils.py: -------------------------------------------------------------------------------- 1 | def make_many(factory, amount=3): 2 | return [factory() for _ in range(amount)] 3 | -------------------------------------------------------------------------------- /todolist/core/accounts/protocols/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = ("UserRepo",) 2 | 3 | 4 | from .user_repo import UserRepo 5 | -------------------------------------------------------------------------------- /todolist/core/todo/protocols/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = ("TodoItemRepo",) 2 | 3 | 4 | from .todo_item_repo import TodoItemRepo 5 | -------------------------------------------------------------------------------- /scripts/pgsql-db/init_db.sql: -------------------------------------------------------------------------------- 1 | CREATE DATABASE todolist_dev; 2 | CREATE DATABASE todolist_test; 3 | CREATE DATABASE todolist_homolog; 4 | CREATE DATABASE todolist_prod; 5 | -------------------------------------------------------------------------------- /todolist/infra/database/models/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = ( 2 | "TodoItem", 3 | "User", 4 | ) 5 | 6 | 7 | from .todo_item import TodoItem 8 | from .user import User 9 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | todolist/__main__.py 4 | todolist/__init__.py 5 | todolist/**/protocols/* 6 | todolist/infra/database/alembic/* 7 | todolist/infra/database/seeds.py 8 | todolist/infra/database/sqlalchemy.py 9 | -------------------------------------------------------------------------------- /tests/unit/config/environment_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from todolist.config.environment import Settings, get_settings 4 | 5 | 6 | @pytest.mark.unit 7 | def test_settings(): 8 | assert Settings() 9 | 10 | 11 | @pytest.mark.unit 12 | def test_initial_settings(): 13 | assert get_settings() 14 | -------------------------------------------------------------------------------- /tests/factories/providers.py: -------------------------------------------------------------------------------- 1 | from faker.providers import BaseProvider 2 | from faker import Faker 3 | 4 | from passlib.hash import argon2 5 | 6 | 7 | fake = Faker() 8 | 9 | 10 | class PasswordHashProvider(BaseProvider): 11 | def password_hash(self) -> str: 12 | return str(argon2.hash(fake.pystr())) 13 | -------------------------------------------------------------------------------- /todolist/api/routers/todo/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi.routing import APIRouter 2 | 3 | from . import todo_item 4 | 5 | 6 | def _build_router() -> APIRouter: 7 | rt = APIRouter() 8 | rt.include_router(todo_item.router, prefix="/item", tags=["Todo Item"]) 9 | return rt 10 | 11 | 12 | router = _build_router() 13 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | max-doc-length = 88 4 | max-complexity = 10 5 | count = True 6 | statistics = True 7 | ignore = 8 | B008, 9 | D100, 10 | D101, 11 | D102, 12 | D103, 13 | D104, 14 | D105, 15 | D106, 16 | D107, 17 | exclude = 18 | __pycache__, 19 | .git, 20 | .mypy_cache, 21 | -------------------------------------------------------------------------------- /tests/unit/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.fixture() 5 | def mock_function(mocker): 6 | return lambda name, return_value: mocker.MagicMock( 7 | name=name, return_value=return_value 8 | ) 9 | 10 | 11 | @pytest.fixture() 12 | def mock_module(mocker): 13 | return lambda name, spec: mocker.Mock(name=name, spec=spec) 14 | -------------------------------------------------------------------------------- /todolist/core/accounts/services/hash_service.py: -------------------------------------------------------------------------------- 1 | from passlib.context import CryptContext 2 | 3 | 4 | _context = CryptContext(schemes=["argon2"], deprecated="auto") 5 | 6 | 7 | def hash_(value: str) -> str: 8 | return str(_context.hash(value)) 9 | 10 | 11 | def verify(value: str, hash_: str) -> bool: 12 | return bool(_context.verify(value, hash_)) 13 | -------------------------------------------------------------------------------- /todolist/api/routers/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi.applications import FastAPI 2 | 3 | from todolist.api.routers import account, root, todo 4 | 5 | 6 | def register_routers(app: FastAPI) -> FastAPI: 7 | app.include_router(root.router) 8 | app.include_router(account.router, prefix="/account") 9 | app.include_router(todo.router, prefix="/todo") 10 | return app 11 | -------------------------------------------------------------------------------- /todolist/api/routers/account/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi.routing import APIRouter 2 | 3 | from . import auth, user 4 | 5 | 6 | def _build_router() -> APIRouter: 7 | rt = APIRouter() 8 | rt.include_router(auth.router, prefix="/oauth2", tags=["Auth"]) 9 | rt.include_router(user.router, prefix="/user", tags=["User"]) 10 | 11 | return rt 12 | 13 | 14 | router = _build_router() 15 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = 3 | "--strict-markers" 4 | "--cov-config=.coveragerc" 5 | "--cov=todolist" 6 | "--cov-report=term-missing" 7 | "--cov-report=html:cov_html" 8 | "--cov-branch" 9 | "--cov-fail-under=100" 10 | faulthandler_timeout = 11 | 5 12 | markers = 13 | integration: mark a test as an integration test 14 | unit: mark a test as an unit test 15 | -------------------------------------------------------------------------------- /todolist/infra/database/models/user.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.schema import Column, Table 2 | from sqlalchemy.types import Integer, Text 3 | 4 | from todolist.infra.database.sqlalchemy import metadata 5 | 6 | User = Table( 7 | "user", 8 | metadata, 9 | Column("id", Integer, primary_key=True), 10 | Column("email", Text(), unique=True, nullable=False), 11 | Column("password_hash", Text(), nullable=False), 12 | ) 13 | -------------------------------------------------------------------------------- /todolist/core/accounts/protocols/user_repo.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Protocol 2 | 3 | from todolist.core.accounts.entities.user import User 4 | 5 | 6 | class UserRepo(Protocol): 7 | async def persist(self, email: str, password_hash: str) -> User: 8 | ... 9 | 10 | async def fetch(self, id_: int) -> Optional[User]: 11 | ... 12 | 13 | async def fetch_by_email(self, email: str) -> Optional[User]: 14 | ... 15 | -------------------------------------------------------------------------------- /scripts/__init__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | from todolist import start_web_server 5 | from todolist.infra.database.seeds import run as run_seeds 6 | 7 | logging.basicConfig(level=logging.INFO) 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | def seeder(): 12 | logger.info("Initializing seeder...") 13 | asyncio.run(run_seeds()) 14 | logger.info("Seeder terminated successfully!") 15 | 16 | 17 | def web_server(): 18 | start_web_server() 19 | -------------------------------------------------------------------------------- /tests/integration/infra/database/repositories/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from tests.utils.database import clear_database 4 | from todolist.infra.database.sqlalchemy import ( 5 | connect_database, 6 | database, 7 | disconnect_database, 8 | ) 9 | 10 | 11 | @pytest.fixture(name="database") 12 | async def database_fixture(): 13 | with clear_database(): 14 | await connect_database() 15 | yield database 16 | await disconnect_database() 17 | -------------------------------------------------------------------------------- /tests/utils/asserts.py: -------------------------------------------------------------------------------- 1 | def assert_validation_error(len_, loc, type_, excinfo): 2 | def write_message(expected, gotten): 3 | return f"expected: '{expected}', got: '{gotten}'" 4 | 5 | errors = excinfo.value.errors() 6 | assert len(errors) == len_, write_message(len_, len(errors)) 7 | 8 | error, *_ = errors 9 | assert error["loc"] == (loc,), write_message(loc, error.get("loc", None)) 10 | assert error["type"] == type_, write_message(type_, error.get("type", None)) 11 | -------------------------------------------------------------------------------- /tests/integration/api/routers/root_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.integration 5 | def test_health_check(test_client, env_settings): 6 | with test_client: 7 | response = test_client.get("/status") 8 | assert response.status_code == 200 9 | assert response.json() == { 10 | "title": env_settings.WEB_APP_TITLE, 11 | "description": env_settings.WEB_APP_DESCRIPTION, 12 | "version": env_settings.WEB_APP_VERSION, 13 | "status": "OK", 14 | } 15 | -------------------------------------------------------------------------------- /tests/utils/database.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | 3 | from todolist.infra.database.sqlalchemy import init_database, metadata 4 | 5 | 6 | def _truncate_tables(): 7 | metadata.bind.execute( 8 | """TRUNCATE {} RESTART IDENTITY""".format( 9 | ",".join(f'"{table.name}"' for table in reversed(metadata.sorted_tables)) 10 | ) 11 | ) 12 | 13 | 14 | @contextmanager 15 | def clear_database(): 16 | init_database() 17 | _truncate_tables() 18 | yield 19 | _truncate_tables() 20 | -------------------------------------------------------------------------------- /todolist/__init__.py: -------------------------------------------------------------------------------- 1 | import uvicorn 2 | 3 | from todolist.config.environment import get_settings 4 | from todolist.api import init_app 5 | 6 | 7 | _SETTINGS = get_settings() 8 | 9 | 10 | web_app = init_app(_SETTINGS) 11 | 12 | 13 | def start_web_server() -> None: 14 | settings = get_settings() 15 | uvicorn.run( 16 | "todolist:web_app", 17 | host=settings.WEB_SERVER_HOST, 18 | port=settings.WEB_SERVER_PORT, 19 | reload=settings.WEB_SERVER_RELOAD, 20 | log_level=settings.LOG_LEVEL, 21 | ) 22 | -------------------------------------------------------------------------------- /tests/utils/auth.py: -------------------------------------------------------------------------------- 1 | from operator import attrgetter 2 | 3 | from todolist.config.environment import get_settings 4 | 5 | 6 | oauth2_instrospect_url = "/account/oauth2/instrospect" 7 | oauth2_token_url = "/account/oauth2/token" 8 | secret_key = attrgetter("JWT_SECRET_KEY")(get_settings()) 9 | 10 | 11 | def auth_headers(token): 12 | return {"Authorization": f"Bearer {token}"} 13 | 14 | 15 | def build_form_data(credentials): 16 | return { 17 | "grant_type": "password", 18 | "username": credentials.email, 19 | "password": credentials.password, 20 | } 21 | -------------------------------------------------------------------------------- /todolist/core/accounts/services/exceptions.py: -------------------------------------------------------------------------------- 1 | class EmailNotUniqueError(Exception): 2 | def __init__(self, email: str, msg="email already registered"): 3 | super().__init__(msg) 4 | self.msg = msg 5 | self.email = email 6 | 7 | def as_dict(self): 8 | return {"msg": self.msg, "email": self.email} 9 | 10 | 11 | class UserNotFoundError(Exception): 12 | def __init__(self, id_, msg="user not found"): 13 | super().__init__(msg) 14 | self.user_id = id_ 15 | self.msg = msg 16 | 17 | def as_dict(self): 18 | return {"msg": self.msg, "user_id": self.user_id} 19 | -------------------------------------------------------------------------------- /todolist/infra/database/alembic/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /todolist/core/accounts/entities/user.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, EmailStr, Field 2 | 3 | 4 | class Credentials(BaseModel): 5 | email: EmailStr 6 | password: str = Field(..., min_length=8, max_length=128) 7 | 8 | class Config: 9 | allow_mutation = False 10 | orm_mode = True 11 | 12 | 13 | class User(BaseModel): 14 | id: int 15 | email: EmailStr 16 | password_hash: str 17 | 18 | class Config: 19 | allow_mutation = False 20 | orm_mode = True 21 | 22 | 23 | class UserRegistry(BaseModel): 24 | id: int 25 | email: EmailStr 26 | 27 | class Config: 28 | allow_mutation = False 29 | orm_mode = True 30 | -------------------------------------------------------------------------------- /todolist/infra/database/models/todo_item.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.schema import CheckConstraint, Column, ForeignKey, Table 2 | from sqlalchemy.types import Boolean, Integer, String 3 | 4 | from todolist.infra.database.models.user import User 5 | from todolist.infra.database.sqlalchemy import metadata 6 | 7 | 8 | TodoItem = Table( 9 | "todo_item", 10 | metadata, 11 | Column("id", Integer, primary_key=True), 12 | Column("msg", String(100), nullable=False), 13 | Column("is_done", Boolean(), nullable=False), 14 | Column("user_id", Integer, ForeignKey(User.c.id), nullable=False), 15 | CheckConstraint("length(msg) >= 1 AND length(msg) <= 50", name="msg_length"), 16 | ) 17 | -------------------------------------------------------------------------------- /tests/integration/api/routers/account/conftest.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | 3 | import pytest 4 | from pytest_factoryboy import register 5 | 6 | from tests.factories.entity_factories import CredentialsFactory, UserFactory 7 | from tests.factories.utils import make_many 8 | 9 | 10 | FACTORIES = [ 11 | CredentialsFactory, 12 | UserFactory, 13 | ] 14 | 15 | for factory in FACTORIES: 16 | register(factory) 17 | 18 | 19 | @pytest.fixture() 20 | def many_credentials(credentials_factory): 21 | return partial(make_many, credentials_factory) 22 | 23 | 24 | @pytest.fixture() 25 | def user(user_factory): 26 | return user_factory() 27 | 28 | 29 | @pytest.fixture() 30 | def users(user_factory): 31 | return partial(make_many, user_factory) 32 | -------------------------------------------------------------------------------- /tests/factories/model_factories.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | from typing import Any, Dict, Iterable, Union 3 | 4 | from sqlalchemy.schema import Table 5 | 6 | from todolist.infra.database.models.todo_item import TodoItem 7 | from todolist.infra.database.models.user import User 8 | from todolist.infra.database.sqlalchemy import metadata 9 | 10 | 11 | ValuesType = Dict[str, Any] 12 | 13 | 14 | def insert_model(model: Table, values: Union[ValuesType, Iterable[ValuesType]]) -> None: 15 | query = model.insert() 16 | if isinstance(values, Dict): 17 | metadata.bind.execute(query, **values) 18 | else: 19 | metadata.bind.execute(query, list(values)) 20 | 21 | 22 | register_user = partial(insert_model, User) 23 | insert_todo_item = partial(insert_model, TodoItem) 24 | -------------------------------------------------------------------------------- /tests/unit/core/accounts/services/hash_service_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from todolist.core.accounts.services import hash_service 4 | 5 | 6 | @pytest.mark.unit 7 | def test_hash_returns_hashed_value(): 8 | value = "some value" 9 | result = hash_service.hash_(value) 10 | second_result = hash_service.hash_(value) 11 | 12 | assert result != value 13 | assert result != second_result 14 | 15 | 16 | @pytest.mark.unit 17 | def test_verify_valid_value(): 18 | value = "some value" 19 | result = hash_service.hash_(value) 20 | 21 | assert hash_service.verify(value, result) 22 | 23 | 24 | @pytest.mark.unit 25 | def test_verify_invalid_value(): 26 | value = "some value" 27 | result = hash_service.hash_("other value") 28 | 29 | assert not hash_service.verify(value, result) 30 | -------------------------------------------------------------------------------- /todolist/api/container.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Callable, cast 3 | 4 | from todolist.core.accounts.protocols import UserRepo 5 | from todolist.core.todo.protocols import TodoItemRepo 6 | from todolist.infra.database.repositories import todo_item_repository, user_repository 7 | 8 | 9 | @dataclass(frozen=True) 10 | class Dependencies: 11 | user_repo: UserRepo 12 | todo_item_repo: TodoItemRepo 13 | 14 | 15 | def _build_dependencies() -> Callable[[], Dependencies]: 16 | deps = Dependencies( 17 | user_repo=cast(UserRepo, user_repository), 18 | todo_item_repo=cast(TodoItemRepo, todo_item_repository), 19 | ) 20 | 21 | def fn() -> Dependencies: 22 | return deps 23 | 24 | return fn 25 | 26 | 27 | get_dependencies = _build_dependencies() 28 | -------------------------------------------------------------------------------- /tests/unit/core/conftest.py: -------------------------------------------------------------------------------- 1 | from asyncio import Future 2 | from functools import partial 3 | 4 | import pytest 5 | from pytest_factoryboy import register 6 | 7 | from tests.factories.entity_factories import UserRegistryFactory 8 | from tests.factories.utils import make_many 9 | 10 | 11 | FACTORIES = [UserRegistryFactory] 12 | 13 | for factory in FACTORIES: 14 | register(factory) 15 | 16 | 17 | @pytest.fixture(name="repo_fn_factory") 18 | def repo_fn_factory_fixture(mocker): 19 | return lambda name: mocker.MagicMock(name=name, return_value=Future()) 20 | 21 | 22 | @pytest.fixture() 23 | def user_registry(user_registry_factory): 24 | return user_registry_factory() 25 | 26 | 27 | @pytest.fixture() 28 | def user_registries(user_registry_factory): 29 | return partial(make_many, user_registry_factory) 30 | -------------------------------------------------------------------------------- /todolist/core/todo/entities/todo_item.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pydantic import BaseModel, Field 4 | 5 | MsgType = Field(..., min_length=3, max_length=100) 6 | OptionalMsgType = Field(None, min_length=3, max_length=100) 7 | 8 | 9 | class TodoItem(BaseModel): 10 | id: int 11 | msg: str = MsgType 12 | is_done: bool 13 | user_id: int 14 | 15 | class Config: 16 | allow_mutation = False 17 | orm_mode = True 18 | 19 | 20 | class CreateTodoItemDto(BaseModel): 21 | msg: str = MsgType 22 | is_done: bool = False 23 | 24 | class Config: 25 | allow_mutation = False 26 | 27 | 28 | class UpdateTodoItemDto(BaseModel): 29 | msg: Optional[str] = OptionalMsgType 30 | is_done: Optional[bool] 31 | 32 | class Config: 33 | allow_mutation = False 34 | -------------------------------------------------------------------------------- /tests/unit/core/accounts/services/exceptions_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from todolist.core.accounts.services.exceptions import ( 4 | EmailNotUniqueError, 5 | UserNotFoundError, 6 | ) 7 | 8 | 9 | @pytest.mark.unit 10 | def test_email_not_unique_error(): 11 | email = "some@email.com" 12 | msg = "some message" 13 | 14 | error = EmailNotUniqueError(email, msg) 15 | assert error.as_dict() == {"msg": msg, "email": email} 16 | 17 | with pytest.raises(EmailNotUniqueError): 18 | raise error 19 | 20 | 21 | @pytest.mark.unit 22 | def test_user_not_found_error(): 23 | id_ = 1 24 | msg = "some message" 25 | 26 | error = UserNotFoundError(id_, msg) 27 | assert error.as_dict() == {"msg": msg, "user_id": id_} 28 | 29 | with pytest.raises(UserNotFoundError): 30 | raise error 31 | -------------------------------------------------------------------------------- /todolist/config/environment.py: -------------------------------------------------------------------------------- 1 | from typing import Callable 2 | 3 | from dotenv import load_dotenv 4 | from pydantic import BaseSettings, PostgresDsn 5 | 6 | 7 | class Settings(BaseSettings): 8 | ENV: str 9 | PYTHONPATH: str 10 | LOG_LEVEL: str 11 | DATABASE_PG_URL: PostgresDsn 12 | JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int 13 | JWT_ALGORITHM: str 14 | JWT_SECRET_KEY: str 15 | WEB_APP_DEBUG: bool 16 | WEB_APP_DESCRIPTION: str 17 | WEB_APP_TITLE: str 18 | WEB_APP_VERSION: str 19 | WEB_SERVER_HOST: str 20 | WEB_SERVER_PORT: int 21 | WEB_SERVER_RELOAD: bool 22 | 23 | 24 | def _configure_initial_settings() -> Callable[[], Settings]: 25 | load_dotenv() 26 | settings = Settings() 27 | 28 | def fn() -> Settings: 29 | return settings 30 | 31 | return fn 32 | 33 | 34 | get_settings = _configure_initial_settings() 35 | -------------------------------------------------------------------------------- /tests/unit/core/accounts/conftest.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | 3 | import pytest 4 | from pytest_factoryboy import register 5 | 6 | from tests.factories.entity_factories import ( 7 | CredentialsFactory, 8 | UserFactory, 9 | ) 10 | from tests.factories.utils import make_many 11 | 12 | FACTORIES = [ 13 | CredentialsFactory, 14 | UserFactory, 15 | ] 16 | 17 | for factory in FACTORIES: 18 | register(factory) 19 | 20 | 21 | @pytest.fixture() 22 | def credentials(credentials_factory): 23 | return credentials_factory() 24 | 25 | 26 | @pytest.fixture() 27 | def many_credentials(credentials_factory): 28 | return partial(make_many, credentials_factory) 29 | 30 | 31 | @pytest.fixture() 32 | def user(user_factory): 33 | return user_factory() 34 | 35 | 36 | @pytest.fixture() 37 | def users(user_factory): 38 | return partial(make_many, user_factory) 39 | -------------------------------------------------------------------------------- /todolist/infra/database/alembic/versions/64f9e3f72798_todoitem_relationship_with_user.py: -------------------------------------------------------------------------------- 1 | """TodoItem relationship with User. 2 | 3 | Revision ID: 64f9e3f72798 4 | Revises: 5c48022e325d 5 | Create Date: 2020-04-21 21:50:55.662077 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = "64f9e3f72798" 13 | down_revision = "5c48022e325d" 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | op.add_column("todo_item", sa.Column("user_id", sa.Integer(), nullable=False)) 20 | op.create_foreign_key( 21 | op.f("fk_todo_item_user_id_user"), "todo_item", "user", ["user_id"], ["id"] 22 | ) 23 | 24 | 25 | def downgrade(): 26 | op.drop_constraint( 27 | op.f("fk_todo_item_user_id_user"), "todo_item", type_="foreignkey" 28 | ) 29 | op.drop_column("todo_item", "user_id") 30 | -------------------------------------------------------------------------------- /todolist/infra/database/alembic/versions/5c48022e325d_create_user_table.py: -------------------------------------------------------------------------------- 1 | """Create user table. 2 | 3 | Revision ID: 5c48022e325d 4 | Revises: b6dab5e5cdac 5 | Create Date: 2020-04-13 00:02:25.343492 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "5c48022e325d" 14 | down_revision = "b6dab5e5cdac" 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | op.create_table( 21 | "user", 22 | sa.Column("id", sa.Integer(), nullable=False), 23 | sa.Column("email", sa.Text(), nullable=False), 24 | sa.Column("password_hash", sa.Text(), nullable=False), 25 | sa.PrimaryKeyConstraint("id", name=op.f("pk_user")), 26 | sa.UniqueConstraint("email", name=op.f("uq_user_email")), 27 | ) 28 | 29 | 30 | def downgrade(): 31 | op.drop_table("user") 32 | -------------------------------------------------------------------------------- /python-fastapi-hex-todo.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | } 6 | ], 7 | "settings": { 8 | "editor.tabSize": 4, 9 | "editor.rulers": [88, 120], 10 | "files.exclude": { 11 | ".mypy_cache/": true, 12 | ".pytest_cache/": true, 13 | "*.egg-info": true, 14 | }, 15 | "python.poetryPath": "poetry", 16 | "python.venvPath": ".venv", 17 | "python.pythonPath": ".venv/bin/python3", 18 | "python.analysis.openFilesOnly": false, 19 | "python.testing.pytestEnabled": true, 20 | "python.testing.pytestPath": ".venv/bin/pytest", 21 | "python.formatting.provider": "black", 22 | "python.linting.enabled": true, 23 | "python.linting.lintOnSave": true, 24 | "python.linting.pylintEnabled": false, 25 | "python.linting.mypyEnabled": true, 26 | "python.linting.mypyArgs": [ 27 | "--config-file=mypy.ini", 28 | ], 29 | "python.linting.flake8Enabled": true, 30 | "python.linting.flake8Args": [ 31 | "--config=.flake8", 32 | ], 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /todolist/infra/database/alembic/versions/b6dab5e5cdac_initial_migration.py: -------------------------------------------------------------------------------- 1 | """Initial migration. 2 | 3 | Revision ID: b6dab5e5cdac 4 | Revises: None 5 | Create Date: 2020-03-15 16:18:16.152717 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "b6dab5e5cdac" 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | op.create_table( 21 | "todo_item", 22 | sa.Column("id", sa.Integer(), nullable=False), 23 | sa.Column("msg", sa.String(length=100), nullable=False), 24 | sa.Column("is_done", sa.Boolean(), nullable=False), 25 | sa.CheckConstraint( 26 | "length(msg) >= 1 AND length(msg) <= 50", 27 | name=op.f("ck_todo_item_msg_length"), 28 | ), 29 | sa.PrimaryKeyConstraint("id", name=op.f("pk_todo_item")), 30 | ) 31 | 32 | 33 | def downgrade(): 34 | op.drop_table("todo_item") 35 | -------------------------------------------------------------------------------- /todolist/core/todo/protocols/todo_item_repo.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable, Optional, Protocol 2 | 3 | from todolist.core.accounts.entities.user import UserRegistry 4 | from todolist.core.todo.entities.todo_item import ( 5 | CreateTodoItemDto, 6 | TodoItem, 7 | UpdateTodoItemDto, 8 | ) 9 | 10 | 11 | class TodoItemRepo(Protocol): 12 | async def delete(self, user: UserRegistry, id_: int) -> bool: 13 | ... 14 | 15 | async def fetch(self, user: UserRegistry, id_: int) -> Optional[TodoItem]: 16 | ... 17 | 18 | async def fetch_all_by_user(self, user: UserRegistry) -> Iterable[TodoItem]: 19 | ... 20 | 21 | async def persist(self, user: UserRegistry, dto: CreateTodoItemDto) -> TodoItem: 22 | ... 23 | 24 | async def replace( 25 | self, user: UserRegistry, dto: CreateTodoItemDto, id_: int, 26 | ) -> Optional[TodoItem]: 27 | ... 28 | 29 | async def update( 30 | self, user: UserRegistry, dto: UpdateTodoItemDto, id_: int, 31 | ) -> Optional[TodoItem]: 32 | ... 33 | -------------------------------------------------------------------------------- /todolist/infra/database/repositories/user_repository.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from todolist.core.accounts.entities.user import User 4 | from todolist.infra.database.models.user import User as Model 5 | from todolist.infra.database.sqlalchemy import database 6 | 7 | 8 | async def fetch(id_: int) -> Optional[User]: 9 | query = Model.select().where(Model.c.id == id_) 10 | result = await database.fetch_one(query) 11 | 12 | return User.parse_obj(dict(result)) if result else None 13 | 14 | 15 | async def fetch_by_email(email: str) -> Optional[User]: 16 | query = Model.select().where(Model.c.email == email) 17 | result = await database.fetch_one(query) 18 | 19 | return User.parse_obj(dict(result)) if result else None 20 | 21 | 22 | async def persist(email: str, password_hash: str) -> User: 23 | values = {"email": email, "password_hash": password_hash} 24 | query = Model.insert().values(**values) 25 | 26 | last_record_id = await database.execute(query) 27 | return User.parse_obj({**values, "id": last_record_id}) 28 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Python 3.8 Release 2 | FROM python:3.8.1-alpine as development 3 | 4 | # Maintainer Information 5 | LABEL maintainer="Giovanni Armane " 6 | LABEL license="MIT" 7 | 8 | # Set environment variables 9 | ENV PYTHON_VERSION=3.8.1 \ 10 | APP_PATH=/home/python/app \ 11 | POETRY_VIRTUALENVS_CREATE=false \ 12 | PATH=/home/python/.local/lib/python3.8/site-packages:/usr/local/bin:/home/python:/home/python/app/bin:$PATH 13 | 14 | # Install and configure dependencies 15 | RUN apk add --no-cache build-base libressl-dev musl-dev libffi-dev postgresql-dev 16 | RUN pip install --no-cache-dir poetry 17 | 18 | # Configure user, groups and working directory for application 19 | RUN adduser -u 1000 -D python && \ 20 | mkdir -p /home/python/app 21 | 22 | # Set workdir 23 | WORKDIR /home/python/app 24 | 25 | # Copy project file and pre-install 26 | COPY pyproject.toml . 27 | COPY poetry.lock . 28 | RUN poetry install 29 | 30 | # Expose ports 31 | EXPOSE 5000 32 | 33 | # Declare volumes 34 | VOLUME [ "/home/python/app" ] 35 | 36 | # Run the app 37 | CMD ["ash"] 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Giovanni Armane 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /todolist/core/todo/services/todo_item_service.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable, Optional, Union 2 | 3 | from todolist.core.accounts.entities.user import UserRegistry 4 | from todolist.core.todo.entities.todo_item import ( 5 | CreateTodoItemDto, 6 | TodoItem, 7 | UpdateTodoItemDto, 8 | ) 9 | from todolist.core.todo.protocols import TodoItemRepo 10 | 11 | 12 | async def create( 13 | repo: TodoItemRepo, user: UserRegistry, dto: CreateTodoItemDto, 14 | ) -> TodoItem: 15 | return await repo.persist(user, dto) 16 | 17 | 18 | async def delete(repo: TodoItemRepo, user: UserRegistry, id_: int) -> bool: 19 | return await repo.delete(user, id_) 20 | 21 | 22 | async def get(repo: TodoItemRepo, user: UserRegistry, id_: int) -> Optional[TodoItem]: 23 | return await repo.fetch(user, id_) 24 | 25 | 26 | async def get_all(repo: TodoItemRepo, user: UserRegistry) -> Iterable[TodoItem]: 27 | return await repo.fetch_all_by_user(user) 28 | 29 | 30 | async def update( 31 | repo: TodoItemRepo, 32 | user: UserRegistry, 33 | dto: Union[CreateTodoItemDto, UpdateTodoItemDto], 34 | id_: int, 35 | ) -> Optional[TodoItem]: 36 | if isinstance(dto, CreateTodoItemDto): 37 | return await repo.replace(user, dto, id_) 38 | else: 39 | return await repo.update(user, dto, id_) 40 | -------------------------------------------------------------------------------- /todolist/api/routers/root.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | from fastapi import status 4 | from fastapi.routing import APIRouter 5 | from pydantic import BaseModel, Field 6 | 7 | from todolist.config.environment import get_settings 8 | 9 | 10 | router = APIRouter() 11 | 12 | 13 | class StatusEnum(str, Enum): 14 | OK = "OK" 15 | FAILURE = "FAILURE" 16 | CRITICAL = "CRITICAL" 17 | UNKNOWN = "UNKNOWN" 18 | 19 | 20 | class HealthCheck(BaseModel): 21 | title: str = Field(..., description="API title") 22 | description: str = Field(..., description="Brief description of the API") 23 | version: str = Field(..., description="API semver version number") 24 | status: StatusEnum = Field(..., description="API current status") 25 | 26 | 27 | @router.get( 28 | "/status", 29 | response_model=HealthCheck, 30 | status_code=status.HTTP_200_OK, 31 | tags=["Health Check"], 32 | summary="Performs health check", 33 | description="Performs health check and returns information about running service.", 34 | ) 35 | def health_check(): 36 | settings = get_settings() 37 | return { 38 | "title": settings.WEB_APP_TITLE, 39 | "description": settings.WEB_APP_DESCRIPTION, 40 | "version": settings.WEB_APP_VERSION, 41 | "status": StatusEnum.OK, 42 | } 43 | -------------------------------------------------------------------------------- /todolist/api/app.py: -------------------------------------------------------------------------------- 1 | from fastapi.applications import FastAPI 2 | from toolz import pipe 3 | 4 | from todolist.api.routers import register_routers as register_routers 5 | from todolist.config.environment import Settings 6 | from todolist.infra.database.sqlalchemy import connect_database, disconnect_database 7 | from todolist.infra.database.sqlalchemy import init_database as init_pgsql_db 8 | 9 | 10 | def create_instance(settings: Settings) -> FastAPI: 11 | return FastAPI( 12 | debug=settings.WEB_APP_DEBUG, 13 | title=settings.WEB_APP_TITLE, 14 | description=settings.WEB_APP_DESCRIPTION, 15 | version=settings.WEB_APP_VERSION, 16 | ) 17 | 18 | 19 | def init_databases(app: FastAPI) -> FastAPI: 20 | init_pgsql_db() 21 | return app 22 | 23 | 24 | def register_events(app: FastAPI) -> FastAPI: 25 | app.on_event("startup")(connect_database) 26 | app.on_event("shutdown")(disconnect_database) 27 | 28 | return app 29 | 30 | 31 | def register_middlewares(app: FastAPI) -> FastAPI: 32 | return app 33 | 34 | 35 | def init_app(settings: Settings) -> FastAPI: 36 | app: FastAPI = pipe( 37 | settings, 38 | create_instance, 39 | init_databases, 40 | register_events, 41 | register_middlewares, 42 | register_routers, 43 | ) 44 | return app 45 | -------------------------------------------------------------------------------- /tests/integration/api/routers/todo/conftest.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | 3 | import pytest 4 | from pytest_factoryboy import register 5 | 6 | from tests.factories.entity_factories import ( 7 | CreateTodoItemDtoFactory, 8 | TodoItemFactory, 9 | UpdateTodoItemDtoFactory, 10 | ) 11 | from tests.factories.utils import make_many 12 | 13 | FACTORIES = [ 14 | CreateTodoItemDtoFactory, 15 | TodoItemFactory, 16 | UpdateTodoItemDtoFactory, 17 | ] 18 | 19 | for factory in FACTORIES: 20 | register(factory) 21 | 22 | 23 | @pytest.fixture() 24 | def create_todo_item_dto(create_todo_item_dto_factory): 25 | return create_todo_item_dto_factory() 26 | 27 | 28 | @pytest.fixture() 29 | def create_todo_item_dtos(create_todo_item_dto_factory): 30 | return partial(make_many, create_todo_item_dto_factory) 31 | 32 | 33 | @pytest.fixture() 34 | def todo_item(todo_item_factory): 35 | return todo_item_factory() 36 | 37 | 38 | @pytest.fixture() 39 | def todo_items(todo_item_factory): 40 | return partial(make_many, todo_item_factory) 41 | 42 | 43 | @pytest.fixture() 44 | def update_todo_item_dto(update_todo_item_dto_factory): 45 | return update_todo_item_dto_factory() 46 | 47 | 48 | @pytest.fixture() 49 | def update_todo_item_dtos(update_todo_item_dto_factory): 50 | return partial(make_many, update_todo_item_dto_factory) 51 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | # Global options 2 | [mypy] 3 | python_version = 3.8 4 | pretty = true 5 | follow_imports = normal 6 | namespace_packages = true 7 | show_column_numbers = true 8 | show_error_codes = true 9 | allow_redefinition = false 10 | check_untyped_defs = true 11 | disallow_any_generics = true 12 | implicit_reexport = false 13 | strict_optional = true 14 | strict_equality = true 15 | warn_no_return = true 16 | warn_redundant_casts = true 17 | warn_return_any = true 18 | warn_unused_configs = true 19 | warn_unused_ignores = true 20 | warn_unreachable = true 21 | plugins = pydantic.mypy 22 | 23 | [pydantic-mypy] 24 | warn_untyped_fields = true 25 | 26 | [mypy-alembic.*] 27 | ignore_missing_imports = true 28 | 29 | [mypy-asyncpg.*] 30 | ignore_missing_imports = true 31 | 32 | [mypy-citext.*] 33 | ignore_missing_imports = true 34 | 35 | [mypy-factory.*] 36 | ignore_missing_imports = true 37 | 38 | [mypy-faker.*] 39 | ignore_missing_imports = true 40 | 41 | [mypy-fastapi.*] 42 | ignore_missing_imports = true 43 | 44 | [mypy-passlib.*] 45 | ignore_missing_imports = true 46 | 47 | [mypy-pytest.*] 48 | ignore_missing_imports = true 49 | 50 | [mypy-pytest_factoryboy.*] 51 | ignore_missing_imports = true 52 | 53 | [mypy-sqlalchemy.*] 54 | ignore_missing_imports = true 55 | 56 | [mypy-toolz.*] 57 | ignore_missing_imports = true 58 | 59 | [mypy-uvicorn.*] 60 | ignore_missing_imports = true 61 | -------------------------------------------------------------------------------- /tests/unit/core/todo/conftest.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | 3 | import pytest 4 | from pytest_factoryboy import register 5 | 6 | from tests.factories.entity_factories import ( 7 | CreateTodoItemDtoFactory, 8 | TodoItemFactory, 9 | UpdateTodoItemDtoFactory, 10 | ) 11 | from tests.factories.utils import make_many 12 | 13 | FACTORIES = [ 14 | CreateTodoItemDtoFactory, 15 | TodoItemFactory, 16 | UpdateTodoItemDtoFactory, 17 | ] 18 | 19 | for factory in FACTORIES: 20 | register(factory) 21 | 22 | 23 | @pytest.fixture() 24 | def create_todo_item_dto(create_todo_item_dto_factory): 25 | return create_todo_item_dto_factory() 26 | 27 | 28 | @pytest.fixture() 29 | def create_todo_item_dtos(create_todo_item_dto_factory): 30 | return partial(make_many, create_todo_item_dto_factory) 31 | 32 | 33 | @pytest.fixture() 34 | def todo_item(todo_item_factory): 35 | return todo_item_factory() 36 | 37 | 38 | @pytest.fixture() 39 | def todo_items(todo_item_factory): 40 | return partial(make_many, todo_item_factory) 41 | 42 | 43 | @pytest.fixture() 44 | def update_todo_item_dto(update_todo_item_dto_factory): 45 | return update_todo_item_dto_factory() 46 | 47 | 48 | @pytest.fixture() 49 | def update_todo_item_dtos(update_todo_item_dto_factory): 50 | return partial(make_many, update_todo_item_dto_factory) 51 | 52 | 53 | @pytest.fixture() 54 | def user_registry(user_registry_factory): 55 | return user_registry_factory() 56 | -------------------------------------------------------------------------------- /todolist/infra/database/sqlalchemy.py: -------------------------------------------------------------------------------- 1 | from contextlib import asynccontextmanager 2 | 3 | import databases 4 | from sqlalchemy.engine import create_engine 5 | from sqlalchemy.schema import MetaData 6 | 7 | from todolist.config.environment import get_settings 8 | 9 | _SETTINGS = get_settings() 10 | 11 | 12 | database = databases.Database(_SETTINGS.DATABASE_PG_URL) 13 | metadata = MetaData( 14 | naming_convention={ 15 | "ix": "ix_%(column_0_label)s", 16 | "uq": "uq_%(table_name)s_%(column_0_name)s", 17 | "ck": "ck_%(table_name)s_%(constraint_name)s", 18 | "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", 19 | "pk": "pk_%(table_name)s", 20 | } 21 | ) 22 | 23 | 24 | @asynccontextmanager 25 | async def database_context(): 26 | await connect_database() 27 | yield database 28 | await disconnect_database() 29 | 30 | 31 | async def connect_database(): 32 | await database.connect() 33 | 34 | 35 | async def disconnect_database(): 36 | await database.disconnect() 37 | 38 | 39 | def init_database() -> None: 40 | import todolist.infra.database.models # noqa: F401 41 | 42 | metadata.bind = create_engine(_SETTINGS.DATABASE_PG_URL) 43 | 44 | 45 | async def truncate_database() -> None: 46 | await database.execute( 47 | """TRUNCATE {} RESTART IDENTITY""".format( 48 | ",".join(f'"{table.name}"' for table in reversed(metadata.sorted_tables)) 49 | ) 50 | ) 51 | -------------------------------------------------------------------------------- /todolist/api/routers/account/user.py: -------------------------------------------------------------------------------- 1 | from fastapi.exceptions import HTTPException 2 | from fastapi.responses import JSONResponse # type: ignore 3 | from fastapi.routing import APIRouter 4 | from pydantic import BaseModel 5 | 6 | from todolist.api.container import get_dependencies 7 | from todolist.core.accounts.entities.user import Credentials, UserRegistry 8 | from todolist.core.accounts.services import user_service 9 | from todolist.core.accounts.services.exceptions import EmailNotUniqueError 10 | from todolist.infra.database.sqlalchemy import database 11 | 12 | 13 | repo = get_dependencies().user_repo 14 | router = APIRouter(default_response_class=JSONResponse) 15 | 16 | 17 | # View Models 18 | class EmailNotUniqueResponse(BaseModel): 19 | class Detail(BaseModel): 20 | msg: str 21 | email: str 22 | 23 | detail: Detail 24 | 25 | 26 | # Handlers 27 | @router.post( 28 | "", 29 | status_code=201, 30 | response_model=UserRegistry, 31 | responses={ 32 | 201: {"description": "User registered", "model": UserRegistry}, 33 | 409: { 34 | "description": "User already registered", 35 | "model": EmailNotUniqueResponse, 36 | }, 37 | }, 38 | ) 39 | @database.transaction() 40 | async def register_user(response: JSONResponse, credentials: Credentials): 41 | try: 42 | return await user_service.register(repo, credentials) 43 | except EmailNotUniqueError as err: 44 | raise HTTPException(409, detail=err.as_dict()) 45 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "todolist" 3 | version = "1.0.0" 4 | description = "TODO aplication made with Python's FastAPI framework and Hexagonal Architecture" 5 | authors = ["Giovanni Armane "] 6 | license = "MIT" 7 | 8 | [tool.poetry.dependencies] 9 | python = "^3.8" 10 | alembic = "^1.4.1" 11 | argon2-cffi = "^19.2.0" 12 | databases = {version = "^0.2.6", extras = ["postgresql"]} 13 | email_validator = "^1.0.5" 14 | fastapi = {extras = ["email_validator", "requests"], version = "^0.52.0"} 15 | passlib = "^1.7.2" 16 | pyjwt = "^1.7.1" 17 | python-multipart = "^0.0.5" 18 | toolz = "^0.10.0" 19 | uvicorn = "^0.11.3" 20 | 21 | [tool.poetry.dev-dependencies] 22 | black = "^19.10b0" 23 | factory_boy = "^2.12.0" 24 | Faker = "^4.0.1" 25 | flake8 = "^3.7.9" 26 | flake8-black = "^0.1.1" 27 | flake8-broken-line = "^0.1.1" 28 | flake8-bugbear = "^20.1.4" 29 | flake8-builtins = "^1.4.2" 30 | flake8-comprehensions = "^3.2.2" 31 | flake8-docstrings = "^1.5.0" 32 | flake8-eradicate = "^0.2.4" 33 | flake8-pytest-style = "^0.2.0" 34 | mccabe = "^0.6.1" 35 | mypy = "^0.761" 36 | pep8-naming = "^0.9.1" 37 | pytest = "^5.3.5" 38 | pytest-asyncio = "^0.10.0" 39 | pytest-cov = "^2.8.1" 40 | pytest-factoryboy = "^2.0.3" 41 | pytest-mock = "^2.0.0" 42 | pytest-sugar = "^0.9.2" 43 | python-dotenv = "^0.12.0" 44 | 45 | [tool.poetry.scripts] 46 | seeder = "scripts:seeder" 47 | web_server = "scripts:web_server" 48 | 49 | [build-system] 50 | requires = ["poetry>=0.12"] 51 | build-backend = "poetry.masonry.api" 52 | -------------------------------------------------------------------------------- /todolist/core/accounts/services/user_service.py: -------------------------------------------------------------------------------- 1 | from typing import Awaitable, Callable, Optional 2 | 3 | from todolist.core.accounts.entities.user import Credentials, User, UserRegistry 4 | from todolist.core.accounts.protocols import UserRepo 5 | from todolist.core.accounts.services import hash_service 6 | from todolist.core.accounts.services.exceptions import ( 7 | EmailNotUniqueError, 8 | UserNotFoundError, 9 | ) 10 | 11 | PersistUserFn = Callable[[str, str], Awaitable[User]] 12 | FetchUserById = Callable[[int], Awaitable[Optional[User]]] 13 | 14 | 15 | async def get_by_credentials( 16 | repo: UserRepo, credentials: Credentials, 17 | ) -> Optional[UserRegistry]: 18 | user = await repo.fetch_by_email(credentials.email.lower()) 19 | 20 | if not user: 21 | return None 22 | 23 | password = credentials.password 24 | password_hash = user.password_hash 25 | 26 | if not hash_service.verify(password, password_hash): 27 | return None 28 | 29 | return UserRegistry(**user.dict()) 30 | 31 | 32 | async def get_by_id(repo: UserRepo, id_: int) -> Optional[UserRegistry]: 33 | user = await repo.fetch(id_) 34 | return UserRegistry(**user.dict()) if user else None 35 | 36 | 37 | async def get_by_id_or_raise(repo: UserRepo, id_: int) -> UserRegistry: 38 | user = await get_by_id(repo, id_) 39 | if not user: 40 | raise UserNotFoundError(id_) 41 | return user 42 | 43 | 44 | async def register(repo: UserRepo, credentials: Credentials) -> UserRegistry: 45 | email = credentials.email.lower() 46 | 47 | user = await repo.fetch_by_email(email) 48 | if user: 49 | raise EmailNotUniqueError(email) 50 | 51 | password_hash = hash_service.hash_(credentials.password) 52 | 53 | user = await repo.persist(email, password_hash) 54 | return UserRegistry(**user.dict()) 55 | -------------------------------------------------------------------------------- /tests/factories/entity_factories.py: -------------------------------------------------------------------------------- 1 | import factory 2 | 3 | from tests.factories.providers import PasswordHashProvider 4 | from todolist.core.accounts.entities.user import Credentials, User, UserRegistry 5 | from todolist.core.todo.entities.todo_item import ( 6 | CreateTodoItemDto, 7 | TodoItem, 8 | UpdateTodoItemDto, 9 | ) 10 | 11 | 12 | # Register providers 13 | providers = [PasswordHashProvider] 14 | 15 | for provider in providers: 16 | factory.Faker.add_provider(provider) 17 | 18 | 19 | # User 20 | class CredentialsFactory(factory.Factory): 21 | class Meta: 22 | model = Credentials 23 | 24 | email = factory.Faker("email") 25 | password = factory.Faker("password", length=16) 26 | 27 | 28 | class UserFactory(factory.Factory): 29 | class Meta: 30 | model = User 31 | 32 | id = factory.Faker("pyint", min_value=0) # noqa: A003 33 | email = factory.Faker("email") 34 | password_hash = factory.Faker("password_hash") 35 | 36 | 37 | class UserRegistryFactory(factory.Factory): 38 | class Meta: 39 | model = UserRegistry 40 | 41 | id = factory.Faker("pyint", min_value=0) # noqa: A003 42 | email = factory.Faker("email") 43 | 44 | 45 | # TodoItem 46 | class TodoItemFactory(factory.Factory): 47 | class Meta: 48 | model = TodoItem 49 | 50 | id = factory.Faker("pyint", min_value=0) # noqa: A003 51 | msg = factory.Faker("pystr", min_chars=3, max_chars=50) 52 | is_done = factory.Faker("pybool") 53 | user_id = factory.Faker("pyint", min_value=0) 54 | 55 | 56 | class CreateTodoItemDtoFactory(factory.Factory): 57 | class Meta: 58 | model = CreateTodoItemDto 59 | 60 | msg = factory.Faker("pystr", min_chars=3, max_chars=50) 61 | is_done = factory.Faker("pybool") 62 | 63 | 64 | class UpdateTodoItemDtoFactory(factory.Factory): 65 | class Meta: 66 | model = UpdateTodoItemDto 67 | 68 | msg = factory.Faker("pystr", min_chars=3, max_chars=50) 69 | is_done = factory.Faker("pybool") 70 | -------------------------------------------------------------------------------- /tests/integration/api/conftest.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | 3 | import pytest 4 | from fastapi.testclient import TestClient # type: ignore 5 | from pytest_factoryboy import register 6 | 7 | from tests.factories.entity_factories import CredentialsFactory 8 | from tests.factories.model_factories import register_user 9 | from tests.utils.auth import build_form_data, oauth2_token_url 10 | from tests.utils.database import clear_database 11 | from todolist.config.environment import get_settings 12 | from todolist.core.accounts.entities.user import User 13 | from todolist.core.accounts.services import hash_service 14 | from todolist.api import init_app 15 | 16 | 17 | LoggedUser = namedtuple("LoggedUser", ["user", "access_token"]) 18 | 19 | 20 | FACTORIES = [CredentialsFactory] 21 | 22 | for factory in FACTORIES: 23 | register(factory) 24 | 25 | 26 | @pytest.fixture(name="env_settings") 27 | def env_settings(): 28 | return get_settings() 29 | 30 | 31 | @pytest.fixture(name="web_app") 32 | def web_app_fixture(env_settings): 33 | return init_app(env_settings) 34 | 35 | 36 | @pytest.fixture(name="test_client") 37 | def test_client_fixture(web_app): 38 | with clear_database(): 39 | yield TestClient(web_app) 40 | 41 | 42 | @pytest.fixture(name="credentials") 43 | def credentials_fixture(credentials_factory): 44 | return credentials_factory() 45 | 46 | 47 | @pytest.fixture() 48 | def logged_user(test_client, credentials): 49 | id_ = 1 50 | email = credentials.email 51 | password_hash = hash_service.hash_(credentials.password) 52 | 53 | register_user( 54 | {"id": id_, "email": credentials.email, "password_hash": password_hash} 55 | ) 56 | with test_client as client: 57 | response = client.post(oauth2_token_url, data=build_form_data(credentials)) 58 | body = response.json() 59 | return LoggedUser( 60 | User(id=id_, email=email, password_hash=password_hash), 61 | body["access_token"], 62 | ) 63 | -------------------------------------------------------------------------------- /tests/integration/api/routers/account/user_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from tests.factories.model_factories import register_user 4 | 5 | 6 | @pytest.mark.integration 7 | class TestRegister: 8 | def test_success(self, test_client, credentials): 9 | with test_client as client: 10 | response = client.post("/account/user", json=credentials.dict()) 11 | data, status_code = response.json(), response.status_code 12 | assert status_code == 201 13 | assert data == { 14 | "id": 1, 15 | "email": credentials.email, 16 | } 17 | 18 | def test_validation_error(self, test_client): 19 | with test_client as client: 20 | response = client.post("/account/user", json={}) 21 | data, status_code = response.json(), response.status_code 22 | assert status_code == 422 23 | assert data == { 24 | "detail": [ 25 | { 26 | "loc": ["body", "credentials", "email"], 27 | "msg": "field required", 28 | "type": "value_error.missing", 29 | }, 30 | { 31 | "loc": ["body", "credentials", "password"], 32 | "msg": "field required", 33 | "type": "value_error.missing", 34 | }, 35 | ] 36 | } 37 | 38 | def test_conflict(self, test_client, user, credentials): 39 | with test_client as client: 40 | email = credentials.email 41 | register_user({**user.dict(), "email": email}) 42 | 43 | response = client.post("/account/user", json=credentials.dict()) 44 | data, status_code = response.json(), response.status_code 45 | 46 | assert status_code == 409 47 | assert data == { 48 | "detail": {"msg": "email already registered", "email": email}, 49 | } 50 | -------------------------------------------------------------------------------- /todolist/infra/database/seeds.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Any, Dict, Iterable 3 | 4 | from databases import Database 5 | from sqlalchemy.schema import Table 6 | 7 | from todolist.core.accounts.services.hash_service import hash_ 8 | from todolist.infra.database.models.todo_item import TodoItem 9 | from todolist.infra.database.models.user import User 10 | from todolist.infra.database.sqlalchemy import ( 11 | database_context, 12 | init_database, 13 | truncate_database, 14 | ) 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | 19 | async def _populate_table( 20 | db: Database, table: Table, values: Iterable[Dict[str, Any]], 21 | ): 22 | name: str = table.name 23 | query = table.insert() 24 | 25 | logger.info(f"Seeding table {name}") 26 | await db.execute_many(query, list(values)) 27 | logger.info(f"Seeded table {name} successfully") 28 | 29 | 30 | async def _populate_user(db: Database) -> None: 31 | values = [ 32 | {"email": "john.doe@gmail.com", "password_hash": hash_("dev@1234")}, 33 | {"email": "jane.doe@gmail.com", "password_hash": hash_("dev2@1234")}, 34 | {"email": "mark.fisher@yahoo.com", "password_hash": hash_("dev3@1234")}, 35 | {"email": "ann.tobias@outlook.com", "password_hash": hash_("dev4@1234")}, 36 | ] 37 | await _populate_table(db, User, values) 38 | for index, _ in enumerate(values): 39 | await _populate_todo_item(db, index + 1) 40 | 41 | 42 | async def _populate_todo_item(db: Database, user_id: int) -> None: 43 | values = [ 44 | {"msg": "Program new awesome web app", "is_done": True, "user_id": user_id}, 45 | {"msg": "Play videogames", "is_done": True, "user_id": user_id}, 46 | {"msg": "Wash dishes", "is_done": False, "user_id": user_id}, 47 | {"msg": "Write blog post", "is_done": False, "user_id": user_id}, 48 | ] 49 | await _populate_table(db, TodoItem, values) 50 | 51 | 52 | async def run() -> None: 53 | logger.info("Initializing databases") 54 | init_database() 55 | async with database_context() as database: 56 | logger.info("Truncating database") 57 | await truncate_database() 58 | logger.info("Populating database") 59 | for fn in [_populate_user]: 60 | await fn(database) 61 | logger.info("Finished populating PostgreSQL database") 62 | -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = todolist/infra/database/alembic 6 | 7 | # template used to generate migration files 8 | # file_template = %%(rev)s_%%(slug)s 9 | 10 | # timezone to use when rendering the date 11 | # within the migration file as well as the filename. 12 | # string value is passed to dateutil.tz.gettz() 13 | # leave blank for localtime 14 | # timezone = 15 | 16 | # max length of characters to apply to the 17 | # "slug" field 18 | # truncate_slug_length = 40 19 | 20 | # set to 'true' to run the environment during 21 | # the 'revision' command, regardless of autogenerate 22 | # revision_environment = false 23 | 24 | # set to 'true' to allow .pyc and .pyo files without 25 | # a source .py file to be detected as revisions in the 26 | # versions/ directory 27 | # sourceless = false 28 | 29 | # version location specification; this defaults 30 | # to todolist/infra/database/alembic/versions. When using multiple version 31 | # directories, initial revisions must be specified with --version-path 32 | # version_locations = %(here)s/bar %(here)s/bat todolist/infra/database/alembic/versions 33 | 34 | # the output encoding used when revision files 35 | # are written from script.py.mako 36 | # output_encoding = utf-8 37 | 38 | 39 | [post_write_hooks] 40 | # post_write_hooks defines scripts or Python functions that are run 41 | # on newly generated revision scripts. See the documentation for further 42 | # detail and examples 43 | 44 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 45 | hooks=black 46 | black.type=console_scripts 47 | black.entrypoint=black 48 | black.options=-l 88 49 | 50 | # Logging configuration 51 | [loggers] 52 | keys = root,sqlalchemy,alembic 53 | 54 | [handlers] 55 | keys = console 56 | 57 | [formatters] 58 | keys = generic 59 | 60 | [logger_root] 61 | level = WARN 62 | handlers = console 63 | qualname = 64 | 65 | [logger_sqlalchemy] 66 | level = WARN 67 | handlers = 68 | qualname = sqlalchemy.engine 69 | 70 | [logger_alembic] 71 | level = INFO 72 | handlers = 73 | qualname = alembic 74 | 75 | [handler_console] 76 | class = StreamHandler 77 | args = (sys.stderr,) 78 | level = NOTSET 79 | formatter = generic 80 | 81 | [formatter_generic] 82 | format = %(levelname)-5.5s [%(name)s] %(message)s 83 | datefmt = %H:%M:%S 84 | -------------------------------------------------------------------------------- /tests/integration/api/routers/account/auth_test.py: -------------------------------------------------------------------------------- 1 | import jwt 2 | import pytest 3 | 4 | from tests.factories.model_factories import register_user 5 | from tests.utils.auth import ( 6 | build_form_data, 7 | oauth2_instrospect_url, 8 | oauth2_token_url, 9 | secret_key, 10 | ) 11 | from todolist.core.accounts.services import hash_service 12 | 13 | 14 | @pytest.mark.integration 15 | class TestOAuth2Token: 16 | def test_success(self, test_client, credentials): 17 | register_user( 18 | { 19 | "email": credentials.email, 20 | "password_hash": hash_service.hash_(credentials.password), 21 | } 22 | ) 23 | with test_client as client: 24 | response = client.post(oauth2_token_url, data=build_form_data(credentials)) 25 | body = response.json() 26 | assert body["access_token"] 27 | assert body["expire"] 28 | assert body["token_type"] == "bearer" 29 | assert response.status_code == 200 30 | 31 | def test_unauthorized(self, test_client, credentials): 32 | with test_client as client: 33 | response = client.post(oauth2_token_url, data=build_form_data(credentials)) 34 | assert response.json().get("detail") == "invalid authentication credentials" 35 | assert response.status_code == 401 36 | 37 | 38 | @pytest.mark.integration 39 | class TestOAuth2Introspect: 40 | def test_success(self, test_client, logged_user): 41 | user, access_token = logged_user 42 | with test_client as client: 43 | response = client.get( 44 | oauth2_instrospect_url, 45 | headers={"Authorization": f"Bearer {access_token}"}, 46 | ) 47 | body = response.json() 48 | assert body == { 49 | "id": user.id, 50 | "email": user.email, 51 | } 52 | assert response.status_code == 200 53 | 54 | def test_unauthorized(self, test_client): 55 | with test_client as client: 56 | token = str(jwt.encode({"sub": "userid:1"}, secret_key)) 57 | response = client.get( 58 | oauth2_instrospect_url, headers={"Authorization": f"Bearer {token}"}, 59 | ) 60 | 61 | assert response.json().get("detail") == "invalid token" 62 | assert response.status_code == 401 63 | -------------------------------------------------------------------------------- /todolist/infra/database/alembic/env.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | 3 | from logging.config import fileConfig 4 | 5 | from sqlalchemy import engine_from_config, pool 6 | 7 | import todolist.infra.database.models 8 | from alembic import context 9 | from todolist.config.environment import get_settings 10 | from todolist.infra.database.sqlalchemy import metadata 11 | 12 | _SETTINGS = get_settings() 13 | 14 | # this is the Alembic Config object, which provides 15 | # access to the values within the .ini file in use. 16 | config = context.config 17 | config.set_main_option("sqlalchemy.url", str(_SETTINGS.DATABASE_PG_URL)) 18 | 19 | # Interpret the config file for Python logging. 20 | # This line sets up loggers basically. 21 | fileConfig(config.config_file_name) 22 | 23 | # add your model's MetaData object here 24 | # for 'autogenerate' support 25 | # from myapp import mymodel 26 | # target_metadata = mymodel.Base.metadata 27 | target_metadata = metadata 28 | 29 | # other values from the config, defined by the needs of env.py, 30 | # can be acquired: 31 | # my_important_option = config.get_main_option("my_important_option") 32 | # ... etc. 33 | 34 | 35 | def run_migrations_offline(): 36 | """Run migrations in 'offline' mode. 37 | 38 | This configures the context with just a URL 39 | and not an Engine, though an Engine is acceptable 40 | here as well. By skipping the Engine creation 41 | we don't even need a DBAPI to be available. 42 | 43 | Calls to context.execute() here emit the given string to the 44 | script output. 45 | 46 | """ 47 | url = config.get_main_option("sqlalchemy.url") 48 | context.configure( 49 | url=url, 50 | target_metadata=target_metadata, 51 | literal_binds=True, 52 | dialect_opts={"paramstyle": "named"}, 53 | compare_type=True, 54 | ) 55 | 56 | with context.begin_transaction(): 57 | context.run_migrations() 58 | 59 | 60 | def run_migrations_online(): 61 | """Run migrations in 'online' mode. 62 | 63 | In this scenario we need to create an Engine 64 | and associate a connection with the context. 65 | 66 | """ 67 | connectable = engine_from_config( 68 | config.get_section(config.config_ini_section), 69 | prefix="sqlalchemy.", 70 | poolclass=pool.NullPool, 71 | ) 72 | 73 | with connectable.connect() as connection: 74 | context.configure( 75 | connection=connection, target_metadata=target_metadata, compare_type=True 76 | ) 77 | 78 | with context.begin_transaction(): 79 | context.run_migrations() 80 | 81 | 82 | if context.is_offline_mode(): 83 | run_migrations_offline() 84 | else: 85 | run_migrations_online() 86 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | volumes: 4 | pgsql-db-data: 5 | driver: local 6 | pg-admin-data: 7 | driver: local 8 | 9 | 10 | services: 11 | base: &base 12 | hostname: app 13 | image: fastapi-todo-app 14 | stdin_open: true 15 | tty: true 16 | build: 17 | context: . 18 | dockerfile: Dockerfile 19 | volumes: 20 | - .:/home/python/app 21 | environment: &env 22 | ENV: "development" 23 | LOG_LEVEL: "debug" 24 | PYTHONPATH: "/home/python/app" 25 | DATABASE_PG_URL: "postgresql://postgres:dev1234@pgsql-db/todolist_dev" 26 | JWT_ACCESS_TOKEN_EXPIRE_MINUTES: 30 27 | JWT_ALGORITHM: "HS256" 28 | JWT_SECRET_KEY: "e8ea520c75684a5022f38fcda321522b199c953f421b631bb135bfd2ff6b1864" 29 | WEB_APP_DEBUG: "true" 30 | WEB_APP_DESCRIPTION: "Todolist Task Management Application" 31 | WEB_APP_TITLE: "Todolist" 32 | WEB_APP_VERSION: "0.0.1" 33 | WEB_SERVER_HOST: "0.0.0.0" 34 | WEB_SERVER_PORT: 8000 35 | WEB_SERVER_RELOAD: "true" 36 | 37 | app: 38 | <<: *base 39 | command: /bin/ash -c "poetry install && alembic upgrade head && poetry run web_server" 40 | ports: 41 | - "8000:8000" 42 | depends_on: 43 | - pgsql-db 44 | 45 | lint: 46 | <<: *base 47 | command: /bin/ash -c "poetry install && flake8 todolist/ tests/" 48 | 49 | static-analysis: 50 | <<: *base 51 | command: /bin/ash -c "poetry install && mypy todolist/ tests/" 52 | 53 | tests: 54 | <<: *base 55 | command: /bin/ash -c "poetry install && alembic upgrade head && pytest" 56 | environment: 57 | <<: *env 58 | ENV: "testing" 59 | DATABASE_PG_URL: "postgresql://postgres:dev1234@pgsql-db/todolist_test" 60 | depends_on: 61 | - pgsql-db 62 | 63 | pgsql-db: 64 | hostname: pgsql-db 65 | image: postgres:12-alpine 66 | environment: 67 | POSTGRES_PASSWORD: "dev1234" 68 | ports: 69 | - "5432:5432" 70 | volumes: 71 | - ./scripts/pgsql-db:/docker-entrypoint-initdb.d 72 | - pgsql-db-data:/var/lib/postgresql/data 73 | 74 | pgadmin: 75 | hostname: pgadmin4 76 | image: dpage/pgadmin4 77 | environment: 78 | PGADMIN_DEFAULT_EMAIL: "dev@dev.com" 79 | PGADMIN_DEFAULT_PASSWORD: "dev@1234" 80 | ports: 81 | - "6001:80" 82 | volumes: 83 | - pg-admin-data:/var/lib/pgadmin 84 | depends_on: 85 | - pgsql-db 86 | 87 | -------------------------------------------------------------------------------- /tests/integration/infra/database/repositories/user_repository_test.py: -------------------------------------------------------------------------------- 1 | from operator import attrgetter 2 | 3 | import pytest 4 | from asyncpg.exceptions import UniqueViolationError 5 | from pytest_factoryboy import register 6 | 7 | from tests.factories.entity_factories import UserFactory 8 | from tests.factories.model_factories import register_user 9 | from todolist.infra.database.repositories import user_repository 10 | 11 | 12 | FACTORIES = [ 13 | UserFactory, 14 | ] 15 | 16 | for factory in FACTORIES: 17 | register(factory) 18 | 19 | 20 | # Fixtures 21 | @pytest.fixture(name="user") 22 | def user_fixture(user_factory): 23 | return user_factory() 24 | 25 | 26 | @pytest.mark.integration 27 | @pytest.mark.asyncio 28 | class TestFetch: 29 | async def test_has_result(self, database, user): 30 | register_user({**user.dict()}) 31 | getter = attrgetter("email", "password_hash") 32 | 33 | async with database.transaction(): 34 | result = await user_repository.fetch(user.id) 35 | assert getter(user) == getter(result) 36 | 37 | async def test_has_no_result(self, database, user): 38 | async with database.transaction(): 39 | result = await user_repository.fetch(user.id) 40 | assert not result 41 | 42 | 43 | @pytest.mark.integration 44 | @pytest.mark.asyncio 45 | class TestFetchByEmail: 46 | async def test_has_result(self, database, user): 47 | register_user({**user.dict()}) 48 | getter = attrgetter("email", "password_hash") 49 | 50 | async with database.transaction(): 51 | result = await user_repository.fetch_by_email(user.email) 52 | assert getter(user) == getter(result) 53 | 54 | async def test_has_no_result(self, database, user): 55 | async with database.transaction(): 56 | result = await user_repository.fetch_by_email(user.email) 57 | assert not result 58 | 59 | 60 | @pytest.mark.integration 61 | @pytest.mark.asyncio 62 | class TestPersist: 63 | async def test_unique_insertion(self, database, user): 64 | getter = attrgetter("email", "password_hash") 65 | email, password_hash = getter(user) 66 | 67 | async with database.transaction(): 68 | result = await user_repository.persist(email, password_hash) 69 | assert email, password_hash == getter(result) 70 | 71 | async def test_non_unique_insertion(self, database, user): 72 | email, password_hash = attrgetter("email", "password_hash")(user) 73 | register_user({**user.dict()}) 74 | 75 | with pytest.raises(UniqueViolationError): 76 | async with database.transaction(): 77 | await user_repository.persist(email, password_hash) 78 | -------------------------------------------------------------------------------- /tests/unit/core/accounts/entities/user_registry_test.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | from typing import Any, Dict 3 | 4 | import pytest 5 | from pydantic import ValidationError 6 | 7 | from tests.utils.asserts import assert_validation_error 8 | from todolist.core.accounts.entities.user import UserRegistry 9 | 10 | DataType = Dict[str, Any] 11 | 12 | 13 | @pytest.fixture(name="valid_data") 14 | def valid_data_fixture() -> DataType: 15 | return { 16 | "id": 1, 17 | "email": "example@example.com", 18 | } 19 | 20 | 21 | @pytest.fixture(name="invalid_data") 22 | def invalid_data_fixture() -> DataType: 23 | return { 24 | "id": "some id", 25 | "email": "some email", 26 | } 27 | 28 | 29 | @pytest.mark.unit 30 | class TestUser: 31 | class TestModel: 32 | def test_validation(self, valid_data): 33 | assert UserRegistry(**valid_data) 34 | 35 | def test_invalidation(self, invalid_data): 36 | with pytest.raises(ValidationError): 37 | assert UserRegistry(**invalid_data) 38 | 39 | def test_immutability(self, valid_data): 40 | entity = UserRegistry(**valid_data) 41 | for key in entity.dict().keys(): 42 | with pytest.raises(TypeError): 43 | setattr(entity, key, "some value") 44 | 45 | class TestId: 46 | assert_validation_error = partial(assert_validation_error, 1, "id") 47 | 48 | def test_must_be_int(self, valid_data): 49 | with pytest.raises(ValidationError) as excinfo: 50 | valid_data.update({"id": "some_id"}) 51 | UserRegistry(**valid_data) 52 | 53 | self.assert_validation_error("type_error.integer", excinfo) 54 | 55 | def test_is_required(self, valid_data): 56 | with pytest.raises(ValidationError) as excinfo: 57 | valid_data.pop("id") 58 | UserRegistry(**valid_data) 59 | 60 | self.assert_validation_error("value_error.missing", excinfo) 61 | 62 | class TestEmail: 63 | assert_validation_error = partial(assert_validation_error, 1, "email") 64 | 65 | def test_must_be_email(self, valid_data): 66 | with pytest.raises(ValidationError) as excinfo: 67 | valid_data.update({"email": ["some string"]}) 68 | UserRegistry(**valid_data) 69 | 70 | self.assert_validation_error("type_error.str", excinfo) 71 | 72 | def test_is_required(self, valid_data): 73 | with pytest.raises(ValidationError) as excinfo: 74 | valid_data.pop("email") 75 | UserRegistry(**valid_data) 76 | 77 | self.assert_validation_error("value_error.missing", excinfo) 78 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.gitignore.io/api/git,code,python 2 | # Edit at https://www.gitignore.io/?templates=git,code,python 3 | 4 | ### Code ### 5 | .vscode/ 6 | .vscode/* 7 | !.vscode/settings.json 8 | !.vscode/tasks.json 9 | !.vscode/launch.json 10 | !.vscode/extensions.json 11 | 12 | ### Git ### 13 | # Created by git for backups. To disable backups in Git: 14 | # $ git config --global mergetool.keepBackup false 15 | *.orig 16 | 17 | # Created by git when using merge tools for conflicts 18 | *.BACKUP.* 19 | *.BASE.* 20 | *.LOCAL.* 21 | *.REMOTE.* 22 | *_BACKUP_*.txt 23 | *_BASE_*.txt 24 | *_LOCAL_*.txt 25 | *_REMOTE_*.txt 26 | 27 | ### Python ### 28 | # Byte-compiled / optimized / DLL files 29 | __pycache__/ 30 | *.py[cod] 31 | *$py.class 32 | 33 | # C extensions 34 | *.so 35 | 36 | # Distribution / packaging 37 | .Python 38 | build/ 39 | develop-eggs/ 40 | dist/ 41 | downloads/ 42 | eggs/ 43 | .eggs/ 44 | lib/ 45 | lib64/ 46 | parts/ 47 | sdist/ 48 | var/ 49 | wheels/ 50 | pip-wheel-metadata/ 51 | share/python-wheels/ 52 | *.egg-info/ 53 | .installed.cfg 54 | *.egg 55 | MANIFEST 56 | 57 | # PyInstaller 58 | # Usually these files are written by a python script from a template 59 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 60 | *.manifest 61 | *.spec 62 | 63 | # Installer logs 64 | pip-log.txt 65 | pip-delete-this-directory.txt 66 | 67 | # Unit test / coverage reports 68 | htmlcov/ 69 | cov_html/ 70 | .tox/ 71 | .nox/ 72 | .coverage 73 | .coverage.* 74 | .cache 75 | nosetests.xml 76 | coverage.xml 77 | *.cover 78 | .hypothesis/ 79 | .pytest_cache/ 80 | 81 | # Translations 82 | *.mo 83 | *.pot 84 | 85 | # Scrapy stuff: 86 | .scrapy 87 | 88 | # Sphinx documentation 89 | docs/_build/ 90 | 91 | # PyBuilder 92 | target/ 93 | 94 | # pyenv 95 | .python-version 96 | 97 | # pipenv 98 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 99 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 100 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 101 | # install all needed dependencies. 102 | #Pipfile.lock 103 | 104 | # poetry 105 | .venv 106 | 107 | # celery beat schedule file 108 | celerybeat-schedule 109 | 110 | # SageMath parsed files 111 | *.sage.py 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # Mr Developer 121 | .mr.developer.cfg 122 | .project 123 | .pydevproject 124 | 125 | # mkdocs documentation 126 | /site 127 | 128 | # mypy 129 | .mypy_cache/ 130 | .dmypy.json 131 | dmypy.json 132 | 133 | # Pyre type checker 134 | .pyre/ 135 | 136 | # End of https://www.gitignore.io/api/git,code,python 137 | -------------------------------------------------------------------------------- /todolist/infra/database/repositories/todo_item_repository.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable, Optional 2 | 3 | from todolist.core.accounts.entities.user import UserRegistry 4 | from todolist.core.todo.entities.todo_item import ( 5 | CreateTodoItemDto, 6 | TodoItem, 7 | UpdateTodoItemDto, 8 | ) 9 | from todolist.infra.database.models.todo_item import TodoItem as TodoItemModel 10 | from todolist.infra.database.sqlalchemy import database 11 | 12 | 13 | async def delete(user: UserRegistry, id_: int) -> bool: 14 | if not await exists_by_id(id_): 15 | return False 16 | 17 | query = ( 18 | TodoItemModel.delete() 19 | .where(TodoItemModel.c.id == id_) 20 | .where(TodoItemModel.c.user_id == user.id) 21 | ) 22 | await database.execute(query) 23 | return True 24 | 25 | 26 | async def exists_by_id(id_: int) -> bool: 27 | query = TodoItemModel.count().where(TodoItemModel.c.id == id_) 28 | return bool(await database.execute(query)) 29 | 30 | 31 | async def fetch(user: UserRegistry, id_: int) -> Optional[TodoItem]: 32 | query = ( 33 | TodoItemModel.select() 34 | .where(TodoItemModel.c.id == id_) 35 | .where(TodoItemModel.c.user_id == user.id) 36 | ) 37 | 38 | result = await database.fetch_one(query) 39 | return TodoItem.parse_obj(dict(result)) if result else None 40 | 41 | 42 | async def fetch_all_by_user(user: UserRegistry) -> Iterable[TodoItem]: 43 | query = TodoItemModel.select().where(TodoItemModel.c.user_id == user.id) 44 | 45 | results = await database.fetch_all(query) 46 | return (TodoItem.parse_obj(dict(r)) for r in results) 47 | 48 | 49 | async def persist(user: UserRegistry, dto: CreateTodoItemDto) -> TodoItem: 50 | values = {**dto.dict(), "user_id": user.id} 51 | query = TodoItemModel.insert().values(**values) 52 | 53 | last_record_id = await database.execute(query) 54 | return TodoItem.parse_obj({**values, "id": last_record_id}) 55 | 56 | 57 | async def replace( 58 | user: UserRegistry, dto: CreateTodoItemDto, id_: int 59 | ) -> Optional[TodoItem]: 60 | if not await exists_by_id(id_): 61 | return None 62 | 63 | values = dto.dict() 64 | query = ( 65 | TodoItemModel.update() 66 | .where(TodoItemModel.c.id == id_) 67 | .where(TodoItemModel.c.user_id == user.id) 68 | .values(**values) 69 | ) 70 | await database.execute(query) 71 | return TodoItem.parse_obj({**values, "id": id_, "user_id": user.id}) 72 | 73 | 74 | async def update( 75 | user: UserRegistry, dto: UpdateTodoItemDto, id_: int 76 | ) -> Optional[TodoItem]: 77 | if not await exists_by_id(id_): 78 | return None 79 | 80 | values = dto.dict(exclude_unset=True) 81 | query = ( 82 | TodoItemModel.update() 83 | .where(TodoItemModel.c.id == id_) 84 | .where(TodoItemModel.c.user_id == user.id) 85 | .values(**values) 86 | ) 87 | await database.execute(query) 88 | 89 | return await fetch(user, id_) 90 | -------------------------------------------------------------------------------- /tests/unit/core/todo/entities/todo_item/update_todo_item_dto_test.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | from typing import Any, Dict 3 | 4 | import pytest 5 | from pydantic import ValidationError 6 | 7 | from tests.utils.asserts import assert_validation_error 8 | from todolist.core.todo.entities.todo_item import UpdateTodoItemDto 9 | 10 | 11 | # Types 12 | DataType = Dict[str, Any] 13 | 14 | 15 | @pytest.fixture(name="valid_data") 16 | def valid_data_fixture() -> DataType: 17 | return { 18 | "msg": "some message", 19 | "is_done": True, 20 | } 21 | 22 | 23 | @pytest.fixture(name="invalid_data") 24 | def invalid_data_fixture() -> DataType: 25 | return {"msg": ["some string"], "is_done": "some bool"} 26 | 27 | 28 | class TestCreateTodoItemDto: 29 | class TestModel: 30 | def test_validation(self, valid_data): 31 | assert UpdateTodoItemDto(**valid_data) 32 | 33 | def test_invalidation(self, invalid_data): 34 | with pytest.raises(ValidationError): 35 | UpdateTodoItemDto(**invalid_data) 36 | 37 | def test_immutability(self, valid_data): 38 | entity = UpdateTodoItemDto(**valid_data) 39 | for key in entity.dict().keys(): 40 | with pytest.raises(TypeError): 41 | setattr(entity, key, "some value") 42 | 43 | class TestMsg: 44 | assert_validation_error = partial(assert_validation_error, 1, "msg") 45 | 46 | def test_must_be_str(self, valid_data): 47 | with pytest.raises(ValidationError) as excinfo: 48 | valid_data.update({"msg": ["some string"]}) 49 | UpdateTodoItemDto(**valid_data) 50 | 51 | self.assert_validation_error("type_error.str", excinfo) 52 | 53 | def test_is_optional(self, valid_data): 54 | valid_data.pop("msg") 55 | entity = UpdateTodoItemDto(**valid_data) 56 | assert entity.msg is None 57 | 58 | def test_min_length_gte_3(self, valid_data): 59 | with pytest.raises(ValidationError) as excinfo: 60 | valid_data.update({"msg": "a" * 2}) 61 | UpdateTodoItemDto(**valid_data) 62 | 63 | self.assert_validation_error("value_error.any_str.min_length", excinfo) 64 | 65 | def test_max_length_lte_100(self, valid_data): 66 | with pytest.raises(ValidationError) as excinfo: 67 | valid_data.update({"msg": "a" * 101}) 68 | UpdateTodoItemDto(**valid_data) 69 | 70 | self.assert_validation_error("value_error.any_str.max_length", excinfo) 71 | 72 | class TestIsDone: 73 | assert_validation_error = partial(assert_validation_error, 1, "is_done") 74 | 75 | def test_must_be_bool(self, valid_data): 76 | with pytest.raises(ValidationError) as excinfo: 77 | valid_data.update({"is_done": "some bool"}) 78 | UpdateTodoItemDto(**valid_data) 79 | 80 | self.assert_validation_error("type_error.bool", excinfo) 81 | 82 | def test_is_optional(self, valid_data): 83 | valid_data.pop("is_done") 84 | entity = UpdateTodoItemDto(**valid_data) 85 | assert entity.is_done is None 86 | -------------------------------------------------------------------------------- /tests/unit/core/todo/services/todo_item_service_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from todolist.core.todo.protocols import TodoItemRepo 4 | from todolist.core.todo.services import todo_item_service 5 | 6 | 7 | @pytest.fixture() 8 | def repo(mock_module): 9 | return mock_module("todo_item_repo", TodoItemRepo) 10 | 11 | 12 | # Tests 13 | @pytest.mark.unit 14 | @pytest.mark.asyncio 15 | async def test_create(repo, user_registry, create_todo_item_dto, todo_item): 16 | # Setup 17 | repo.persist.return_value = todo_item 18 | 19 | # Test 20 | result = await todo_item_service.create(repo, user_registry, create_todo_item_dto) 21 | 22 | # Assertions 23 | repo.persist.assert_called_once_with(user_registry, create_todo_item_dto) 24 | assert result == todo_item 25 | 26 | 27 | @pytest.mark.unit 28 | @pytest.mark.asyncio 29 | async def test_delete(repo, user_registry): 30 | # Setup 31 | id_ = 1 32 | repo.delete.return_value = True 33 | 34 | # Tests 35 | result = await todo_item_service.delete(repo, user_registry, id_) 36 | 37 | # Assertions 38 | repo.delete.assert_called_once_with(user_registry, id_) 39 | assert result is True 40 | 41 | 42 | @pytest.mark.unit 43 | @pytest.mark.asyncio 44 | async def test_get(repo, user_registry, todo_item): 45 | # Setup 46 | id_ = todo_item.id 47 | repo.fetch.return_value = todo_item 48 | 49 | # Tests 50 | result = await todo_item_service.get(repo, user_registry, id_) 51 | 52 | # Assertions 53 | repo.fetch.assert_called_once_with(user_registry, id_) 54 | assert result == todo_item 55 | 56 | 57 | @pytest.mark.unit 58 | @pytest.mark.asyncio 59 | async def test_get_all_by_user(repo, user_registry, todo_items): 60 | # Setup 61 | items = todo_items() 62 | repo.fetch_all_by_user.return_value = items 63 | 64 | # Tests 65 | result = await todo_item_service.get_all(repo, user_registry) 66 | 67 | # Assertions 68 | repo.fetch_all_by_user.assert_called_once_with(user_registry) 69 | assert result == items 70 | 71 | 72 | @pytest.mark.unit 73 | @pytest.mark.asyncio 74 | class TestUpdate: 75 | async def test_when_is_create( 76 | self, repo, user_registry, create_todo_item_dto, todo_item 77 | ): 78 | # Setup 79 | id_ = 1 80 | repo.replace.return_value = todo_item 81 | 82 | # Tests 83 | result = await todo_item_service.update( 84 | repo, user_registry, create_todo_item_dto, id_ 85 | ) 86 | 87 | # Assertions 88 | repo.replace.assert_called_once_with(user_registry, create_todo_item_dto, id_) 89 | assert result == todo_item 90 | 91 | async def test_when_is_update( 92 | self, repo, user_registry, update_todo_item_dto, todo_item 93 | ): 94 | # Setup 95 | id_ = 1 96 | repo.update.return_value = todo_item 97 | 98 | # Tests 99 | result = await todo_item_service.update( 100 | repo, user_registry, update_todo_item_dto, id_ 101 | ) 102 | 103 | # Assertions 104 | repo.update.assert_called_once_with(user_registry, update_todo_item_dto, id_) 105 | assert result == todo_item 106 | -------------------------------------------------------------------------------- /tests/unit/core/todo/entities/todo_item/create_todo_item_dto_test.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | from typing import Any, Dict 3 | 4 | import pytest 5 | from pydantic import ValidationError 6 | 7 | from tests.utils.asserts import assert_validation_error 8 | from todolist.core.todo.entities.todo_item import CreateTodoItemDto 9 | 10 | 11 | # Types 12 | DataType = Dict[str, Any] 13 | 14 | 15 | # Fixtures 16 | @pytest.fixture(name="valid_data") 17 | def valid_data_fixture() -> DataType: 18 | return { 19 | "msg": "some message", 20 | "is_done": True, 21 | } 22 | 23 | 24 | @pytest.fixture(name="invalid_data") 25 | def invalid_data_fixture() -> DataType: 26 | return {"msg": ["some string"], "is_done": "some bool"} 27 | 28 | 29 | @pytest.mark.unit 30 | class TestCreateTodoItemDto: 31 | class TestModel: 32 | def test_validation(self, valid_data): 33 | assert CreateTodoItemDto(**valid_data) 34 | 35 | def test_invalidation(self, invalid_data): 36 | with pytest.raises(ValidationError): 37 | CreateTodoItemDto(**invalid_data) 38 | 39 | def test_immutability(self, valid_data): 40 | entity = CreateTodoItemDto(**valid_data) 41 | for key in entity.dict().keys(): 42 | with pytest.raises(TypeError): 43 | setattr(entity, key, "some value") 44 | 45 | class TestMsg: 46 | assert_validation_error = partial(assert_validation_error, 1, "msg") 47 | 48 | def test_must_be_str(self, valid_data): 49 | with pytest.raises(ValidationError) as excinfo: 50 | valid_data.update({"msg": ["some string"]}) 51 | CreateTodoItemDto(**valid_data) 52 | 53 | self.assert_validation_error("type_error.str", excinfo) 54 | 55 | def test_is_required(self, valid_data): 56 | with pytest.raises(ValidationError) as excinfo: 57 | valid_data.pop("msg") 58 | CreateTodoItemDto(**valid_data) 59 | 60 | self.assert_validation_error("value_error.missing", excinfo) 61 | 62 | def test_min_length_gte_3(self, valid_data): 63 | with pytest.raises(ValidationError) as excinfo: 64 | valid_data.update({"msg": "a" * 2}) 65 | CreateTodoItemDto(**valid_data) 66 | 67 | self.assert_validation_error("value_error.any_str.min_length", excinfo) 68 | 69 | def test_max_length_lte_100(self, valid_data): 70 | with pytest.raises(ValidationError) as excinfo: 71 | valid_data.update({"msg": "a" * 101}) 72 | CreateTodoItemDto(**valid_data) 73 | 74 | self.assert_validation_error("value_error.any_str.max_length", excinfo) 75 | 76 | class TestIsDone: 77 | assert_validation_error = partial(assert_validation_error, 1, "is_done") 78 | 79 | def test_must_be_bool(self, valid_data): 80 | with pytest.raises(ValidationError) as excinfo: 81 | valid_data.update({"is_done": "some bool"}) 82 | CreateTodoItemDto(**valid_data) 83 | 84 | self.assert_validation_error("type_error.bool", excinfo) 85 | 86 | def test_default_is_false(self, valid_data): 87 | valid_data.pop("is_done") 88 | entity = CreateTodoItemDto(**valid_data) 89 | assert entity.is_done is False 90 | -------------------------------------------------------------------------------- /tests/unit/core/accounts/entities/credentials_test.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | from typing import Any, Dict 3 | 4 | import pytest 5 | from pydantic import ValidationError 6 | 7 | from tests.utils.asserts import assert_validation_error 8 | from todolist.core.accounts.entities.user import Credentials 9 | 10 | DataType = Dict[str, Any] 11 | 12 | 13 | @pytest.fixture(name="valid_data") 14 | def valid_data_fixture() -> DataType: 15 | return { 16 | "email": "example@example.com", 17 | "password": "some password", 18 | } 19 | 20 | 21 | @pytest.fixture(name="invalid_data") 22 | def invalid_data_fixture() -> DataType: 23 | return { 24 | "email": "some email", 25 | "password_hash": ["some_hash"], 26 | } 27 | 28 | 29 | @pytest.mark.unit 30 | class TestCredentials: 31 | class TestModel: 32 | def test_validation(self, valid_data): 33 | assert Credentials(**valid_data) 34 | 35 | def test_invalidation(self, invalid_data): 36 | with pytest.raises(ValidationError): 37 | assert Credentials(**invalid_data) 38 | 39 | def test_immutability(self, valid_data): 40 | entity = Credentials(**valid_data) 41 | for key in entity.dict().keys(): 42 | with pytest.raises(TypeError): 43 | setattr(entity, key, "some value") 44 | 45 | class TestEmail: 46 | assert_validation_error = partial(assert_validation_error, 1, "email") 47 | 48 | def test_must_be_email(self, valid_data): 49 | with pytest.raises(ValidationError) as excinfo: 50 | valid_data.update({"email": ["some string"]}) 51 | Credentials(**valid_data) 52 | 53 | self.assert_validation_error("type_error.str", excinfo) 54 | 55 | def test_is_required(self, valid_data): 56 | with pytest.raises(ValidationError) as excinfo: 57 | valid_data.pop("email") 58 | Credentials(**valid_data) 59 | 60 | self.assert_validation_error("value_error.missing", excinfo) 61 | 62 | class TestPassword: 63 | assert_validation_error = partial(assert_validation_error, 1, "password") 64 | 65 | def test_must_be_secret_str(self, valid_data): 66 | with pytest.raises(ValidationError) as excinfo: 67 | valid_data.update({"password": ["some string"]}) 68 | Credentials(**valid_data) 69 | 70 | self.assert_validation_error("type_error.str", excinfo) 71 | 72 | def test_is_required(self, valid_data): 73 | with pytest.raises(ValidationError) as excinfo: 74 | valid_data.pop("password") 75 | Credentials(**valid_data) 76 | 77 | self.assert_validation_error("value_error.missing", excinfo) 78 | 79 | def test_min_length_gte_8(self, valid_data): 80 | with pytest.raises(ValidationError) as excinfo: 81 | valid_data.update({"password": "a" * 7}) 82 | Credentials(**valid_data) 83 | 84 | self.assert_validation_error("value_error.any_str.min_length", excinfo) 85 | 86 | def test_max_length_lte_128(self, valid_data): 87 | with pytest.raises(ValidationError) as excinfo: 88 | valid_data.update({"password": "a" * 129}) 89 | Credentials(**valid_data) 90 | 91 | self.assert_validation_error("value_error.any_str.max_length", excinfo) 92 | -------------------------------------------------------------------------------- /tests/unit/core/accounts/entities/user_test.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | from typing import Any, Dict 3 | 4 | import pytest 5 | from pydantic import ValidationError 6 | 7 | from tests.utils.asserts import assert_validation_error 8 | from todolist.core.accounts.entities.user import User 9 | 10 | DataType = Dict[str, Any] 11 | 12 | 13 | @pytest.fixture(name="valid_data") 14 | def valid_data_fixture() -> DataType: 15 | return { 16 | "id": 1, 17 | "email": "example@example.com", 18 | "password_hash": """ 19 | $argon2i$v=19$m=512,t=2,p=2$aI2R0hpDyLm3ltLa+1/rvQ$LqPKjd6n8yniKtAithoR7A 20 | """, 21 | } 22 | 23 | 24 | @pytest.fixture(name="invalid_data") 25 | def invalid_data_fixture() -> DataType: 26 | return { 27 | "id": "some string", 28 | "email": "some email", 29 | "password_hash": ["some_hash"], 30 | } 31 | 32 | 33 | @pytest.mark.unit 34 | class TestUser: 35 | class TestModel: 36 | def test_validation(self, valid_data): 37 | assert User(**valid_data) 38 | 39 | def test_invalidation(self, invalid_data): 40 | with pytest.raises(ValidationError): 41 | assert User(**invalid_data) 42 | 43 | def test_immutability(self, valid_data): 44 | entity = User(**valid_data) 45 | for key in entity.dict().keys(): 46 | with pytest.raises(TypeError): 47 | setattr(entity, key, "some value") 48 | 49 | class TestId: 50 | assert_validation_error = partial(assert_validation_error, 1, "id") 51 | 52 | def test_must_be_int(self, valid_data): 53 | with pytest.raises(ValidationError) as excinfo: 54 | valid_data.update({"id": "some_id"}) 55 | User(**valid_data) 56 | 57 | self.assert_validation_error("type_error.integer", excinfo) 58 | 59 | def test_is_required(self, valid_data): 60 | with pytest.raises(ValidationError) as excinfo: 61 | valid_data.pop("id") 62 | User(**valid_data) 63 | 64 | self.assert_validation_error("value_error.missing", excinfo) 65 | 66 | class TestEmail: 67 | assert_validation_error = partial(assert_validation_error, 1, "email") 68 | 69 | def test_must_be_email(self, valid_data): 70 | with pytest.raises(ValidationError) as excinfo: 71 | valid_data.update({"email": ["some string"]}) 72 | User(**valid_data) 73 | 74 | self.assert_validation_error("type_error.str", excinfo) 75 | 76 | def test_is_required(self, valid_data): 77 | with pytest.raises(ValidationError) as excinfo: 78 | valid_data.pop("email") 79 | User(**valid_data) 80 | 81 | self.assert_validation_error("value_error.missing", excinfo) 82 | 83 | class TestPasswordHash: 84 | assert_validation_error = partial(assert_validation_error, 1, "password_hash") 85 | 86 | def test_must_be_secret_str(self, valid_data): 87 | with pytest.raises(ValidationError) as excinfo: 88 | valid_data.update({"password_hash": ["some string"]}) 89 | User(**valid_data) 90 | 91 | self.assert_validation_error("type_error.str", excinfo) 92 | 93 | def test_is_required(self, valid_data): 94 | with pytest.raises(ValidationError) as excinfo: 95 | valid_data.pop("password_hash") 96 | User(**valid_data) 97 | 98 | self.assert_validation_error("value_error.missing", excinfo) 99 | -------------------------------------------------------------------------------- /todolist/api/routers/account/auth.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from enum import Enum 3 | from operator import attrgetter 4 | from typing import Any, Dict 5 | 6 | import jwt 7 | from fastapi import Depends, HTTPException # type: ignore 8 | from fastapi.responses import JSONResponse # type: ignore 9 | from fastapi.routing import APIRouter 10 | from fastapi.security import OAuth2PasswordBearer # type: ignore 11 | from fastapi.security import OAuth2PasswordRequestForm # type: ignore 12 | from pydantic import BaseModel 13 | 14 | from todolist.api.container import get_dependencies 15 | from todolist.config.environment import get_settings 16 | from todolist.core.accounts.entities.user import Credentials, UserRegistry 17 | from todolist.core.accounts.services import user_service 18 | 19 | 20 | _secret_key, _expire_minutes, _algorithm = attrgetter( 21 | "JWT_SECRET_KEY", "JWT_ACCESS_TOKEN_EXPIRE_MINUTES", "JWT_ALGORITHM" 22 | )(get_settings()) 23 | 24 | _credentials_exception = HTTPException( 25 | status_code=401, detail="invalid token", headers={"WWW-Authenticate": "Bearer"}, 26 | ) 27 | 28 | 29 | repo = get_dependencies().user_repo 30 | 31 | 32 | # View models 33 | class TokenType(str, Enum): 34 | bearer = "bearer" 35 | 36 | 37 | class Token(BaseModel): 38 | access_token: str 39 | expire: int 40 | token_type: TokenType = TokenType.bearer 41 | 42 | 43 | # OAuth2 scheme 44 | oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/account/oauth2/token") 45 | 46 | # Router 47 | router = APIRouter(default_response_class=JSONResponse) 48 | 49 | 50 | # Token handlers 51 | def _decode_token(token: str) -> int: 52 | try: 53 | payload = jwt.decode(token, _secret_key, algorithms=[_algorithm]) 54 | _, id_ = str(payload.get("sub")).split(":") 55 | return int(id_) 56 | except jwt.PyJWTError: 57 | raise _credentials_exception 58 | 59 | 60 | def _encode_token(*, data: Dict[str, Any], expires_delta: timedelta) -> Token: 61 | expire = datetime.utcnow() + expires_delta 62 | access_token = jwt.encode( 63 | {**data.copy(), "exp": expire}, _secret_key, algorithm=_algorithm 64 | ) 65 | return Token(access_token=access_token, expire=expire.timestamp()) 66 | 67 | 68 | # Authorizers 69 | async def get_current_user(token: str = Depends(oauth2_scheme)) -> UserRegistry: 70 | id_ = _decode_token(token) 71 | user = await user_service.get_by_id_or_raise(repo, id_) 72 | return user 73 | 74 | 75 | # Handlers 76 | @router.post( 77 | "/token", 78 | response_model=Token, 79 | responses={ 80 | 200: {"description": "User authenticated"}, 81 | 401: {"description": "User unauthorized"}, 82 | }, 83 | ) 84 | async def token(form_data: OAuth2PasswordRequestForm = Depends()): 85 | username, password = attrgetter("username", "password")(form_data) 86 | credentials = Credentials(email=username, password=password) 87 | 88 | user = await user_service.get_by_credentials(repo, credentials) 89 | if not user: 90 | raise HTTPException( 91 | status_code=401, detail="invalid authentication credentials", 92 | ) 93 | 94 | expire = timedelta(minutes=_expire_minutes) 95 | return _encode_token(data={"sub": f"userid:{user.id}"}, expires_delta=expire) 96 | 97 | 98 | @router.get( 99 | "/instrospect", 100 | response_model=UserRegistry, 101 | responses={ 102 | 200: {"description": "User registry"}, 103 | 401: {"description": "User unauthorized"}, 104 | }, 105 | ) 106 | def instrospect(user: UserRegistry = Depends(get_current_user)): 107 | return user 108 | -------------------------------------------------------------------------------- /todolist/api/routers/todo/todo_item.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from fastapi.param_functions import Depends 4 | from fastapi.responses import JSONResponse # type: ignore 5 | from fastapi.routing import APIRouter 6 | 7 | from todolist.api.container import get_dependencies 8 | from todolist.api.routers.account.auth import get_current_user 9 | from todolist.core.accounts.entities.user import UserRegistry 10 | from todolist.core.todo.entities.todo_item import ( 11 | CreateTodoItemDto, 12 | TodoItem, 13 | UpdateTodoItemDto, 14 | ) 15 | from todolist.core.todo.services import todo_item_service 16 | from todolist.infra.database.sqlalchemy import database 17 | 18 | 19 | repo = get_dependencies().todo_item_repo 20 | router = APIRouter() 21 | 22 | 23 | # Handlers 24 | @router.post( 25 | "", 26 | response_class=JSONResponse, 27 | response_model=TodoItem, 28 | status_code=201, 29 | responses={201: {"description": "Item created"}}, 30 | ) 31 | @database.transaction() 32 | async def create( 33 | dto: CreateTodoItemDto, user: UserRegistry = Depends(get_current_user) 34 | ): 35 | return await todo_item_service.create(repo, user, dto) 36 | 37 | 38 | @router.delete( 39 | "/{item_id}", 40 | response_class=JSONResponse, 41 | status_code=204, 42 | responses={ 43 | 204: {"description": "Item deleted"}, 44 | 401: {"description": "User unauthorized"}, 45 | 404: {"description": "Item not found"}, 46 | }, 47 | ) 48 | @database.transaction() 49 | async def delete(item_id: int, user: UserRegistry = Depends(get_current_user)): 50 | result = await todo_item_service.delete(repo, user, item_id) 51 | status_code = 204 if result else 404 52 | return JSONResponse(status_code=status_code) 53 | 54 | 55 | @router.get( 56 | "", 57 | response_class=JSONResponse, 58 | response_model=List[TodoItem], 59 | status_code=200, 60 | responses={ 61 | 200: {"description": "Items found"}, 62 | 401: {"description": "User unauthorized"}, 63 | }, 64 | ) 65 | @database.transaction() 66 | async def get_all(user: UserRegistry = Depends(get_current_user)): 67 | return list(await todo_item_service.get_all(repo, user)) 68 | 69 | 70 | @router.get( 71 | "/{item_id}", 72 | response_class=JSONResponse, 73 | response_model=TodoItem, 74 | status_code=200, 75 | responses={ 76 | 200: {"description": "Item found"}, 77 | 401: {"description": "User unauthorized"}, 78 | 404: {"description": "Item not found"}, 79 | }, 80 | ) 81 | @database.transaction() 82 | async def get(item_id: int, user: UserRegistry = Depends(get_current_user)): 83 | item = await todo_item_service.get(repo, user, item_id) 84 | if not item: 85 | return JSONResponse(status_code=404) 86 | return item 87 | 88 | 89 | @router.put( 90 | "/{item_id}", 91 | response_class=JSONResponse, 92 | response_model=TodoItem, 93 | status_code=200, 94 | responses={ 95 | 200: {"description": "Item replaced"}, 96 | 401: {"description": "User unauthorized"}, 97 | 404: {"description": "Item not found"}, 98 | }, 99 | ) 100 | @database.transaction() 101 | async def replace( 102 | dto: CreateTodoItemDto, item_id: int, user: UserRegistry = Depends(get_current_user) 103 | ): 104 | item = await todo_item_service.update(repo, user, dto, item_id) 105 | return item if item else JSONResponse(status_code=404) 106 | 107 | 108 | @router.patch( 109 | "/{item_id}", 110 | response_class=JSONResponse, 111 | response_model=TodoItem, 112 | status_code=200, 113 | responses={ 114 | 200: {"description": "Item updated"}, 115 | 401: {"description": "User unauthorized"}, 116 | 404: {"description": "Item not found"}, 117 | }, 118 | ) 119 | @database.transaction() 120 | async def update( 121 | dto: UpdateTodoItemDto, item_id: int, user: UserRegistry = Depends(get_current_user) 122 | ): 123 | item = await todo_item_service.update(repo, user, dto, item_id) 124 | return item if item else JSONResponse(status_code=404) 125 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FastAPI Todolist 2 | 3 | ## Description 4 | 5 | Todolist aplication made with Python's FastAPI framework and Hexagonal Architecture. 6 | 7 | This is a test repository for the purpose of testing FastAPI framework capabilities with DDD, functional programming and Hexagonal (or Ports and Adapters) arquitecture 8 | 9 | ## Overview 10 | 11 | This project is comprised of the following languages and libraries: 12 | 13 | * Language: [Python 3.8+](https://www.python.org/) 14 | * Package management: [Poetry](https://python-poetry.org/) 15 | * Web framework: [FastAPI](https://fastapi.tiangolo.com/) 16 | * Production web server: [Uvicorn](http://www.uvicorn.org/) 17 | * Relational database: [Postgres](https://www.postgresql.org/) 18 | * Relational database async support: [databases](https://www.encode.io/databases/) 19 | * Relational database migrations: [Alembic](https://alembic.sqlalchemy.org/en/latest/) 20 | * Relational ORM: [SQLAlchemy](https://www.sqlalchemy.org/) 21 | * Functional programming utilities: [Toolz](https://toolz.readthedocs.io/en/latest/) 22 | * Password hashing utilities: [Passlib](https://passlib.readthedocs.io/) 23 | * Data parsing and validation: [Pydantic](https://pydantic-docs.helpmanual.io/) 24 | * Testing: [Pytest](https://docs.pytest.org/en/latest/) 25 | * Linter: [Flake8](https://flake8.pycqa.org/en/latest/) 26 | * Static type checker: [Mypy](https://mypy.readthedocs.io/en/stable/index.html) 27 | * Formatter: [Black](https://github.com/psf/black) 28 | 29 | Auxiliary libraries were omitted but can be found in the [pyproject](https://github.com/GArmane/python-fastapi-hex-todo/blob/master/pyproject.toml) file. 30 | 31 | ## Development 32 | 33 | To start development it is recommended to have these utilities installed in a local development machine: 34 | 35 | * [Python 3.8+](https://www.python.org/) 36 | * [Docker](https://www.docker.com/) 37 | * [Git](https://git-scm.com/) 38 | * [Plis](https://github.com/IcaliaLabs/plis) 39 | 40 | For better development experience, it is recommended these tools: 41 | 42 | * [Visual Studio Code](https://code.visualstudio.com/) 43 | * [Poetry](https://python-poetry.org/) 44 | 45 | Be certain that you are installing Poetry with the correct version of Python in your machine, that is, Python 3. 46 | 47 | This project is already configured with VS Code IDE in mind. To have access of tools and code analysis utilities, you only need to install the project dependencies locally with `poetry install` and to open the project workspace file on VS Code. 48 | 49 | The IDE should be automatically configured with standard rules and options for optimal development experience. 50 | 51 | ### Running the API 52 | 53 | To run the API in development mode, follow these steps: 54 | 55 | * Start a container with: `plis start --service-ports app ash` 56 | * Inside the container run: `poetry install` 57 | * Start the web server with: `poetry run web_server` 58 | * Seed DB data with: `poetry run seeder` 59 | * Run migrations with: `alembic upgrade head` 60 | * Test the API with: `pytest` 61 | * Check code style with: `black --check todolist` 62 | * Format code with: `black todolist` 63 | * Lint the code with: `flake8 todolist tests` 64 | * Run static analysis with: `mypy job_form_api tests` 65 | 66 | ### PGAdmin 67 | 68 | A configured instance of PGAdmin for database monitoring and management is provided by default. 69 | 70 | To start it, run: `plis start pgadmin` 71 | 72 | ### Linting, static check and code style guide 73 | 74 | Flake8 is the tool of choice for linting. It is configured to warn about code problems, complexity problems, common bugs and to help developers write better code. 75 | 76 | Mypy is the tool of choice for static type checking. This project adopts gradual typing as the metodology for code typing. The rules of Mypy will be updated periodically to ensure that the entire code base is typed and consistent. 77 | 78 | Black is the tool of choice for formating and code style guide. Black is an uncompromising formatter for Python code, that is, no code style discussions are necessary, the tool is always correct. 79 | 80 | Linter and static type checking rules can be discussed and reviewed with the entire team. Any merge request that tries to change these rules without consent is automatically rejected and closed. 81 | -------------------------------------------------------------------------------- /tests/unit/core/todo/entities/todo_item/todo_item_test.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | from typing import Any, Dict 3 | 4 | import pytest 5 | from pydantic import ValidationError 6 | 7 | from tests.utils.asserts import assert_validation_error 8 | from todolist.core.todo.entities.todo_item import TodoItem 9 | 10 | # Types 11 | DataType = Dict[str, Any] 12 | 13 | 14 | # Fixtures 15 | @pytest.fixture(name="valid_data") 16 | def valid_data_fixture() -> DataType: 17 | return { 18 | "id": 1, 19 | "msg": "some message", 20 | "is_done": True, 21 | "user_id": 1, 22 | } 23 | 24 | 25 | @pytest.fixture(name="invalid_data") 26 | def invalid_data_fixture() -> DataType: 27 | return { 28 | "id": "some integer", 29 | "msg": ["some string"], 30 | "is_done": "some bool", 31 | "user_id": "some id", 32 | } 33 | 34 | 35 | @pytest.mark.unit 36 | class TestTodoItem: 37 | class TestModel: 38 | def test_validation(self, valid_data): 39 | assert TodoItem(**valid_data) 40 | 41 | def test_invalidation(self, invalid_data): 42 | with pytest.raises(ValidationError): 43 | TodoItem(**invalid_data) 44 | 45 | def test_immutability(self, valid_data): 46 | tdi = TodoItem(**valid_data) 47 | for key in tdi.dict().keys(): 48 | with pytest.raises(TypeError): 49 | setattr(tdi, key, "some value") 50 | 51 | class TestId: 52 | assert_validation_error = partial(assert_validation_error, 1, "id") 53 | 54 | def test_must_be_int(self, valid_data): 55 | with pytest.raises(ValidationError) as excinfo: 56 | valid_data.update({"id": "some integer"}) 57 | TodoItem(**valid_data) 58 | 59 | self.assert_validation_error("type_error.integer", excinfo) 60 | 61 | def test_is_required(self, valid_data): 62 | with pytest.raises(ValidationError) as excinfo: 63 | valid_data.pop("id") 64 | TodoItem(**valid_data) 65 | 66 | self.assert_validation_error("value_error.missing", excinfo) 67 | 68 | class TestMsg: 69 | assert_validation_error = partial(assert_validation_error, 1, "msg") 70 | 71 | def test_must_be_str(self, valid_data): 72 | with pytest.raises(ValidationError) as excinfo: 73 | valid_data.update({"msg": ["some string"]}) 74 | TodoItem(**valid_data) 75 | 76 | self.assert_validation_error("type_error.str", excinfo) 77 | 78 | def test_is_required(self, valid_data): 79 | with pytest.raises(ValidationError) as excinfo: 80 | valid_data.pop("msg") 81 | TodoItem(**valid_data) 82 | 83 | self.assert_validation_error("value_error.missing", excinfo) 84 | 85 | def test_min_length_gte_3(self, valid_data): 86 | with pytest.raises(ValidationError) as excinfo: 87 | valid_data.update({"msg": "a" * 2}) 88 | TodoItem(**valid_data) 89 | 90 | self.assert_validation_error("value_error.any_str.min_length", excinfo) 91 | 92 | def test_max_length_lte_100(self, valid_data): 93 | with pytest.raises(ValidationError) as excinfo: 94 | valid_data.update({"msg": "a" * 101}) 95 | TodoItem(**valid_data) 96 | 97 | self.assert_validation_error("value_error.any_str.max_length", excinfo) 98 | 99 | class TestIsDone: 100 | assert_validation_error = partial(assert_validation_error, 1, "is_done") 101 | 102 | def test_must_be_bool(self, valid_data): 103 | with pytest.raises(ValidationError) as excinfo: 104 | valid_data.update({"is_done": "some bool"}) 105 | TodoItem(**valid_data) 106 | 107 | self.assert_validation_error("type_error.bool", excinfo) 108 | 109 | def test_is_required(self, valid_data): 110 | with pytest.raises(ValidationError) as excinfo: 111 | valid_data.pop("is_done") 112 | TodoItem(**valid_data) 113 | 114 | self.assert_validation_error("value_error.missing", excinfo) 115 | 116 | class TestUserId: 117 | assert_validation_error = partial(assert_validation_error, 1, "user_id") 118 | 119 | def test_must_be_int(self, valid_data): 120 | with pytest.raises(ValidationError) as excinfo: 121 | valid_data.update({"user_id": "some integer"}) 122 | TodoItem(**valid_data) 123 | 124 | self.assert_validation_error("type_error.integer", excinfo) 125 | 126 | def test_is_required(self, valid_data): 127 | with pytest.raises(ValidationError) as excinfo: 128 | valid_data.pop("user_id") 129 | TodoItem(**valid_data) 130 | 131 | self.assert_validation_error("value_error.missing", excinfo) 132 | -------------------------------------------------------------------------------- /tests/integration/infra/database/repositories/todo_item_repository_test.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | from operator import attrgetter 3 | 4 | import pytest 5 | from pytest_factoryboy import register 6 | 7 | from tests.factories.entity_factories import ( 8 | CreateTodoItemDtoFactory, 9 | UpdateTodoItemDtoFactory, 10 | UserFactory, 11 | ) 12 | from tests.factories.model_factories import insert_todo_item, register_user 13 | from tests.factories.utils import make_many 14 | from todolist.core.accounts.entities.user import UserRegistry 15 | from todolist.infra.database.repositories import todo_item_repository 16 | 17 | FACTORIES = [ 18 | CreateTodoItemDtoFactory, 19 | UpdateTodoItemDtoFactory, 20 | UserFactory, 21 | ] 22 | 23 | for factory in FACTORIES: 24 | register(factory) 25 | 26 | 27 | @pytest.fixture(name="create_todo_item_dto") 28 | def create_todo_item_dto_fixture(create_todo_item_dto_factory): 29 | return create_todo_item_dto_factory() 30 | 31 | 32 | @pytest.fixture(name="create_todo_item_dtos") 33 | def create_todo_item_dtos_fixture(create_todo_item_dto_factory): 34 | return partial(make_many, create_todo_item_dto_factory) 35 | 36 | 37 | @pytest.fixture(name="update_todo_item_dto") 38 | def update_todo_item_dto_fixture(update_todo_item_dto_factory): 39 | return update_todo_item_dto_factory() 40 | 41 | 42 | @pytest.fixture(name="update_todo_item_dtos") 43 | def update_todo_item_dtos_fixture(update_todo_item_dto_factory): 44 | return partial(make_many, update_todo_item_dto_factory) 45 | 46 | 47 | @pytest.fixture(name="user_registry") 48 | def user_registry(user_factory): 49 | values = user_factory().dict() 50 | register_user(values) 51 | return UserRegistry(**values) 52 | 53 | 54 | @pytest.mark.integration 55 | @pytest.mark.asyncio 56 | async def test_delete(database, user_registry, create_todo_item_dto): 57 | id_ = 1 58 | insert_todo_item( 59 | {**create_todo_item_dto.dict(), "id": id_, "user_id": user_registry.id} 60 | ) 61 | 62 | async with database.transaction(): 63 | assert await todo_item_repository.delete(user_registry, id_) 64 | assert not await todo_item_repository.delete(user_registry, id_) 65 | 66 | 67 | @pytest.mark.integration 68 | @pytest.mark.asyncio 69 | async def test_exists_by_id(database, user_registry, create_todo_item_dtos): 70 | values = [ 71 | {**value.dict(), "id": idx + 1, "user_id": user_registry.id} 72 | for idx, value in enumerate(create_todo_item_dtos(5)) 73 | ] 74 | insert_todo_item(values) 75 | 76 | async with database.transaction(): 77 | for id_ in range(1, 6): 78 | assert await todo_item_repository.exists_by_id(id_) 79 | 80 | 81 | @pytest.mark.integration 82 | @pytest.mark.asyncio 83 | async def test_fetch_all_by_user(database, user_registry, create_todo_item_dtos): 84 | values = create_todo_item_dtos(5) 85 | insert_todo_item([{**dto.dict(), "user_id": user_registry.id} for dto in values]) 86 | getter = attrgetter("msg", "is_done") 87 | 88 | async with database.transaction(): 89 | results = await todo_item_repository.fetch_all_by_user(user_registry) 90 | for result, value in zip(results, values): 91 | assert getter(result) == getter(value) 92 | 93 | 94 | @pytest.mark.integration 95 | @pytest.mark.asyncio 96 | async def test_fetch(database, user_registry, create_todo_item_dto): 97 | id_ = 1 98 | insert_todo_item( 99 | {**create_todo_item_dto.dict(), "id": id_, "user_id": user_registry.id} 100 | ) 101 | getter = attrgetter("msg", "is_done") 102 | 103 | async with database.transaction(): 104 | result = await todo_item_repository.fetch(user_registry, id_) 105 | assert getter(result) == getter(create_todo_item_dto) 106 | 107 | 108 | @pytest.mark.integration 109 | @pytest.mark.asyncio 110 | async def test_persist(database, user_registry, create_todo_item_dto): 111 | getter = attrgetter("msg", "is_done") 112 | 113 | async with database.transaction(): 114 | result = await todo_item_repository.persist(user_registry, create_todo_item_dto) 115 | assert getter(result) == getter(create_todo_item_dto) 116 | 117 | 118 | @pytest.mark.integration 119 | @pytest.mark.asyncio 120 | async def test_replace(database, user_registry, create_todo_item_dto): 121 | id_ = 1 122 | insert_todo_item( 123 | { 124 | **create_todo_item_dto.dict(), 125 | "msg": "message to replace", 126 | "is_done": False, 127 | "id": id_, 128 | "user_id": user_registry.id, 129 | } 130 | ) 131 | getter = attrgetter("msg", "is_done") 132 | 133 | async with database.transaction(): 134 | result = await todo_item_repository.replace( 135 | user_registry, create_todo_item_dto, id_ 136 | ) 137 | assert getter(result) == getter(create_todo_item_dto) 138 | 139 | 140 | @pytest.mark.integration 141 | @pytest.mark.asyncio 142 | async def test_update( 143 | database, user_registry, create_todo_item_dto, update_todo_item_dto 144 | ): 145 | id_ = 1 146 | insert_todo_item( 147 | {**create_todo_item_dto.dict(), "id": id_, "user_id": user_registry.id} 148 | ) 149 | getter = attrgetter("msg", "is_done") 150 | 151 | async with database.transaction(): 152 | result = await todo_item_repository.update( 153 | user_registry, update_todo_item_dto, id_ 154 | ) 155 | assert getter(result) == getter(update_todo_item_dto) 156 | -------------------------------------------------------------------------------- /tests/unit/core/accounts/services/user_service_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from todolist.core.accounts.entities.user import User 4 | from todolist.core.accounts.protocols import UserRepo 5 | from todolist.core.accounts.services import hash_service, user_service 6 | from todolist.core.accounts.services.exceptions import ( 7 | EmailNotUniqueError, 8 | UserNotFoundError, 9 | ) 10 | 11 | 12 | @pytest.fixture() 13 | def user_repo(mock_module): 14 | return mock_module("user_repo", UserRepo) 15 | 16 | 17 | # Tests 18 | @pytest.mark.unit 19 | @pytest.mark.asyncio 20 | class TestGetByCredentials: 21 | async def test_valid_credentials(self, user_repo, credentials, user): 22 | # Setup 23 | email = credentials.email 24 | password_hash = hash_service.hash_(credentials.password) 25 | 26 | user_repo.fetch_by_email.return_value = User( 27 | **{**user.dict(), "email": email, "password_hash": password_hash} 28 | ) 29 | 30 | # Test 31 | result = await user_service.get_by_credentials(user_repo, credentials) 32 | 33 | # Assertions 34 | user_repo.fetch_by_email.assert_called_once_with(email) 35 | assert result and result.email == email 36 | 37 | async def test_user_not_found(self, user_repo, credentials): 38 | # Setup 39 | user_repo.fetch_by_email.return_value = None 40 | 41 | # Test 42 | result = await user_service.get_by_credentials(user_repo, credentials) 43 | 44 | # Assertions 45 | user_repo.fetch_by_email.assert_called_once_with(credentials.email) 46 | assert not result 47 | 48 | async def test_invalid_credentials(self, user_repo, credentials, user): 49 | # Setup 50 | email = credentials.email 51 | password_hash = hash_service.hash_("other password") 52 | 53 | user_repo.fetch_by_email.return_value = User( 54 | **{**user.dict(), "email": email, "password_hash": password_hash} 55 | ) 56 | 57 | # Test 58 | result = await user_service.get_by_credentials(user_repo, credentials) 59 | 60 | # Assertions 61 | user_repo.fetch_by_email.assert_called_once_with(email) 62 | assert not result 63 | 64 | 65 | @pytest.mark.unit 66 | @pytest.mark.asyncio 67 | class TestGetById: 68 | async def test_valid_id(self, user_repo, user): 69 | # Setup 70 | id_ = 1 71 | user_repo.fetch.return_value = User(**{**user.dict(), "id": id_}) 72 | 73 | # Test 74 | result = await user_service.get_by_id(user_repo, id_) 75 | 76 | # Assertions 77 | user_repo.fetch.assert_called_once_with(id_) 78 | assert result and result.id == id_ 79 | 80 | async def test_invalid_id(self, user_repo): 81 | # Setup 82 | id_ = 1 83 | user_repo.fetch.return_value = None 84 | 85 | # Test 86 | result = await user_service.get_by_id(user_repo, id_) 87 | 88 | # Assertions 89 | user_repo.fetch.assert_called_once_with(id_) 90 | assert not result 91 | 92 | 93 | @pytest.mark.unit 94 | @pytest.mark.asyncio 95 | class TestGetByIdOrRaise: 96 | async def test_valid_id(self, user_repo, user): 97 | # Setup 98 | id_ = 1 99 | user_repo.fetch.return_value = User(**{**user.dict(), "id": id_}) 100 | 101 | # Test 102 | result = await user_service.get_by_id_or_raise(user_repo, id_) 103 | 104 | # Assertions 105 | user_repo.fetch.assert_called_once_with(id_) 106 | assert result and result.id == id_ 107 | 108 | async def test_invalid_id(self, user_repo): 109 | # Setup 110 | id_ = 1 111 | user_repo.fetch.return_value = None 112 | 113 | # Test 114 | with pytest.raises(UserNotFoundError) as excinfo: 115 | await user_service.get_by_id_or_raise(user_repo, id_) 116 | 117 | # Assertions 118 | user_repo.fetch.assert_called_once_with(id_) 119 | error = excinfo.value 120 | assert error.msg == "user not found" 121 | assert error.user_id == id_ 122 | 123 | 124 | @pytest.mark.unit 125 | @pytest.mark.asyncio 126 | class TestRegister: 127 | async def test_register_unique(self, user_repo, credentials, user): 128 | # Setup 129 | email = credentials.email 130 | password_hash = hash_service.hash_(credentials.password) 131 | 132 | user_repo.fetch_by_email.return_value = None 133 | user_repo.persist.return_value = User( 134 | **{**user.dict(), "email": email, "password_hash": password_hash} 135 | ) 136 | 137 | # Test 138 | result = await user_service.register(user_repo, credentials) 139 | 140 | # Assertions 141 | user_repo.fetch_by_email.assert_called_once() 142 | user_repo.persist.assert_called_once() 143 | assert result.email == credentials.email 144 | 145 | async def test_register_not_unique(self, user_repo, credentials, user): 146 | # Setup 147 | email = credentials.email 148 | password_hash = hash_service.hash_(credentials.password) 149 | 150 | user_repo.fetch_by_email.return_value = User( 151 | **{**user.dict(), "email": email, "password_hash": password_hash} 152 | ) 153 | 154 | # Test 155 | with pytest.raises(EmailNotUniqueError) as excinfo: 156 | await user_service.register(user_repo, credentials) 157 | 158 | # Assertions 159 | error = excinfo.value 160 | assert error.msg == "email already registered" 161 | assert error.email == email 162 | -------------------------------------------------------------------------------- /tests/integration/api/routers/todo/todo_item_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from tests.factories.model_factories import insert_todo_item 4 | from tests.utils.auth import auth_headers 5 | 6 | 7 | @pytest.mark.integration 8 | class TestHandleCreateOne: 9 | def test_success(self, test_client, logged_user, create_todo_item_dto): 10 | with test_client: 11 | response = test_client.post( 12 | "/todo/item", 13 | json=create_todo_item_dto.dict(), 14 | headers=auth_headers(logged_user.access_token), 15 | ) 16 | data, status_code = response.json(), response.status_code 17 | assert status_code == 201 18 | assert data == { 19 | "id": 1, 20 | "msg": create_todo_item_dto.msg, 21 | "is_done": create_todo_item_dto.is_done, 22 | "user_id": logged_user.user.id, 23 | } 24 | 25 | def test_validation_error(self, test_client, logged_user): 26 | with test_client: 27 | response = test_client.post( 28 | "/todo/item", json={}, headers=auth_headers(logged_user.access_token) 29 | ) 30 | data, status_code = response.json(), response.status_code 31 | assert status_code == 422 32 | assert data == { 33 | "detail": [ 34 | { 35 | "loc": ["body", "dto", "msg"], 36 | "msg": "field required", 37 | "type": "value_error.missing", 38 | } 39 | ] 40 | } 41 | 42 | def test_authorization(self, test_client): 43 | with test_client: 44 | response = test_client.post("/todo/item") 45 | data, status_code = response.json(), response.status_code 46 | assert data == {"detail": "Not authenticated"} 47 | assert status_code == 401 48 | 49 | 50 | @pytest.mark.integration 51 | class TestHandleDeleteOne: 52 | def test_success(self, test_client, logged_user, create_todo_item_dto): 53 | with test_client: 54 | id_ = 1 55 | insert_todo_item( 56 | { 57 | **create_todo_item_dto.dict(), 58 | "id": id_, 59 | "user_id": logged_user.user.id, 60 | } 61 | ) 62 | response = test_client.delete( 63 | f"/todo/item/{id_}", headers=auth_headers(logged_user.access_token) 64 | ) 65 | assert response.status_code == 204 66 | 67 | def test_item_not_found(self, test_client, logged_user): 68 | with test_client: 69 | id_ = 1 70 | response = test_client.delete( 71 | f"/todo/item/{id_}", headers=auth_headers(logged_user.access_token) 72 | ) 73 | assert response.status_code == 404 74 | 75 | def test_validation_error(self, test_client, logged_user): 76 | with test_client: 77 | id_ = 1.0 78 | response = test_client.delete( 79 | f"/todo/item/{id_}", headers=auth_headers(logged_user.access_token) 80 | ) 81 | data, status_code = response.json(), response.status_code 82 | assert status_code == 422 83 | assert data == { 84 | "detail": [ 85 | { 86 | "loc": ["path", "item_id"], 87 | "msg": "value is not a valid integer", 88 | "type": "type_error.integer", 89 | } 90 | ] 91 | } 92 | 93 | def test_authorization(self, test_client): 94 | with test_client: 95 | response = test_client.delete("/todo/item/1") 96 | data, status_code = response.json(), response.status_code 97 | assert data == {"detail": "Not authenticated"} 98 | assert status_code == 401 99 | 100 | 101 | @pytest.mark.integration 102 | class TestHandleGetAll: 103 | def test_success(self, test_client, logged_user, create_todo_item_dtos): 104 | with test_client: 105 | values = [ 106 | {**value.dict(), "id": idx, "user_id": logged_user.user.id} 107 | for idx, value in enumerate(create_todo_item_dtos(5)) 108 | ] 109 | insert_todo_item(values) 110 | 111 | response = test_client.get( 112 | "/todo/item", headers=auth_headers(logged_user.access_token) 113 | ) 114 | data, status_code = response.json(), response.status_code 115 | assert status_code == 200 116 | assert len(data) == 5 117 | assert data == values 118 | 119 | def test_authorization(self, test_client): 120 | with test_client: 121 | response = test_client.get("/todo/item") 122 | data, status_code = response.json(), response.status_code 123 | assert data == {"detail": "Not authenticated"} 124 | assert status_code == 401 125 | 126 | 127 | @pytest.mark.integration 128 | class TestHandleGetOne: 129 | def test_success(self, test_client, logged_user, create_todo_item_dto): 130 | with test_client: 131 | id_ = 1 132 | value = { 133 | **create_todo_item_dto.dict(), 134 | "id": id_, 135 | "user_id": logged_user.user.id, 136 | } 137 | insert_todo_item(value) 138 | 139 | response = test_client.get( 140 | f"/todo/item/{id_}", headers=auth_headers(logged_user.access_token) 141 | ) 142 | data, status_code = response.json(), response.status_code 143 | assert status_code == 200 144 | assert data == value 145 | 146 | def test_item_not_found(self, test_client, logged_user): 147 | with test_client: 148 | id_ = 1 149 | response = test_client.get( 150 | f"/todo/item/{id_}", headers=auth_headers(logged_user.access_token) 151 | ) 152 | assert response.status_code == 404 153 | 154 | def test_authorization(self, test_client): 155 | with test_client: 156 | response = test_client.get(f"/todo/item/1") 157 | data, status_code = response.json(), response.status_code 158 | assert data == {"detail": "Not authenticated"} 159 | assert status_code == 401 160 | 161 | 162 | @pytest.mark.integration 163 | class TestHandleReplaceOne: 164 | def test_success(self, test_client, logged_user, create_todo_item_dto): 165 | with test_client: 166 | id_ = 1 167 | user_id = logged_user.user.id 168 | 169 | insert_todo_item( 170 | { 171 | **create_todo_item_dto.dict(), 172 | "msg": create_todo_item_dto.msg[::-1], 173 | "id": id_, 174 | "user_id": user_id, 175 | } 176 | ) 177 | 178 | response = test_client.put( 179 | f"/todo/item/{id_}", 180 | json=create_todo_item_dto.dict(), 181 | headers=auth_headers(logged_user.access_token), 182 | ) 183 | data, status_code = response.json(), response.status_code 184 | assert status_code == 200 185 | assert data == { 186 | "id": id_, 187 | "msg": create_todo_item_dto.msg, 188 | "is_done": create_todo_item_dto.is_done, 189 | "user_id": user_id, 190 | } 191 | 192 | def test_item_not_found(self, test_client, logged_user, create_todo_item_dto): 193 | with test_client: 194 | id_ = 1 195 | response = test_client.put( 196 | f"/todo/item/{id_}", 197 | json=create_todo_item_dto.dict(), 198 | headers=auth_headers(logged_user.access_token), 199 | ) 200 | assert response.status_code == 404 201 | 202 | def test_validation_error(self, test_client, logged_user): 203 | with test_client: 204 | id_ = 1 205 | response = test_client.put( 206 | f"/todo/item/{id_}", 207 | json={}, 208 | headers=auth_headers(logged_user.access_token), 209 | ) 210 | data, status_code = response.json(), response.status_code 211 | assert status_code == 422 212 | assert data == { 213 | "detail": [ 214 | { 215 | "loc": ["body", "dto", "msg"], 216 | "msg": "field required", 217 | "type": "value_error.missing", 218 | } 219 | ] 220 | } 221 | 222 | def test_authorization(self, test_client): 223 | with test_client: 224 | response = test_client.put(f"/todo/item/1") 225 | data, status_code = response.json(), response.status_code 226 | assert data == {"detail": "Not authenticated"} 227 | assert status_code == 401 228 | 229 | 230 | @pytest.mark.integration 231 | class TestHandleUpdateOne: 232 | def test_success( 233 | self, test_client, logged_user, create_todo_item_dto, update_todo_item_dto 234 | ): 235 | with test_client: 236 | id_ = 1 237 | user_id = logged_user.user.id 238 | insert_todo_item( 239 | { 240 | **create_todo_item_dto.dict(), 241 | "msg": create_todo_item_dto.msg, 242 | "id": id_, 243 | "user_id": user_id, 244 | } 245 | ) 246 | 247 | response = test_client.patch( 248 | f"/todo/item/{id_}", 249 | json=update_todo_item_dto.dict(exclude={"msg"}), 250 | headers=auth_headers(logged_user.access_token), 251 | ) 252 | data, status_code = response.json(), response.status_code 253 | assert status_code == 200 254 | assert data == { 255 | "id": id_, 256 | "msg": create_todo_item_dto.msg, 257 | "is_done": update_todo_item_dto.is_done, 258 | "user_id": user_id, 259 | } 260 | 261 | def test_item_not_found(self, test_client, logged_user, update_todo_item_dto): 262 | with test_client: 263 | id_ = 6 264 | response = test_client.patch( 265 | f"/todo/item/{id_}", 266 | json=update_todo_item_dto.dict(), 267 | headers=auth_headers(logged_user.access_token), 268 | ) 269 | assert response.status_code == 404 270 | 271 | def test_authorization(self, test_client): 272 | with test_client: 273 | response = test_client.patch(f"/todo/item/1") 274 | data, status_code = response.json(), response.status_code 275 | assert data == {"detail": "Not authenticated"} 276 | assert status_code == 401 277 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | category = "main" 3 | description = "A database migration tool for SQLAlchemy." 4 | name = "alembic" 5 | optional = false 6 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 7 | version = "1.4.1" 8 | 9 | [package.dependencies] 10 | Mako = "*" 11 | SQLAlchemy = ">=1.1.0" 12 | python-dateutil = "*" 13 | python-editor = ">=0.3" 14 | 15 | [[package]] 16 | category = "dev" 17 | description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 18 | name = "appdirs" 19 | optional = false 20 | python-versions = "*" 21 | version = "1.4.3" 22 | 23 | [[package]] 24 | category = "main" 25 | description = "The secure Argon2 password hashing algorithm." 26 | name = "argon2-cffi" 27 | optional = false 28 | python-versions = "*" 29 | version = "19.2.0" 30 | 31 | [package.dependencies] 32 | cffi = ">=1.0.0" 33 | six = "*" 34 | 35 | [package.extras] 36 | dev = ["coverage", "hypothesis", "pytest", "sphinx", "wheel", "pre-commit"] 37 | docs = ["sphinx"] 38 | tests = ["coverage", "hypothesis", "pytest"] 39 | 40 | [[package]] 41 | category = "main" 42 | description = "An asyncio PostgreSQL driver" 43 | name = "asyncpg" 44 | optional = false 45 | python-versions = ">=3.5.0" 46 | version = "0.20.1" 47 | 48 | [package.extras] 49 | dev = ["Cython (0.29.14)", "pytest (>=3.6.0)", "Sphinx (>=1.7.3,<1.8.0)", "sphinxcontrib-asyncio (>=0.2.0,<0.3.0)", "sphinx-rtd-theme (>=0.2.4,<0.3.0)", "pycodestyle (>=2.5.0,<2.6.0)", "flake8 (>=3.7.9,<3.8.0)", "uvloop (>=0.14.0,<0.15.0)"] 50 | docs = ["Sphinx (>=1.7.3,<1.8.0)", "sphinxcontrib-asyncio (>=0.2.0,<0.3.0)", "sphinx-rtd-theme (>=0.2.4,<0.3.0)"] 51 | test = ["pycodestyle (>=2.5.0,<2.6.0)", "flake8 (>=3.7.9,<3.8.0)", "uvloop (>=0.14.0,<0.15.0)"] 52 | 53 | [[package]] 54 | category = "dev" 55 | description = "Atomic file writes." 56 | marker = "sys_platform == \"win32\"" 57 | name = "atomicwrites" 58 | optional = false 59 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 60 | version = "1.3.0" 61 | 62 | [[package]] 63 | category = "dev" 64 | description = "Classes Without Boilerplate" 65 | name = "attrs" 66 | optional = false 67 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 68 | version = "19.3.0" 69 | 70 | [package.extras] 71 | azure-pipelines = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "pytest-azurepipelines"] 72 | dev = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "sphinx", "pre-commit"] 73 | docs = ["sphinx", "zope.interface"] 74 | tests = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] 75 | 76 | [[package]] 77 | category = "dev" 78 | description = "The uncompromising code formatter." 79 | name = "black" 80 | optional = false 81 | python-versions = ">=3.6" 82 | version = "19.10b0" 83 | 84 | [package.dependencies] 85 | appdirs = "*" 86 | attrs = ">=18.1.0" 87 | click = ">=6.5" 88 | pathspec = ">=0.6,<1" 89 | regex = "*" 90 | toml = ">=0.9.4" 91 | typed-ast = ">=1.4.0" 92 | 93 | [package.extras] 94 | d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] 95 | 96 | [[package]] 97 | category = "main" 98 | description = "Foreign Function Interface for Python calling C code." 99 | name = "cffi" 100 | optional = false 101 | python-versions = "*" 102 | version = "1.14.0" 103 | 104 | [package.dependencies] 105 | pycparser = "*" 106 | 107 | [[package]] 108 | category = "main" 109 | description = "Composable command line interface toolkit" 110 | name = "click" 111 | optional = false 112 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 113 | version = "7.1.1" 114 | 115 | [[package]] 116 | category = "dev" 117 | description = "Cross-platform colored terminal text." 118 | marker = "sys_platform == \"win32\"" 119 | name = "colorama" 120 | optional = false 121 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 122 | version = "0.4.3" 123 | 124 | [[package]] 125 | category = "dev" 126 | description = "Code coverage measurement for Python" 127 | name = "coverage" 128 | optional = false 129 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" 130 | version = "5.0.3" 131 | 132 | [package.extras] 133 | toml = ["toml"] 134 | 135 | [[package]] 136 | category = "main" 137 | description = "Async database support for Python." 138 | name = "databases" 139 | optional = false 140 | python-versions = ">=3.6" 141 | version = "0.2.6" 142 | 143 | [package.dependencies] 144 | sqlalchemy = "*" 145 | 146 | [package.dependencies.asyncpg] 147 | optional = true 148 | version = "*" 149 | 150 | [package.dependencies.psycopg2-binary] 151 | optional = true 152 | version = "*" 153 | 154 | [package.extras] 155 | mysql = ["aiomysql", "pymysql"] 156 | postgresql = ["asyncpg", "psycopg2-binary"] 157 | sqlite = ["aiosqlite"] 158 | 159 | [[package]] 160 | category = "main" 161 | description = "DNS toolkit" 162 | name = "dnspython" 163 | optional = false 164 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 165 | version = "1.16.0" 166 | 167 | [package.extras] 168 | DNSSEC = ["pycryptodome", "ecdsa (>=0.13)"] 169 | IDNA = ["idna (>=2.1)"] 170 | 171 | [[package]] 172 | category = "main" 173 | description = "A robust email syntax and deliverability validation library for Python 2.x/3.x." 174 | name = "email-validator" 175 | optional = false 176 | python-versions = "*" 177 | version = "1.0.5" 178 | 179 | [package.dependencies] 180 | dnspython = ">=1.15.0" 181 | idna = ">=2.0.0" 182 | 183 | [[package]] 184 | category = "dev" 185 | description = "Discover and load entry points from installed packages." 186 | name = "entrypoints" 187 | optional = false 188 | python-versions = ">=2.7" 189 | version = "0.3" 190 | 191 | [[package]] 192 | category = "dev" 193 | description = "Removes commented-out code." 194 | name = "eradicate" 195 | optional = false 196 | python-versions = "*" 197 | version = "1.0" 198 | 199 | [[package]] 200 | category = "dev" 201 | description = "A versatile test fixtures replacement based on thoughtbot's factory_bot for Ruby." 202 | name = "factory-boy" 203 | optional = false 204 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 205 | version = "2.12.0" 206 | 207 | [package.dependencies] 208 | Faker = ">=0.7.0" 209 | 210 | [[package]] 211 | category = "dev" 212 | description = "Faker is a Python package that generates fake data for you." 213 | name = "faker" 214 | optional = false 215 | python-versions = ">=3.4" 216 | version = "4.0.1" 217 | 218 | [package.dependencies] 219 | python-dateutil = ">=2.4" 220 | text-unidecode = "1.3" 221 | 222 | [[package]] 223 | category = "main" 224 | description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" 225 | name = "fastapi" 226 | optional = false 227 | python-versions = ">=3.6" 228 | version = "0.52.0" 229 | 230 | [package.dependencies] 231 | pydantic = ">=0.32.2,<2.0.0" 232 | starlette = "0.13.2" 233 | 234 | [package.extras] 235 | all = ["requests", "aiofiles", "jinja2", "python-multipart", "itsdangerous", "pyyaml", "graphene", "ujson", "email-validator", "uvicorn", "async-exit-stack", "async-generator"] 236 | dev = ["pyjwt", "passlib", "autoflake", "flake8", "uvicorn", "graphene"] 237 | doc = ["mkdocs", "mkdocs-material", "markdown-include"] 238 | test = ["pytest (>=4.0.0)", "pytest-cov", "mypy", "black", "isort", "requests", "email-validator", "sqlalchemy", "peewee", "databases", "orjson", "async-exit-stack", "async-generator", "python-multipart", "aiofiles", "ujson", "flask"] 239 | 240 | [[package]] 241 | category = "dev" 242 | description = "the modular source code checker: pep8, pyflakes and co" 243 | name = "flake8" 244 | optional = false 245 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 246 | version = "3.7.9" 247 | 248 | [package.dependencies] 249 | entrypoints = ">=0.3.0,<0.4.0" 250 | mccabe = ">=0.6.0,<0.7.0" 251 | pycodestyle = ">=2.5.0,<2.6.0" 252 | pyflakes = ">=2.1.0,<2.2.0" 253 | 254 | [[package]] 255 | category = "dev" 256 | description = "flake8 plugin to call black as a code style validator" 257 | name = "flake8-black" 258 | optional = false 259 | python-versions = "*" 260 | version = "0.1.1" 261 | 262 | [package.dependencies] 263 | black = ">=19.3b0" 264 | flake8 = ">=3.0.0" 265 | 266 | [[package]] 267 | category = "dev" 268 | description = "Flake8 plugin to forbid backslashes for line breaks" 269 | name = "flake8-broken-line" 270 | optional = false 271 | python-versions = ">=3.6,<4.0" 272 | version = "0.1.1" 273 | 274 | [package.dependencies] 275 | flake8 = ">=3.5,<4.0" 276 | 277 | [[package]] 278 | category = "dev" 279 | description = "A plugin for flake8 finding likely bugs and design problems in your program. Contains warnings that don't belong in pyflakes and pycodestyle." 280 | name = "flake8-bugbear" 281 | optional = false 282 | python-versions = ">=3.6" 283 | version = "20.1.4" 284 | 285 | [package.dependencies] 286 | attrs = ">=19.2.0" 287 | flake8 = ">=3.0.0" 288 | 289 | [[package]] 290 | category = "dev" 291 | description = "Check for python builtins being used as variables or parameters." 292 | name = "flake8-builtins" 293 | optional = false 294 | python-versions = "*" 295 | version = "1.4.2" 296 | 297 | [package.dependencies] 298 | flake8 = "*" 299 | 300 | [package.extras] 301 | test = ["coverage", "coveralls", "mock", "pytest", "pytest-cov"] 302 | 303 | [[package]] 304 | category = "dev" 305 | description = "A flake8 plugin to help you write better list/set/dict comprehensions." 306 | name = "flake8-comprehensions" 307 | optional = false 308 | python-versions = ">=3.5" 309 | version = "3.2.2" 310 | 311 | [package.dependencies] 312 | flake8 = ">=3.0,<3.2.0 || >3.2.0,<4" 313 | 314 | [[package]] 315 | category = "dev" 316 | description = "Extension for flake8 which uses pydocstyle to check docstrings" 317 | name = "flake8-docstrings" 318 | optional = false 319 | python-versions = "*" 320 | version = "1.5.0" 321 | 322 | [package.dependencies] 323 | flake8 = ">=3" 324 | pydocstyle = ">=2.1" 325 | 326 | [[package]] 327 | category = "dev" 328 | description = "Flake8 plugin to find commented out code" 329 | name = "flake8-eradicate" 330 | optional = false 331 | python-versions = ">=3.6,<4.0" 332 | version = "0.2.4" 333 | 334 | [package.dependencies] 335 | attrs = ">=18.2,<20.0" 336 | eradicate = ">=0.2.1,<1.1.0" 337 | flake8 = ">=3.5,<4.0" 338 | 339 | [[package]] 340 | category = "dev" 341 | description = "The package provides base classes and utils for flake8 plugin writing" 342 | name = "flake8-plugin-utils" 343 | optional = false 344 | python-versions = ">=3.6,<4.0" 345 | version = "1.2.0" 346 | 347 | [[package]] 348 | category = "dev" 349 | description = "Polyfill package for Flake8 plugins" 350 | name = "flake8-polyfill" 351 | optional = false 352 | python-versions = "*" 353 | version = "1.0.2" 354 | 355 | [package.dependencies] 356 | flake8 = "*" 357 | 358 | [[package]] 359 | category = "dev" 360 | description = "A flake8 plugin checking common style issues or inconsistencies with pytest-based tests." 361 | name = "flake8-pytest-style" 362 | optional = false 363 | python-versions = ">=3.6,<4.0" 364 | version = "0.2.0" 365 | 366 | [package.dependencies] 367 | flake8-plugin-utils = ">=1.0,<2.0" 368 | 369 | [[package]] 370 | category = "main" 371 | description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" 372 | name = "h11" 373 | optional = false 374 | python-versions = "*" 375 | version = "0.9.0" 376 | 377 | [[package]] 378 | category = "main" 379 | description = "A collection of framework independent HTTP protocol utils." 380 | marker = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"" 381 | name = "httptools" 382 | optional = false 383 | python-versions = "*" 384 | version = "0.1.1" 385 | 386 | [package.extras] 387 | test = ["Cython (0.29.14)"] 388 | 389 | [[package]] 390 | category = "main" 391 | description = "Internationalized Domain Names in Applications (IDNA)" 392 | name = "idna" 393 | optional = false 394 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 395 | version = "2.9" 396 | 397 | [[package]] 398 | category = "dev" 399 | description = "A port of Ruby on Rails inflector to Python" 400 | name = "inflection" 401 | optional = false 402 | python-versions = "*" 403 | version = "0.3.1" 404 | 405 | [[package]] 406 | category = "main" 407 | description = "A super-fast templating language that borrows the best ideas from the existing templating languages." 408 | name = "mako" 409 | optional = false 410 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 411 | version = "1.1.2" 412 | 413 | [package.dependencies] 414 | MarkupSafe = ">=0.9.2" 415 | 416 | [package.extras] 417 | babel = ["babel"] 418 | lingua = ["lingua"] 419 | 420 | [[package]] 421 | category = "main" 422 | description = "Safely add untrusted strings to HTML/XML markup." 423 | name = "markupsafe" 424 | optional = false 425 | python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" 426 | version = "1.1.1" 427 | 428 | [[package]] 429 | category = "dev" 430 | description = "McCabe checker, plugin for flake8" 431 | name = "mccabe" 432 | optional = false 433 | python-versions = "*" 434 | version = "0.6.1" 435 | 436 | [[package]] 437 | category = "dev" 438 | description = "More routines for operating on iterables, beyond itertools" 439 | name = "more-itertools" 440 | optional = false 441 | python-versions = ">=3.5" 442 | version = "8.2.0" 443 | 444 | [[package]] 445 | category = "dev" 446 | description = "Optional static typing for Python" 447 | name = "mypy" 448 | optional = false 449 | python-versions = ">=3.5" 450 | version = "0.761" 451 | 452 | [package.dependencies] 453 | mypy-extensions = ">=0.4.3,<0.5.0" 454 | typed-ast = ">=1.4.0,<1.5.0" 455 | typing-extensions = ">=3.7.4" 456 | 457 | [package.extras] 458 | dmypy = ["psutil (>=4.0)"] 459 | 460 | [[package]] 461 | category = "dev" 462 | description = "Experimental type system extensions for programs checked with the mypy typechecker." 463 | name = "mypy-extensions" 464 | optional = false 465 | python-versions = "*" 466 | version = "0.4.3" 467 | 468 | [[package]] 469 | category = "dev" 470 | description = "Core utilities for Python packages" 471 | name = "packaging" 472 | optional = false 473 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 474 | version = "20.3" 475 | 476 | [package.dependencies] 477 | pyparsing = ">=2.0.2" 478 | six = "*" 479 | 480 | [[package]] 481 | category = "main" 482 | description = "comprehensive password hashing framework supporting over 30 schemes" 483 | name = "passlib" 484 | optional = false 485 | python-versions = "*" 486 | version = "1.7.2" 487 | 488 | [package.extras] 489 | argon2 = ["argon2-cffi (>=18.2.0)"] 490 | bcrypt = ["bcrypt (>=3.1.0)"] 491 | build_docs = ["sphinx (>=1.6)", "sphinxcontrib-fulltoc (>=1.2.0)", "cloud-sptheme (>=1.10.0)"] 492 | totp = ["cryptography"] 493 | 494 | [[package]] 495 | category = "dev" 496 | description = "Utility library for gitignore style pattern matching of file paths." 497 | name = "pathspec" 498 | optional = false 499 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 500 | version = "0.7.0" 501 | 502 | [[package]] 503 | category = "dev" 504 | description = "Check PEP-8 naming conventions, plugin for flake8" 505 | name = "pep8-naming" 506 | optional = false 507 | python-versions = "*" 508 | version = "0.9.1" 509 | 510 | [package.dependencies] 511 | flake8-polyfill = ">=1.0.2,<2" 512 | 513 | [[package]] 514 | category = "dev" 515 | description = "plugin and hook calling mechanisms for python" 516 | name = "pluggy" 517 | optional = false 518 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 519 | version = "0.13.1" 520 | 521 | [package.extras] 522 | dev = ["pre-commit", "tox"] 523 | 524 | [[package]] 525 | category = "main" 526 | description = "psycopg2 - Python-PostgreSQL Database Adapter" 527 | name = "psycopg2-binary" 528 | optional = false 529 | python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" 530 | version = "2.8.4" 531 | 532 | [[package]] 533 | category = "dev" 534 | description = "library with cross-python path, ini-parsing, io, code, log facilities" 535 | name = "py" 536 | optional = false 537 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 538 | version = "1.8.1" 539 | 540 | [[package]] 541 | category = "dev" 542 | description = "Python style guide checker" 543 | name = "pycodestyle" 544 | optional = false 545 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 546 | version = "2.5.0" 547 | 548 | [[package]] 549 | category = "main" 550 | description = "C parser in Python" 551 | name = "pycparser" 552 | optional = false 553 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 554 | version = "2.20" 555 | 556 | [[package]] 557 | category = "main" 558 | description = "Data validation and settings management using python 3.6 type hinting" 559 | name = "pydantic" 560 | optional = false 561 | python-versions = ">=3.6" 562 | version = "1.4" 563 | 564 | [package.extras] 565 | dotenv = ["python-dotenv (>=0.10.4)"] 566 | email = ["email-validator (>=1.0.3)"] 567 | typing_extensions = ["typing-extensions (>=3.7.2)"] 568 | 569 | [[package]] 570 | category = "dev" 571 | description = "Python docstring style checker" 572 | name = "pydocstyle" 573 | optional = false 574 | python-versions = ">=3.5" 575 | version = "5.0.2" 576 | 577 | [package.dependencies] 578 | snowballstemmer = "*" 579 | 580 | [[package]] 581 | category = "dev" 582 | description = "passive checker of Python programs" 583 | name = "pyflakes" 584 | optional = false 585 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 586 | version = "2.1.1" 587 | 588 | [[package]] 589 | category = "main" 590 | description = "JSON Web Token implementation in Python" 591 | name = "pyjwt" 592 | optional = false 593 | python-versions = "*" 594 | version = "1.7.1" 595 | 596 | [package.extras] 597 | crypto = ["cryptography (>=1.4)"] 598 | flake8 = ["flake8", "flake8-import-order", "pep8-naming"] 599 | test = ["pytest (>=4.0.1,<5.0.0)", "pytest-cov (>=2.6.0,<3.0.0)", "pytest-runner (>=4.2,<5.0.0)"] 600 | 601 | [[package]] 602 | category = "dev" 603 | description = "Python parsing module" 604 | name = "pyparsing" 605 | optional = false 606 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 607 | version = "2.4.6" 608 | 609 | [[package]] 610 | category = "dev" 611 | description = "pytest: simple powerful testing with Python" 612 | name = "pytest" 613 | optional = false 614 | python-versions = ">=3.5" 615 | version = "5.3.5" 616 | 617 | [package.dependencies] 618 | atomicwrites = ">=1.0" 619 | attrs = ">=17.4.0" 620 | colorama = "*" 621 | more-itertools = ">=4.0.0" 622 | packaging = "*" 623 | pluggy = ">=0.12,<1.0" 624 | py = ">=1.5.0" 625 | wcwidth = "*" 626 | 627 | [package.extras] 628 | checkqa-mypy = ["mypy (v0.761)"] 629 | testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] 630 | 631 | [[package]] 632 | category = "dev" 633 | description = "Pytest support for asyncio." 634 | name = "pytest-asyncio" 635 | optional = false 636 | python-versions = ">= 3.5" 637 | version = "0.10.0" 638 | 639 | [package.dependencies] 640 | pytest = ">=3.0.6" 641 | 642 | [package.extras] 643 | testing = ["async-generator (>=1.3)", "coverage", "hypothesis (>=3.64)"] 644 | 645 | [[package]] 646 | category = "dev" 647 | description = "Pytest plugin for measuring coverage." 648 | name = "pytest-cov" 649 | optional = false 650 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 651 | version = "2.8.1" 652 | 653 | [package.dependencies] 654 | coverage = ">=4.4" 655 | pytest = ">=3.6" 656 | 657 | [package.extras] 658 | testing = ["fields", "hunter", "process-tests (2.0.2)", "six", "virtualenv"] 659 | 660 | [[package]] 661 | category = "dev" 662 | description = "Factory Boy support for pytest." 663 | name = "pytest-factoryboy" 664 | optional = false 665 | python-versions = "*" 666 | version = "2.0.3" 667 | 668 | [package.dependencies] 669 | factory_boy = ">=2.10.0" 670 | inflection = "*" 671 | pytest = ">=3.3.2" 672 | 673 | [[package]] 674 | category = "dev" 675 | description = "Thin-wrapper around the mock package for easier use with py.test" 676 | name = "pytest-mock" 677 | optional = false 678 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 679 | version = "2.0.0" 680 | 681 | [package.dependencies] 682 | pytest = ">=2.7" 683 | 684 | [package.extras] 685 | dev = ["pre-commit", "tox"] 686 | 687 | [[package]] 688 | category = "dev" 689 | description = "pytest-sugar is a plugin for pytest that changes the default look and feel of pytest (e.g. progressbar, show tests that fail instantly)." 690 | name = "pytest-sugar" 691 | optional = false 692 | python-versions = "*" 693 | version = "0.9.2" 694 | 695 | [package.dependencies] 696 | packaging = ">=14.1" 697 | pytest = ">=2.9" 698 | termcolor = ">=1.1.0" 699 | 700 | [[package]] 701 | category = "main" 702 | description = "Extensions to the standard Python datetime module" 703 | name = "python-dateutil" 704 | optional = false 705 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" 706 | version = "2.8.1" 707 | 708 | [package.dependencies] 709 | six = ">=1.5" 710 | 711 | [[package]] 712 | category = "dev" 713 | description = "Add .env support to your django/flask apps in development and deployments" 714 | name = "python-dotenv" 715 | optional = false 716 | python-versions = "*" 717 | version = "0.12.0" 718 | 719 | [package.extras] 720 | cli = ["click (>=5.0)"] 721 | 722 | [[package]] 723 | category = "main" 724 | description = "Programmatically open an editor, capture the result." 725 | name = "python-editor" 726 | optional = false 727 | python-versions = "*" 728 | version = "1.0.4" 729 | 730 | [[package]] 731 | category = "main" 732 | description = "A streaming multipart parser for Python" 733 | name = "python-multipart" 734 | optional = false 735 | python-versions = "*" 736 | version = "0.0.5" 737 | 738 | [package.dependencies] 739 | six = ">=1.4.0" 740 | 741 | [[package]] 742 | category = "dev" 743 | description = "Alternative regular expression module, to replace re." 744 | name = "regex" 745 | optional = false 746 | python-versions = "*" 747 | version = "2020.2.20" 748 | 749 | [[package]] 750 | category = "main" 751 | description = "Python 2 and 3 compatibility utilities" 752 | name = "six" 753 | optional = false 754 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 755 | version = "1.14.0" 756 | 757 | [[package]] 758 | category = "dev" 759 | description = "This package provides 26 stemmers for 25 languages generated from Snowball algorithms." 760 | name = "snowballstemmer" 761 | optional = false 762 | python-versions = "*" 763 | version = "2.0.0" 764 | 765 | [[package]] 766 | category = "main" 767 | description = "Database Abstraction Library" 768 | name = "sqlalchemy" 769 | optional = false 770 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 771 | version = "1.3.15" 772 | 773 | [package.extras] 774 | mssql = ["pyodbc"] 775 | mssql_pymssql = ["pymssql"] 776 | mssql_pyodbc = ["pyodbc"] 777 | mysql = ["mysqlclient"] 778 | oracle = ["cx-oracle"] 779 | postgresql = ["psycopg2"] 780 | postgresql_pg8000 = ["pg8000"] 781 | postgresql_psycopg2binary = ["psycopg2-binary"] 782 | postgresql_psycopg2cffi = ["psycopg2cffi"] 783 | pymysql = ["pymysql"] 784 | 785 | [[package]] 786 | category = "main" 787 | description = "The little ASGI library that shines." 788 | name = "starlette" 789 | optional = false 790 | python-versions = ">=3.6" 791 | version = "0.13.2" 792 | 793 | [package.extras] 794 | full = ["aiofiles", "graphene", "itsdangerous", "jinja2", "python-multipart", "pyyaml", "requests", "ujson"] 795 | 796 | [[package]] 797 | category = "dev" 798 | description = "ANSII Color formatting for output in terminal." 799 | name = "termcolor" 800 | optional = false 801 | python-versions = "*" 802 | version = "1.1.0" 803 | 804 | [[package]] 805 | category = "dev" 806 | description = "The most basic Text::Unidecode port" 807 | name = "text-unidecode" 808 | optional = false 809 | python-versions = "*" 810 | version = "1.3" 811 | 812 | [[package]] 813 | category = "dev" 814 | description = "Python Library for Tom's Obvious, Minimal Language" 815 | name = "toml" 816 | optional = false 817 | python-versions = "*" 818 | version = "0.10.0" 819 | 820 | [[package]] 821 | category = "main" 822 | description = "List processing tools and functional utilities" 823 | name = "toolz" 824 | optional = false 825 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 826 | version = "0.10.0" 827 | 828 | [[package]] 829 | category = "dev" 830 | description = "a fork of Python 2 and 3 ast modules with type comment support" 831 | name = "typed-ast" 832 | optional = false 833 | python-versions = "*" 834 | version = "1.4.1" 835 | 836 | [[package]] 837 | category = "dev" 838 | description = "Backported and Experimental Type Hints for Python 3.5+" 839 | name = "typing-extensions" 840 | optional = false 841 | python-versions = "*" 842 | version = "3.7.4.1" 843 | 844 | [[package]] 845 | category = "main" 846 | description = "The lightning-fast ASGI server." 847 | name = "uvicorn" 848 | optional = false 849 | python-versions = "*" 850 | version = "0.11.3" 851 | 852 | [package.dependencies] 853 | click = ">=7.0.0,<8.0.0" 854 | h11 = ">=0.8,<0.10" 855 | httptools = ">=0.1.0,<0.2.0" 856 | uvloop = ">=0.14.0" 857 | websockets = ">=8.0.0,<9.0.0" 858 | 859 | [[package]] 860 | category = "main" 861 | description = "Fast implementation of asyncio event loop on top of libuv" 862 | marker = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"" 863 | name = "uvloop" 864 | optional = false 865 | python-versions = "*" 866 | version = "0.14.0" 867 | 868 | [[package]] 869 | category = "dev" 870 | description = "Measures number of Terminal column cells of wide-character codes" 871 | name = "wcwidth" 872 | optional = false 873 | python-versions = "*" 874 | version = "0.1.8" 875 | 876 | [[package]] 877 | category = "main" 878 | description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" 879 | name = "websockets" 880 | optional = false 881 | python-versions = ">=3.6.1" 882 | version = "8.1" 883 | 884 | [metadata] 885 | content-hash = "2ed037fae6a5723c8be9b212ac0500fa3869f51dbcdd0b2e5085b5a03ae19eba" 886 | python-versions = "^3.8" 887 | 888 | [metadata.files] 889 | alembic = [ 890 | {file = "alembic-1.4.1.tar.gz", hash = "sha256:791a5686953c4b366d3228c5377196db2f534475bb38d26f70eb69668efd9028"}, 891 | ] 892 | appdirs = [ 893 | {file = "appdirs-1.4.3-py2.py3-none-any.whl", hash = "sha256:d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e"}, 894 | {file = "appdirs-1.4.3.tar.gz", hash = "sha256:9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92"}, 895 | ] 896 | argon2-cffi = [ 897 | {file = "argon2-cffi-19.2.0.tar.gz", hash = "sha256:ffaa623eea77b497ffbdd1a51e941b33d3bf552c60f14dbee274c4070677bda3"}, 898 | {file = "argon2_cffi-19.2.0-cp27-cp27m-macosx_10_6_intel.whl", hash = "sha256:5335f4caae27c00097bdd17c6a07a0ef32f47364cff3141451229c481f82abc6"}, 899 | {file = "argon2_cffi-19.2.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:73976c0b9d0fc847f967835fce93a9d1c07bf7422f74de2316e25ef6d59ee07e"}, 900 | {file = "argon2_cffi-19.2.0-cp27-cp27m-win32.whl", hash = "sha256:f2c7f3cd5fe6770fa21ee3d4bb7ba0e1c207e18ead511d912cbf9571c598c345"}, 901 | {file = "argon2_cffi-19.2.0-cp27-cp27m-win_amd64.whl", hash = "sha256:0abe0ab4f3ba927367812a5c345dc6ae7d58129e0c47732746da8058cccdfd55"}, 902 | {file = "argon2_cffi-19.2.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:27cb7791793a854ca9f9de5cc62e93c1a7812d11d600ef3ca14fe93ae7426799"}, 903 | {file = "argon2_cffi-19.2.0-cp34-abi3-macosx_10_6_intel.whl", hash = "sha256:00e94a65d46c3f6f2ffab769e860efc21f77e55bd8fcdfde422bd07c632b3fcc"}, 904 | {file = "argon2_cffi-19.2.0-cp34-abi3-manylinux1_x86_64.whl", hash = "sha256:49ec16a14e8182ed63daa5d7a13d12da6bfc46eebaac4b238c8d15533b621cf0"}, 905 | {file = "argon2_cffi-19.2.0-cp34-cp34m-win32.whl", hash = "sha256:748b565d4006a7e9f2b71f9d6ff660b4e150cc38faa29ca1b64b58e238c013b0"}, 906 | {file = "argon2_cffi-19.2.0-cp34-cp34m-win_amd64.whl", hash = "sha256:0920cfe813cd82e85c4fa84c8a01801a7cb376a8ed8267a1608e70a886953ac7"}, 907 | {file = "argon2_cffi-19.2.0-cp35-cp35m-win32.whl", hash = "sha256:f79045e7673d72ed0f33d78a5e104702f989eb1a0e0eb0ab5534cebc9cdb9142"}, 908 | {file = "argon2_cffi-19.2.0-cp35-cp35m-win_amd64.whl", hash = "sha256:e09d644829887c956478db83c47d1ad26f167b8f08e2ccebc6b78e59aeca60b5"}, 909 | {file = "argon2_cffi-19.2.0-cp36-cp36m-win32.whl", hash = "sha256:246860c7955aa614518a19277b06dda34902c54535aefd9220d07c774391890e"}, 910 | {file = "argon2_cffi-19.2.0-cp36-cp36m-win_amd64.whl", hash = "sha256:13082f670904dcb7f4ef39216139ace2d981f9050a8f3766e566f845e8c634fc"}, 911 | {file = "argon2_cffi-19.2.0-cp37-cp37m-win32.whl", hash = "sha256:184fee11f483b9168a32afaea8064abe464c1bb090d320f64f3609f1bbbdb691"}, 912 | {file = "argon2_cffi-19.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:e27488da690afac92e87cb61ba9cf9a22bff790413f55655ad017456cd58f22c"}, 913 | {file = "argon2_cffi-19.2.0-cp38-cp38-win32.whl", hash = "sha256:72fae6bf37c25fdb9b4d30c2b9d658a72ac775249dd132ab3ac03adde619dc14"}, 914 | {file = "argon2_cffi-19.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:61d2b16c08ce5c24f91d2d2917c2300a90bb78672060876a335e1014727ced57"}, 915 | ] 916 | asyncpg = [ 917 | {file = "asyncpg-0.20.1-cp35-cp35m-macosx_10_13_x86_64.whl", hash = "sha256:f7184689177eeb5a11fa1b2baf3f6f2e26bfd7a85acf4de1a3adbd0867d7c0e2"}, 918 | {file = "asyncpg-0.20.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:f0c9719ac00615f097fe91082b785bce36dbf02a5ec4115ede0ebfd2cd9500cb"}, 919 | {file = "asyncpg-0.20.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:1388caa456070dab102be874205e3ae8fd1de2577d5de9fa22e65ba5c0f8b110"}, 920 | {file = "asyncpg-0.20.1-cp35-cp35m-win32.whl", hash = "sha256:ec6e7046c98730cb2ba4df41387e10cb8963a3ac2918f69ae416f8aab9ca7b1b"}, 921 | {file = "asyncpg-0.20.1-cp35-cp35m-win_amd64.whl", hash = "sha256:25edb0b947eb632b6b53e5a4b36cba5677297bb34cbaba270019714d0a5fed76"}, 922 | {file = "asyncpg-0.20.1-cp36-cp36m-macosx_10_13_x86_64.whl", hash = "sha256:95cd2df61ee00b789bdcd04a080e6d9188693b841db2bf9a87ebaed9e53147e0"}, 923 | {file = "asyncpg-0.20.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:058baec9d6b75612412baa872a1aa47317d0ff88c318a49f9c4a2389043d5a8d"}, 924 | {file = "asyncpg-0.20.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:c773c7dbe2f4d3ebc9e3030e94303e45d6742e6c2fc25da0c46a56ea3d83caeb"}, 925 | {file = "asyncpg-0.20.1-cp36-cp36m-win32.whl", hash = "sha256:5664d1bd8abe64fc60a0e701eb85fa1d8c9a4a8018a5a59164d27238f2caf395"}, 926 | {file = "asyncpg-0.20.1-cp36-cp36m-win_amd64.whl", hash = "sha256:57666dfae38f4dbf84ffbf0c5c0f78733fef0e8e083230275dcb9ccad1d5ee09"}, 927 | {file = "asyncpg-0.20.1-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:0c336903c3b08e970f8af2f606332f1738dba156bca83ed0467dc2f5c70da796"}, 928 | {file = "asyncpg-0.20.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:ad5ba062e09673b1a4b8d0facaf5a6d9719bf7b337440d10b07fe994d90a9552"}, 929 | {file = "asyncpg-0.20.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:ba90d3578bc6dddcbce461875672fd9bdb34f0b8215b68612dd3b65a956ff51c"}, 930 | {file = "asyncpg-0.20.1-cp37-cp37m-win32.whl", hash = "sha256:da238592235717419a6a7b5edc8564da410ebfd056ca4ecc41e70b1b5df86fba"}, 931 | {file = "asyncpg-0.20.1-cp37-cp37m-win_amd64.whl", hash = "sha256:74510234c294c6a6767089ba9c938f09a491426c24405634eb357bd91dffd734"}, 932 | {file = "asyncpg-0.20.1-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:391aea89871df8c1560750af6c7170f2772c2d133b34772acf3637e3cf4db93e"}, 933 | {file = "asyncpg-0.20.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:a981500bf6947926e53c48f4d60ae080af1b4ad7fa78e363465a5b5ad4f2b65e"}, 934 | {file = "asyncpg-0.20.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:a9e6fd6f0f9e8bd77e9a4e1ef9a4f83a80674d9136a754ae3603e915da96b627"}, 935 | {file = "asyncpg-0.20.1-cp38-cp38-win32.whl", hash = "sha256:e39aac2b3a2f839ce65aa255ce416de899c58b7d38d601d24ca35558e13b48e3"}, 936 | {file = "asyncpg-0.20.1-cp38-cp38-win_amd64.whl", hash = "sha256:2af6a5a705accd36e13292ea43d08c20b15e52d684beb522cb3a7d3c9c8f3f48"}, 937 | {file = "asyncpg-0.20.1.tar.gz", hash = "sha256:394bf19bdddbba07a38cd6fb526ebf66e120444d6b3097332b78efd5b26495b0"}, 938 | ] 939 | atomicwrites = [ 940 | {file = "atomicwrites-1.3.0-py2.py3-none-any.whl", hash = "sha256:03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4"}, 941 | {file = "atomicwrites-1.3.0.tar.gz", hash = "sha256:75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6"}, 942 | ] 943 | attrs = [ 944 | {file = "attrs-19.3.0-py2.py3-none-any.whl", hash = "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c"}, 945 | {file = "attrs-19.3.0.tar.gz", hash = "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"}, 946 | ] 947 | black = [ 948 | {file = "black-19.10b0-py36-none-any.whl", hash = "sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b"}, 949 | {file = "black-19.10b0.tar.gz", hash = "sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539"}, 950 | ] 951 | cffi = [ 952 | {file = "cffi-1.14.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:1cae98a7054b5c9391eb3249b86e0e99ab1e02bb0cc0575da191aedadbdf4384"}, 953 | {file = "cffi-1.14.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:cf16e3cf6c0a5fdd9bc10c21687e19d29ad1fe863372b5543deaec1039581a30"}, 954 | {file = "cffi-1.14.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:f2b0fa0c01d8a0c7483afd9f31d7ecf2d71760ca24499c8697aeb5ca37dc090c"}, 955 | {file = "cffi-1.14.0-cp27-cp27m-win32.whl", hash = "sha256:99f748a7e71ff382613b4e1acc0ac83bf7ad167fb3802e35e90d9763daba4d78"}, 956 | {file = "cffi-1.14.0-cp27-cp27m-win_amd64.whl", hash = "sha256:c420917b188a5582a56d8b93bdd8e0f6eca08c84ff623a4c16e809152cd35793"}, 957 | {file = "cffi-1.14.0-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:399aed636c7d3749bbed55bc907c3288cb43c65c4389964ad5ff849b6370603e"}, 958 | {file = "cffi-1.14.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:cab50b8c2250b46fe738c77dbd25ce017d5e6fb35d3407606e7a4180656a5a6a"}, 959 | {file = "cffi-1.14.0-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:001bf3242a1bb04d985d63e138230802c6c8d4db3668fb545fb5005ddf5bb5ff"}, 960 | {file = "cffi-1.14.0-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:e56c744aa6ff427a607763346e4170629caf7e48ead6921745986db3692f987f"}, 961 | {file = "cffi-1.14.0-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:b8c78301cefcf5fd914aad35d3c04c2b21ce8629b5e4f4e45ae6812e461910fa"}, 962 | {file = "cffi-1.14.0-cp35-cp35m-win32.whl", hash = "sha256:8c0ffc886aea5df6a1762d0019e9cb05f825d0eec1f520c51be9d198701daee5"}, 963 | {file = "cffi-1.14.0-cp35-cp35m-win_amd64.whl", hash = "sha256:8a6c688fefb4e1cd56feb6c511984a6c4f7ec7d2a1ff31a10254f3c817054ae4"}, 964 | {file = "cffi-1.14.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:95cd16d3dee553f882540c1ffe331d085c9e629499ceadfbda4d4fde635f4b7d"}, 965 | {file = "cffi-1.14.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:66e41db66b47d0d8672d8ed2708ba91b2f2524ece3dee48b5dfb36be8c2f21dc"}, 966 | {file = "cffi-1.14.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:028a579fc9aed3af38f4892bdcc7390508adabc30c6af4a6e4f611b0c680e6ac"}, 967 | {file = "cffi-1.14.0-cp36-cp36m-win32.whl", hash = "sha256:cef128cb4d5e0b3493f058f10ce32365972c554572ff821e175dbc6f8ff6924f"}, 968 | {file = "cffi-1.14.0-cp36-cp36m-win_amd64.whl", hash = "sha256:337d448e5a725bba2d8293c48d9353fc68d0e9e4088d62a9571def317797522b"}, 969 | {file = "cffi-1.14.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e577934fc5f8779c554639376beeaa5657d54349096ef24abe8c74c5d9c117c3"}, 970 | {file = "cffi-1.14.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:62ae9af2d069ea2698bf536dcfe1e4eed9090211dbaafeeedf5cb6c41b352f66"}, 971 | {file = "cffi-1.14.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:14491a910663bf9f13ddf2bc8f60562d6bc5315c1f09c704937ef17293fb85b0"}, 972 | {file = "cffi-1.14.0-cp37-cp37m-win32.whl", hash = "sha256:c43866529f2f06fe0edc6246eb4faa34f03fe88b64a0a9a942561c8e22f4b71f"}, 973 | {file = "cffi-1.14.0-cp37-cp37m-win_amd64.whl", hash = "sha256:2089ed025da3919d2e75a4d963d008330c96751127dd6f73c8dc0c65041b4c26"}, 974 | {file = "cffi-1.14.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:3b911c2dbd4f423b4c4fcca138cadde747abdb20d196c4a48708b8a2d32b16dd"}, 975 | {file = "cffi-1.14.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:7e63cbcf2429a8dbfe48dcc2322d5f2220b77b2e17b7ba023d6166d84655da55"}, 976 | {file = "cffi-1.14.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:3d311bcc4a41408cf5854f06ef2c5cab88f9fded37a3b95936c9879c1640d4c2"}, 977 | {file = "cffi-1.14.0-cp38-cp38-win32.whl", hash = "sha256:675686925a9fb403edba0114db74e741d8181683dcf216be697d208857e04ca8"}, 978 | {file = "cffi-1.14.0-cp38-cp38-win_amd64.whl", hash = "sha256:00789914be39dffba161cfc5be31b55775de5ba2235fe49aa28c148236c4e06b"}, 979 | {file = "cffi-1.14.0.tar.gz", hash = "sha256:2d384f4a127a15ba701207f7639d94106693b6cd64173d6c8988e2c25f3ac2b6"}, 980 | ] 981 | click = [ 982 | {file = "click-7.1.1-py2.py3-none-any.whl", hash = "sha256:e345d143d80bf5ee7534056164e5e112ea5e22716bbb1ce727941f4c8b471b9a"}, 983 | {file = "click-7.1.1.tar.gz", hash = "sha256:8a18b4ea89d8820c5d0c7da8a64b2c324b4dabb695804dbfea19b9be9d88c0cc"}, 984 | ] 985 | colorama = [ 986 | {file = "colorama-0.4.3-py2.py3-none-any.whl", hash = "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff"}, 987 | {file = "colorama-0.4.3.tar.gz", hash = "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1"}, 988 | ] 989 | coverage = [ 990 | {file = "coverage-5.0.3-cp27-cp27m-macosx_10_12_x86_64.whl", hash = "sha256:cc1109f54a14d940b8512ee9f1c3975c181bbb200306c6d8b87d93376538782f"}, 991 | {file = "coverage-5.0.3-cp27-cp27m-macosx_10_13_intel.whl", hash = "sha256:be18f4ae5a9e46edae3f329de2191747966a34a3d93046dbdf897319923923bc"}, 992 | {file = "coverage-5.0.3-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:3230d1003eec018ad4a472d254991e34241e0bbd513e97a29727c7c2f637bd2a"}, 993 | {file = "coverage-5.0.3-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:e69215621707119c6baf99bda014a45b999d37602cb7043d943c76a59b05bf52"}, 994 | {file = "coverage-5.0.3-cp27-cp27m-win32.whl", hash = "sha256:1daa3eceed220f9fdb80d5ff950dd95112cd27f70d004c7918ca6dfc6c47054c"}, 995 | {file = "coverage-5.0.3-cp27-cp27m-win_amd64.whl", hash = "sha256:51bc7710b13a2ae0c726f69756cf7ffd4362f4ac36546e243136187cfcc8aa73"}, 996 | {file = "coverage-5.0.3-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:9bea19ac2f08672636350f203db89382121c9c2ade85d945953ef3c8cf9d2a68"}, 997 | {file = "coverage-5.0.3-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:5012d3b8d5a500834783689a5d2292fe06ec75dc86ee1ccdad04b6f5bf231691"}, 998 | {file = "coverage-5.0.3-cp35-cp35m-macosx_10_12_x86_64.whl", hash = "sha256:d513cc3db248e566e07a0da99c230aca3556d9b09ed02f420664e2da97eac301"}, 999 | {file = "coverage-5.0.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:3dbb72eaeea5763676a1a1efd9b427a048c97c39ed92e13336e726117d0b72bf"}, 1000 | {file = "coverage-5.0.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:15cf13a6896048d6d947bf7d222f36e4809ab926894beb748fc9caa14605d9c3"}, 1001 | {file = "coverage-5.0.3-cp35-cp35m-win32.whl", hash = "sha256:fca1669d464f0c9831fd10be2eef6b86f5ebd76c724d1e0706ebdff86bb4adf0"}, 1002 | {file = "coverage-5.0.3-cp35-cp35m-win_amd64.whl", hash = "sha256:1e44a022500d944d42f94df76727ba3fc0a5c0b672c358b61067abb88caee7a0"}, 1003 | {file = "coverage-5.0.3-cp36-cp36m-macosx_10_13_x86_64.whl", hash = "sha256:b26aaf69713e5674efbde4d728fb7124e429c9466aeaf5f4a7e9e699b12c9fe2"}, 1004 | {file = "coverage-5.0.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:722e4557c8039aad9592c6a4213db75da08c2cd9945320220634f637251c3894"}, 1005 | {file = "coverage-5.0.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:7afad9835e7a651d3551eab18cbc0fdb888f0a6136169fbef0662d9cdc9987cf"}, 1006 | {file = "coverage-5.0.3-cp36-cp36m-win32.whl", hash = "sha256:25dbf1110d70bab68a74b4b9d74f30e99b177cde3388e07cc7272f2168bd1477"}, 1007 | {file = "coverage-5.0.3-cp36-cp36m-win_amd64.whl", hash = "sha256:c312e57847db2526bc92b9bfa78266bfbaabac3fdcd751df4d062cd4c23e46dc"}, 1008 | {file = "coverage-5.0.3-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:a8b8ac7876bc3598e43e2603f772d2353d9931709345ad6c1149009fd1bc81b8"}, 1009 | {file = "coverage-5.0.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:527b4f316e6bf7755082a783726da20671a0cc388b786a64417780b90565b987"}, 1010 | {file = "coverage-5.0.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:d649dc0bcace6fcdb446ae02b98798a856593b19b637c1b9af8edadf2b150bea"}, 1011 | {file = "coverage-5.0.3-cp37-cp37m-win32.whl", hash = "sha256:cd60f507c125ac0ad83f05803063bed27e50fa903b9c2cfee3f8a6867ca600fc"}, 1012 | {file = "coverage-5.0.3-cp37-cp37m-win_amd64.whl", hash = "sha256:c60097190fe9dc2b329a0eb03393e2e0829156a589bd732e70794c0dd804258e"}, 1013 | {file = "coverage-5.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:d7008a6796095a79544f4da1ee49418901961c97ca9e9d44904205ff7d6aa8cb"}, 1014 | {file = "coverage-5.0.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:ea9525e0fef2de9208250d6c5aeeee0138921057cd67fcef90fbed49c4d62d37"}, 1015 | {file = "coverage-5.0.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:c62a2143e1313944bf4a5ab34fd3b4be15367a02e9478b0ce800cb510e3bbb9d"}, 1016 | {file = "coverage-5.0.3-cp38-cp38m-win32.whl", hash = "sha256:b0840b45187699affd4c6588286d429cd79a99d509fe3de0f209594669bb0954"}, 1017 | {file = "coverage-5.0.3-cp38-cp38m-win_amd64.whl", hash = "sha256:76e2057e8ffba5472fd28a3a010431fd9e928885ff480cb278877c6e9943cc2e"}, 1018 | {file = "coverage-5.0.3-cp39-cp39m-win32.whl", hash = "sha256:b63dd43f455ba878e5e9f80ba4f748c0a2156dde6e0e6e690310e24d6e8caf40"}, 1019 | {file = "coverage-5.0.3-cp39-cp39m-win_amd64.whl", hash = "sha256:da93027835164b8223e8e5af2cf902a4c80ed93cb0909417234f4a9df3bcd9af"}, 1020 | {file = "coverage-5.0.3.tar.gz", hash = "sha256:77afca04240c40450c331fa796b3eab6f1e15c5ecf8bf2b8bee9706cd5452fef"}, 1021 | ] 1022 | databases = [ 1023 | {file = "databases-0.2.6.tar.gz", hash = "sha256:a04db1d158a91db7bd49db16e14266e8e6c7336f06f88c700147690683c769a3"}, 1024 | ] 1025 | dnspython = [ 1026 | {file = "dnspython-1.16.0-py2.py3-none-any.whl", hash = "sha256:f69c21288a962f4da86e56c4905b49d11aba7938d3d740e80d9e366ee4f1632d"}, 1027 | {file = "dnspython-1.16.0.zip", hash = "sha256:36c5e8e38d4369a08b6780b7f27d790a292b2b08eea01607865bf0936c558e01"}, 1028 | ] 1029 | email-validator = [ 1030 | {file = "email_validator-1.0.5-py2.py3-none-any.whl", hash = "sha256:e3e6ede1765d7c1e580d2050d834b2689361f7da2d50ce74df6a5968fca7cb13"}, 1031 | ] 1032 | entrypoints = [ 1033 | {file = "entrypoints-0.3-py2.py3-none-any.whl", hash = "sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19"}, 1034 | {file = "entrypoints-0.3.tar.gz", hash = "sha256:c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451"}, 1035 | ] 1036 | eradicate = [ 1037 | {file = "eradicate-1.0.tar.gz", hash = "sha256:4ffda82aae6fd49dfffa777a857cb758d77502a1f2e0f54c9ac5155a39d2d01a"}, 1038 | ] 1039 | factory-boy = [ 1040 | {file = "factory_boy-2.12.0-py2.py3-none-any.whl", hash = "sha256:728df59b372c9588b83153facf26d3d28947fc750e8e3c95cefa9bed0e6394ee"}, 1041 | {file = "factory_boy-2.12.0.tar.gz", hash = "sha256:faf48d608a1735f0d0a3c9cbf536d64f9132b547dae7ba452c4d99a79e84a370"}, 1042 | ] 1043 | faker = [ 1044 | {file = "Faker-4.0.1-py3-none-any.whl", hash = "sha256:ee24608768549c2c69e593e9d7a3b53c9498ae735534243ec8390cae5d529f8b"}, 1045 | {file = "Faker-4.0.1.tar.gz", hash = "sha256:440d68fe0e46c1658b1975b2497abe0c24a7f772e3892253f31e713ffcc48965"}, 1046 | ] 1047 | fastapi = [ 1048 | {file = "fastapi-0.52.0-py3-none-any.whl", hash = "sha256:532648b4e16dd33673d71dc0b35dff1b4d20c709d04078010e258b9f3a79771a"}, 1049 | {file = "fastapi-0.52.0.tar.gz", hash = "sha256:721b11d8ffde52c669f52741b6d9d761fe2e98778586f4cfd6f5e47254ba5016"}, 1050 | ] 1051 | flake8 = [ 1052 | {file = "flake8-3.7.9-py2.py3-none-any.whl", hash = "sha256:49356e766643ad15072a789a20915d3c91dc89fd313ccd71802303fd67e4deca"}, 1053 | {file = "flake8-3.7.9.tar.gz", hash = "sha256:45681a117ecc81e870cbf1262835ae4af5e7a8b08e40b944a8a6e6b895914cfb"}, 1054 | ] 1055 | flake8-black = [ 1056 | {file = "flake8-black-0.1.1.tar.gz", hash = "sha256:56f85aaa5a83f06a3f61e680e3b50f156b5e557ebdcb964d823d86f4c108b0c8"}, 1057 | ] 1058 | flake8-broken-line = [ 1059 | {file = "flake8-broken-line-0.1.1.tar.gz", hash = "sha256:30378a3749911e453d0a9e03204156cbbd35bcc03fb89f12e6a5206e5baf3537"}, 1060 | {file = "flake8_broken_line-0.1.1-py3-none-any.whl", hash = "sha256:7721725dce3aeee1df371a252822f1fcecfaf2766dcf5bac54ee1b3f779ee9d1"}, 1061 | ] 1062 | flake8-bugbear = [ 1063 | {file = "flake8-bugbear-20.1.4.tar.gz", hash = "sha256:bd02e4b009fb153fe6072c31c52aeab5b133d508095befb2ffcf3b41c4823162"}, 1064 | {file = "flake8_bugbear-20.1.4-py36.py37.py38-none-any.whl", hash = "sha256:a3ddc03ec28ba2296fc6f89444d1c946a6b76460f859795b35b77d4920a51b63"}, 1065 | ] 1066 | flake8-builtins = [ 1067 | {file = "flake8-builtins-1.4.2.tar.gz", hash = "sha256:c44415fb19162ef3737056e700d5b99d48c3612a533943b4e16419a5d3de3a64"}, 1068 | {file = "flake8_builtins-1.4.2-py2.py3-none-any.whl", hash = "sha256:29bc0f7e68af481d088f5c96f8aeb02520abdfc900500484e3af969f42a38a5f"}, 1069 | ] 1070 | flake8-comprehensions = [ 1071 | {file = "flake8-comprehensions-3.2.2.tar.gz", hash = "sha256:e7db586bb6eb95afdfd87ed244c90e57ae1352db8ef0ad3012fca0200421e5df"}, 1072 | {file = "flake8_comprehensions-3.2.2-py3-none-any.whl", hash = "sha256:d08323aa801aef33477cd33f2f5ce3acb1aafd26803ab0d171d85d514c1273a2"}, 1073 | ] 1074 | flake8-docstrings = [ 1075 | {file = "flake8-docstrings-1.5.0.tar.gz", hash = "sha256:3d5a31c7ec6b7367ea6506a87ec293b94a0a46c0bce2bb4975b7f1d09b6f3717"}, 1076 | {file = "flake8_docstrings-1.5.0-py2.py3-none-any.whl", hash = "sha256:a256ba91bc52307bef1de59e2a009c3cf61c3d0952dbe035d6ff7208940c2edc"}, 1077 | ] 1078 | flake8-eradicate = [ 1079 | {file = "flake8-eradicate-0.2.4.tar.gz", hash = "sha256:b693e9dfe6da42dbc7fb75af8486495b9414d1ab0372d15efcf85a2ac85fd368"}, 1080 | {file = "flake8_eradicate-0.2.4-py3-none-any.whl", hash = "sha256:b0bcdbb70a489fb799f9ee11fefc57bd0d3251e1ea9bdc5bf454443cccfd620c"}, 1081 | ] 1082 | flake8-plugin-utils = [ 1083 | {file = "flake8-plugin-utils-1.2.0.tar.gz", hash = "sha256:0ec78b72e48b2bdaf0037e97105f0770a2b59b0e7b2519aaec35993f3538073f"}, 1084 | {file = "flake8_plugin_utils-1.2.0-py3-none-any.whl", hash = "sha256:94b04623082dd64e97b93177e53125d5d17657c9864a4d3a29ef31a9a6c39e15"}, 1085 | ] 1086 | flake8-polyfill = [ 1087 | {file = "flake8-polyfill-1.0.2.tar.gz", hash = "sha256:e44b087597f6da52ec6393a709e7108b2905317d0c0b744cdca6208e670d8eda"}, 1088 | {file = "flake8_polyfill-1.0.2-py2.py3-none-any.whl", hash = "sha256:12be6a34ee3ab795b19ca73505e7b55826d5f6ad7230d31b18e106400169b9e9"}, 1089 | ] 1090 | flake8-pytest-style = [ 1091 | {file = "flake8-pytest-style-0.2.0.tar.gz", hash = "sha256:f0a0ab6f4ff121ebd402049a93d04fb9ea149476bcd2867879f0f5b391cb7c26"}, 1092 | {file = "flake8_pytest_style-0.2.0-py3-none-any.whl", hash = "sha256:79537f9e4bfa2ad4a3b7528a30215c396c6cf179ae68610ffcd2de50763198c5"}, 1093 | ] 1094 | h11 = [ 1095 | {file = "h11-0.9.0-py2.py3-none-any.whl", hash = "sha256:4bc6d6a1238b7615b266ada57e0618568066f57dd6fa967d1290ec9309b2f2f1"}, 1096 | {file = "h11-0.9.0.tar.gz", hash = "sha256:33d4bca7be0fa039f4e84d50ab00531047e53d6ee8ffbc83501ea602c169cae1"}, 1097 | ] 1098 | httptools = [ 1099 | {file = "httptools-0.1.1-cp35-cp35m-macosx_10_13_x86_64.whl", hash = "sha256:a2719e1d7a84bb131c4f1e0cb79705034b48de6ae486eb5297a139d6a3296dce"}, 1100 | {file = "httptools-0.1.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:fa3cd71e31436911a44620473e873a256851e1f53dee56669dae403ba41756a4"}, 1101 | {file = "httptools-0.1.1-cp36-cp36m-macosx_10_13_x86_64.whl", hash = "sha256:86c6acd66765a934e8730bf0e9dfaac6fdcf2a4334212bd4a0a1c78f16475ca6"}, 1102 | {file = "httptools-0.1.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:bc3114b9edbca5a1eb7ae7db698c669eb53eb8afbbebdde116c174925260849c"}, 1103 | {file = "httptools-0.1.1-cp36-cp36m-win_amd64.whl", hash = "sha256:ac0aa11e99454b6a66989aa2d44bca41d4e0f968e395a0a8f164b401fefe359a"}, 1104 | {file = "httptools-0.1.1-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:96da81e1992be8ac2fd5597bf0283d832287e20cb3cfde8996d2b00356d4e17f"}, 1105 | {file = "httptools-0.1.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:56b6393c6ac7abe632f2294da53f30d279130a92e8ae39d8d14ee2e1b05ad1f2"}, 1106 | {file = "httptools-0.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:96eb359252aeed57ea5c7b3d79839aaa0382c9d3149f7d24dd7172b1bcecb009"}, 1107 | {file = "httptools-0.1.1-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:fea04e126014169384dee76a153d4573d90d0cbd1d12185da089f73c78390437"}, 1108 | {file = "httptools-0.1.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:3592e854424ec94bd17dc3e0c96a64e459ec4147e6d53c0a42d0ebcef9cb9c5d"}, 1109 | {file = "httptools-0.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:0a4b1b2012b28e68306575ad14ad5e9120b34fccd02a81eb08838d7e3bbb48be"}, 1110 | {file = "httptools-0.1.1.tar.gz", hash = "sha256:41b573cf33f64a8f8f3400d0a7faf48e1888582b6f6e02b82b9bd4f0bf7497ce"}, 1111 | ] 1112 | idna = [ 1113 | {file = "idna-2.9-py2.py3-none-any.whl", hash = "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa"}, 1114 | {file = "idna-2.9.tar.gz", hash = "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb"}, 1115 | ] 1116 | inflection = [ 1117 | {file = "inflection-0.3.1.tar.gz", hash = "sha256:18ea7fb7a7d152853386523def08736aa8c32636b047ade55f7578c4edeb16ca"}, 1118 | ] 1119 | mako = [ 1120 | {file = "Mako-1.1.2-py2.py3-none-any.whl", hash = "sha256:8e8b53c71c7e59f3de716b6832c4e401d903af574f6962edbbbf6ecc2a5fe6c9"}, 1121 | {file = "Mako-1.1.2.tar.gz", hash = "sha256:3139c5d64aa5d175dbafb95027057128b5fbd05a40c53999f3905ceb53366d9d"}, 1122 | ] 1123 | markupsafe = [ 1124 | {file = "MarkupSafe-1.1.1-cp27-cp27m-macosx_10_6_intel.whl", hash = "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161"}, 1125 | {file = "MarkupSafe-1.1.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7"}, 1126 | {file = "MarkupSafe-1.1.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183"}, 1127 | {file = "MarkupSafe-1.1.1-cp27-cp27m-win32.whl", hash = "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b"}, 1128 | {file = "MarkupSafe-1.1.1-cp27-cp27m-win_amd64.whl", hash = "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e"}, 1129 | {file = "MarkupSafe-1.1.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f"}, 1130 | {file = "MarkupSafe-1.1.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1"}, 1131 | {file = "MarkupSafe-1.1.1-cp34-cp34m-macosx_10_6_intel.whl", hash = "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5"}, 1132 | {file = "MarkupSafe-1.1.1-cp34-cp34m-manylinux1_i686.whl", hash = "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1"}, 1133 | {file = "MarkupSafe-1.1.1-cp34-cp34m-manylinux1_x86_64.whl", hash = "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735"}, 1134 | {file = "MarkupSafe-1.1.1-cp34-cp34m-win32.whl", hash = "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21"}, 1135 | {file = "MarkupSafe-1.1.1-cp34-cp34m-win_amd64.whl", hash = "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235"}, 1136 | {file = "MarkupSafe-1.1.1-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b"}, 1137 | {file = "MarkupSafe-1.1.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f"}, 1138 | {file = "MarkupSafe-1.1.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905"}, 1139 | {file = "MarkupSafe-1.1.1-cp35-cp35m-win32.whl", hash = "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1"}, 1140 | {file = "MarkupSafe-1.1.1-cp35-cp35m-win_amd64.whl", hash = "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d"}, 1141 | {file = "MarkupSafe-1.1.1-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff"}, 1142 | {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473"}, 1143 | {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e"}, 1144 | {file = "MarkupSafe-1.1.1-cp36-cp36m-win32.whl", hash = "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66"}, 1145 | {file = "MarkupSafe-1.1.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5"}, 1146 | {file = "MarkupSafe-1.1.1-cp37-cp37m-macosx_10_6_intel.whl", hash = "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d"}, 1147 | {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e"}, 1148 | {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6"}, 1149 | {file = "MarkupSafe-1.1.1-cp37-cp37m-win32.whl", hash = "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2"}, 1150 | {file = "MarkupSafe-1.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c"}, 1151 | {file = "MarkupSafe-1.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15"}, 1152 | {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2"}, 1153 | {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42"}, 1154 | {file = "MarkupSafe-1.1.1-cp38-cp38-win32.whl", hash = "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b"}, 1155 | {file = "MarkupSafe-1.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"}, 1156 | {file = "MarkupSafe-1.1.1.tar.gz", hash = "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b"}, 1157 | ] 1158 | mccabe = [ 1159 | {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, 1160 | {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, 1161 | ] 1162 | more-itertools = [ 1163 | {file = "more-itertools-8.2.0.tar.gz", hash = "sha256:b1ddb932186d8a6ac451e1d95844b382f55e12686d51ca0c68b6f61f2ab7a507"}, 1164 | {file = "more_itertools-8.2.0-py3-none-any.whl", hash = "sha256:5dd8bcf33e5f9513ffa06d5ad33d78f31e1931ac9a18f33d37e77a180d393a7c"}, 1165 | ] 1166 | mypy = [ 1167 | {file = "mypy-0.761-cp35-cp35m-macosx_10_6_x86_64.whl", hash = "sha256:7f672d02fffcbace4db2b05369142e0506cdcde20cea0e07c7c2171c4fd11dd6"}, 1168 | {file = "mypy-0.761-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:87c556fb85d709dacd4b4cb6167eecc5bbb4f0a9864b69136a0d4640fdc76a36"}, 1169 | {file = "mypy-0.761-cp35-cp35m-win_amd64.whl", hash = "sha256:c6d27bd20c3ba60d5b02f20bd28e20091d6286a699174dfad515636cb09b5a72"}, 1170 | {file = "mypy-0.761-cp36-cp36m-macosx_10_6_x86_64.whl", hash = "sha256:4b9365ade157794cef9685791032521233729cb00ce76b0ddc78749abea463d2"}, 1171 | {file = "mypy-0.761-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:634aef60b4ff0f650d3e59d4374626ca6153fcaff96ec075b215b568e6ee3cb0"}, 1172 | {file = "mypy-0.761-cp36-cp36m-win_amd64.whl", hash = "sha256:53ea810ae3f83f9c9b452582261ea859828a9ed666f2e1ca840300b69322c474"}, 1173 | {file = "mypy-0.761-cp37-cp37m-macosx_10_6_x86_64.whl", hash = "sha256:0a9a45157e532da06fe56adcfef8a74629566b607fa2c1ac0122d1ff995c748a"}, 1174 | {file = "mypy-0.761-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:7eadc91af8270455e0d73565b8964da1642fe226665dd5c9560067cd64d56749"}, 1175 | {file = "mypy-0.761-cp37-cp37m-win_amd64.whl", hash = "sha256:e2bb577d10d09a2d8822a042a23b8d62bc3b269667c9eb8e60a6edfa000211b1"}, 1176 | {file = "mypy-0.761-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2c35cae79ceb20d47facfad51f952df16c2ae9f45db6cb38405a3da1cf8fc0a7"}, 1177 | {file = "mypy-0.761-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:f97a605d7c8bc2c6d1172c2f0d5a65b24142e11a58de689046e62c2d632ca8c1"}, 1178 | {file = "mypy-0.761-cp38-cp38-win_amd64.whl", hash = "sha256:a6bd44efee4dc8c3324c13785a9dc3519b3ee3a92cada42d2b57762b7053b49b"}, 1179 | {file = "mypy-0.761-py3-none-any.whl", hash = "sha256:7e396ce53cacd5596ff6d191b47ab0ea18f8e0ec04e15d69728d530e86d4c217"}, 1180 | {file = "mypy-0.761.tar.gz", hash = "sha256:85baab8d74ec601e86134afe2bcccd87820f79d2f8d5798c889507d1088287bf"}, 1181 | ] 1182 | mypy-extensions = [ 1183 | {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, 1184 | {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, 1185 | ] 1186 | packaging = [ 1187 | {file = "packaging-20.3-py2.py3-none-any.whl", hash = "sha256:82f77b9bee21c1bafbf35a84905d604d5d1223801d639cf3ed140bd651c08752"}, 1188 | {file = "packaging-20.3.tar.gz", hash = "sha256:3c292b474fda1671ec57d46d739d072bfd495a4f51ad01a055121d81e952b7a3"}, 1189 | ] 1190 | passlib = [ 1191 | {file = "passlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:68c35c98a7968850e17f1b6892720764cc7eed0ef2b7cb3116a89a28e43fe177"}, 1192 | {file = "passlib-1.7.2.tar.gz", hash = "sha256:8d666cef936198bc2ab47ee9b0410c94adf2ba798e5a84bf220be079ae7ab6a8"}, 1193 | ] 1194 | pathspec = [ 1195 | {file = "pathspec-0.7.0-py2.py3-none-any.whl", hash = "sha256:163b0632d4e31cef212976cf57b43d9fd6b0bac6e67c26015d611a647d5e7424"}, 1196 | {file = "pathspec-0.7.0.tar.gz", hash = "sha256:562aa70af2e0d434367d9790ad37aed893de47f1693e4201fd1d3dca15d19b96"}, 1197 | ] 1198 | pep8-naming = [ 1199 | {file = "pep8-naming-0.9.1.tar.gz", hash = "sha256:a33d38177056321a167decd6ba70b890856ba5025f0a8eca6a3eda607da93caf"}, 1200 | {file = "pep8_naming-0.9.1-py2.py3-none-any.whl", hash = "sha256:45f330db8fcfb0fba57458c77385e288e7a3be1d01e8ea4268263ef677ceea5f"}, 1201 | ] 1202 | pluggy = [ 1203 | {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, 1204 | {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, 1205 | ] 1206 | psycopg2-binary = [ 1207 | {file = "psycopg2-binary-2.8.4.tar.gz", hash = "sha256:3a2522b1d9178575acee4adf8fd9f979f9c0449b00b4164bb63c3475ea6528ed"}, 1208 | {file = "psycopg2_binary-2.8.4-cp27-cp27m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:890167d5091279a27e2505ff0e1fb273f8c48c41d35c5b92adbf4af80e6b2ed6"}, 1209 | {file = "psycopg2_binary-2.8.4-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:dbc5cd56fff1a6152ca59445178652756f4e509f672e49ccdf3d79c1043113a4"}, 1210 | {file = "psycopg2_binary-2.8.4-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:7f42a8490c4fe854325504ce7a6e4796b207960dabb2cbafe3c3959cb00d1d7e"}, 1211 | {file = "psycopg2_binary-2.8.4-cp27-cp27m-win32.whl", hash = "sha256:8578d6b8192e4c805e85f187bc530d0f52ba86c39172e61cd51f68fddd648103"}, 1212 | {file = "psycopg2_binary-2.8.4-cp27-cp27m-win_amd64.whl", hash = "sha256:5dd90c5438b4f935c9d01fcbad3620253da89d19c1f5fca9158646407ed7df35"}, 1213 | {file = "psycopg2_binary-2.8.4-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:9aadff9032e967865f9778485571e93908d27dab21d0fdfdec0ca779bb6f8ad9"}, 1214 | {file = "psycopg2_binary-2.8.4-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:659c815b5b8e2a55193ede2795c1e2349b8011497310bb936da7d4745652823b"}, 1215 | {file = "psycopg2_binary-2.8.4-cp34-cp34m-manylinux1_i686.whl", hash = "sha256:2166e770cb98f02ed5ee2b0b569d40db26788e0bf2ec3ae1a0d864ea6f1d8309"}, 1216 | {file = "psycopg2_binary-2.8.4-cp34-cp34m-manylinux1_x86_64.whl", hash = "sha256:7e6e3c52e6732c219c07bd97fff6c088f8df4dae3b79752ee3a817e6f32e177e"}, 1217 | {file = "psycopg2_binary-2.8.4-cp34-cp34m-win32.whl", hash = "sha256:040234f8a4a8dfd692662a8308d78f63f31a97e1c42d2480e5e6810c48966a29"}, 1218 | {file = "psycopg2_binary-2.8.4-cp34-cp34m-win_amd64.whl", hash = "sha256:69b13fdf12878b10dc6003acc8d0abf3ad93e79813fd5f3812497c1c9fb9be49"}, 1219 | {file = "psycopg2_binary-2.8.4-cp35-cp35m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:19dc39616850342a2a6db70559af55b22955f86667b5f652f40c0e99253d9881"}, 1220 | {file = "psycopg2_binary-2.8.4-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:9f24f383a298a0c0f9b3113b982e21751a8ecde6615494a3f1470eb4a9d70e9e"}, 1221 | {file = "psycopg2_binary-2.8.4-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:eaed1c65f461a959284649e37b5051224f4db6ebdc84e40b5e65f2986f101a08"}, 1222 | {file = "psycopg2_binary-2.8.4-cp35-cp35m-win32.whl", hash = "sha256:4c6717962247445b4f9e21c962ea61d2e884fc17df5ddf5e35863b016f8a1f03"}, 1223 | {file = "psycopg2_binary-2.8.4-cp35-cp35m-win_amd64.whl", hash = "sha256:84156313f258eafff716b2961644a4483a9be44a5d43551d554844d15d4d224e"}, 1224 | {file = "psycopg2_binary-2.8.4-cp36-cp36m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:3b5deaa3ee7180585a296af33e14c9b18c218d148e735c7accf78130765a47e3"}, 1225 | {file = "psycopg2_binary-2.8.4-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:5057669b6a66aa9ca118a2a860159f0ee3acf837eda937bdd2a64f3431361a2d"}, 1226 | {file = "psycopg2_binary-2.8.4-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:afd96845e12638d2c44d213d4810a08f4dc4a563f9a98204b7428e567014b1cd"}, 1227 | {file = "psycopg2_binary-2.8.4-cp36-cp36m-win32.whl", hash = "sha256:a73021b44813b5c84eda4a3af5826dd72356a900bac9bd9dd1f0f81ee1c22c2f"}, 1228 | {file = "psycopg2_binary-2.8.4-cp36-cp36m-win_amd64.whl", hash = "sha256:407af6d7e46593415f216c7f56ba087a9a42bd6dc2ecb86028760aa45b802bd7"}, 1229 | {file = "psycopg2_binary-2.8.4-cp37-cp37m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:3aa773580f85a28ffdf6f862e59cb5a3cc7ef6885121f2de3fca8d6ada4dbf3b"}, 1230 | {file = "psycopg2_binary-2.8.4-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:eac8a3499754790187bb00574ab980df13e754777d346f85e0ff6df929bcd964"}, 1231 | {file = "psycopg2_binary-2.8.4-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:7a1cb80e35e1ccea3e11a48afe65d38744a0e0bde88795cc56a4d05b6e4f9d70"}, 1232 | {file = "psycopg2_binary-2.8.4-cp37-cp37m-win32.whl", hash = "sha256:086f7e89ec85a6704db51f68f0dcae432eff9300809723a6e8782c41c2f48e03"}, 1233 | {file = "psycopg2_binary-2.8.4-cp37-cp37m-win_amd64.whl", hash = "sha256:b73ddf033d8cd4cc9dfed6324b1ad2a89ba52c410ef6877998422fcb9c23e3a8"}, 1234 | {file = "psycopg2_binary-2.8.4-cp38-cp38-macosx_10_9_x86_64.macosx_10_9_intel.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:4c3c09fb674401f630626310bcaf6cd6285daf0d5e4c26d6e55ca26a2734e39b"}, 1235 | {file = "psycopg2_binary-2.8.4-cp38-cp38-manylinux1_i686.whl", hash = "sha256:18ca813fdb17bc1db73fe61b196b05dd1ca2165b884dd5ec5568877cabf9b039"}, 1236 | {file = "psycopg2_binary-2.8.4-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:50446fae5681fc99f87e505d4e77c9407e683ab60c555ec302f9ac9bffa61103"}, 1237 | {file = "psycopg2_binary-2.8.4-cp38-cp38-win32.whl", hash = "sha256:98e10634792ac0e9e7a92a76b4991b44c2325d3e7798270a808407355e7bb0a1"}, 1238 | {file = "psycopg2_binary-2.8.4-cp38-cp38-win_amd64.whl", hash = "sha256:b8f490f5fad1767a1331df1259763b3bad7d7af12a75b950c2843ba319b2415f"}, 1239 | ] 1240 | py = [ 1241 | {file = "py-1.8.1-py2.py3-none-any.whl", hash = "sha256:c20fdd83a5dbc0af9efd622bee9a5564e278f6380fffcacc43ba6f43db2813b0"}, 1242 | {file = "py-1.8.1.tar.gz", hash = "sha256:5e27081401262157467ad6e7f851b7aa402c5852dbcb3dae06768434de5752aa"}, 1243 | ] 1244 | pycodestyle = [ 1245 | {file = "pycodestyle-2.5.0-py2.py3-none-any.whl", hash = "sha256:95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56"}, 1246 | {file = "pycodestyle-2.5.0.tar.gz", hash = "sha256:e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c"}, 1247 | ] 1248 | pycparser = [ 1249 | {file = "pycparser-2.20-py2.py3-none-any.whl", hash = "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705"}, 1250 | {file = "pycparser-2.20.tar.gz", hash = "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0"}, 1251 | ] 1252 | pydantic = [ 1253 | {file = "pydantic-1.4-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:07911aab70f3bc52bb845ce1748569c5e70478ac977e106a150dd9d0465ebf04"}, 1254 | {file = "pydantic-1.4-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:012c422859bac2e03ab3151ea6624fecf0e249486be7eb8c6ee69c91740c6752"}, 1255 | {file = "pydantic-1.4-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:61d22d36808087d3184ed6ac0d91dd71c533b66addb02e4a9930e1e30833202f"}, 1256 | {file = "pydantic-1.4-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:f863456d3d4bf817f2e5248553dee3974c5dc796f48e6ddb599383570f4215ac"}, 1257 | {file = "pydantic-1.4-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:bbbed364376f4a0aebb9ea452ff7968b306499a9e74f4db69b28ff2cd4043a11"}, 1258 | {file = "pydantic-1.4-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:e27559cedbd7f59d2375bfd6eea29a330ea1a5b0589c34d6b4e0d7bec6027bbf"}, 1259 | {file = "pydantic-1.4-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:50e4e948892a6815649ad5a9a9379ad1e5f090f17842ac206535dfaed75c6f2f"}, 1260 | {file = "pydantic-1.4-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:8848b4eb458469739126e4c1a202d723dd092e087f8dbe3104371335f87ba5df"}, 1261 | {file = "pydantic-1.4-cp38-cp38-manylinux1_i686.whl", hash = "sha256:831a0265a9e3933b3d0f04d1a81bba543bafbe4119c183ff2771871db70524ab"}, 1262 | {file = "pydantic-1.4-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:47b8db7024ba3d46c3d4768535e1cf87b6c8cf92ccd81e76f4e1cb8ee47688b3"}, 1263 | {file = "pydantic-1.4-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:51f11c8bbf794a68086540da099aae4a9107447c7a9d63151edbb7d50110cf21"}, 1264 | {file = "pydantic-1.4-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:6100d7862371115c40be55cc4b8d766a74b1d0dbaf99dbfe72bb4bac0faf89ed"}, 1265 | {file = "pydantic-1.4-py36.py37.py38-none-any.whl", hash = "sha256:72184c1421103cca128300120f8f1185fb42a9ea73a1c9845b1c53db8c026a7d"}, 1266 | {file = "pydantic-1.4.tar.gz", hash = "sha256:f17ec336e64d4583311249fb179528e9a2c27c8a2eaf590ec6ec2c6dece7cb3f"}, 1267 | ] 1268 | pydocstyle = [ 1269 | {file = "pydocstyle-5.0.2-py3-none-any.whl", hash = "sha256:da7831660b7355307b32778c4a0dbfb137d89254ef31a2b2978f50fc0b4d7586"}, 1270 | {file = "pydocstyle-5.0.2.tar.gz", hash = "sha256:f4f5d210610c2d153fae39093d44224c17429e2ad7da12a8b419aba5c2f614b5"}, 1271 | ] 1272 | pyflakes = [ 1273 | {file = "pyflakes-2.1.1-py2.py3-none-any.whl", hash = "sha256:17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0"}, 1274 | {file = "pyflakes-2.1.1.tar.gz", hash = "sha256:d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2"}, 1275 | ] 1276 | pyjwt = [ 1277 | {file = "PyJWT-1.7.1-py2.py3-none-any.whl", hash = "sha256:5c6eca3c2940464d106b99ba83b00c6add741c9becaec087fb7ccdefea71350e"}, 1278 | {file = "PyJWT-1.7.1.tar.gz", hash = "sha256:8d59a976fb773f3e6a39c85636357c4f0e242707394cadadd9814f5cbaa20e96"}, 1279 | ] 1280 | pyparsing = [ 1281 | {file = "pyparsing-2.4.6-py2.py3-none-any.whl", hash = "sha256:c342dccb5250c08d45fd6f8b4a559613ca603b57498511740e65cd11a2e7dcec"}, 1282 | {file = "pyparsing-2.4.6.tar.gz", hash = "sha256:4c830582a84fb022400b85429791bc551f1f4871c33f23e44f353119e92f969f"}, 1283 | ] 1284 | pytest = [ 1285 | {file = "pytest-5.3.5-py3-none-any.whl", hash = "sha256:ff615c761e25eb25df19edddc0b970302d2a9091fbce0e7213298d85fb61fef6"}, 1286 | {file = "pytest-5.3.5.tar.gz", hash = "sha256:0d5fe9189a148acc3c3eb2ac8e1ac0742cb7618c084f3d228baaec0c254b318d"}, 1287 | ] 1288 | pytest-asyncio = [ 1289 | {file = "pytest-asyncio-0.10.0.tar.gz", hash = "sha256:9fac5100fd716cbecf6ef89233e8590a4ad61d729d1732e0a96b84182df1daaf"}, 1290 | {file = "pytest_asyncio-0.10.0-py3-none-any.whl", hash = "sha256:d734718e25cfc32d2bf78d346e99d33724deeba774cc4afdf491530c6184b63b"}, 1291 | ] 1292 | pytest-cov = [ 1293 | {file = "pytest-cov-2.8.1.tar.gz", hash = "sha256:cc6742d8bac45070217169f5f72ceee1e0e55b0221f54bcf24845972d3a47f2b"}, 1294 | {file = "pytest_cov-2.8.1-py2.py3-none-any.whl", hash = "sha256:cdbdef4f870408ebdbfeb44e63e07eb18bb4619fae852f6e760645fa36172626"}, 1295 | ] 1296 | pytest-factoryboy = [ 1297 | {file = "pytest-factoryboy-2.0.3.tar.gz", hash = "sha256:ffef3fb7ddec1299d3df0d334846259023f3d1da5ab887ad880139a8253a5a1a"}, 1298 | ] 1299 | pytest-mock = [ 1300 | {file = "pytest-mock-2.0.0.tar.gz", hash = "sha256:b35eb281e93aafed138db25c8772b95d3756108b601947f89af503f8c629413f"}, 1301 | {file = "pytest_mock-2.0.0-py2.py3-none-any.whl", hash = "sha256:cb67402d87d5f53c579263d37971a164743dc33c159dfb4fb4a86f37c5552307"}, 1302 | ] 1303 | pytest-sugar = [ 1304 | {file = "pytest-sugar-0.9.2.tar.gz", hash = "sha256:fcd87a74b2bce5386d244b49ad60549bfbc4602527797fac167da147983f58ab"}, 1305 | {file = "pytest_sugar-0.9.2-py2.py3-none-any.whl", hash = "sha256:26cf8289fe10880cbbc130bd77398c4e6a8b936d8393b116a5c16121d95ab283"}, 1306 | ] 1307 | python-dateutil = [ 1308 | {file = "python-dateutil-2.8.1.tar.gz", hash = "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c"}, 1309 | {file = "python_dateutil-2.8.1-py2.py3-none-any.whl", hash = "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"}, 1310 | ] 1311 | python-dotenv = [ 1312 | {file = "python-dotenv-0.12.0.tar.gz", hash = "sha256:92b3123fb2d58a284f76cc92bfe4ee6c502c32ded73e8b051c4f6afc8b6751ed"}, 1313 | {file = "python_dotenv-0.12.0-py2.py3-none-any.whl", hash = "sha256:81822227f771e0cab235a2939f0f265954ac4763cafd806d845801c863bf372f"}, 1314 | ] 1315 | python-editor = [ 1316 | {file = "python-editor-1.0.4.tar.gz", hash = "sha256:51fda6bcc5ddbbb7063b2af7509e43bd84bfc32a4ff71349ec7847713882327b"}, 1317 | {file = "python_editor-1.0.4-py2-none-any.whl", hash = "sha256:5f98b069316ea1c2ed3f67e7f5df6c0d8f10b689964a4a811ff64f0106819ec8"}, 1318 | {file = "python_editor-1.0.4-py2.7.egg", hash = "sha256:ea87e17f6ec459e780e4221f295411462e0d0810858e055fc514684350a2f522"}, 1319 | {file = "python_editor-1.0.4-py3-none-any.whl", hash = "sha256:1bf6e860a8ad52a14c3ee1252d5dc25b2030618ed80c022598f00176adc8367d"}, 1320 | {file = "python_editor-1.0.4-py3.5.egg", hash = "sha256:c3da2053dbab6b29c94e43c486ff67206eafbe7eb52dbec7390b5e2fb05aac77"}, 1321 | ] 1322 | python-multipart = [ 1323 | {file = "python-multipart-0.0.5.tar.gz", hash = "sha256:f7bb5f611fc600d15fa47b3974c8aa16e93724513b49b5f95c81e6624c83fa43"}, 1324 | ] 1325 | regex = [ 1326 | {file = "regex-2020.2.20-cp27-cp27m-win32.whl", hash = "sha256:99272d6b6a68c7ae4391908fc15f6b8c9a6c345a46b632d7fdb7ef6c883a2bbb"}, 1327 | {file = "regex-2020.2.20-cp27-cp27m-win_amd64.whl", hash = "sha256:974535648f31c2b712a6b2595969f8ab370834080e00ab24e5dbb9d19b8bfb74"}, 1328 | {file = "regex-2020.2.20-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:5de40649d4f88a15c9489ed37f88f053c15400257eeb18425ac7ed0a4e119400"}, 1329 | {file = "regex-2020.2.20-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:82469a0c1330a4beb3d42568f82dffa32226ced006e0b063719468dcd40ffdf0"}, 1330 | {file = "regex-2020.2.20-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:d58a4fa7910102500722defbde6e2816b0372a4fcc85c7e239323767c74f5cbc"}, 1331 | {file = "regex-2020.2.20-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:f1ac2dc65105a53c1c2d72b1d3e98c2464a133b4067a51a3d2477b28449709a0"}, 1332 | {file = "regex-2020.2.20-cp36-cp36m-win32.whl", hash = "sha256:8c2b7fa4d72781577ac45ab658da44c7518e6d96e2a50d04ecb0fd8f28b21d69"}, 1333 | {file = "regex-2020.2.20-cp36-cp36m-win_amd64.whl", hash = "sha256:269f0c5ff23639316b29f31df199f401e4cb87529eafff0c76828071635d417b"}, 1334 | {file = "regex-2020.2.20-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:bed7986547ce54d230fd8721aba6fd19459cdc6d315497b98686d0416efaff4e"}, 1335 | {file = "regex-2020.2.20-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:046e83a8b160aff37e7034139a336b660b01dbfe58706f9d73f5cdc6b3460242"}, 1336 | {file = "regex-2020.2.20-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:b33ebcd0222c1d77e61dbcd04a9fd139359bded86803063d3d2d197b796c63ce"}, 1337 | {file = "regex-2020.2.20-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:bba52d72e16a554d1894a0cc74041da50eea99a8483e591a9edf1025a66843ab"}, 1338 | {file = "regex-2020.2.20-cp37-cp37m-win32.whl", hash = "sha256:01b2d70cbaed11f72e57c1cfbaca71b02e3b98f739ce33f5f26f71859ad90431"}, 1339 | {file = "regex-2020.2.20-cp37-cp37m-win_amd64.whl", hash = "sha256:113309e819634f499d0006f6200700c8209a2a8bf6bd1bdc863a4d9d6776a5d1"}, 1340 | {file = "regex-2020.2.20-cp38-cp38-manylinux1_i686.whl", hash = "sha256:25f4ce26b68425b80a233ce7b6218743c71cf7297dbe02feab1d711a2bf90045"}, 1341 | {file = "regex-2020.2.20-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:9b64a4cc825ec4df262050c17e18f60252cdd94742b4ba1286bcfe481f1c0f26"}, 1342 | {file = "regex-2020.2.20-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:9ff16d994309b26a1cdf666a6309c1ef51ad4f72f99d3392bcd7b7139577a1f2"}, 1343 | {file = "regex-2020.2.20-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:c7f58a0e0e13fb44623b65b01052dae8e820ed9b8b654bb6296bc9c41f571b70"}, 1344 | {file = "regex-2020.2.20-cp38-cp38-win32.whl", hash = "sha256:200539b5124bc4721247a823a47d116a7a23e62cc6695744e3eb5454a8888e6d"}, 1345 | {file = "regex-2020.2.20-cp38-cp38-win_amd64.whl", hash = "sha256:7f78f963e62a61e294adb6ff5db901b629ef78cb2a1cfce3cf4eeba80c1c67aa"}, 1346 | {file = "regex-2020.2.20.tar.gz", hash = "sha256:9e9624440d754733eddbcd4614378c18713d2d9d0dc647cf9c72f64e39671be5"}, 1347 | ] 1348 | six = [ 1349 | {file = "six-1.14.0-py2.py3-none-any.whl", hash = "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c"}, 1350 | {file = "six-1.14.0.tar.gz", hash = "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a"}, 1351 | ] 1352 | snowballstemmer = [ 1353 | {file = "snowballstemmer-2.0.0-py2.py3-none-any.whl", hash = "sha256:209f257d7533fdb3cb73bdbd24f436239ca3b2fa67d56f6ff88e86be08cc5ef0"}, 1354 | {file = "snowballstemmer-2.0.0.tar.gz", hash = "sha256:df3bac3df4c2c01363f3dd2cfa78cce2840a79b9f1c2d2de9ce8d31683992f52"}, 1355 | ] 1356 | sqlalchemy = [ 1357 | {file = "SQLAlchemy-1.3.15.tar.gz", hash = "sha256:c4cca4aed606297afbe90d4306b49ad3a4cd36feb3f87e4bfd655c57fd9ef445"}, 1358 | ] 1359 | starlette = [ 1360 | {file = "starlette-0.13.2-py3-none-any.whl", hash = "sha256:6169ee78ded501095d1dda7b141a1dc9f9934d37ad23196e180150ace2c6449b"}, 1361 | {file = "starlette-0.13.2.tar.gz", hash = "sha256:a9bb130fa7aa736eda8a814b6ceb85ccf7a209ed53843d0d61e246b380afa10f"}, 1362 | ] 1363 | termcolor = [ 1364 | {file = "termcolor-1.1.0.tar.gz", hash = "sha256:1d6d69ce66211143803fbc56652b41d73b4a400a2891d7bf7a1cdf4c02de613b"}, 1365 | ] 1366 | text-unidecode = [ 1367 | {file = "text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93"}, 1368 | {file = "text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8"}, 1369 | ] 1370 | toml = [ 1371 | {file = "toml-0.10.0-py2.7.egg", hash = "sha256:f1db651f9657708513243e61e6cc67d101a39bad662eaa9b5546f789338e07a3"}, 1372 | {file = "toml-0.10.0-py2.py3-none-any.whl", hash = "sha256:235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e"}, 1373 | {file = "toml-0.10.0.tar.gz", hash = "sha256:229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c"}, 1374 | ] 1375 | toolz = [ 1376 | {file = "toolz-0.10.0.tar.gz", hash = "sha256:08fdd5ef7c96480ad11c12d472de21acd32359996f69a5259299b540feba4560"}, 1377 | ] 1378 | typed-ast = [ 1379 | {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3"}, 1380 | {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb"}, 1381 | {file = "typed_ast-1.4.1-cp35-cp35m-win32.whl", hash = "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919"}, 1382 | {file = "typed_ast-1.4.1-cp35-cp35m-win_amd64.whl", hash = "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01"}, 1383 | {file = "typed_ast-1.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75"}, 1384 | {file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652"}, 1385 | {file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"}, 1386 | {file = "typed_ast-1.4.1-cp36-cp36m-win32.whl", hash = "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1"}, 1387 | {file = "typed_ast-1.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa"}, 1388 | {file = "typed_ast-1.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614"}, 1389 | {file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41"}, 1390 | {file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b"}, 1391 | {file = "typed_ast-1.4.1-cp37-cp37m-win32.whl", hash = "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe"}, 1392 | {file = "typed_ast-1.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355"}, 1393 | {file = "typed_ast-1.4.1-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6"}, 1394 | {file = "typed_ast-1.4.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907"}, 1395 | {file = "typed_ast-1.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d"}, 1396 | {file = "typed_ast-1.4.1-cp38-cp38-win32.whl", hash = "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c"}, 1397 | {file = "typed_ast-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4"}, 1398 | {file = "typed_ast-1.4.1-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34"}, 1399 | {file = "typed_ast-1.4.1.tar.gz", hash = "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b"}, 1400 | ] 1401 | typing-extensions = [ 1402 | {file = "typing_extensions-3.7.4.1-py2-none-any.whl", hash = "sha256:910f4656f54de5993ad9304959ce9bb903f90aadc7c67a0bef07e678014e892d"}, 1403 | {file = "typing_extensions-3.7.4.1-py3-none-any.whl", hash = "sha256:cf8b63fedea4d89bab840ecbb93e75578af28f76f66c35889bd7065f5af88575"}, 1404 | {file = "typing_extensions-3.7.4.1.tar.gz", hash = "sha256:091ecc894d5e908ac75209f10d5b4f118fbdb2eb1ede6a63544054bb1edb41f2"}, 1405 | ] 1406 | uvicorn = [ 1407 | {file = "uvicorn-0.11.3-py3-none-any.whl", hash = "sha256:0f58170165c4495f563d8224b2f415a0829af0412baa034d6f777904613087fd"}, 1408 | {file = "uvicorn-0.11.3.tar.gz", hash = "sha256:6fdaf8e53bf1b2ddf0fe9ed06079b5348d7d1d87b3365fe2549e6de0d49e631c"}, 1409 | ] 1410 | uvloop = [ 1411 | {file = "uvloop-0.14.0-cp35-cp35m-macosx_10_11_x86_64.whl", hash = "sha256:08b109f0213af392150e2fe6f81d33261bb5ce968a288eb698aad4f46eb711bd"}, 1412 | {file = "uvloop-0.14.0-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:4544dcf77d74f3a84f03dd6278174575c44c67d7165d4c42c71db3fdc3860726"}, 1413 | {file = "uvloop-0.14.0-cp36-cp36m-macosx_10_11_x86_64.whl", hash = "sha256:b4f591aa4b3fa7f32fb51e2ee9fea1b495eb75b0b3c8d0ca52514ad675ae63f7"}, 1414 | {file = "uvloop-0.14.0-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:f07909cd9fc08c52d294b1570bba92186181ca01fe3dc9ffba68955273dd7362"}, 1415 | {file = "uvloop-0.14.0-cp37-cp37m-macosx_10_11_x86_64.whl", hash = "sha256:afd5513c0ae414ec71d24f6f123614a80f3d27ca655a4fcf6cabe50994cc1891"}, 1416 | {file = "uvloop-0.14.0-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:e7514d7a48c063226b7d06617cbb12a14278d4323a065a8d46a7962686ce2e95"}, 1417 | {file = "uvloop-0.14.0-cp38-cp38-macosx_10_11_x86_64.whl", hash = "sha256:bcac356d62edd330080aed082e78d4b580ff260a677508718f88016333e2c9c5"}, 1418 | {file = "uvloop-0.14.0-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:4315d2ec3ca393dd5bc0b0089d23101276778c304d42faff5dc4579cb6caef09"}, 1419 | {file = "uvloop-0.14.0.tar.gz", hash = "sha256:123ac9c0c7dd71464f58f1b4ee0bbd81285d96cdda8bc3519281b8973e3a461e"}, 1420 | ] 1421 | wcwidth = [ 1422 | {file = "wcwidth-0.1.8-py2.py3-none-any.whl", hash = "sha256:8fd29383f539be45b20bd4df0dc29c20ba48654a41e661925e612311e9f3c603"}, 1423 | {file = "wcwidth-0.1.8.tar.gz", hash = "sha256:f28b3e8a6483e5d49e7f8949ac1a78314e740333ae305b4ba5defd3e74fb37a8"}, 1424 | ] 1425 | websockets = [ 1426 | {file = "websockets-8.1-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:3762791ab8b38948f0c4d281c8b2ddfa99b7e510e46bd8dfa942a5fff621068c"}, 1427 | {file = "websockets-8.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:3db87421956f1b0779a7564915875ba774295cc86e81bc671631379371af1170"}, 1428 | {file = "websockets-8.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:4f9f7d28ce1d8f1295717c2c25b732c2bc0645db3215cf757551c392177d7cb8"}, 1429 | {file = "websockets-8.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:295359a2cc78736737dd88c343cd0747546b2174b5e1adc223824bcaf3e164cb"}, 1430 | {file = "websockets-8.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:1d3f1bf059d04a4e0eb4985a887d49195e15ebabc42364f4eb564b1d065793f5"}, 1431 | {file = "websockets-8.1-cp36-cp36m-win32.whl", hash = "sha256:2db62a9142e88535038a6bcfea70ef9447696ea77891aebb730a333a51ed559a"}, 1432 | {file = "websockets-8.1-cp36-cp36m-win_amd64.whl", hash = "sha256:0e4fb4de42701340bd2353bb2eee45314651caa6ccee80dbd5f5d5978888fed5"}, 1433 | {file = "websockets-8.1-cp37-cp37m-macosx_10_6_intel.whl", hash = "sha256:9b248ba3dd8a03b1a10b19efe7d4f7fa41d158fdaa95e2cf65af5a7b95a4f989"}, 1434 | {file = "websockets-8.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:ce85b06a10fc65e6143518b96d3dca27b081a740bae261c2fb20375801a9d56d"}, 1435 | {file = "websockets-8.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:965889d9f0e2a75edd81a07592d0ced54daa5b0785f57dc429c378edbcffe779"}, 1436 | {file = "websockets-8.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:751a556205d8245ff94aeef23546a1113b1dd4f6e4d102ded66c39b99c2ce6c8"}, 1437 | {file = "websockets-8.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:3ef56fcc7b1ff90de46ccd5a687bbd13a3180132268c4254fc0fa44ecf4fc422"}, 1438 | {file = "websockets-8.1-cp37-cp37m-win32.whl", hash = "sha256:7ff46d441db78241f4c6c27b3868c9ae71473fe03341340d2dfdbe8d79310acc"}, 1439 | {file = "websockets-8.1-cp37-cp37m-win_amd64.whl", hash = "sha256:20891f0dddade307ffddf593c733a3fdb6b83e6f9eef85908113e628fa5a8308"}, 1440 | {file = "websockets-8.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c1ec8db4fac31850286b7cd3b9c0e1b944204668b8eb721674916d4e28744092"}, 1441 | {file = "websockets-8.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:5c01fd846263a75bc8a2b9542606927cfad57e7282965d96b93c387622487485"}, 1442 | {file = "websockets-8.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:9bef37ee224e104a413f0780e29adb3e514a5b698aabe0d969a6ba426b8435d1"}, 1443 | {file = "websockets-8.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:d705f8aeecdf3262379644e4b55107a3b55860eb812b673b28d0fbc347a60c55"}, 1444 | {file = "websockets-8.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:c8a116feafdb1f84607cb3b14aa1418424ae71fee131642fc568d21423b51824"}, 1445 | {file = "websockets-8.1-cp38-cp38-win32.whl", hash = "sha256:e898a0863421650f0bebac8ba40840fc02258ef4714cb7e1fd76b6a6354bda36"}, 1446 | {file = "websockets-8.1-cp38-cp38-win_amd64.whl", hash = "sha256:f8a7bff6e8664afc4e6c28b983845c5bc14965030e3fb98789734d416af77c4b"}, 1447 | {file = "websockets-8.1.tar.gz", hash = "sha256:5c65d2da8c6bce0fca2528f69f44b2f977e06954c8512a952222cea50dad430f"}, 1448 | ] 1449 | --------------------------------------------------------------------------------