diff options
-rw-r--r-- | backend/authentication/backend.py | 11 | ||||
-rw-r--r-- | backend/authentication/user.py | 5 | ||||
-rw-r--r-- | backend/models/form.py | 12 | ||||
-rw-r--r-- | backend/models/form_response.py | 2 | ||||
-rw-r--r-- | backend/models/question.py | 8 | ||||
-rw-r--r-- | backend/route.py | 10 | ||||
-rw-r--r-- | backend/route_manager.py | 60 | ||||
-rw-r--r-- | poetry.lock | 163 | ||||
-rw-r--r-- | pyproject.toml | 1 |
9 files changed, 205 insertions, 67 deletions
diff --git a/backend/authentication/backend.py b/backend/authentication/backend.py index 38668eb..f1d2ece 100644 --- a/backend/authentication/backend.py +++ b/backend/authentication/backend.py @@ -1,6 +1,5 @@ import jwt import typing as t -from abc import ABC from starlette import authentication from starlette.requests import Request @@ -10,11 +9,11 @@ from backend import constants from .user import User -class JWTAuthenticationBackend(authentication.AuthenticationBackend, ABC): +class JWTAuthenticationBackend(authentication.AuthenticationBackend): """Custom Starlette authentication backend for JWT.""" @staticmethod - def get_token_from_header(header: str) -> t.Optional[str]: + def get_token_from_header(header: str) -> str: """Parse JWT token from header value.""" try: prefix, token = header.split() @@ -32,10 +31,10 @@ class JWTAuthenticationBackend(authentication.AuthenticationBackend, ABC): async def authenticate( self, request: Request - ) -> t.Optional[t.Tuple[authentication.AuthCredentials, authentication.BaseUser]]: + ) -> t.Optional[tuple[authentication.AuthCredentials, authentication.BaseUser]]: """Handles JWT authentication process.""" if "Authorization" not in request.headers: - return + return None auth = request.headers["Authorization"] token = self.get_token_from_header(auth) @@ -47,7 +46,7 @@ class JWTAuthenticationBackend(authentication.AuthenticationBackend, ABC): scopes = ["authenticated"] - if payload.get("admin", False) is True: + if payload.get("admin") is True: scopes.append("admin") return authentication.AuthCredentials(scopes), User(token, payload) diff --git a/backend/authentication/user.py b/backend/authentication/user.py index afa243f..722c348 100644 --- a/backend/authentication/user.py +++ b/backend/authentication/user.py @@ -1,13 +1,12 @@ import typing as t -from abc import ABC from starlette.authentication import BaseUser -class User(BaseUser, ABC): +class User(BaseUser): """Starlette BaseUser implementation for JWT authentication.""" - def __init__(self, token: str, payload: t.Dict) -> None: + def __init__(self, token: str, payload: dict[str, t.Any]) -> None: self.token = token self.payload = payload diff --git a/backend/models/form.py b/backend/models/form.py index 2cf8486..cb58065 100644 --- a/backend/models/form.py +++ b/backend/models/form.py @@ -12,8 +12,8 @@ class Form(BaseModel): """Schema model for form.""" id: str = Field(alias="_id") - features: t.List[str] - questions: t.List[Question] + features: list[str] + questions: list[Question] name: str description: str @@ -21,12 +21,12 @@ class Form(BaseModel): allow_population_by_field_name = True @validator("features") - def validate_features(cls, value: t.List[str]) -> t.Optional[t.List[str]]: + def validate_features(cls, value: list[str]) -> t.Optional[list[str]]: """Validates is all features in allowed list.""" # Uppercase everything to avoid mixed case in DB value = [v.upper() for v in value] - allowed_values = list(v.value for v in FormFeatures.__members__.values()) - if not all(v in allowed_values for v in value): + allowed_values = [v.value for v in FormFeatures.__members__.values()] + if any(v not in allowed_values for v in value): raise ValueError("Form features list contains one or more invalid values.") if FormFeatures.COLLECT_EMAIL in value and FormFeatures.REQUIRES_LOGIN not in value: # noqa @@ -34,7 +34,7 @@ class Form(BaseModel): return value - def dict(self, admin: bool = True, **kwargs: t.Dict) -> t.Dict[str, t.Any]: + def dict(self, admin: bool = True, **kwargs: t.Any) -> dict[str, t.Any]: """Wrapper for original function to exclude private data for public access.""" data = super().dict(**kwargs) diff --git a/backend/models/form_response.py b/backend/models/form_response.py index bea070f..f3296cd 100644 --- a/backend/models/form_response.py +++ b/backend/models/form_response.py @@ -12,7 +12,7 @@ class FormResponse(BaseModel): id: str = Field(alias="_id") user: t.Optional[DiscordUser] antispam: t.Optional[AntiSpam] - response: t.Dict[str, t.Any] + response: dict[str, t.Any] form_id: str class Config: diff --git a/backend/models/question.py b/backend/models/question.py index 1a012ff..3b98024 100644 --- a/backend/models/question.py +++ b/backend/models/question.py @@ -11,7 +11,7 @@ class Question(BaseModel): id: str = Field(alias="_id") name: str type: str - data: t.Dict[str, t.Any] + data: dict[str, t.Any] class Config: allow_population_by_field_name = True @@ -31,14 +31,14 @@ class Question(BaseModel): @root_validator def validate_question_data( cls, - value: t.Dict[str, t.Any] - ) -> t.Optional[t.Dict[str, t.Any]]: + value: dict[str, t.Any] + ) -> t.Optional[dict[str, t.Any]]: """Check does required data exists for question type and remove other data.""" # When question type don't need data, don't add anything to keep DB clean. if value.get("type") not in REQUIRED_QUESTION_TYPE_DATA: return value - for key, data_type in REQUIRED_QUESTION_TYPE_DATA[value.get("type")].items(): + for key, data_type in REQUIRED_QUESTION_TYPE_DATA[value["type"]].items(): if key not in value.get("data", {}): raise ValueError(f"Required question data key '{key}' not provided.") diff --git a/backend/route.py b/backend/route.py index eb69ebc..d8c38fc 100644 --- a/backend/route.py +++ b/backend/route.py @@ -5,13 +5,13 @@ from starlette.endpoints import HTTPEndpoint class Route(HTTPEndpoint): - name: str = None - path: str = None + name: str + path: str @classmethod - def check_parameters(cls) -> "Route": - if cls.name is None: + def check_parameters(cls): + if not hasattr(cls, "name"): raise ValueError(f"Route {cls.__name__} has not defined a name") - if cls.path is None: + if not hasattr(cls, "path"): raise ValueError(f"Route {cls.__name__} has not defined a path") diff --git a/backend/route_manager.py b/backend/route_manager.py index bac275a..c48ea8e 100644 --- a/backend/route_manager.py +++ b/backend/route_manager.py @@ -4,15 +4,17 @@ Module to dynamically generate a Starlette routing map based on a directory tree import importlib import inspect +import typing as t + from pathlib import Path -from starlette.routing import Route as StarletteRoute, Mount +from starlette.routing import Route as StarletteRoute, BaseRoute, Mount from nested_dict import nested_dict from backend.route import Route -def construct_route_map_from_dict(route_dict: dict) -> list: +def construct_route_map_from_dict(route_dict: dict) -> list[BaseRoute]: route_map = [] for mount, item in route_dict.items(): if inspect.isclass(item): @@ -26,35 +28,39 @@ def construct_route_map_from_dict(route_dict: dict) -> list: return route_map -def create_route_map() -> list: - routes_directory = Path("backend") / "routes" - - route_dict = nested_dict() - - for file in routes_directory.rglob("*.py"): - import_name = f"{'.'.join(file.parent.parts)}.{file.stem}" +def is_route_class(member: t.Any) -> bool: + return inspect.isclass(member) and issubclass(member, Route) and member != Route - route = importlib.import_module(import_name) - for _member_name, member in inspect.getmembers(route): - if inspect.isclass(member): - if issubclass(member, Route) and member != Route: - member.check_parameters() +def route_classes() -> t.Iterator[tuple[Path, type[Route]]]: + routes_directory = Path("backend") / "routes" - levels = file.parent.parts[2:] + for module_path in routes_directory.rglob("*.py"): + import_name = f"{'.'.join(file.parent.parts)}.{file.stem}" + route_module = importlib.import_module(import_name) + for _member_name, member in inspect.getmembers(route_module): + if is_route_class(member): + member.check_parameters() + yield (module_path, member) - current_level = None - for level in levels: - if current_level is None: - current_level = route_dict[f"/{level}"] - else: - current_level = current_level[f"/{level}"] - if current_level is not None: - current_level[member.path] = member - else: - route_dict[member.path] = member +def create_route_map() -> list[BaseRoute]: + route_dict = nested_dict() - route_map = construct_route_map_from_dict(route_dict.to_dict()) + for module_path, member in route_classes(): + # module_path == Path("backend/routes/foo/bar/baz/bin.py") + # => levels == ["foo", "bar", "baz"] + levels = file.parent.parts[2:] + current_level = None + for level in levels: + if current_level is None: + current_level = route_dict[f"/{level}"] + else: + current_level = current_level[f"/{level}"] + + if current_level is not None: + current_level[member.path] = member + else: + route_dict[member.path] = member - return route_map + return construct_route_map_from_dict(route_dict.to_dict()) diff --git a/poetry.lock b/poetry.lock index d3ee7b5..c93f2f6 100644 --- a/poetry.lock +++ b/poetry.lock @@ -10,6 +10,28 @@ python-versions = "*" pycares = ">=3.0.0" [[package]] +name = "atomicwrites" +version = "1.4.0" +description = "Atomic file writes." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "attrs" +version = "20.3.0" +description = "Classes Without Boilerplate" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.extras] +dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "furo", "sphinx", "pre-commit"] +docs = ["furo", "sphinx", "zope.interface"] +tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] +tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six"] + +[[package]] name = "certifi" version = "2020.12.5" description = "Python package for providing Mozilla's CA Bundle." @@ -77,10 +99,10 @@ optional = false python-versions = ">=3.4" [package.extras] -tornado = ["tornado (>=0.2)"] +eventlet = ["eventlet (>=0.9.7)"] gevent = ["gevent (>=0.13)"] setproctitle = ["setproctitle"] -eventlet = ["eventlet (>=0.9.7)"] +tornado = ["tornado (>=0.2)"] [[package]] name = "h11" @@ -143,6 +165,14 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] +name = "iniconfig" +version = "1.1.1" +description = "iniconfig: brain-dead simple config-ini parsing" +category = "main" +optional = false +python-versions = "*" + +[[package]] name = "mccabe" version = "0.6.1" description = "McCabe checker, plugin for flake8" @@ -170,6 +200,36 @@ optional = false python-versions = "*" [[package]] +name = "packaging" +version = "20.8" +description = "Core utilities for Python packages" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.dependencies] +pyparsing = ">=2.0.2" + +[[package]] +name = "pluggy" +version = "0.13.1" +description = "plugin and hook calling mechanisms for python" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.extras] +dev = ["pre-commit", "tox"] + +[[package]] +name = "py" +version = "1.10.0" +description = "library with cross-python path, ini-parsing, io, code, log facilities" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] name = "pycares" version = "3.1.1" description = "Python interface for c-ares" @@ -209,8 +269,8 @@ python-versions = ">=3.6" [package.extras] dotenv = ["python-dotenv (>=0.10.4)"] -typing_extensions = ["typing-extensions (>=3.7.2)"] email = ["email-validator (>=1.0.3)"] +typing_extensions = ["typing-extensions (>=3.7.2)"] [[package]] name = "pydnsbl" @@ -221,8 +281,8 @@ optional = false python-versions = "*" [package.dependencies] -idna = ">=2.9,<3" aiodns = ">=1.1.1,<=2.0" +idna = ">=2.9,<3" [[package]] name = "pyflakes" @@ -241,9 +301,9 @@ optional = false python-versions = "*" [package.extras] -test = ["pytest (>=4.0.1,<5.0.0)", "pytest-cov (>=2.6.0,<3.0.0)", "pytest-runner (>=4.2,<5.0.0)"] crypto = ["cryptography (>=1.4)"] flake8 = ["flake8", "flake8-import-order", "pep8-naming"] +test = ["pytest (>=4.0.1,<5.0.0)", "pytest-cov (>=2.6.0,<3.0.0)", "pytest-runner (>=4.2,<5.0.0)"] [[package]] name = "pymongo" @@ -254,14 +314,43 @@ optional = false python-versions = "*" [package.extras] -tls = ["ipaddress"] -encryption = ["pymongocrypt (<2.0.0)"] aws = ["pymongo-auth-aws (<2.0.0)"] +encryption = ["pymongocrypt (<2.0.0)"] gssapi = ["pykerberos"] +ocsp = ["pyopenssl (>=17.2.0)", "requests (<3.0.0)", "service-identity (>=18.1.0)"] snappy = ["python-snappy"] srv = ["dnspython (>=1.16.0,<1.17.0)"] +tls = ["ipaddress"] zstd = ["zstandard"] -ocsp = ["pyopenssl (>=17.2.0)", "requests (<3.0.0)", "service-identity (>=18.1.0)"] + +[[package]] +name = "pyparsing" +version = "2.4.7" +description = "Python parsing module" +category = "main" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "pytest" +version = "6.2.0" +description = "pytest: simple powerful testing with Python" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} +attrs = ">=19.2.0" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<1.0.0a1" +py = ">=1.8.2" +toml = "*" + +[package.extras] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] [[package]] name = "python-dotenv" @@ -280,7 +369,7 @@ version = "5.3.1" description = "YAML parser and emitter for Python" category = "main" optional = false -python-versions = "*" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "rfc3986" @@ -316,6 +405,14 @@ python-versions = ">=3.6" full = ["aiofiles", "graphene", "itsdangerous", "jinja2", "python-multipart", "pyyaml", "requests", "ujson"] [[package]] +name = "toml" +version = "0.10.2" +description = "Python Library for Tom's Obvious, Minimal Language" +category = "main" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] name = "uvicorn" version = "0.12.3" description = "The lightning-fast ASGI server." @@ -324,14 +421,14 @@ optional = false python-versions = "*" [package.dependencies] -PyYAML = {version = ">=5.1", optional = true, markers = "extra == \"standard\""} -uvloop = {version = ">=0.14.0", optional = true, markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\" and extra == \"standard\""} +click = ">=7.0.0,<8.0.0" +colorama = {version = ">=0.4", optional = true, markers = "sys_platform == \"win32\" and extra == \"standard\""} h11 = ">=0.8" -watchgod = {version = ">=0.6,<0.7", optional = true, markers = "extra == \"standard\""} httptools = {version = ">=0.1.0,<0.2.0", optional = true, markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\" and extra == \"standard\""} -colorama = {version = ">=0.4", optional = true, markers = "sys_platform == \"win32\" and extra == \"standard\""} python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} -click = ">=7.0.0,<8.0.0" +PyYAML = {version = ">=5.1", optional = true, markers = "extra == \"standard\""} +uvloop = {version = ">=0.14.0", optional = true, markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\" and extra == \"standard\""} +watchgod = {version = ">=0.6,<0.7", optional = true, markers = "extra == \"standard\""} websockets = {version = ">=8.0.0,<9.0.0", optional = true, markers = "extra == \"standard\""} [package.extras] @@ -364,13 +461,21 @@ python-versions = ">=3.6.1" [metadata] lock-version = "1.1" python-versions = "^3.9" -content-hash = "9b3e9077c7cfab780a3333a213bbfee2eaa406da1f165a7a66cf1a83ac23011b" +content-hash = "8edb3e972a11289bbffe636a012e3ec8bdda1f58a127f1c0b5e273acf8a8a83b" [metadata.files] aiodns = [ {file = "aiodns-2.0.0-py2.py3-none-any.whl", hash = "sha256:aaa5ac584f40fe778013df0aa6544bf157799bd3f608364b451840ed2c8688de"}, {file = "aiodns-2.0.0.tar.gz", hash = "sha256:815fdef4607474295d68da46978a54481dd1e7be153c7d60f9e72773cd38d77d"}, ] +atomicwrites = [ + {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, + {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, +] +attrs = [ + {file = "attrs-20.3.0-py2.py3-none-any.whl", hash = "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6"}, + {file = "attrs-20.3.0.tar.gz", hash = "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"}, +] certifi = [ {file = "certifi-2020.12.5-py2.py3-none-any.whl", hash = "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"}, {file = "certifi-2020.12.5.tar.gz", hash = "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c"}, @@ -463,6 +568,10 @@ idna = [ {file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"}, {file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"}, ] +iniconfig = [ + {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, + {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, +] mccabe = [ {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, @@ -474,6 +583,18 @@ motor = [ nested-dict = [ {file = "nested_dict-1.61.tar.gz", hash = "sha256:de0fb5bac82ba7bcc23736f09373f18628ea57f92bbaa13480d23f261c41e771"}, ] +packaging = [ + {file = "packaging-20.8-py2.py3-none-any.whl", hash = "sha256:24e0da08660a87484d1602c30bb4902d74816b6985b93de36926f5bc95741858"}, + {file = "packaging-20.8.tar.gz", hash = "sha256:78598185a7008a470d64526a8059de9aaa449238f280fc9eb6b13ba6c4109093"}, +] +pluggy = [ + {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, + {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, +] +py = [ + {file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"}, + {file = "py-1.10.0.tar.gz", hash = "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3"}, +] pycares = [ {file = "pycares-3.1.1-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:81edb016d9e43dde7473bc3999c29cdfee3a6b67308fed1ea21049f458e83ae0"}, {file = "pycares-3.1.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:1917b82494907a4a342db420bc4dd5bac355a5fa3984c35ba9bf51422b020b48"}, @@ -615,6 +736,14 @@ pymongo = [ {file = "pymongo-3.11.2-py2.7-macosx-10.14-intel.egg", hash = "sha256:c1d1992bbdf363b22b5a9543ab7d7c6f27a1498826d50d91319b803ddcf1142e"}, {file = "pymongo-3.11.2.tar.gz", hash = "sha256:c2b67881392a9e85aa108e75f62cdbe372d5a3f17ea5f8d3436dcf4662052f14"}, ] +pyparsing = [ + {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, + {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, +] +pytest = [ + {file = "pytest-6.2.0-py3-none-any.whl", hash = "sha256:d69e1a80b34fe4d596c9142f35d9e523d98a2838976f1a68419a8f051b24cec6"}, + {file = "pytest-6.2.0.tar.gz", hash = "sha256:b12e09409c5bdedc28d308469e156127004a436b41e9b44f9bff6446cbab9152"}, +] python-dotenv = [ {file = "python-dotenv-0.14.0.tar.gz", hash = "sha256:8c10c99a1b25d9a68058a1ad6f90381a62ba68230ca93966882a4dbc3bc9c33d"}, {file = "python_dotenv-0.14.0-py2.py3-none-any.whl", hash = "sha256:c10863aee750ad720f4f43436565e4c1698798d763b63234fb5021b6c616e423"}, @@ -646,6 +775,10 @@ starlette = [ {file = "starlette-0.13.8-py3-none-any.whl", hash = "sha256:40afea6ffa830849800cc4efdf006a86ad579d6ba6b64cb1925a1897b020ba6e"}, {file = "starlette-0.13.8.tar.gz", hash = "sha256:82df29b2149437ad828a883674bf031788600c876dae50835e98398bd1706183"}, ] +toml = [ + {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, + {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, +] uvicorn = [ {file = "uvicorn-0.12.3-py3-none-any.whl", hash = "sha256:562ef6aaa8fa723ab6b82cf9e67a774088179d0ec57cb17e447b15d58b603bcf"}, {file = "uvicorn-0.12.3.tar.gz", hash = "sha256:5836edaf4d278fe67ba0298c0537bdb6398cf359eb644f79e6500ca1aad232b3"}, diff --git a/pyproject.toml b/pyproject.toml index 41e5165..6130f73 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ httpx = "^0.16.1" gunicorn = "^20.0.4" pydantic = "^1.7.2" pydnsbl = "^1.1" +pytest = "^6.2.0" [tool.poetry.dev-dependencies] flake8 = "^3.8.4" |