From bd80629a364570eee91e7968cd95d5dd7e4491f0 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sat, 6 Jul 2024 00:35:40 +0100 Subject: Update all dependencies --- pyproject.toml | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) (limited to 'pyproject.toml') diff --git a/pyproject.toml b/pyproject.toml index 2f1046d..b9c496b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,23 +6,23 @@ authors = ["Joe Banks "] license = "MIT" [tool.poetry.dependencies] -python = "^3.9" -starlette = "^0.20.4" +python = "^3.12" +starlette = "^0.37.2" nested_dict = "^1.61" -uvicorn = {extras = ["standard"], version = "^0.18.2"} -motor = "3.0.0" -python-dotenv = "^0.20.0" -pyjwt = "^2.4.0" -httpx = "^0.23.0" -gunicorn = "^20.1.0" -pydantic = "^1.8.2" -spectree = "^0.10.5" -deepmerge = "^1.0.1" -sentry-sdk = "^1.9.3" +uvicorn = { extras = ["standard"], version = "^0.30.1" } +motor = "3.5.0" +python-dotenv = "^1.0.1" +pyjwt = "^2.8.0" +httpx = "^0.27.0" +gunicorn = "^22.0.0" +pydantic = "^1.10.17" +spectree = "^1.2.10" +deepmerge = "^1.1.1" +sentry-sdk = "^2.7.1" [tool.poetry.dev-dependencies] -flake8 = "^5.0.4" -flake8-annotations = "^2.9.1" +flake8 = "^7.1.0" +flake8-annotations = "^3.1.1" [build-system] requires = ["poetry>=0.12"] -- cgit v1.2.3 From 38faefcf44eae93f5d407c5b1b2b1b1b149b972a Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sun, 7 Jul 2024 02:27:38 +0100 Subject: Change linting config to Ruff --- poetry.lock | 112 +++++++++++++++------------------------------------------ pyproject.toml | 46 ++++++++++++++++++++++-- tox.ini | 10 ------ 3 files changed, 71 insertions(+), 97 deletions(-) delete mode 100644 tox.ini (limited to 'pyproject.toml') diff --git a/poetry.lock b/poetry.lock index f1420cc..5e50fec 100644 --- a/poetry.lock +++ b/poetry.lock @@ -20,25 +20,6 @@ doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphin test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] trio = ["trio (>=0.23)"] -[[package]] -name = "attrs" -version = "23.2.0" -description = "Classes Without Boilerplate" -optional = false -python-versions = ">=3.7" -files = [ - {file = "attrs-23.2.0-py3-none-any.whl", hash = "sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1"}, - {file = "attrs-23.2.0.tar.gz", hash = "sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30"}, -] - -[package.extras] -cov = ["attrs[tests]", "coverage[toml] (>=5.3)"] -dev = ["attrs[tests]", "pre-commit"] -docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope-interface"] -tests = ["attrs[tests-no-zope]", "zope-interface"] -tests-mypy = ["mypy (>=1.6)", "pytest-mypy-plugins"] -tests-no-zope = ["attrs[tests-mypy]", "cloudpickle", "hypothesis", "pympler", "pytest (>=4.3.0)", "pytest-xdist[psutil]"] - [[package]] name = "certifi" version = "2024.7.4" @@ -106,37 +87,6 @@ idna = ["idna (>=3.6)"] trio = ["trio (>=0.23)"] wmi = ["wmi (>=1.5.1)"] -[[package]] -name = "flake8" -version = "7.1.0" -description = "the modular source code checker: pep8 pyflakes and co" -optional = false -python-versions = ">=3.8.1" -files = [ - {file = "flake8-7.1.0-py2.py3-none-any.whl", hash = "sha256:2e416edcc62471a64cea09353f4e7bdba32aeb079b6e360554c659a122b1bc6a"}, - {file = "flake8-7.1.0.tar.gz", hash = "sha256:48a07b626b55236e0fb4784ee69a465fbf59d79eec1f5b4785c3d3bc57d17aa5"}, -] - -[package.dependencies] -mccabe = ">=0.7.0,<0.8.0" -pycodestyle = ">=2.12.0,<2.13.0" -pyflakes = ">=3.2.0,<3.3.0" - -[[package]] -name = "flake8-annotations" -version = "3.1.1" -description = "Flake8 Type Annotation Checks" -optional = false -python-versions = ">=3.8.1" -files = [ - {file = "flake8_annotations-3.1.1-py3-none-any.whl", hash = "sha256:102935bdcbfa714759a152aeb07b14aee343fc0b6f7c55ad16968ce3e0e91a8a"}, - {file = "flake8_annotations-3.1.1.tar.gz", hash = "sha256:6c98968ccc6bdc0581d363bf147a87df2f01d0d078264b2da805799d911cf5fe"}, -] - -[package.dependencies] -attrs = ">=21.4" -flake8 = ">=5.0" - [[package]] name = "gunicorn" version = "22.0.0" @@ -273,17 +223,6 @@ files = [ {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, ] -[[package]] -name = "mccabe" -version = "0.7.0" -description = "McCabe checker, plugin for flake8" -optional = false -python-versions = ">=3.6" -files = [ - {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, - {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, -] - [[package]] name = "motor" version = "3.5.0" @@ -329,17 +268,6 @@ files = [ {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, ] -[[package]] -name = "pycodestyle" -version = "2.12.0" -description = "Python style guide checker" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pycodestyle-2.12.0-py2.py3-none-any.whl", hash = "sha256:949a39f6b86c3e1515ba1787c2022131d165a8ad271b11370a8819aa070269e4"}, - {file = "pycodestyle-2.12.0.tar.gz", hash = "sha256:442f950141b4f43df752dd303511ffded3a04c2b6fb7f65980574f0c31e6e79c"}, -] - [[package]] name = "pydantic" version = "1.10.17" @@ -399,17 +327,6 @@ typing-extensions = ">=4.2.0" dotenv = ["python-dotenv (>=0.10.4)"] email = ["email-validator (>=1.0.3)"] -[[package]] -name = "pyflakes" -version = "3.2.0" -description = "passive checker of Python programs" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pyflakes-3.2.0-py2.py3-none-any.whl", hash = "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a"}, - {file = "pyflakes-3.2.0.tar.gz", hash = "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f"}, -] - [[package]] name = "pyjwt" version = "2.8.0" @@ -572,6 +489,33 @@ files = [ {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, ] +[[package]] +name = "ruff" +version = "0.5.1" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +files = [ + {file = "ruff-0.5.1-py3-none-linux_armv6l.whl", hash = "sha256:6ecf968fcf94d942d42b700af18ede94b07521bd188aaf2cd7bc898dd8cb63b6"}, + {file = "ruff-0.5.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:204fb0a472f00f2e6280a7c8c7c066e11e20e23a37557d63045bf27a616ba61c"}, + {file = "ruff-0.5.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d235968460e8758d1e1297e1de59a38d94102f60cafb4d5382033c324404ee9d"}, + {file = "ruff-0.5.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38beace10b8d5f9b6bdc91619310af6d63dd2019f3fb2d17a2da26360d7962fa"}, + {file = "ruff-0.5.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e478d2f09cf06add143cf8c4540ef77b6599191e0c50ed976582f06e588c994"}, + {file = "ruff-0.5.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f0368d765eec8247b8550251c49ebb20554cc4e812f383ff9f5bf0d5d94190b0"}, + {file = "ruff-0.5.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3a9a9a1b582e37669b0138b7c1d9d60b9edac880b80eb2baba6d0e566bdeca4d"}, + {file = "ruff-0.5.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bdd9f723e16003623423affabcc0a807a66552ee6a29f90eddad87a40c750b78"}, + {file = "ruff-0.5.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:be9fd62c1e99539da05fcdc1e90d20f74aec1b7a1613463ed77870057cd6bd96"}, + {file = "ruff-0.5.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e216fc75a80ea1fbd96af94a6233d90190d5b65cc3d5dfacf2bd48c3e067d3e1"}, + {file = "ruff-0.5.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c4c2112e9883a40967827d5c24803525145e7dab315497fae149764979ac7929"}, + {file = "ruff-0.5.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dfaf11c8a116394da3b65cd4b36de30d8552fa45b8119b9ef5ca6638ab964fa3"}, + {file = "ruff-0.5.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d7ceb9b2fe700ee09a0c6b192c5ef03c56eb82a0514218d8ff700f6ade004108"}, + {file = "ruff-0.5.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bac6288e82f6296f82ed5285f597713acb2a6ae26618ffc6b429c597b392535c"}, + {file = "ruff-0.5.1-py3-none-win32.whl", hash = "sha256:5c441d9c24ec09e1cb190a04535c5379b36b73c4bc20aa180c54812c27d1cca4"}, + {file = "ruff-0.5.1-py3-none-win_amd64.whl", hash = "sha256:b1789bf2cd3d1b5a7d38397cac1398ddf3ad7f73f4de01b1e913e2abc7dfc51d"}, + {file = "ruff-0.5.1-py3-none-win_arm64.whl", hash = "sha256:2875b7596a740cbbd492f32d24be73e545a4ce0a3daf51e4f4e609962bfd3cd2"}, + {file = "ruff-0.5.1.tar.gz", hash = "sha256:3164488aebd89b1745b47fd00604fb4358d774465f20d1fcd907f9c0fc1b0655"}, +] + [[package]] name = "sentry-sdk" version = "2.7.1" @@ -941,4 +885,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "2cad085a61d097d57131b552ba3128a3ccf5a4c2fa787b50604fcf3e4ef69ab5" +content-hash = "581941f04a13b9af2b9f8571661f5c9a22054bd86b3adf009e6aeafb0a3f3615" diff --git a/pyproject.toml b/pyproject.toml index b9c496b..00b4bdc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,10 +20,50 @@ spectree = "^1.2.10" deepmerge = "^1.1.1" sentry-sdk = "^2.7.1" -[tool.poetry.dev-dependencies] -flake8 = "^7.1.0" -flake8-annotations = "^3.1.1" +[tool.poetry.group.dev.dependencies] +ruff = "^0.5.1" [build-system] requires = ["poetry>=0.12"] build-backend = "poetry.masonry.api" + +[tool.ruff] +target-version = "py312" +extend-exclude = [".cache"] +line-length = 100 +unsafe-fixes = true +preview = true +output-format = "concise" + +[tool.ruff.lint] +select = ["ALL"] +ignore = [ + "ANN002", "ANN003", "ANN101", "ANN102", "ANN204", "ANN206", "ANN401", + "B904", + "C401", "C408", + "CPY001", + "D100", "D104", "D105", "D107", "D203", "D212", "D214", "D215", "D301", + "D400", "D401", "D402", "D404", "D405", "D406", "D407", "D408", "D409", "D410", "D411", "D412", "D413", "D414", "D416", "D417", + "E731", + "RET504", + "RUF005", + "S311", + "SIM102", "SIM108", + "PD", + "PLR0913", "PLR0917", "PLR6301", + "DTZ003", + "INP001", + "D102", + "D103", "D103", "D101", + "S113", + "DTZ005", + "N805", + + # Rules suggested to be ignored when using ruff format + "COM812", "D206", "E111", "E114", "E117", "E501", "ISC001", "Q000", "Q001", "Q002", "Q003", "W191", +] + +[tool.ruff.lint.isort] +order-by-type = false +case-sensitive = true +combine-as-imports = true diff --git a/tox.ini b/tox.ini deleted file mode 100644 index 451c3dd..0000000 --- a/tox.ini +++ /dev/null @@ -1,10 +0,0 @@ -[flake8] -max-line-length=100 -exclude=.cache,.venv,.git -docstring-convention=all -import-order-style=pycharm -ignore= - # Type annotations - ANN002,ANN003,ANN101,ANN102 - # Line breaks - W503 -- cgit v1.2.3 From d0e09d2ba567f23d91ac76d1844966bafb9b063a Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sun, 7 Jul 2024 02:29:26 +0100 Subject: Apply fixable lint settings with Ruff --- backend/__init__.py | 6 +- backend/authentication/backend.py | 41 +++++------ backend/authentication/user.py | 14 ++-- backend/constants.py | 5 +- backend/discord.py | 60 ++++++++++------ backend/middleware.py | 4 +- backend/models/__init__.py | 8 +-- backend/models/discord_role.py | 14 ++-- backend/models/discord_user.py | 30 ++++---- backend/models/form.py | 98 +++++++++++++------------- backend/models/form_response.py | 15 ++-- backend/models/question.py | 33 +++++---- backend/route.py | 11 +-- backend/route_manager.py | 10 ++- backend/routes/admin.py | 12 ++-- backend/routes/auth/authorize.py | 42 +++++------ backend/routes/discord.py | 18 ++--- backend/routes/forms/discover.py | 21 ++---- backend/routes/forms/form.py | 16 ++--- backend/routes/forms/index.py | 26 ++----- backend/routes/forms/response.py | 21 +++--- backend/routes/forms/responses.py | 46 +++++-------- backend/routes/forms/submit.py | 134 ++++++++++++++++++------------------ backend/routes/forms/unittesting.py | 68 ++++++++++-------- backend/routes/index.py | 21 +++--- backend/validation.py | 2 +- pyproject.toml | 12 ++-- 27 files changed, 387 insertions(+), 401 deletions(-) (limited to 'pyproject.toml') diff --git a/backend/__init__.py b/backend/__init__.py index dcbdcdf..67015d7 100644 --- a/backend/__init__.py +++ b/backend/__init__.py @@ -27,7 +27,7 @@ sentry_sdk.init( dsn=constants.FORMS_BACKEND_DSN, send_default_pii=True, release=SENTRY_RELEASE, - environment=SENTRY_RELEASE + environment=SENTRY_RELEASE, ) middleware = [ @@ -36,10 +36,10 @@ middleware = [ allow_origins=["https://forms.pythondiscord.com"], allow_origin_regex=ALLOW_ORIGIN_REGEX, allow_headers=[ - "Content-Type" + "Content-Type", ], allow_methods=["*"], - allow_credentials=True + allow_credentials=True, ), Middleware(DatabaseMiddleware), Middleware(AuthenticationMiddleware, backend=JWTAuthenticationBackend()), diff --git a/backend/authentication/backend.py b/backend/authentication/backend.py index 54385e2..2512761 100644 --- a/backend/authentication/backend.py +++ b/backend/authentication/backend.py @@ -1,11 +1,9 @@ -import typing as t - import jwt from starlette import authentication from starlette.requests import Request -from backend import constants -from backend import discord +from backend import constants, discord + # We must import user such way here to avoid circular imports from .user import User @@ -19,20 +17,19 @@ class JWTAuthenticationBackend(authentication.AuthenticationBackend): try: prefix, token = cookie.split() except ValueError: - raise authentication.AuthenticationError( - "Unable to split prefix and token from authorization cookie." - ) + msg = "Unable to split prefix and token from authorization cookie." + raise authentication.AuthenticationError(msg) if prefix.upper() != "JWT": - raise authentication.AuthenticationError( - f"Invalid authorization cookie prefix '{prefix}'." - ) + msg = f"Invalid authorization cookie prefix '{prefix}'." + raise authentication.AuthenticationError(msg) return token async def authenticate( - self, request: Request - ) -> t.Optional[tuple[authentication.AuthCredentials, authentication.BaseUser]]: + self, + request: Request, + ) -> tuple[authentication.AuthCredentials, authentication.BaseUser] | None: """Handles JWT authentication process.""" cookie = request.cookies.get("token") if not cookie: @@ -48,21 +45,25 @@ class JWTAuthenticationBackend(authentication.AuthenticationBackend): scopes = ["authenticated"] if not payload.get("token"): - raise authentication.AuthenticationError("Token is missing from JWT.") + msg = "Token is missing from JWT." + raise authentication.AuthenticationError(msg) if not payload.get("refresh"): - raise authentication.AuthenticationError( - "Refresh token is missing from JWT." - ) + msg = "Refresh token is missing from JWT." + raise authentication.AuthenticationError(msg) try: user_details = payload.get("user_details") if not user_details or not user_details.get("id"): - raise authentication.AuthenticationError("Improper user details.") - except Exception: - raise authentication.AuthenticationError("Could not parse user details.") + msg = "Improper user details." + raise authentication.AuthenticationError(msg) # noqa: TRY301 + except Exception: # noqa: BLE001 + msg = "Could not parse user details." + raise authentication.AuthenticationError(msg) user = User( - token, user_details, await discord.get_member(request.state.db, user_details["id"]) + token, + user_details, + await discord.get_member(request.state.db, user_details["id"]), ) if await user.fetch_admin_status(request.state.db): scopes.append("admin") diff --git a/backend/authentication/user.py b/backend/authentication/user.py index cd5a249..c81b7a9 100644 --- a/backend/authentication/user.py +++ b/backend/authentication/user.py @@ -1,4 +1,3 @@ -import typing import typing as t import jwt @@ -16,7 +15,7 @@ class User(BaseUser): self, token: str, payload: dict[str, t.Any], - member: typing.Optional[models.DiscordMember], + member: models.DiscordMember | None, ) -> None: self.token = token self.payload = payload @@ -31,11 +30,11 @@ class User(BaseUser): @property def display_name(self) -> str: """Return username and discriminator as display name.""" - return f"{self.payload['username']}#{self.payload['discriminator']}" + return f"{self.payload["username"]}#{self.payload["discriminator"]}" @property def discord_mention(self) -> str: - return f"<@{self.payload['id']}>" + return f"<@{self.payload["id"]}>" @property def user_id(self) -> str: @@ -61,9 +60,10 @@ class User(BaseUser): return roles async def fetch_admin_status(self, database: Database) -> bool: - self.admin = await database.admins.find_one( - {"_id": self.payload["id"]} - ) is not None + query = {"_id": self.payload["id"]} + found_admin = await database.admins.find_one(query) + + self.admin = found_admin is not None return self.admin diff --git a/backend/constants.py b/backend/constants.py index e1c38d3..8089077 100644 --- a/backend/constants.py +++ b/backend/constants.py @@ -18,7 +18,8 @@ PRODUCTION_URL = "https://forms.pythondiscord.com" OAUTH2_CLIENT_ID = os.getenv("OAUTH2_CLIENT_ID") OAUTH2_CLIENT_SECRET = os.getenv("OAUTH2_CLIENT_SECRET") OAUTH2_REDIRECT_URI = os.getenv( - "OAUTH2_REDIRECT_URI", "https://forms.pythondiscord.com/callback" + "OAUTH2_REDIRECT_URI", + "https://forms.pythondiscord.com/callback", ) GIT_SHA = os.getenv("GIT_SHA", "dev") @@ -28,7 +29,7 @@ DOCS_PASSWORD = os.getenv("DOCS_PASSWORD") SECRET_KEY = os.getenv("SECRET_KEY", binascii.hexlify(os.urandom(30)).decode()) DISCORD_BOT_TOKEN = os.getenv("DISCORD_BOT_TOKEN") -DISCORD_GUILD = os.getenv("DISCORD_GUILD", 267624335836053506) +DISCORD_GUILD = os.getenv("DISCORD_GUILD", "267624335836053506") HCAPTCHA_API_SECRET = os.getenv("HCAPTCHA_API_SECRET") diff --git a/backend/discord.py b/backend/discord.py index ff6c1bb..dc5989a 100644 --- a/backend/discord.py +++ b/backend/discord.py @@ -2,7 +2,6 @@ import datetime import json -import typing import httpx import starlette.requests @@ -17,7 +16,7 @@ async def fetch_bearer_token(code: str, redirect: str, *, refresh: bool) -> dict data = { "client_id": constants.OAUTH2_CLIENT_ID, "client_secret": constants.OAUTH2_CLIENT_SECRET, - "redirect_uri": f"{redirect}/callback" + "redirect_uri": f"{redirect}/callback", } if refresh: @@ -27,9 +26,13 @@ async def fetch_bearer_token(code: str, redirect: str, *, refresh: bool) -> dict data["grant_type"] = "authorization_code" data["code"] = code - r = await client.post(f"{constants.DISCORD_API_BASE_URL}/oauth2/token", headers={ - "Content-Type": "application/x-www-form-urlencoded" - }, data=data) + r = await client.post( + f"{constants.DISCORD_API_BASE_URL}/oauth2/token", + headers={ + "Content-Type": "application/x-www-form-urlencoded", + }, + data=data, + ) r.raise_for_status() @@ -38,9 +41,12 @@ async def fetch_bearer_token(code: str, redirect: str, *, refresh: bool) -> dict async def fetch_user_details(bearer_token: str) -> dict: async with httpx.AsyncClient() as client: - r = await client.get(f"{constants.DISCORD_API_BASE_URL}/users/@me", headers={ - "Authorization": f"Bearer {bearer_token}" - }) + r = await client.get( + f"{constants.DISCORD_API_BASE_URL}/users/@me", + headers={ + "Authorization": f"Bearer {bearer_token}", + }, + ) r.raise_for_status() @@ -52,7 +58,7 @@ async def _get_role_info() -> list[models.DiscordRole]: async with httpx.AsyncClient() as client: r = await client.get( f"{constants.DISCORD_API_BASE_URL}/guilds/{constants.DISCORD_GUILD}/roles", - headers={"Authorization": f"Bot {constants.DISCORD_BOT_TOKEN}"} + headers={"Authorization": f"Bot {constants.DISCORD_BOT_TOKEN}"}, ) r.raise_for_status() @@ -60,7 +66,9 @@ async def _get_role_info() -> list[models.DiscordRole]: async def get_roles( - database: Database, *, force_refresh: bool = False + database: Database, + *, + force_refresh: bool = False, ) -> list[models.DiscordRole]: """ Get a list of all roles from the cache, or discord API if not available. @@ -86,23 +94,26 @@ async def get_roles( if len(roles) == 0: # Fetch roles from the API and insert into the database roles = await _get_role_info() - await collection.insert_many({ - "name": role.name, - "id": role.id, - "data": role.json(), - "inserted_at": datetime.datetime.now(tz=datetime.timezone.utc), - } for role in roles) + await collection.insert_many( + { + "name": role.name, + "id": role.id, + "data": role.json(), + "inserted_at": datetime.datetime.now(tz=datetime.UTC), + } + for role in roles + ) return roles -async def _fetch_member_api(member_id: str) -> typing.Optional[models.DiscordMember]: +async def _fetch_member_api(member_id: str) -> models.DiscordMember | None: """Get a member by ID from the configured guild using the discord API.""" async with httpx.AsyncClient() as client: r = await client.get( f"{constants.DISCORD_API_BASE_URL}/guilds/{constants.DISCORD_GUILD}" f"/members/{member_id}", - headers={"Authorization": f"Bot {constants.DISCORD_BOT_TOKEN}"} + headers={"Authorization": f"Bot {constants.DISCORD_BOT_TOKEN}"}, ) if r.status_code == 404: @@ -113,8 +124,11 @@ async def _fetch_member_api(member_id: str) -> typing.Optional[models.DiscordMem async def get_member( - database: Database, user_id: str, *, force_refresh: bool = False -) -> typing.Optional[models.DiscordMember]: + database: Database, + user_id: str, + *, + force_refresh: bool = False, +) -> models.DiscordMember | None: """ Get a member from the cache, or from the discord API. @@ -147,7 +161,7 @@ async def get_member( await collection.insert_one({ "user": user_id, "data": member.json(), - "inserted_at": datetime.datetime.now(tz=datetime.timezone.utc), + "inserted_at": datetime.datetime.now(tz=datetime.UTC), }) return member @@ -161,7 +175,9 @@ class UnauthorizedError(exceptions.HTTPException): async def _verify_access_helper( - form_id: str, request: starlette.requests.Request, attribute: str + form_id: str, + request: starlette.requests.Request, + attribute: str, ) -> None: """A low level helper to validate access to a form resource based on the user's scopes.""" form = await request.state.db.forms.find_one({"_id": form_id}) diff --git a/backend/middleware.py b/backend/middleware.py index 7a3bdc8..0b08859 100644 --- a/backend/middleware.py +++ b/backend/middleware.py @@ -7,14 +7,13 @@ from backend.constants import DATABASE_URL, DOCS_PASSWORD, MONGO_DATABASE class DatabaseMiddleware: - def __init__(self, app: ASGIApp) -> None: self._app = app async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: client: AsyncIOMotorClient = AsyncIOMotorClient( DATABASE_URL, - tlsAllowInvalidCertificates=True + tlsAllowInvalidCertificates=True, ) db = client[MONGO_DATABASE] Request(scope).state.db = db @@ -22,7 +21,6 @@ class DatabaseMiddleware: class ProtectedDocsMiddleware: - def __init__(self, app: ASGIApp) -> None: self._app = app diff --git a/backend/models/__init__.py b/backend/models/__init__.py index a9f76e0..336e28b 100644 --- a/backend/models/__init__.py +++ b/backend/models/__init__.py @@ -7,13 +7,13 @@ from .question import CodeQuestion, Question __all__ = [ "AntiSpam", + "CodeQuestion", + "DiscordMember", "DiscordRole", "DiscordUser", - "DiscordMember", "Form", + "FormList", "FormResponse", - "CodeQuestion", "Question", - "FormList", - "ResponseList" + "ResponseList", ] diff --git a/backend/models/discord_role.py b/backend/models/discord_role.py index ada35ef..195f557 100644 --- a/backend/models/discord_role.py +++ b/backend/models/discord_role.py @@ -1,13 +1,11 @@ -import typing - from pydantic import BaseModel class RoleTags(BaseModel): """Meta information about a discord role.""" - bot_id: typing.Optional[str] - integration_id: typing.Optional[str] + bot_id: str | None + integration_id: str | None premium_subscriber: bool def __init__(self, **data) -> None: @@ -20,7 +18,7 @@ class RoleTags(BaseModel): We manually parse the raw data to determine if the field exists, and give it a useful bool value. """ - data["premium_subscriber"] = "premium_subscriber" in data.keys() + data["premium_subscriber"] = "premium_subscriber" in data super().__init__(**data) @@ -31,10 +29,10 @@ class DiscordRole(BaseModel): name: str color: int hoist: bool - icon: typing.Optional[str] - unicode_emoji: typing.Optional[str] + icon: str | None + unicode_emoji: str | None position: int permissions: str managed: bool mentionable: bool - tags: typing.Optional[RoleTags] + tags: RoleTags | None diff --git a/backend/models/discord_user.py b/backend/models/discord_user.py index 0eca15b..be10672 100644 --- a/backend/models/discord_user.py +++ b/backend/models/discord_user.py @@ -11,15 +11,15 @@ class _User(BaseModel): username: str id: str discriminator: str - avatar: t.Optional[str] - bot: t.Optional[bool] - system: t.Optional[bool] - locale: t.Optional[str] - verified: t.Optional[bool] - email: t.Optional[str] - flags: t.Optional[int] - premium_type: t.Optional[int] - public_flags: t.Optional[int] + avatar: str | None + bot: bool | None + system: bool | None + locale: str | None + verified: bool | None + email: str | None + flags: int | None + premium_type: int | None + public_flags: int | None class DiscordUser(_User): @@ -33,16 +33,16 @@ class DiscordMember(BaseModel): """A discord guild member.""" user: _User - nick: t.Optional[str] - avatar: t.Optional[str] + nick: str | None + avatar: str | None roles: list[str] joined_at: datetime.datetime - premium_since: t.Optional[datetime.datetime] + premium_since: datetime.datetime | None deaf: bool mute: bool - pending: t.Optional[bool] - permissions: t.Optional[str] - communication_disabled_until: t.Optional[datetime.datetime] + pending: bool | None + permissions: str | None + communication_disabled_until: datetime.datetime | None def dict(self, *args, **kwargs) -> dict[str, t.Any]: """Convert the model to a python dict, and encode timestamps in a serializable format.""" diff --git a/backend/models/form.py b/backend/models/form.py index 10c8bfd..3db267e 100644 --- a/backend/models/form.py +++ b/backend/models/form.py @@ -5,6 +5,7 @@ from pydantic import BaseModel, Field, constr, root_validator, validator from pydantic.error_wrappers import ErrorWrapper, ValidationError from backend.constants import DISCORD_GUILD, FormFeatures, WebHook + from .question import Question PUBLIC_FIELDS = [ @@ -14,20 +15,22 @@ PUBLIC_FIELDS = [ "name", "description", "submitted_text", - "discord_role" + "discord_role", ] class _WebHook(BaseModel): """Schema model of discord webhooks.""" + url: str - message: t.Optional[str] + message: str | None @validator("url") def validate_url(cls, url: str) -> str: """Validates URL parameter.""" if "discord.com/api/webhooks/" not in url: - raise ValueError("URL must be a discord webhook.") + msg = "URL must be a discord webhook." + raise ValueError(msg) return url @@ -40,56 +43,55 @@ class Form(BaseModel): questions: list[Question] name: str description: str - submitted_text: t.Optional[str] = None + submitted_text: str | None = None webhook: _WebHook = None - discord_role: t.Optional[str] - response_readers: t.Optional[list[str]] - editors: t.Optional[list[str]] + discord_role: str | None + response_readers: list[str] | None + editors: list[str] | None class Config: allow_population_by_field_name = True @validator("features") - def validate_features(cls, value: list[str]) -> t.Optional[list[str]]: + def validate_features(cls, value: list[str]) -> 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 = [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.") + msg = "Form features list contains one or more invalid values." + raise ValueError(msg) if FormFeatures.REQUIRES_LOGIN.value not in value: if FormFeatures.COLLECT_EMAIL.value in value: - raise ValueError( - "COLLECT_EMAIL feature require REQUIRES_LOGIN feature." - ) + msg = "COLLECT_EMAIL feature require REQUIRES_LOGIN feature." + raise ValueError(msg) if FormFeatures.ASSIGN_ROLE.value in value: - raise ValueError("ASSIGN_ROLE feature require REQUIRES_LOGIN feature.") + msg = "ASSIGN_ROLE feature require REQUIRES_LOGIN feature." + raise ValueError(msg) return value @validator("response_readers", "editors") - def validate_role_scoping(cls, value: t.Optional[list[str]]) -> t.Optional[list[str]]: + def validate_role_scoping(cls, value: list[str] | None) -> list[str]: """Ensure special role based permissions aren't granted to the @everyone role.""" - if value and str(DISCORD_GUILD) in value: - raise ValueError("You can not add the everyone role as an access scope.") + if value and DISCORD_GUILD in value: + msg = "You can not add the everyone role as an access scope." + raise ValueError(msg) return value @root_validator - def validate_role(cls, values: dict[str, t.Any]) -> t.Optional[dict[str, t.Any]]: + def validate_role(cls, values: dict[str, t.Any]) -> dict[str, t.Any]: """Validates does Discord role provided when flag provided.""" - if ( - FormFeatures.ASSIGN_ROLE.value in values.get("features", []) - and not values.get("discord_role") - ): - raise ValueError( - "discord_role field is required when ASSIGN_ROLE flag is provided." - ) + is_role_assigner = FormFeatures.ASSIGN_ROLE.value in values.get("features", []) + if is_role_assigner and not values.get("discord_role"): + msg = "discord_role field is required when ASSIGN_ROLE flag is provided." + raise ValueError(msg) return values - def dict(self, admin: bool = True, **kwargs) -> dict[str, t.Any]: + def dict(self, admin: bool = True, **kwargs) -> dict[str, t.Any]: # noqa: FBT001, FBT002 """Wrapper for original function to exclude private data for public access.""" data = super().dict(**kwargs) @@ -97,10 +99,7 @@ class Form(BaseModel): if not admin: for field in PUBLIC_FIELDS: - if field == "id" and kwargs.get("by_alias"): - fetch_field = "_id" - else: - fetch_field = field + fetch_field = "_id" if field == "id" and kwargs.get("by_alias") else field returned_data[field] = data[fetch_field] else: @@ -110,17 +109,20 @@ class Form(BaseModel): class FormList(BaseModel): - __root__: t.List[Form] + __root__: list[Form] -async def validate_hook_url(url: str) -> t.Optional[ValidationError]: +async def validate_hook_url(url: str) -> ValidationError | None: """Validator for discord webhook urls.""" - async def validate() -> t.Optional[str]: + + async def validate() -> str | None: if not isinstance(url, str): - raise ValueError("Webhook URL must be a string.") + msg = "Webhook URL must be a string." + raise TypeError(msg) if "discord.com/api/webhooks/" not in url: - raise ValueError("URL must be a discord webhook.") + msg = "URL must be a discord webhook." + raise ValueError(msg) try: async with httpx.AsyncClient() as client: @@ -129,36 +131,32 @@ async def validate_hook_url(url: str) -> t.Optional[ValidationError]: except httpx.RequestError as error: # Catch exceptions in request format - raise ValueError( - f"Encountered error while trying to connect to url: `{error}`" - ) + msg = f"Encountered error while trying to connect to url: `{error}`" + raise ValueError(msg) except httpx.HTTPStatusError as error: # Catch exceptions in response status = error.response.status_code if status == 401: - raise ValueError( - "Could not authenticate with target. Please check the webhook url." - ) - elif status == 404: - raise ValueError( - "Target could not find webhook url. Please check the webhook url." - ) - else: - raise ValueError( - f"Unknown error ({status}) while connecting to target: {error}" - ) + msg = "Could not authenticate with target. Please check the webhook url." + raise ValueError(msg) + if status == 404: + msg = "Target could not find webhook url. Please check the webhook url." + raise ValueError(msg) + + msg = f"Unknown error ({status}) while connecting to target: {error}" + raise ValueError(msg) return url # Validate, and return errors, if any try: await validate() - except Exception as e: + except Exception as e: # noqa: BLE001 loc = ( WebHook.__name__.lower(), - WebHook.URL.value + WebHook.URL.value, ) return ValidationError([ErrorWrapper(e, loc=loc)], _WebHook) diff --git a/backend/models/form_response.py b/backend/models/form_response.py index 933f5e4..3c8297b 100644 --- a/backend/models/form_response.py +++ b/backend/models/form_response.py @@ -11,19 +11,20 @@ class FormResponse(BaseModel): """Schema model for form response.""" id: str = Field(alias="_id") - user: t.Optional[DiscordUser] - antispam: t.Optional[AntiSpam] + user: DiscordUser | None + antispam: AntiSpam | None response: dict[str, t.Any] form_id: str timestamp: str @validator("timestamp", pre=True) - def set_timestamp(cls, iso_string: t.Optional[str]) -> t.Optional[str]: + def set_timestamp(cls, iso_string: str | None) -> str: if iso_string is None: - return datetime.datetime.now(tz=datetime.timezone.utc).isoformat() + return datetime.datetime.now(tz=datetime.UTC).isoformat() - elif not isinstance(iso_string, str): - raise ValueError("Submission timestamp must be a string.") + if not isinstance(iso_string, str): + msg = "Submission timestamp must be a string." + raise TypeError(msg) # Convert to datetime and back to ensure string is valid return datetime.datetime.fromisoformat(iso_string).isoformat() @@ -33,4 +34,4 @@ class FormResponse(BaseModel): class ResponseList(BaseModel): - __root__: t.List[FormResponse] + __root__: list[FormResponse] diff --git a/backend/models/question.py b/backend/models/question.py index 201aa51..a13ce93 100644 --- a/backend/models/question.py +++ b/backend/models/question.py @@ -4,11 +4,12 @@ from pydantic import BaseModel, Field, root_validator, validator from backend.constants import QUESTION_TYPES, REQUIRED_QUESTION_TYPE_DATA -_TESTS_TYPE = t.Union[t.Dict[str, str], int] +_TESTS_TYPE = dict[str, str] | int class Unittests(BaseModel): """Schema model for unittest suites in code questions.""" + allow_failure: bool = False tests: _TESTS_TYPE @@ -16,17 +17,19 @@ class Unittests(BaseModel): def validate_tests(cls, value: _TESTS_TYPE) -> _TESTS_TYPE: """Confirm that at least one test exists in a test suite.""" if isinstance(value, dict): - keys = len(value.keys()) - (1 if "setUp" in value.keys() else 0) + keys = len(value.keys()) - (1 if "setUp" in value else 0) if keys == 0: - raise ValueError("Must have at least one test in a test suite.") + msg = "Must have at least one test in a test suite." + raise ValueError(msg) return value class CodeQuestion(BaseModel): """Schema model for questions of type `code`.""" + language: str - unittests: t.Optional[Unittests] + unittests: Unittests | None class Question(BaseModel): @@ -42,22 +45,20 @@ class Question(BaseModel): allow_population_by_field_name = True @validator("type", pre=True) - def validate_question_type(cls, value: str) -> t.Optional[str]: + def validate_question_type(cls, value: str) -> str: """Checks if question type in currently allowed types list.""" value = value.lower() if value not in QUESTION_TYPES: - raise ValueError( - f"{value} is not valid question type. " - f"Allowed question types: {QUESTION_TYPES}." - ) + msg = f"{value} is not valid question type. Allowed question types: {QUESTION_TYPES}." + raise ValueError(msg) return value @root_validator def validate_question_data( - cls, - value: dict[str, t.Any] - ) -> t.Optional[dict[str, t.Any]]: + cls, + value: dict[str, t.Any], + ) -> 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: @@ -65,13 +66,15 @@ class Question(BaseModel): 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.") + msg = f"Required question data key '{key}' not provided." + raise ValueError(msg) if not isinstance(value["data"][key], data_type): - raise ValueError( + msg = ( f"Question data key '{key}' expects {data_type.__name__}, " - f"got {type(value['data'][key]).__name__} instead." + f"got {type(value["data"][key]).__name__} instead." ) + raise TypeError(msg) # Validate unittest options if value.get("type").lower() == "code": diff --git a/backend/route.py b/backend/route.py index d778bf0..a9ea7ad 100644 --- a/backend/route.py +++ b/backend/route.py @@ -1,6 +1,5 @@ -""" -Base class for implementing dynamic routing. -""" +"""Base class for implementing dynamic routing.""" + from starlette.endpoints import HTTPEndpoint @@ -11,7 +10,9 @@ class Route(HTTPEndpoint): @classmethod def check_parameters(cls) -> None: if not hasattr(cls, "name"): - raise ValueError(f"Route {cls.__name__} has not defined a name") + msg = f"Route {cls.__name__} has not defined a name" + raise ValueError(msg) if not hasattr(cls, "path"): - raise ValueError(f"Route {cls.__name__} has not defined a path") + msg = f"Route {cls.__name__} has not defined a path" + raise ValueError(msg) diff --git a/backend/route_manager.py b/backend/route_manager.py index 2d95bb2..b35ca0b 100644 --- a/backend/route_manager.py +++ b/backend/route_manager.py @@ -1,6 +1,4 @@ -""" -Module to dynamically generate a Starlette routing map based on a directory tree. -""" +"""Module to dynamically generate a Starlette routing map based on a directory tree.""" import importlib import inspect @@ -27,7 +25,7 @@ def construct_route_map_from_dict(route_dict: dict) -> list[BaseRoute]: return route_map -def is_route_class(member: t.Any) -> bool: # noqa: ANN401 +def is_route_class(member: t.Any) -> bool: return inspect.isclass(member) and issubclass(member, Route) and member != Route @@ -35,7 +33,7 @@ def route_classes() -> t.Iterator[tuple[Path, type[Route]]]: routes_directory = Path("backend") / "routes" for module_path in routes_directory.rglob("*.py"): - import_name = f"{'.'.join(module_path.parent.parts)}.{module_path.stem}" + import_name = f"{".".join(module_path.parent.parts)}.{module_path.stem}" route_module = importlib.import_module(import_name) for _member_name, member in inspect.getmembers(route_module): if is_route_class(member): @@ -47,7 +45,7 @@ def create_route_map() -> list[BaseRoute]: route_dict = nested_dict() for module_path, member in route_classes(): - # module_path == Path("backend/routes/foo/bar/baz/bin.py") + # For Path: "backend/routes/foo/bar/baz/bin.py" # => levels == ["foo", "bar", "baz"] levels = module_path.parent.parts[2:] current_level = None diff --git a/backend/routes/admin.py b/backend/routes/admin.py index 0fd0700..848abce 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -1,6 +1,5 @@ -""" -Adds new admin user. -""" +"""Adds new admin user.""" + from pydantic import BaseModel, Field from spectree import Response from starlette.authentication import requires @@ -22,7 +21,7 @@ async def grant(request: Request) -> JSONResponse: admin = AdminModel(**data) if await request.state.db.admins.find_one( - {"_id": admin.id} + {"_id": admin.id}, ): return JSONResponse({"error": "already_exists"}, status_code=400) @@ -40,7 +39,7 @@ class AdminRoute(Route): @api.validate( json=AdminModel, resp=Response(HTTP_200=OkayResponse, HTTP_400=ErrorMessage), - tags=["admin"] + tags=["admin"], ) async def post(self, request: Request) -> JSONResponse: """Grant a user administrator privileges.""" @@ -48,6 +47,7 @@ class AdminRoute(Route): if not constants.PRODUCTION: + class AdminDev(Route): """Adds new admin user with no authentication.""" @@ -57,7 +57,7 @@ if not constants.PRODUCTION: @api.validate( json=AdminModel, resp=Response(HTTP_200=OkayResponse, HTTP_400=ErrorMessage), - tags=["admin"] + tags=["admin"], ) async def post(self, request: Request) -> JSONResponse: """ diff --git a/backend/routes/auth/authorize.py b/backend/routes/auth/authorize.py index 42fb3ec..bc80a7d 100644 --- a/backend/routes/auth/authorize.py +++ b/backend/routes/auth/authorize.py @@ -1,9 +1,6 @@ -""" -Use a token received from the Discord OAuth2 system to fetch user information. -""" +"""Use a token received from the Discord OAuth2 system to fetch user information.""" import datetime -from typing import Union import httpx import jwt @@ -35,8 +32,8 @@ class AuthorizeResponse(BaseModel): async def process_token( bearer_token: dict, - request: Request -) -> Union[AuthorizeResponse, AUTH_FAILURE]: + request: Request, +) -> AuthorizeResponse | responses.JSONResponse: """Post a bearer token to Discord, and return a JWT and username.""" interaction_start = datetime.datetime.now() @@ -57,7 +54,7 @@ async def process_token( "refresh": bearer_token["refresh_token"], "user_details": user_details, "in_guild": bool(member), - "expiry": token_expiry.isoformat() + "expiry": token_expiry.isoformat(), } token = jwt.encode(data, SECRET_KEY, algorithm="HS256") @@ -65,18 +62,18 @@ async def process_token( response = responses.JSONResponse({ "username": user.display_name, - "expiry": token_expiry.isoformat() + "expiry": token_expiry.isoformat(), }) - await set_response_token(response, request, token, bearer_token["expires_in"]) + set_response_token(response, request, token, bearer_token["expires_in"]) return response -async def set_response_token( - response: responses.Response, - request: Request, - new_token: str, - expiry: int +def set_response_token( + response: responses.Response, + request: Request, + new_token: str, + expiry: int, ) -> None: """Helper that handles logic for updating a token in a set-cookie response.""" origin_url = request.headers.get("origin") @@ -94,19 +91,18 @@ async def set_response_token( samesite = "None" response.set_cookie( - "token", f"JWT {new_token}", + "token", + f"JWT {new_token}", secure=constants.PRODUCTION, httponly=True, samesite=samesite, domain=domain, - max_age=expiry + max_age=expiry, ) class AuthorizeRoute(Route): - """ - Use the authorization code from Discord to generate a JWT token. - """ + """Use the authorization code from Discord to generate a JWT token.""" name = "authorize" path = "/authorize" @@ -114,7 +110,7 @@ class AuthorizeRoute(Route): @api.validate( json=AuthorizeRequest, resp=Response(HTTP_200=AuthorizeResponse, HTTP_400=ErrorMessage), - tags=["auth"] + tags=["auth"], ) async def post(self, request: Request) -> responses.JSONResponse: """Generate an authorization token.""" @@ -129,9 +125,7 @@ class AuthorizeRoute(Route): class TokenRefreshRoute(Route): - """ - Use the refresh code from a JWT to get a new token and generate a new JWT token. - """ + """Use the refresh code from a JWT to get a new token and generate a new JWT token.""" name = "refresh" path = "/refresh" @@ -139,7 +133,7 @@ class TokenRefreshRoute(Route): @requires(["authenticated"]) @api.validate( resp=Response(HTTP_200=AuthorizeResponse, HTTP_400=ErrorMessage), - tags=["auth"] + tags=["auth"], ) async def post(self, request: Request) -> responses.JSONResponse: """Refresh an authorization token.""" diff --git a/backend/routes/discord.py b/backend/routes/discord.py index bca1edb..53b8af3 100644 --- a/backend/routes/discord.py +++ b/backend/routes/discord.py @@ -10,7 +10,8 @@ from backend import discord, models, route from backend.validation import ErrorMessage, api NOT_FOUND_EXCEPTION = JSONResponse( - {"error": "Could not find the requested resource in the guild or cache."}, status_code=404 + {"error": "Could not find the requested resource in the guild or cache."}, + status_code=404, ) @@ -28,7 +29,7 @@ class RolesRoute(route.Route): @requires(["authenticated", "admin"]) @api.validate( resp=Response(HTTP_200=RolesResponse), - tags=["roles"] + tags=["roles"], ) async def patch(self, request: Request) -> JSONResponse: """Refresh the roles database.""" @@ -54,7 +55,7 @@ class MemberRoute(route.Route): @api.validate( resp=Response(HTTP_200=models.DiscordMember, HTTP_400=ErrorMessage), json=MemberRequest, - tags=["auth"] + tags=["auth"], ) async def delete(self, request: Request) -> JSONResponse: """Force a resync of the cache for the given user.""" @@ -63,21 +64,20 @@ class MemberRoute(route.Route): if member: return JSONResponse(member.dict()) - else: - return NOT_FOUND_EXCEPTION + return NOT_FOUND_EXCEPTION @requires(["authenticated", "admin"]) @api.validate( resp=Response(HTTP_200=models.DiscordMember, HTTP_400=ErrorMessage), json=MemberRequest, - tags=["auth"] + tags=["auth"], ) async def get(self, request: Request) -> JSONResponse: """Get a user's roles on the configured server.""" body = await request.json() member = await discord.get_member(request.state.db, body["user_id"]) - if member: - return JSONResponse(member.dict()) - else: + if not member: return NOT_FOUND_EXCEPTION + + return JSONResponse(member.dict()) diff --git a/backend/routes/forms/discover.py b/backend/routes/forms/discover.py index 75ff495..0fe10b5 100644 --- a/backend/routes/forms/discover.py +++ b/backend/routes/forms/discover.py @@ -1,6 +1,5 @@ -""" -Return a list of all publicly discoverable forms to unauthenticated users. -""" +"""Return a list of all publicly discoverable forms to unauthenticated users.""" + from spectree.response import Response from starlette.requests import Request from starlette.responses import JSONResponse @@ -12,7 +11,7 @@ from backend.validation import api __FEATURES = [ constants.FormFeatures.OPEN.value, - constants.FormFeatures.REQUIRES_LOGIN.value + constants.FormFeatures.REQUIRES_LOGIN.value, ] if not constants.PRODUCTION: __FEATURES.append(constants.FormFeatures.DISCOVERABLE.value) @@ -22,7 +21,7 @@ __QUESTION = Question( name="Click the button below to log into the forms application.", type="section", data={"text": ""}, - required=False + required=False, ) AUTH_FORM = Form( @@ -31,14 +30,12 @@ AUTH_FORM = Form( questions=[__QUESTION], name="Login", description="Log into Python Discord Forms.", - submitted_text="This page can't be submitted." + submitted_text="This page can't be submitted.", ) class DiscoverableFormsList(Route): - """ - List all discoverable forms that should be shown on the homepage. - """ + """List all discoverable forms that should be shown on the homepage.""" name = "discoverable_forms_list" path = "/discoverable" @@ -46,15 +43,11 @@ class DiscoverableFormsList(Route): @api.validate(resp=Response(HTTP_200=FormList), tags=["forms"]) async def get(self, request: Request) -> JSONResponse: """List all discoverable forms that should be shown on the homepage.""" - forms = [] cursor = request.state.db.forms.find({"features": "DISCOVERABLE"}).sort("name") # Parse it to Form and then back to dictionary # to replace _id with id - for form in await cursor.to_list(None): - forms.append(Form(**form)) - - forms = [form.dict(admin=False) for form in forms] + forms = [Form(**form).dict(admin=False) for form in await cursor.to_list(None)] # Return an empty form in development environments to help with authentication. if not constants.PRODUCTION: diff --git a/backend/routes/forms/form.py b/backend/routes/forms/form.py index 020193c..410102a 100644 --- a/backend/routes/forms/form.py +++ b/backend/routes/forms/form.py @@ -1,6 +1,5 @@ -""" -Returns, updates or deletes a single form given an ID. -""" +"""Returns, updates or deletes a single form given an ID.""" + import json.decoder import deepmerge @@ -48,7 +47,7 @@ class SingleForm(Route): admin = False filters = { - "_id": form_id + "_id": form_id, } if not admin: @@ -71,7 +70,7 @@ class SingleForm(Route): HTTP_400=ErrorMessage, HTTP_404=ErrorMessage, ), - tags=["forms"] + tags=["forms"], ) async def patch(self, request: Request) -> JSONResponse: """Updates form by ID.""" @@ -90,7 +89,7 @@ class SingleForm(Route): # Build Data Merger merge_strategy = [ - (dict, ["merge"]) + (dict, ["merge"]), ] merger = deepmerge.Merger(merge_strategy, ["override"], ["override"]) @@ -105,13 +104,12 @@ class SingleForm(Route): await request.state.db.forms.replace_one({"_id": form_id}, form.dict()) return JSONResponse(form.dict()) - else: - return JSONResponse({"error": "not_found"}, status_code=404) + return JSONResponse({"error": "not_found"}, status_code=404) @requires(["authenticated", "admin"]) @api.validate( resp=Response(HTTP_200=OkayResponse, HTTP_401=ErrorMessage, HTTP_404=ErrorMessage), - tags=["forms"] + tags=["forms"], ) async def delete(self, request: Request) -> JSONResponse: """Deletes form by ID.""" diff --git a/backend/routes/forms/index.py b/backend/routes/forms/index.py index 38be693..1fdfc48 100644 --- a/backend/routes/forms/index.py +++ b/backend/routes/forms/index.py @@ -1,6 +1,5 @@ -""" -Return a list of all forms to authenticated users. -""" +"""Return a list of all forms to authenticated users.""" + from spectree.response import Response from starlette.authentication import requires from starlette.requests import Request @@ -14,9 +13,7 @@ from backend.validation import ErrorMessage, OkayResponse, api class FormsList(Route): - """ - List all available forms for authorized viewers. - """ + """List all available forms for authorized viewers.""" name = "forms_list_create" path = "/" @@ -25,24 +22,17 @@ class FormsList(Route): @api.validate(resp=Response(HTTP_200=FormList), tags=["forms"]) async def get(self, request: Request) -> JSONResponse: """Return a list of all forms to authenticated users.""" - forms = [] cursor = request.state.db.forms.find() - for form in await cursor.to_list(None): - forms.append(Form(**form)) # For converting _id to id - - # Covert them back to dictionaries - forms = [form.dict() for form in forms] + forms = [Form(**form).dict() for form in await cursor.to_list(None)] - return JSONResponse( - forms - ) + return JSONResponse(forms) @requires(["authenticated", "Helpers"]) @api.validate( json=Form, resp=Response(HTTP_200=OkayResponse, HTTP_400=ErrorMessage), - tags=["forms"] + tags=["forms"], ) async def post(self, request: Request) -> JSONResponse: """Create a new form.""" @@ -66,9 +56,7 @@ class FormsList(Route): form = Form(**form_data) if await request.state.db.forms.find_one({"_id": form.id}): - return JSONResponse({ - "error": "id_taken" - }, status_code=400) + return JSONResponse({"error": "id_taken"}, status_code=400) await request.state.db.forms.insert_one(form.dict(by_alias=True)) return JSONResponse(form.dict()) diff --git a/backend/routes/forms/response.py b/backend/routes/forms/response.py index 565701f..b4f7f04 100644 --- a/backend/routes/forms/response.py +++ b/backend/routes/forms/response.py @@ -1,6 +1,4 @@ -""" -Returns or deletes form response by ID. -""" +"""Returns or deletes form response by ID.""" from spectree import Response as RouteResponse from starlette.authentication import requires @@ -22,7 +20,7 @@ class Response(Route): @requires(["authenticated"]) @api.validate( resp=RouteResponse(HTTP_200=FormResponse, HTTP_404=ErrorMessage), - tags=["forms", "responses"] + tags=["forms", "responses"], ) async def get(self, request: Request) -> JSONResponse: """Return a single form response by ID.""" @@ -32,30 +30,29 @@ class Response(Route): if raw_response := await request.state.db.responses.find_one( { "_id": request.path_params["response_id"], - "form_id": form_id - } + "form_id": form_id, + }, ): response = FormResponse(**raw_response) return JSONResponse(response.dict()) - else: - return JSONResponse({"error": "response_not_found"}, status_code=404) + return JSONResponse({"error": "response_not_found"}, status_code=404) @requires(["authenticated", "admin"]) @api.validate( resp=RouteResponse(HTTP_200=OkayResponse, HTTP_404=ErrorMessage), - tags=["forms", "responses"] + tags=["forms", "responses"], ) async def delete(self, request: Request) -> JSONResponse: """Delete a form response by ID.""" if not await request.state.db.responses.find_one( { "_id": request.path_params["response_id"], - "form_id": request.path_params["form_id"] - } + "form_id": request.path_params["form_id"], + }, ): return JSONResponse({"error": "not_found"}, status_code=404) await request.state.db.responses.delete_one( - {"_id": request.path_params["response_id"]} + {"_id": request.path_params["response_id"]}, ) return JSONResponse({"status": "ok"}) diff --git a/backend/routes/forms/responses.py b/backend/routes/forms/responses.py index 818ebce..85e5af2 100644 --- a/backend/routes/forms/responses.py +++ b/backend/routes/forms/responses.py @@ -1,6 +1,5 @@ -""" -Returns all form responses by form ID. -""" +"""Returns all form responses by form ID.""" + from pydantic import BaseModel from spectree import Response from starlette.authentication import requires @@ -18,9 +17,7 @@ class ResponseIdList(BaseModel): class Responses(Route): - """ - Returns all form responses by form ID. - """ + """Returns all form responses by form ID.""" name = "form_responses" path = "/{form_id:str}/responses" @@ -28,7 +25,7 @@ class Responses(Route): @requires(["authenticated"]) @api.validate( resp=Response(HTTP_200=ResponseList), - tags=["forms", "responses"] + tags=["forms", "responses"], ) async def get(self, request: Request) -> JSONResponse: """Returns all form responses by form ID.""" @@ -36,11 +33,9 @@ class Responses(Route): await discord.verify_response_access(form_id, request) cursor = request.state.db.responses.find( - {"form_id": form_id} + {"form_id": form_id}, ) - responses = [ - FormResponse(**response) for response in await cursor.to_list(None) - ] + responses = [FormResponse(**response) for response in await cursor.to_list(None)] return JSONResponse([response.dict() for response in responses]) @requires(["authenticated", "admin"]) @@ -49,14 +44,14 @@ class Responses(Route): resp=Response( HTTP_200=OkayResponse, HTTP_404=ErrorMessage, - HTTP_400=ErrorMessage + HTTP_400=ErrorMessage, ), - tags=["forms", "responses"] + tags=["forms", "responses"], ) async def delete(self, request: Request) -> JSONResponse: """Bulk deletes form responses by IDs.""" if not await request.state.db.forms.find_one( - {"_id": request.path_params["form_id"]} + {"_id": request.path_params["form_id"]}, ): return JSONResponse({"error": "not_found"}, status_code=404) @@ -67,37 +62,34 @@ class Responses(Route): ids = set(response_ids.ids) cursor = request.state.db.responses.find( - {"_id": {"$in": list(ids)}} # Convert here back to list, may throw error. + {"_id": {"$in": list(ids)}}, # Convert here back to list, may throw error. ) - entries = [ - FormResponse(**submission) for submission in await cursor.to_list(None) - ] + entries = [FormResponse(**submission) for submission in await cursor.to_list(None)] actual_ids = {entry.id for entry in entries} if len(ids) != len(actual_ids): return JSONResponse( { "error": "responses_not_found", - "ids": list(ids - actual_ids) + "ids": list(ids - actual_ids), }, - status_code=404 + status_code=404, ) if any(entry.form_id != request.path_params["form_id"] for entry in entries): return JSONResponse( { "error": "wrong_form", - "ids": list( - entry.id for entry in entries - if entry.id != request.path_params["form_id"] - ) + "ids": [ + entry.id for entry in entries if entry.id != request.path_params["form_id"] + ], }, - status_code=400 + status_code=400, ) await request.state.db.responses.delete_many( { - "_id": {"$in": list(actual_ids)} - } + "_id": {"$in": list(actual_ids)}, + }, ) return JSONResponse({"status": "ok"}) diff --git a/backend/routes/forms/submit.py b/backend/routes/forms/submit.py index 765856e..8f01e2b 100644 --- a/backend/routes/forms/submit.py +++ b/backend/routes/forms/submit.py @@ -1,6 +1,4 @@ -""" -Submit a form. -""" +"""Submit a form.""" import asyncio import binascii @@ -8,10 +6,9 @@ import datetime import hashlib import typing import uuid -from typing import Any, Optional +from typing import Any import httpx -import pymongo.database import sentry_sdk from pydantic import ValidationError from pydantic.main import BaseModel @@ -29,13 +26,16 @@ from backend.routes.forms.discover import AUTH_FORM from backend.routes.forms.unittesting import BypassDetectedError, execute_unittest from backend.validation import ErrorMessage, api +if typing.TYPE_CHECKING: + import pymongo.database + HCAPTCHA_VERIFY_URL = "https://hcaptcha.com/siteverify" HCAPTCHA_HEADERS = { - "Content-Type": "application/x-www-form-urlencoded" + "Content-Type": "application/x-www-form-urlencoded", } DISCORD_HEADERS = { - "Authorization": f"Bot {constants.DISCORD_BOT_TOKEN}" + "Authorization": f"Bot {constants.DISCORD_BOT_TOKEN}", } @@ -46,7 +46,7 @@ class SubmissionResponse(BaseModel): class PartialSubmission(BaseModel): response: dict[str, Any] - captcha: Optional[str] + captcha: str | None class UnittestError(BaseModel): @@ -62,9 +62,7 @@ class UnittestErrorMessage(ErrorMessage): class SubmitForm(Route): - """ - Submit a form with the provided form ID. - """ + """Submit a form with the provided form ID.""" name = "submit_form" path = "/submit/{form_id:str}" @@ -75,9 +73,9 @@ class SubmitForm(Route): HTTP_200=SubmissionResponse, HTTP_404=ErrorMessage, HTTP_400=ErrorMessage, - HTTP_422=UnittestErrorMessage + HTTP_422=UnittestErrorMessage, ), - tags=["forms", "responses"] + tags=["forms", "responses"], ) async def post(self, request: Request) -> JSONResponse: """Submit a response to the form.""" @@ -92,7 +90,7 @@ class SubmitForm(Route): if old != request.user.token: try: expiry = datetime.datetime.fromisoformat( - request.user.decoded_token.get("expiry") + request.user.decoded_token.get("expiry"), ) except ValueError: expiry = None @@ -117,7 +115,7 @@ class SubmitForm(Route): id="not-submitted", form_id=AUTH_FORM.id, response={question.id: None for question in AUTH_FORM.questions}, - timestamp=datetime.datetime.now().isoformat() + timestamp=datetime.datetime.now().isoformat(), ).dict() return JSONResponse({"form": AUTH_FORM.dict(admin=False), "response": response}) @@ -131,8 +129,9 @@ class SubmitForm(Route): ip_hash_ctx = hashlib.md5() ip_hash_ctx.update( request.headers.get( - "Cf-Connecting-IP", request.client.host - ).encode() + "Cf-Connecting-IP", + request.client.host, + ).encode(), ) ip_hash = binascii.hexlify(ip_hash_ctx.digest()) user_agent_hash_ctx = hashlib.md5() @@ -142,12 +141,12 @@ class SubmitForm(Route): async with httpx.AsyncClient() as client: query_params = { "secret": constants.HCAPTCHA_API_SECRET, - "response": data.get("captcha") + "response": data.get("captcha"), } r = await client.post( HCAPTCHA_VERIFY_URL, params=query_params, - headers=HCAPTCHA_HEADERS + headers=HCAPTCHA_HEADERS, ) r.raise_for_status() captcha_data = r.json() @@ -155,7 +154,7 @@ class SubmitForm(Route): response["antispam"] = { "ip_hash": ip_hash.decode(), "user_agent_hash": user_agent_hash.decode(), - "captcha_pass": captcha_data["success"] + "captcha_pass": captcha_data["success"], } if constants.FormFeatures.REQUIRES_LOGIN.value in form.features: @@ -164,16 +163,12 @@ class SubmitForm(Route): response["user"]["admin"] = request.user.admin if ( - constants.FormFeatures.COLLECT_EMAIL.value in form.features - and "email" not in response["user"] + constants.FormFeatures.COLLECT_EMAIL.value in form.features + and "email" not in response["user"] ): - return JSONResponse({ - "error": "email_required" - }, status_code=400) + return JSONResponse({"error": "email_required"}, status_code=400) else: - return JSONResponse({ - "error": "missing_discord_data" - }, status_code=400) + return JSONResponse({"error": "missing_discord_data"}, status_code=400) missing_fields = [] for question in form.questions: @@ -184,10 +179,13 @@ class SubmitForm(Route): missing_fields.append(question.id) if missing_fields: - return JSONResponse({ - "error": "missing_fields", - "fields": missing_fields - }, status_code=400) + return JSONResponse( + { + "error": "missing_fields", + "fields": missing_fields, + }, + status_code=400, + ) try: response_obj = FormResponse(**response) @@ -200,10 +198,12 @@ class SubmitForm(Route): if len(errors): username = getattr(request.user, "user_id", "Unknown") - sentry_sdk.capture_exception(BypassDetectedError( - f"Detected unittest bypass attempt on form {form.id} by {username}. " - f"Submission has been written to reporting database ({response_obj.id})." - )) + sentry_sdk.capture_exception( + BypassDetectedError( + f"Detected unittest bypass attempt on form {form.id} by {username}. " + f"Submission has been written to reporting database ({response_obj.id}).", + ) + ) database: pymongo.database.Database = request.state.db await database.get_collection("violations").insert_one({ "user": username, @@ -219,7 +219,7 @@ class SubmitForm(Route): for test in unittest_results: response_obj.response[test.question_id] = { "value": response_obj.response[test.question_id], - "passed": test.passed + "passed": test.passed, } if test.return_code == 0: @@ -238,9 +238,8 @@ class SubmitForm(Route): # Report a failure on internal errors, # or if the test suite doesn't allow failures if not test.passed: - allow_failure = ( - form.questions[test.question_index].data["unittests"]["allow_failure"] - ) + question = form.questions[test.question_index] + allow_failure = question.data["unittests"]["allow_failure"] # An error while communicating with the test runner if test.return_code == 99: @@ -251,15 +250,16 @@ class SubmitForm(Route): failures.append(test) if len(failures): - return JSONResponse({ - "error": "failed_tests", - "test_results": [ - test._asdict() for test in failures - ] - }, status_code=status_code) + return JSONResponse( + { + "error": "failed_tests", + "test_results": [test._asdict() for test in failures], + }, + status_code=status_code, + ) await request.state.db.responses.insert_one( - response_obj.dict(by_alias=True) + response_obj.dict(by_alias=True), ) tasks = BackgroundTasks() @@ -272,36 +272,37 @@ class SubmitForm(Route): self.send_submission_webhook, form=form, response=response_obj, - request_user=request_user + request_user=request_user, ) if constants.FormFeatures.ASSIGN_ROLE.value in form.features: tasks.add_task( self.assign_role, form=form, - request_user=request.user + request_user=request.user, ) - return JSONResponse({ - "form": form.dict(admin=False), - "response": response_obj.dict() - }, background=tasks) + return JSONResponse( + { + "form": form.dict(admin=False), + "response": response_obj.dict(), + }, + background=tasks, + ) - else: - return JSONResponse({ - "error": "Open form not found" - }, status_code=404) + return JSONResponse({"error": "Open form not found"}, status_code=404) @staticmethod async def send_submission_webhook( - form: Form, - response: FormResponse, - request_user: typing.Optional[User] + form: Form, + response: FormResponse, + request_user: User | None, ) -> None: """Helper to send a submission message to a discord webhook.""" # Stop if webhook is not available if form.webhook is None: - raise ValueError("Got empty webhook.") + msg = "Got empty webhook." + raise ValueError(msg) try: mention = request_user.discord_mention @@ -330,7 +331,7 @@ class SubmitForm(Route): hook = { "embeds": [embed], "allowed_mentions": {"parse": ["users", "roles"]}, - "username": form.name or "Python Discord Forms" + "username": form.name or "Python Discord Forms", } # Set hook message @@ -345,8 +346,8 @@ class SubmitForm(Route): "time": response.timestamp, } - for key in ctx: - message = message.replace(f"{{{key}}}", str(ctx[key])) + for key, val in ctx.items(): + message = message.replace(f"{{{key}}}", str(val)) hook["content"] = message.replace("_USER_MENTION_", mention) @@ -359,11 +360,12 @@ class SubmitForm(Route): async def assign_role(form: Form, request_user: User) -> None: """Assigns Discord role to user when user submitted response.""" if not form.discord_role: - raise ValueError("Got empty Discord role ID.") + msg = "Got empty Discord role ID." + raise ValueError(msg) url = ( f"{constants.DISCORD_API_BASE_URL}/guilds/{constants.DISCORD_GUILD}" - f"/members/{request_user.payload['id']}/roles/{form.discord_role}" + f"/members/{request_user.payload["id"]}/roles/{form.discord_role}" ) async with httpx.AsyncClient() as client: diff --git a/backend/routes/forms/unittesting.py b/backend/routes/forms/unittesting.py index a02afea..3239d35 100644 --- a/backend/routes/forms/unittesting.py +++ b/backend/routes/forms/unittesting.py @@ -1,7 +1,8 @@ import base64 -from collections import namedtuple from itertools import count +from pathlib import Path from textwrap import indent +from typing import NamedTuple import httpx from httpx import HTTPStatusError @@ -9,7 +10,7 @@ from httpx import HTTPStatusError from backend.constants import SNEKBOX_URL from backend.models import Form, FormResponse -with open("resources/unittest_template.py") as file: +with Path("resources/unittest_template.py").open(encoding="utf8") as file: TEST_TEMPLATE = file.read() @@ -17,9 +18,12 @@ class BypassDetectedError(Exception): """Detected an attempt at bypassing the unittests.""" -UnittestResult = namedtuple( - "UnittestResult", "question_id question_index return_code passed result" -) +class UnittestResult(NamedTuple): + question_id: str + question_index: int + return_code: int + passed: bool + result: str def filter_unittests(form: Form) -> Form: @@ -46,11 +50,11 @@ def _make_unit_code(units: dict[str, str]) -> str: elif unit_name == "tearDown": result += "\ndef tearDown(self):" else: - name = f"test_{unit_name.removeprefix('#').removeprefix('test_')}" + name = f"test_{unit_name.removeprefix("#").removeprefix("test_")}" result += f"\nasync def {name}(self):" # Unite code - result += f"\n{indent(unit_code, ' ')}" + result += f"\n{indent(unit_code, " ")}" return indent(result, " ") @@ -72,7 +76,8 @@ async def _post_eval(code: str) -> dict[str, str]: async def execute_unittest( - form_response: FormResponse, form: Form + form_response: FormResponse, + form: Form, ) -> tuple[list[UnittestResult], list[BypassDetectedError]]: """Execute all the unittests in this form and return the results.""" unittest_results = [] @@ -80,16 +85,17 @@ async def execute_unittest( for index, question in enumerate(form.questions): if question.type == "code": - # Exit early if the suite doesn't have any tests if question.data["unittests"] is None: - unittest_results.append(UnittestResult( - question_id=question.id, - question_index=index, - return_code=0, - passed=True, - result="" - )) + unittest_results.append( + UnittestResult( + question_id=question.id, + question_index=index, + return_code=0, + passed=True, + result="", + ) + ) continue passed = False @@ -98,7 +104,7 @@ async def execute_unittest( hidden_test_counter = count(1) hidden_tests = { test.removeprefix("#").removeprefix("test_"): next(hidden_test_counter) - for test in question.data["unittests"]["tests"].keys() + for test in question.data["unittests"]["tests"] if test.startswith("#") } @@ -124,18 +130,18 @@ async def execute_unittest( try: passed = bool(int(stdout[0])) except ValueError: - raise BypassDetectedError("Detected a bypass when reading result code.") + msg = "Detected a bypass when reading result code." + raise BypassDetectedError(msg) if passed and stdout.strip() != "1": # Most likely a bypass attempt # A 1 was written to stdout to indicate success, # followed by the actual output - raise BypassDetectedError( - "Detected improper value for stdout in unittest." - ) + msg = "Detected improper value for stdout in unittest." + raise BypassDetectedError(msg) # If the test failed, we have to populate the result string. - elif not passed: + if not passed: failed_tests = stdout[1:].strip().split(";") # Redact failed hidden tests @@ -146,7 +152,7 @@ async def execute_unittest( result = ";".join(failed_tests) else: result = "" - elif return_code in (5, 6, 99): + elif return_code in {5, 6, 99}: result = response["stdout"] # Killed by NsJail elif return_code == 137: @@ -162,12 +168,14 @@ async def execute_unittest( errors.append(error) passed = False - unittest_results.append(UnittestResult( - question_id=question.id, - question_index=index, - return_code=return_code, - passed=passed, - result=result - )) + unittest_results.append( + UnittestResult( + question_id=question.id, + question_index=index, + return_code=return_code, + passed=passed, + result=result, + ) + ) return unittest_results, errors diff --git a/backend/routes/index.py b/backend/routes/index.py index 207c36a..c6e38ea 100644 --- a/backend/routes/index.py +++ b/backend/routes/index.py @@ -1,6 +1,5 @@ -""" -Index route for the forms API. -""" +"""Index route for the forms API.""" + import platform from pydantic import BaseModel @@ -20,13 +19,13 @@ class IndexResponse(BaseModel): description=( "The connecting client, in production this will" " be an IP of our internal load balancer" - ) + ), ) sha: str = Field( - description="Current release Git SHA in production." + description="Current release Git SHA in production.", ) node: str = Field( - description="The node that processed the request." + description="The node that processed the request.", ) @@ -42,24 +41,22 @@ class IndexRoute(Route): @api.validate(resp=Response(HTTP_200=IndexResponse)) def get(self, request: Request) -> JSONResponse: - """ - Return a hello from Python Discord forms! - """ + """Return a hello from Python Discord forms!.""" response_data = { "message": "Hello, world!", "client": request.client.host, "user": { - "authenticated": False + "authenticated": False, }, "sha": GIT_SHA, - "node": platform.uname().node + "node": platform.uname().node, } if request.user.is_authenticated: response_data["user"] = { "authenticated": True, "user": request.user.payload, - "scopes": request.auth.scopes + "scopes": request.auth.scopes, } return JSONResponse(response_data) diff --git a/backend/validation.py b/backend/validation.py index 8771924..0560701 100644 --- a/backend/validation.py +++ b/backend/validation.py @@ -7,7 +7,7 @@ from spectree import SpecTree api = SpecTree( "starlette", TITLE="Python Discord Forms", - PATH="docs" + PATH="docs", ) diff --git a/pyproject.toml b/pyproject.toml index 00b4bdc..82d2c5b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ build-backend = "poetry.masonry.api" [tool.ruff] target-version = "py312" -extend-exclude = [".cache"] +extend-exclude = [".cache", "resources"] line-length = 100 unsafe-fixes = true preview = true @@ -47,16 +47,18 @@ ignore = [ "E731", "RET504", "RUF005", - "S311", + "S311", "S113", "S324", "SIM102", "SIM108", "PD", - "PLR0913", "PLR0917", "PLR6301", + "PLR0913", "PLR0917", "PLR6301", "PLR1702", "PLR0915", "PLR2004", + "PLR0912", "PLR0914", "PLR0911", "DTZ003", "INP001", "D102", - "D103", "D103", "D101", - "S113", + "D103", "D103", "D101", "D106", + "C901", "DTZ005", + "TRY004", "N805", # Rules suggested to be ignored when using ruff format -- cgit v1.2.3 From f44689a581ac2861b2d6d5d343b6934fa3f1f0ae Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sun, 7 Jul 2024 03:17:00 +0100 Subject: Add pre-commit configuration --- .pre-commit-config.yaml | 29 ++++++++++++ poetry.lock | 119 +++++++++++++++++++++++++++++++++++++++++++++++- pyproject.toml | 1 + 3 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 .pre-commit-config.yaml (limited to 'pyproject.toml') diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..bdf3a86 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,29 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: check-merge-conflict + - id: check-toml + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace + args: [--markdown-linebreak-ext=md] + + - repo: local + hooks: + - id: ruff-lint + name: ruff linting + description: Run ruff linting + entry: poetry run ruff check --force-exclude + language: system + "types_or": [python, pyi] + require_serial: true + args: [--fix, --exit-non-zero-on-fix] + + - id: ruff-format + name: ruff formatting + description: Run ruff formatting + entry: poetry run ruff format --force-exclude + language: system + "types_or": [python, pyi] + require_serial: true diff --git a/poetry.lock b/poetry.lock index 5e50fec..cefd81e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -31,6 +31,17 @@ files = [ {file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"}, ] +[[package]] +name = "cfgv" +version = "3.4.0" +description = "Validate configuration and produce human readable error messages." +optional = false +python-versions = ">=3.8" +files = [ + {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, + {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, +] + [[package]] name = "click" version = "8.1.7" @@ -67,6 +78,17 @@ files = [ {file = "deepmerge-1.1.1.tar.gz", hash = "sha256:53a489dc9449636e480a784359ae2aab3191748c920649551c8e378622f0eca4"}, ] +[[package]] +name = "distlib" +version = "0.3.8" +description = "Distribution utilities" +optional = false +python-versions = "*" +files = [ + {file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"}, + {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, +] + [[package]] name = "dnspython" version = "2.6.1" @@ -87,6 +109,22 @@ idna = ["idna (>=3.6)"] trio = ["trio (>=0.23)"] wmi = ["wmi (>=1.5.1)"] +[[package]] +name = "filelock" +version = "3.15.4" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.8" +files = [ + {file = "filelock-3.15.4-py3-none-any.whl", hash = "sha256:6ca1fffae96225dab4c6eaf1c4f4f28cd2568d3ec2a44e15a08520504de468e7"}, + {file = "filelock-3.15.4.tar.gz", hash = "sha256:2207938cbc1844345cb01a5a95524dae30f0ce089eba5b00378295a17e3e90cb"}, +] + +[package.extras] +docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8.0.1)", "pytest (>=7.4.3)", "pytest-asyncio (>=0.21)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)", "virtualenv (>=20.26.2)"] +typing = ["typing-extensions (>=4.8)"] + [[package]] name = "gunicorn" version = "22.0.0" @@ -212,6 +250,20 @@ cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] +[[package]] +name = "identify" +version = "2.5.36" +description = "File identification library for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "identify-2.5.36-py2.py3-none-any.whl", hash = "sha256:37d93f380f4de590500d9dba7db359d0d3da95ffe7f9de1753faa159e71e7dfa"}, + {file = "identify-2.5.36.tar.gz", hash = "sha256:e5e00f54165f9047fbebeb4a560f9acfb8af4c88232be60a488e9b68d122745d"}, +] + +[package.extras] +license = ["ukkonen"] + [[package]] name = "idna" version = "3.7" @@ -257,6 +309,17 @@ files = [ {file = "nested_dict-1.61.tar.gz", hash = "sha256:de0fb5bac82ba7bcc23736f09373f18628ea57f92bbaa13480d23f261c41e771"}, ] +[[package]] +name = "nodeenv" +version = "1.9.1" +description = "Node.js virtual environment builder" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, + {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, +] + [[package]] name = "packaging" version = "24.1" @@ -268,6 +331,40 @@ files = [ {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, ] +[[package]] +name = "platformdirs" +version = "4.2.2" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.8" +files = [ + {file = "platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee"}, + {file = "platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3"}, +] + +[package.extras] +docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"] +type = ["mypy (>=1.8)"] + +[[package]] +name = "pre-commit" +version = "3.7.1" +description = "A framework for managing and maintaining multi-language pre-commit hooks." +optional = false +python-versions = ">=3.9" +files = [ + {file = "pre_commit-3.7.1-py2.py3-none-any.whl", hash = "sha256:fae36fd1d7ad7d6a5a1c0b0d5adb2ed1a3bda5a21bf6c3e5372073d7a11cd4c5"}, + {file = "pre_commit-3.7.1.tar.gz", hash = "sha256:8ca3ad567bc78a4972a3f1a477e94a79d4597e8140a6e0b651c5e33899c3654a"}, +] + +[package.dependencies] +cfgv = ">=2.0.0" +identify = ">=1.0.0" +nodeenv = ">=0.11.1" +pyyaml = ">=5.1" +virtualenv = ">=20.10.0" + [[package]] name = "pydantic" version = "1.10.17" @@ -714,6 +811,26 @@ files = [ docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] test = ["Cython (>=0.29.36,<0.30.0)", "aiohttp (==3.9.0b0)", "aiohttp (>=3.8.1)", "flake8 (>=5.0,<6.0)", "mypy (>=0.800)", "psutil", "pyOpenSSL (>=23.0.0,<23.1.0)", "pycodestyle (>=2.9.0,<2.10.0)"] +[[package]] +name = "virtualenv" +version = "20.26.3" +description = "Virtual Python Environment builder" +optional = false +python-versions = ">=3.7" +files = [ + {file = "virtualenv-20.26.3-py3-none-any.whl", hash = "sha256:8cc4a31139e796e9a7de2cd5cf2489de1217193116a8fd42328f1bd65f434589"}, + {file = "virtualenv-20.26.3.tar.gz", hash = "sha256:4c43a2a236279d9ea36a0d76f98d84bd6ca94ac4e0f4a3b9d46d05e10fea542a"}, +] + +[package.dependencies] +distlib = ">=0.3.7,<1" +filelock = ">=3.12.2,<4" +platformdirs = ">=3.9.1,<5" + +[package.extras] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] + [[package]] name = "watchfiles" version = "0.22.0" @@ -885,4 +1002,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "581941f04a13b9af2b9f8571661f5c9a22054bd86b3adf009e6aeafb0a3f3615" +content-hash = "88fdfb53c24083f67305ff9e01dfb0bff29874b2de3ef9afc2d45b76e7d7da68" diff --git a/pyproject.toml b/pyproject.toml index 82d2c5b..01dfed7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ sentry-sdk = "^2.7.1" [tool.poetry.group.dev.dependencies] ruff = "^0.5.1" +pre-commit = "^3.7.1" [build-system] requires = ["poetry>=0.12"] -- cgit v1.2.3 From d04e74cd9a45d11d321faf174aec86768ac2ffc6 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sun, 7 Jul 2024 23:33:37 +0100 Subject: Stop using gunicorn and use uvicorn directly to run application --- Dockerfile | 2 +- poetry.lock | 34 +--------------------------------- pyproject.toml | 1 - 3 files changed, 2 insertions(+), 35 deletions(-) (limited to 'pyproject.toml') diff --git a/Dockerfile b/Dockerfile index 6606302..1229b56 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,4 +19,4 @@ ENV GIT_SHA=$git_sha # Start the server with uvicorn ENTRYPOINT ["poetry", "run"] -CMD ["gunicorn", "-w", "2", "-b", "0.0.0.0:8000", "-k", "uvicorn.workers.UvicornWorker", "backend:app"] +CMD ["uvicorn", "backend:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/poetry.lock b/poetry.lock index cefd81e..6a3e2e0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -125,27 +125,6 @@ docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1 testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8.0.1)", "pytest (>=7.4.3)", "pytest-asyncio (>=0.21)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)", "virtualenv (>=20.26.2)"] typing = ["typing-extensions (>=4.8)"] -[[package]] -name = "gunicorn" -version = "22.0.0" -description = "WSGI HTTP Server for UNIX" -optional = false -python-versions = ">=3.7" -files = [ - {file = "gunicorn-22.0.0-py3-none-any.whl", hash = "sha256:350679f91b24062c86e386e198a15438d53a7a8207235a78ba1b53df4c4378d9"}, - {file = "gunicorn-22.0.0.tar.gz", hash = "sha256:4a0b436239ff76fb33f11c07a16482c521a7e09c1ce3cc293c2330afe01bec63"}, -] - -[package.dependencies] -packaging = "*" - -[package.extras] -eventlet = ["eventlet (>=0.24.1,!=0.36.0)"] -gevent = ["gevent (>=1.4.0)"] -setproctitle = ["setproctitle"] -testing = ["coverage", "eventlet", "gevent", "pytest", "pytest-cov"] -tornado = ["tornado (>=0.2)"] - [[package]] name = "h11" version = "0.14.0" @@ -320,17 +299,6 @@ files = [ {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, ] -[[package]] -name = "packaging" -version = "24.1" -description = "Core utilities for Python packages" -optional = false -python-versions = ">=3.8" -files = [ - {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, - {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, -] - [[package]] name = "platformdirs" version = "4.2.2" @@ -1002,4 +970,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "88fdfb53c24083f67305ff9e01dfb0bff29874b2de3ef9afc2d45b76e7d7da68" +content-hash = "86bcd95d5baef49cc4c7efa6f791359af74e93f96f8f0ce9d8275c1e14e768ed" diff --git a/pyproject.toml b/pyproject.toml index 01dfed7..edea8a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,6 @@ motor = "3.5.0" python-dotenv = "^1.0.1" pyjwt = "^2.8.0" httpx = "^0.27.0" -gunicorn = "^22.0.0" pydantic = "^1.10.17" spectree = "^1.2.10" deepmerge = "^1.1.1" -- cgit v1.2.3