diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 07123e2a..53f68601 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,13 +1,13 @@ # These are supported funding model platforms -github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +github: seapagan patreon: # Replace with a single Patreon username open_collective: # Replace with a single Open Collective username -ko_fi: # Replace with a single Ko-fi username +ko_fi: grantramsay tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry liberapay: # Replace with a single Liberapay username issuehunt: # Replace with a single IssueHunt username otechie: # Replace with a single Otechie username lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry -custom: ['https://www.buymeacoffee.com/seapagan'] +custom: ["https://www.buymeacoffee.com/seapagan"] diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml new file mode 100644 index 00000000..65c70da0 --- /dev/null +++ b/.github/workflows/ruff.yml @@ -0,0 +1,17 @@ +name: Linting + +on: [push, pull_request, workflow_dispatch] + +jobs: + ruff: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Linting + uses: chartboost/ruff-action@v1 + with: + args: check + - name: Check Formatting + uses: chartboost/ruff-action@v1 + with: + args: format --check diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 46795f1b..b324d40a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,9 +2,9 @@ name: Tests on: push: - branches: ["main"] + branches: ["main", "develop"] pull_request: - branches: ["main"] + branches: ["main", "develop"] workflow_dispatch: jobs: @@ -13,7 +13,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11"] + python-version: ["3.9", "3.10", "3.11", "3.12"] services: postgres: diff --git a/.markdownlint.json b/.markdownlint.json index a1326d4b..e36d5245 100644 --- a/.markdownlint.json +++ b/.markdownlint.json @@ -1,4 +1,9 @@ { "MD014": false, - "MD046": false + "MD046": false, + "MD033": { + "allowed_elements": [ + "swagger-ui" + ] + } } diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e6108bf5..00fbbd8e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,49 +6,41 @@ repos: hooks: - id: check-yaml args: [--unsafe] - - id: end-of-file-fixer - id: trailing-whitespace - - repo: https://github.com/psf/black - rev: 23.10.1 - hooks: - - id: black - args: [--line-length=80] - - repo: https://github.com/PyCQA/flake8 - rev: 6.1.0 - hooks: - - id: flake8 - args: [--max-line-length=80] - additional_dependencies: [flake8-docstrings, flake8-pyproject] - # files: ^my_appname/|^test_suite_name/ - - repo: https://github.com/pycqa/isort - rev: 5.12.0 + - id: check-toml + - id: check-merge-conflict + - id: end-of-file-fixer + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.1.4 hooks: - - id: isort - args: [--profile=black, --line-length=80] + - id: ruff + name: "lint with ruff" + - id: ruff-format + name: "format with ruff" + - repo: https://github.com/jackdewinter/pymarkdown rev: v0.9.14 hooks: - id: pymarkdown - exclude: ^.github/|^docs/ - args: [-d, "MD014", scan] - - repo: https://github.com/pycqa/pydocstyle - rev: 6.3.0 + name: "check markdown" + exclude: ^.github/|CHANGELOG + args: [-d, "MD046", scan] + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: "v1.6.1" # Use the sha / tag you want to point at hooks: - - id: pydocstyle - additional_dependencies: ["pydocstyle[toml]"] - exclude: ^app/migrations/|__init__.py - - repo: https://github.com/PyCQA/bandit - rev: 1.7.5 - hooks: - - id: bandit - args: ["--silent", "-c", "pyproject.toml", "-r"] - additional_dependencies: ["bandit[toml]"] + - id: mypy + name: "run mypy" + - repo: https://github.com/python-poetry/poetry rev: "1.7.0" hooks: - id: poetry-check + name: "check poetry files" # - id: poetry-lock - id: poetry-export + name: "export production dependencies" args: [ "--without-hashes", @@ -56,4 +48,18 @@ repos: "requirements.txt", "-o", "requirements.txt", + "--without", + "dev", + ] + - id: poetry-export + name: "export development dependencies" + args: + [ + "--without-hashes", + "-f", + "requirements.txt", + "-o", + "requirements-dev.txt", + "--with", + "dev", ] diff --git a/.vscode/settings.json b/.vscode/settings.json index 2231365f..427b9e32 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,13 +4,12 @@ "source.fixAll": true, "source.organizeImports": true }, - "editor.defaultFormatter": "ms-python.black-formatter" + "editor.defaultFormatter": "charliermarsh.ruff" }, "autoDocstring.startOnNewLine": true, "beautify.language": { "html": ["htm", "html", "django-html"] }, - "black-formatter.args": ["--line-length=80"], "editor.formatOnPaste": false, "editor.formatOnSave": true, "emmet.includeLanguages": { @@ -27,28 +26,33 @@ }, "files.eol": "\n", "files.exclude": { + "**/.cache": true, + "**/.mypy_cache": true, + "**/.pytest_cache": true, + "**/.ruff_cache": true, "**/__pycache__": true }, - "flake8.args": ["--max-line-length=80"], "git.alwaysSignOff": true, "git.enableCommitSigning": true, "html.format.indentHandlebars": true, "html.format.templating": true, - "isort.args": ["--profile", "black", "--src=${workspaceFolder}"], "material-icon-theme.languages.associations": { "jinja-html": "django" }, - "pylint.args": ["--load-plugins", "pylint-pydantic", "pylint-pytest"], + "mypy-type-checker.importStrategy": "useBundled", + "mypy-type-checker.reportingScope": "workspace", "python.analysis.autoImportCompletions": true, "python.analysis.autoImportUserSymbols": true, "python.analysis.extraPaths": [], "python.analysis.indexing": true, "python.analysis.stubPath": "/home/seapagan/stubs", - "python.analysis.typeCheckingMode": "basic", + "python.analysis.typeCheckingMode": "off", "python.languageServer": "Pylance", "python.pythonPath": "./.venv/bin/python", "python.testing.pytestArgs": ["tests"], "python.testing.pytestEnabled": true, "python.testing.unittestEnabled": false, - "ruff.organizeImports": false + "ruff.fixAll": false, + "ruff.organizeImports": true, + "mypy-type-checker.args": ["--strict"] } diff --git a/LICENSE.txt b/LICENSE.txt index 4657088c..d73d56ee 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2022 Grant Ramsay +Copyright (c) 2022 - 2023 Grant Ramsay Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/TODO.md b/TODO.md index 11853e28..79179945 100644 --- a/TODO.md +++ b/TODO.md @@ -13,7 +13,8 @@ - Add a `logout` route to immediately invalidate the users token and refresh token. This will need a database to be kept of invalidated tokens (which can periodically be auto-purged of tokens that would be time-expired anyway.) -- Allow to resend a registration email +- Allow to resend a registration email (part of the code is already there for + this, but was not functioning properly so disabled). - Send an email to the **User** when they change their password or update their profile, are Banned/Unbanned and to **Admins** when important events happen. - Update current and future email templates with actual content, and change @@ -45,6 +46,8 @@ - option to remove the customization functionality from the CLI. Useful once you have customized the template and don't want to give the end-user the ability to change it easily. +- ctrl-c on the `custom metadata` command should not bring up a Rich + stack-trace, but exit cleanly. ## Documentation diff --git a/api-admin b/api-admin index e724d9c2..1d4ed50d 100755 --- a/api-admin +++ b/api-admin @@ -1,5 +1,7 @@ #! /bin/env python """Run administrative tasks for the Template system.""" +from typing import Optional + import typer from rich import print # pylint: disable=W0622 from rich.panel import Panel @@ -10,7 +12,7 @@ from app.config.helpers import get_api_details, get_api_version app = typer.Typer(add_completion=False, no_args_is_help=True) -def cli_header(): +def cli_header() -> None: """Show a common header for all commands.""" name, _, _ = get_api_details() print( @@ -25,14 +27,14 @@ def cli_header(): @app.callback(invoke_without_command=True) def main( - version: bool = typer.Option( + version: Optional[bool] = typer.Option( False, "--version", "-v", help="Show the version and exit.", is_eager=True, - ) -): + ), +) -> None: """Run administrative tasks for the FastAPI Template system.""" if version: name, desc, _ = get_api_details() @@ -46,16 +48,27 @@ def main( border_style="green", ) ) - raise typer.Exit() + raise typer.Exit cli_header() -app.add_typer(dev.app, name="serve") -app.add_typer(user.app, name="user", help="Add or modify users.") +app.add_typer( + dev.app, + name="serve", +) +app.add_typer( + user.app, + name="user", + help="Add or modify users.", +) app.add_typer( custom.app, name="custom", help="Customize the Application Metadata." ) -app.add_typer(db.app, name="db", help="Control the Database.") +app.add_typer( + db.app, + name="db", + help="Control the Database.", +) app.add_typer( docs.app, name="docs", help="Generate and upload API documentation." ) diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 00000000..817faee8 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,4 @@ +"""This is the main module for the application. + +It is usually ran from 'uvicorn' as a 'FastAPI' application. +""" diff --git a/app/commands/__init__.py b/app/commands/__init__.py index e69de29b..02775157 100644 --- a/app/commands/__init__.py +++ b/app/commands/__init__.py @@ -0,0 +1 @@ +"""This module contains all the CLI commands for the 'api-admin' command.""" diff --git a/app/commands/custom.py b/app/commands/custom.py index 4c53d7c7..7300bb49 100644 --- a/app/commands/custom.py +++ b/app/commands/custom.py @@ -1,6 +1,9 @@ """CLI functionality to customize the template.""" +from __future__ import annotations + +import datetime import sys -from datetime import date +from typing import Literal, Union import asyncclick as click import tomli @@ -17,10 +20,12 @@ get_toml_path, ) +LicenceType = Union[dict[str, str], Literal["Unknown"]] + app = typer.Typer(no_args_is_help=True) -def init(): +def init() -> None: """Create a default metadata file, overwrite any existing.""" data = { "title": "API Template", @@ -33,12 +38,15 @@ def init(): "author": "Grant Ramsay (seapagan)", "website": "https://www.gnramsay.com", "email": "seapagan@gmail.com", - "this_year": date.today().year, + "this_year": datetime.datetime.now(tz=datetime.timezone.utc) + .date() + .today() + .year, } out = Template(TEMPLATE).render(data) try: - with open(get_config_path(), "w", encoding="UTF-8") as file: + with get_config_path().open("w", encoding="UTF-8") as file: file.write(out) except OSError as err: print(f"Cannot Write the metadata : {err}") @@ -55,12 +63,12 @@ def init(): sys.exit(1) -def get_licenses(): +def get_licenses() -> list[str]: """Return a list of possible Open-source Licence types.""" return [licence["name"] for licence in LICENCES] -def get_case_insensitive_dict(choice): +def get_case_insensitive_dict(choice: str) -> LicenceType: """Return the dictionary with specified key, case insensitive. We already know the key exists, however it may have wrong case. @@ -71,7 +79,7 @@ def get_case_insensitive_dict(choice): return "Unknown" -def choose_license(): +def choose_license() -> LicenceType: """Select a licence from a fixed list.""" license_list = get_licenses() license_strings = ", ".join(license_list) @@ -88,9 +96,9 @@ def choose_license(): return get_case_insensitive_dict(choice) -def choose_version(current_version): +def choose_version(current_version: str) -> str: """Change the version or reset it.""" - choice = click.prompt( + choice: str = click.prompt( "Version Number (use * to reset to '0.0.1')", type=str, default=current_version, @@ -101,7 +109,7 @@ def choose_version(current_version): @app.command() -def metadata(): +def metadata() -> None: """Customize the Application Metadata. This includes the title and description displayed on the root route and @@ -140,7 +148,9 @@ def metadata(): ), } - data["this_year"] = date.today().year + data["this_year"] = ( + datetime.datetime.now(tz=datetime.timezone.utc).today().year + ) print("\nYou have entered the following data:") print(f"[green]Title : [/green]{data['title']}") @@ -158,7 +168,7 @@ def metadata(): print("\n[green]-> Writing out Metadata .... ", end="") out = Template(TEMPLATE).render(data) try: - with open(get_config_path(), "w", encoding="UTF-8") as file: + with get_config_path().open(mode="w", encoding="UTF-8") as file: file.write(out) except OSError as err: print(f"Cannot Write the metadata : {err}") @@ -166,7 +176,7 @@ def metadata(): # update the pyproject.toml file try: - with open(get_toml_path(), "rb") as file: + with get_toml_path().open(mode="rb") as file: config = tomli.load(file) config["tool"]["poetry"]["name"] = data["title"] config["tool"]["poetry"]["version"] = data["version"] @@ -174,7 +184,7 @@ def metadata(): config["tool"]["poetry"]["authors"] = [ f"{data['author']} <{data['email']}>" ] - with open(get_toml_path(), "wb") as file: + with get_toml_path().open(mode="wb") as file: tomli_w.dump(config, file) except OSError as err: print(f"Cannot update the pyproject.toml file : {err}") diff --git a/app/commands/db.py b/app/commands/db.py index 1cd7506f..95d5a295 100644 --- a/app/commands/db.py +++ b/app/commands/db.py @@ -1,5 +1,7 @@ """CLI command to control the Database.""" +from typing import Optional + import typer from alembic import command from alembic.config import Config @@ -13,7 +15,7 @@ @app.command() def init( - force: bool = typer.Option( + force: Optional[bool] = typer.Option( False, "--force", "-f", @@ -21,7 +23,7 @@ def init( "Warning! This deletes all data in the database. Are you sure?" ), help="Do not ask for confirmation.", - ) + ), ) -> None: """Re-Initialise the database using Alembic. @@ -40,7 +42,7 @@ def init( @app.command() def drop( - force: bool = typer.Option( + force: Optional[bool] = typer.Option( False, "--force", "-f", @@ -48,7 +50,7 @@ def drop( "Warning! This deletes all data in the database. Are you sure?" ), help="Do not ask for confirmation.", - ) + ), ) -> None: """Drop all tables and reset the Database. @@ -64,7 +66,7 @@ def drop( @app.command() -def upgrade(): +def upgrade() -> None: """Apply the latest Database Migrations.""" print("\nUpgrading Database ... ", end="") @@ -80,8 +82,8 @@ def revision( "-m", prompt=("Enter the commit message for the revision"), help="Provide a message for this commit.", - ) -): + ), +) -> None: """Create a new revision. The revision will be created in the `alembic/versions` directory, and is diff --git a/app/commands/dev.py b/app/commands/dev.py index 3e50a640..75751d5c 100644 --- a/app/commands/dev.py +++ b/app/commands/dev.py @@ -1,5 +1,6 @@ """CLI command to run a dev server.""" -import subprocess # nosec +import subprocess +from typing import Optional # nosec import typer from rich import print # pylint: disable=W0622 @@ -18,7 +19,7 @@ def serve( "-h", help="Define the interface to run the server on.", ), - reload: bool = typer.Option( + reload: Optional[bool] = typer.Option( True, help="Enable auto-reload on code changes", ), @@ -32,4 +33,4 @@ def serve( f"uvicorn app.main:app --port={port} --host={host} " f"{'--reload' if reload else ''}" ) - subprocess.call(cmd_line, shell=True) # nosec + subprocess.call(cmd_line, shell=True) # noqa: S602 diff --git a/app/commands/docs.py b/app/commands/docs.py index c625e057..0e5e07df 100644 --- a/app/commands/docs.py +++ b/app/commands/docs.py @@ -1,6 +1,5 @@ """CLI commands for generating documentation.""" import json -import os from pathlib import Path import typer @@ -18,7 +17,7 @@ def openapi( filename: str = typer.Option( "openapi.json", help="Filename for the OpenAPI schema" ), -): +) -> None: """Generate an OpenAPI schema from the current routes. By default this will be stored in the project root as `openapi.json`, @@ -29,9 +28,9 @@ def openapi( openapi_file = Path(prefix, filename) print( "Generating OpenAPI schema at [bold]" - f"{os.path.abspath(openapi_file)}[/bold]\n" + f"{openapi_file.resolve()}[/bold]\n" ) - with open(openapi_file, "w") as f: + with openapi_file.open(mode="w") as f: json.dump( get_openapi( title=main_app.title, diff --git a/app/commands/test.py b/app/commands/test.py index 4c309ecd..d8cc320e 100644 --- a/app/commands/test.py +++ b/app/commands/test.py @@ -25,7 +25,7 @@ def setup() -> None: """Populate the test databases.""" - async def prepare_database(): + async def prepare_database() -> None: """Drop and recreate the database.""" async with async_engine.begin() as conn: await conn.run_sync(Base.metadata.drop_all) @@ -42,4 +42,4 @@ async def prepare_database(): ) as exc: print(f"\n[red] -> Error: {exc}") print("Failed to migrate the test database.") - raise typer.Exit(code=1) + raise typer.Exit(1) from exc diff --git a/app/commands/user.py b/app/commands/user.py index 22827587..03d11815 100644 --- a/app/commands/user.py +++ b/app/commands/user.py @@ -1,21 +1,28 @@ """Add a user from the command line, optionally make superuser.""" +from __future__ import annotations + from asyncio import run as aiorun +from typing import TYPE_CHECKING, Optional import typer from fastapi import HTTPException from rich import print # pylint: disable=W0622 from rich.console import Console from rich.table import Table +from sqlalchemy.exc import SQLAlchemyError from app.database.db import async_session from app.managers.user import UserManager from app.models.enums import RoleType from app.models.user import User +if TYPE_CHECKING: + from collections.abc import Sequence + app = typer.Typer(no_args_is_help=True) -def show_table(title: str, user_list): +def show_table(title: str, user_list: Sequence[User]) -> None: """Show User data in a tabulated format.""" console = Console() table = Table( @@ -82,7 +89,7 @@ def create( show_default=False, hide_input=True, ), - admin: bool = typer.Option( + admin: Optional[bool] = typer.Option( False, "--admin", "-a", @@ -90,14 +97,14 @@ def create( prompt="Should this user be an Admin?", help="Make this user an Admin", ), -): +) -> None: """Create a new user. This can optionally be an admin user. Values are either taken from the command line options, or interactively for any that are missing. """ - async def create_user(user_data: dict): + async def create_user(user_data: dict[str, str | RoleType]) -> None: """Asny function to create a new user.""" try: async with async_session() as session: @@ -109,15 +116,12 @@ async def create_user(user_data: dict): ) except HTTPException as err: print(f"\n[red]-> ERROR adding User : [bold]{err.detail}\n") - except Exception as err: + except SQLAlchemyError as err: print(f"\n[red]-> ERROR adding User : [bold]{err}\n") - if admin: - role_type = RoleType.admin - else: - role_type = RoleType.user + role_type = RoleType.admin if admin else RoleType.user - user_data = { + user_data: dict[str, str | RoleType] = { "email": email, "first_name": first_name, "last_name": last_name, @@ -128,24 +132,27 @@ async def create_user(user_data: dict): aiorun(create_user(user_data)) -@app.command() -def list(): +@app.command(name="list") +def list_all_users() -> None: """List all users in the database. Show one line per user with Id, Email, First Name, Last Name and Role. Also include verified/banned status and a total count. """ - async def list_users(): + async def _list_users() -> Sequence[User]: """Async function to list all users in the database.""" try: async with async_session() as session: user_list = await UserManager.get_all_users(session) - return user_list - except Exception as exc: + + except SQLAlchemyError as exc: print(f"\n[red]-> ERROR listing Users : [bold]{exc}\n") + raise typer.Exit(1) from exc + else: + return user_list - user_list = aiorun(list_users()) + user_list = aiorun(_list_users()) if user_list: show_table("Registered Users", user_list) else: @@ -159,23 +166,25 @@ def show( help="The user's id", show_default=False, ), -): +) -> None: """Show details for a single user.""" - async def show_user(): + async def _show_user() -> User: """Async function to show details for a single user.""" try: async with async_session() as session: user = await UserManager.get_user_by_id(user_id, session) + except HTTPException as exc: + print( + f"\n[red]-> ERROR getting User details : [bold]{exc.detail}\n" + ) + raise typer.Exit(1) from exc + else: return user - except Exception as exc: - print(f"\n[red]-> ERROR getting User details : [bold]{exc}\n") - user = aiorun(show_user()) + user = aiorun(_show_user()) if user: show_table(f"Showing details for User {user_id}", [user]) - else: - print("\n[red]-> ERROR getting User details : [bold]User not found\n") @app.command() @@ -185,22 +194,24 @@ def verify( help="The user's id", show_default=False, ), -): +) -> None: """Manually verify a user by id.""" - async def verify_user(user_id: int): + async def _verify_user(user_id: int) -> User | None: """Async function to verify a user by id.""" try: async with async_session() as session: user = await session.get(User, user_id) if user: - user.verified = True # type: ignore + user.verified = True await session.commit() - return user - except Exception as exc: + except SQLAlchemyError as exc: print(f"\n[red]-> ERROR verifying User : [bold]{exc}\n") + raise typer.Exit(1) from exc + else: + return user - user = aiorun(verify_user(user_id)) + user = aiorun(_verify_user(user_id)) if user: print( f"\n[green]-> User [bold]{user_id}[/bold] verified succesfully.\n" @@ -216,29 +227,31 @@ def ban( help="The user's id", show_default=False, ), - unban: bool = typer.Option( + unban: Optional[bool] = typer.Option( False, "--unban", "-u", flag_value=True, help="Unban this user instead of banning them", ), -): +) -> None: """Ban or Unban a user by id.""" - async def ban_user(user_id: int, unban: bool): + async def _ban_user(user_id: int, unban: Optional[bool]) -> User | None: """Async function to ban or unban a user.""" try: async with async_session() as session: user = await session.get(User, user_id) if user: - user.banned = not unban # type: ignore + user.banned = not unban await session.commit() - return user - except Exception as exc: - print(f"\n[RED]-> ERROR banning or unbanning User : [bold]{exc}\n") + except SQLAlchemyError as exc: + print(f"\n[RED]-> ERROR banning or unbanning User : [bold]{exc}\n") + raise typer.Exit(1) from exc + else: + return user - user = aiorun(ban_user(user_id, unban)) + user = aiorun(_ban_user(user_id, unban)) if user: print( f"\n[green]-> User [bold]{user_id}[/bold] " @@ -257,11 +270,11 @@ def delete( ..., help="The user's id", show_default=False, - ) -): + ), +) -> None: """Delete the user with the given id.""" - async def delete_user(user_id: int): + async def _delete_user(user_id: int) -> User | None: """Async function to delete a user.""" try: async with async_session() as session: @@ -269,11 +282,13 @@ async def delete_user(user_id: int): if user: await session.delete(user) await session.commit() - return user - except Exception as exc: + except SQLAlchemyError as exc: print(f"\n[RED]-> ERROR deleting that User : [bold]{exc}\n") + raise typer.Exit(1) from exc + else: + return user - user = aiorun(delete_user(user_id)) + user = aiorun(_delete_user(user_id)) if user: print( diff --git a/app/config/__init__.py b/app/config/__init__.py index e69de29b..920d09ef 100644 --- a/app/config/__init__.py +++ b/app/config/__init__.py @@ -0,0 +1 @@ +"""This module contains the configuration for the application.""" diff --git a/app/config/helpers.py b/app/config/helpers.py index 279d7316..43298912 100644 --- a/app/config/helpers.py +++ b/app/config/helpers.py @@ -3,32 +3,30 @@ import sys from dataclasses import dataclass from pathlib import Path -from typing import Dict, List import tomli -def get_toml_path(): +def get_toml_path() -> Path: """Return the full path of the pyproject.toml.""" - script_dir = Path(os.path.dirname(os.path.realpath(__name__))) + script_dir = Path(os.path.realpath(__name__)).parent return script_dir / "pyproject.toml" -def get_config_path(): +def get_config_path() -> Path: """Return the full path of the custom config file.""" - script_dir = Path(os.path.dirname(os.path.realpath(sys.argv[0]))) + script_dir = Path(os.path.realpath(sys.argv[0])).parent return script_dir / "app" / "config" / "metadata.py" def get_api_version() -> str: """Return the API version from the pyproject.toml file.""" try: - with open(get_toml_path(), "rb") as file: + toml_path = get_toml_path() + with toml_path.open(mode="rb") as file: config = tomli.load(file) - version = config["tool"]["poetry"]["version"] - - return version + version: str = config["tool"]["poetry"]["version"] except KeyError as exc: print(f"Cannot find the API version in the pyproject.toml file : {exc}") @@ -38,11 +36,15 @@ def get_api_version() -> str: print(f"Cannot read the pyproject.toml file : {exc}") sys.exit(2) + else: + return version + -def get_api_details() -> tuple[str, str, List]: +def get_api_details() -> tuple[str, str, list[str]]: """Return the API Name from the pyproject.toml file.""" try: - with open(get_toml_path(), "rb") as file: + toml_path = get_toml_path() + with toml_path.open(mode="rb") as file: config = tomli.load(file) name = config["tool"]["poetry"]["name"] desc = config["tool"]["poetry"]["description"] @@ -79,7 +81,7 @@ class MetadataBase: # List of acceptable Opensource Licenses with a link to their text. -LICENCES: List[Dict[str, str]] = [ +LICENCES: list[dict[str, str]] = [ {"name": "Apache2", "url": "https://opensource.org/licenses/Apache-2.0"}, {"name": "BSD3", "url": "https://opensource.org/licenses/BSD-3-Clause"}, {"name": "BSD2", "url": "https://opensource.org/licenses/BSD-2-Clause"}, diff --git a/app/config/settings.py b/app/config/settings.py index 0a472b64..15947edb 100644 --- a/app/config/settings.py +++ b/app/config/settings.py @@ -1,7 +1,6 @@ """Control the app settings, including reading from a .env file.""" import sys from functools import lru_cache -from typing import Dict from pydantic_settings import BaseSettings, SettingsConfigDict @@ -54,8 +53,8 @@ class Settings(BaseSettings): api_title: str = custom_metadata.title api_description: str = custom_metadata.description repository: str = custom_metadata.repository - contact: Dict[str, str] = custom_metadata.contact - license_info: Dict[str, str] = custom_metadata.license_info + contact: dict[str, str] = custom_metadata.contact + license_info: dict[str, str] = custom_metadata.license_info year: str = custom_metadata.year # email settings diff --git a/app/database/__init__.py b/app/database/__init__.py index e69de29b..9fd3740e 100644 --- a/app/database/__init__.py +++ b/app/database/__init__.py @@ -0,0 +1,4 @@ +"""The database module. + +This takes care of the database connection and configuration. +""" diff --git a/app/database/db.py b/app/database/db.py index e8d35ce7..cb1dbd39 100644 --- a/app/database/db.py +++ b/app/database/db.py @@ -1,5 +1,6 @@ """Setup the Database and support functions..""" -from typing import Any, AsyncGenerator +from collections.abc import AsyncGenerator +from typing import Any from sqlalchemy import MetaData from sqlalchemy.ext.asyncio import ( @@ -42,6 +43,5 @@ class Base(DeclarativeBase): async def get_database() -> AsyncGenerator[AsyncSession, Any]: """Return the database connection as a Generator.""" - async with async_session() as session: - async with session.begin(): - yield session + async with async_session() as session, session.begin(): + yield session diff --git a/app/database/helpers.py b/app/database/helpers.py index 083796d5..a742f0b1 100644 --- a/app/database/helpers.py +++ b/app/database/helpers.py @@ -1,37 +1,39 @@ """Define some database helper functions.""" from __future__ import annotations -from typing import TYPE_CHECKING, Union +from typing import TYPE_CHECKING, Any from sqlalchemy import select from app.models.user import User -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: no cover + from collections.abc import Sequence + from sqlalchemy.ext.asyncio import AsyncSession -async def get_all_users_(session: AsyncSession): +async def get_all_users_(session: AsyncSession) -> Sequence[User]: """Return all Users in the database.""" result = await session.execute(select(User)) return result.scalars().all() -async def get_user_by_email_(email, session: AsyncSession) -> Union[User, None]: +async def get_user_by_email_(email: str, session: AsyncSession) -> User | None: """Return a specific user by their email address.""" result = await session.execute(select(User).where(User.email == email)) return result.scalars().first() -async def get_user_by_id_( - user_id: int, session: AsyncSession -) -> Union[User, None]: +async def get_user_by_id_(user_id: int, session: AsyncSession) -> User | None: """Return a specific user by their email address.""" result = await session.execute(select(User).where(User.id == user_id)) return result.scalars().first() -async def add_new_user_(user_data: dict, session: AsyncSession) -> User: +async def add_new_user_( + user_data: dict[str, Any], session: AsyncSession +) -> User: """Add a new user to the database.""" new_user = User(**user_data) session.add(new_user) diff --git a/app/main.py b/app/main.py index 085c3f86..895b918a 100644 --- a/app/main.py +++ b/app/main.py @@ -1,10 +1,13 @@ """Main file for the FastAPI Template.""" +from collections.abc import AsyncGenerator from contextlib import asynccontextmanager +from typing import Any from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from rich import print # pylint: disable=W0622 +from sqlalchemy.exc import SQLAlchemyError from app.config.helpers import get_api_version from app.config.settings import get_settings @@ -14,7 +17,7 @@ @asynccontextmanager -async def lifespan(app: FastAPI): +async def lifespan(app: FastAPI) -> AsyncGenerator[Any, None]: """Lifespan function Replaces the previous startup/shutdown functions. Currently we only ensure that the database is available and configured @@ -25,7 +28,7 @@ async def lifespan(app: FastAPI): await session.connection() print("[green]INFO: [/green][bold]Database configuration Tested.") - except Exception as exc: + except SQLAlchemyError as exc: print(f"[red]ERROR: [bold]Have you set up your .env file?? ({exc})") print( "[yellow]WARNING: [/yellow]Clearing routes and enabling " diff --git a/app/managers/__init__.py b/app/managers/__init__.py index e69de29b..e8178636 100644 --- a/app/managers/__init__.py +++ b/app/managers/__init__.py @@ -0,0 +1 @@ +"""This module contains the managers for the app.""" diff --git a/app/managers/auth.py b/app/managers/auth.py index a5bee40e..3b95823f 100644 --- a/app/managers/auth.py +++ b/app/managers/auth.py @@ -1,5 +1,5 @@ """Define the Autorization Manager.""" -from datetime import datetime, timedelta +import datetime from typing import Optional import jwt @@ -25,8 +25,8 @@ class ResponseMessages: CANT_GENERATE_JWT = "Unable to generate the JWT" CANT_GENERATE_REFRESH = "Unable to generate the Refresh Token" CANT_GENERATE_VERIFY = "Unable to generate the Verification Token" - INVALID_TOKEN = "That token is Invalid" # nosec - EXPIRED_TOKEN = "That token has Expired" # nosec + INVALID_TOKEN = "That token is Invalid" # noqa: S105 + EXPIRED_TOKEN = "That token has Expired" # noqa: S105 VERIFICATION_SUCCESS = "User succesfully Verified" USER_NOT_FOUND = "User not Found" ALREADY_VALIDATED = "You are already validated" @@ -37,36 +37,39 @@ class AuthManager: """Handle the JWT Auth.""" @staticmethod - def encode_token(user): + def encode_token(user: User) -> str: """Create and return a JTW token.""" try: payload = { "sub": user.id, - "exp": datetime.utcnow() - + timedelta(minutes=get_settings().access_token_expire_minutes), + "exp": datetime.datetime.now(tz=datetime.timezone.utc) + + datetime.timedelta( + minutes=get_settings().access_token_expire_minutes + ), } return jwt.encode( payload, get_settings().secret_key, algorithm="HS256" ) - except Exception as exc: + except (jwt.PyJWTError, AttributeError) as exc: # log the exception raise HTTPException( status.HTTP_401_UNAUTHORIZED, ResponseMessages.CANT_GENERATE_JWT ) from exc @staticmethod - def encode_refresh_token(user): + def encode_refresh_token(user: User) -> str: """Create and return a JTW token.""" try: payload = { "sub": user.id, - "exp": datetime.utcnow() + timedelta(minutes=60 * 24 * 30), + "exp": datetime.datetime.now(tz=datetime.timezone.utc) + + datetime.timedelta(minutes=60 * 24 * 30), "typ": "refresh", } return jwt.encode( payload, get_settings().secret_key, algorithm="HS256" ) - except Exception as exc: + except (jwt.PyJWTError, AttributeError) as exc: # log the exception raise HTTPException( status.HTTP_401_UNAUTHORIZED, @@ -74,18 +77,19 @@ def encode_refresh_token(user): ) from exc @staticmethod - def encode_verify_token(user): + def encode_verify_token(user: User) -> str: """Create and return a JTW token.""" try: payload = { "sub": user.id, - "exp": datetime.utcnow() + timedelta(minutes=10), + "exp": datetime.datetime.now(tz=datetime.timezone.utc) + + datetime.timedelta(minutes=10), "typ": "verify", } return jwt.encode( payload, get_settings().secret_key, algorithm="HS256" ) - except Exception as exc: + except (jwt.PyJWTError, AttributeError) as exc: # log the exception raise HTTPException( status.HTTP_401_UNAUTHORIZED, @@ -122,7 +126,6 @@ async def refresh( status.HTTP_401_UNAUTHORIZED, ResponseMessages.INVALID_TOKEN ) new_token = AuthManager.encode_token(user_data) - return new_token except jwt.ExpiredSignatureError as exc: raise HTTPException( @@ -132,6 +135,8 @@ async def refresh( raise HTTPException( status.HTTP_401_UNAUTHORIZED, ResponseMessages.INVALID_TOKEN ) from exc + else: + return new_token @staticmethod async def verify(code: str, session: AsyncSession) -> None: @@ -255,7 +260,6 @@ async def __call__( get_settings().secret_key, algorithms=["HS256"], ) - user_data = await get_user_by_id_(payload["sub"], db) # block a banned or unverified user if user_data: @@ -265,8 +269,7 @@ async def __call__( ResponseMessages.INVALID_TOKEN, ) request.state.user = user_data - return user_data - return None + except jwt.ExpiredSignatureError as exc: raise HTTPException( status.HTTP_401_UNAUTHORIZED, ResponseMessages.EXPIRED_TOKEN @@ -275,18 +278,20 @@ async def __call__( raise HTTPException( status.HTTP_401_UNAUTHORIZED, ResponseMessages.INVALID_TOKEN ) from exc + else: + return user_data # type: ignore oauth2_schema = CustomHTTPBearer() -def is_admin(request: Request): +def is_admin(request: Request) -> None: """Block if user is not an Admin.""" if request.state.user.role != RoleType.admin: raise HTTPException(status.HTTP_403_FORBIDDEN, "Forbidden") -def can_edit_user(request: Request): +def can_edit_user(request: Request) -> None: """Check if the user can edit this resource. True if they own the resource or are Admin @@ -298,7 +303,7 @@ def can_edit_user(request: Request): raise HTTPException(status.HTTP_403_FORBIDDEN, "Forbidden") -def is_banned(request: Request): +def is_banned(request: Request) -> None: """Dont let banned users access the route.""" if request.state.user.banned: raise HTTPException(status.HTTP_403_FORBIDDEN, "Banned!") diff --git a/app/managers/email.py b/app/managers/email.py index 95805f53..8d46f0a4 100644 --- a/app/managers/email.py +++ b/app/managers/email.py @@ -2,9 +2,9 @@ from __future__ import annotations from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional -from fastapi import BackgroundTasks # noqa TC002 +from fastapi import BackgroundTasks # noqa: TCH002 from fastapi.responses import JSONResponse from fastapi_mail import ConnectionConfig, FastMail, MessageSchema, MessageType @@ -17,7 +17,7 @@ class EmailManager: """Class to manage all Email operations.""" - def __init__(self, suppress_send: bool = False): + def __init__(self, suppress_send: Optional[bool] = False) -> None: """Initialize the EmailManager. Define the configuration instance. @@ -68,7 +68,7 @@ def background_send( def template_send( self, backgroundtasks: BackgroundTasks, email_data: EmailTemplateSchema - ): + ) -> None: """Send an email using a Jinja Template.""" message = MessageSchema( subject=email_data.subject, diff --git a/app/managers/user.py b/app/managers/user.py index 4936d126..59377d58 100644 --- a/app/managers/user.py +++ b/app/managers/user.py @@ -1,7 +1,7 @@ """Define the User manager.""" from __future__ import annotations -from typing import TYPE_CHECKING, Dict, Optional, Tuple +from typing import TYPE_CHECKING, Any, Optional from asyncpg import UniqueViolationError from email_validator import EmailNotValidError, validate_email @@ -22,7 +22,9 @@ from app.models.user import User from app.schemas.email import EmailTemplateSchema -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: no cover + from collections.abc import Sequence + from sqlalchemy.ext.asyncio import AsyncSession from app.models.enums import RoleType @@ -52,10 +54,10 @@ class UserManager: @staticmethod async def register( - user_data: Dict, + user_data: dict[str, Any], session: AsyncSession, background_tasks: Optional[BackgroundTasks] = None, - ) -> Tuple[str, str]: + ) -> tuple[str, str]: """Register a new user.""" # make sure relevant fields are not empty if not all(user_data.values()): @@ -65,7 +67,7 @@ async def register( # create a new dictionary to return, otherwise the original is modified # and can cause random testing issues - new_user: Dict = user_data.copy() + new_user = user_data.copy() new_user["password"] = pwd_context.hash(user_data["password"]) new_user["banned"] = False @@ -95,6 +97,10 @@ async def register( ) from err user_do = await get_user_by_email_(new_user["email"], session) + # below is purely for mypy, as it can't tell that the above function + # will always return a User object in this case (we have just created + # it without an exception, so it must exist) + assert user_do # noqa: S101 if background_tasks: email = EmailManager() @@ -125,7 +131,9 @@ async def register( return token, refresh @staticmethod - async def login(user_data: Dict, session: AsyncSession) -> Tuple[str, str]: + async def login( + user_data: dict[str, str], session: AsyncSession + ) -> tuple[str, str]: """Log in an existing User.""" user_do = await get_user_by_email_(user_data["email"], session) @@ -201,7 +209,7 @@ async def change_password( @staticmethod async def set_ban_status( - user_id: int, state: bool, my_id: int, session: AsyncSession + user_id: int, state: Optional[bool], my_id: int, session: AsyncSession ) -> None: """Ban or un-ban the specified user based on supplied status.""" if my_id == user_id: @@ -232,12 +240,12 @@ async def change_role( ) @staticmethod - async def get_all_users(session: AsyncSession): + async def get_all_users(session: AsyncSession) -> Sequence[User]: """Get all Users.""" return await get_all_users_(session) @staticmethod - async def get_user_by_id(user_id: int, session: AsyncSession): + async def get_user_by_id(user_id: int, session: AsyncSession) -> User: """Return one user by ID.""" user = await session.get(User, user_id) if not user: @@ -247,7 +255,7 @@ async def get_user_by_id(user_id: int, session: AsyncSession): return user @staticmethod - async def get_user_by_email(email: str, session: AsyncSession): + async def get_user_by_email(email: str, session: AsyncSession) -> User: """Return one user by Email.""" user = await get_user_by_email_(email, session) if not user: diff --git a/app/models/__init__.py b/app/models/__init__.py index e69de29b..668c2d02 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -0,0 +1 @@ +"""Define all the database models for the application.""" diff --git a/app/models/user.py b/app/models/user.py index 79509567..5ddedd89 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -25,6 +25,6 @@ class User(Base): banned: Mapped[bool] = mapped_column(Boolean, default=False) verified: Mapped[bool] = mapped_column(Boolean, default=False) - def __repr__(self): + def __repr__(self) -> str: """Define the model representation.""" return f'User({self.id}, "{self.first_name} {self.last_name}")' diff --git a/app/resources/__init__.py b/app/resources/__init__.py index e69de29b..e46c843e 100644 --- a/app/resources/__init__.py +++ b/app/resources/__init__.py @@ -0,0 +1 @@ +"""Define all the resources (routes) for the API.""" diff --git a/app/resources/auth.py b/app/resources/auth.py index d8d54de4..c69c7bda 100644 --- a/app/resources/auth.py +++ b/app/resources/auth.py @@ -22,7 +22,7 @@ async def register( background_tasks: BackgroundTasks, user_data: UserRegisterRequest, session: AsyncSession = Depends(get_database), -): +) -> dict[str, str]: """Register a new User and return a JWT token plus a Refresh Token. The JWT token should be sent as a Bearer token for each access to a @@ -48,7 +48,7 @@ async def register( ) async def login( user_data: UserLoginRequest, session: AsyncSession = Depends(get_database) -): +) -> dict[str, str]: """Login an existing User and return a JWT token plus a Refresh Token. The JWT token should be sent as a Bearer token for each access to a @@ -70,7 +70,7 @@ async def login( async def generate_refresh_token( refresh_token: TokenRefreshRequest, session: AsyncSession = Depends(get_database), -): +) -> dict[str, str]: """Return a new JWT, given a valid Refresh token. The Refresh token will not be updated at this time, it will still expire 30 @@ -81,7 +81,9 @@ async def generate_refresh_token( @router.get("/verify/", status_code=status.HTTP_200_OK) -async def verify(code: str = "", session: AsyncSession = Depends(get_database)): +async def verify( + code: str = "", session: AsyncSession = Depends(get_database) +) -> None: """Verify a new user. The code is sent to new user by email, which must then be validated here. diff --git a/app/resources/config_error.py b/app/resources/config_error.py index 51228dc7..7c12a6dc 100644 --- a/app/resources/config_error.py +++ b/app/resources/config_error.py @@ -7,7 +7,7 @@ @router.api_route("/") @router.api_route("/{full_path}") -def catch_all(): +def catch_all() -> None: """Catch anything including the root route. It will only be loaded if there is an issue to configure the database. diff --git a/app/resources/home.py b/app/resources/home.py index eb762ffb..f4161788 100644 --- a/app/resources/home.py +++ b/app/resources/home.py @@ -10,11 +10,13 @@ router = APIRouter() templates = Jinja2Templates(directory="app/templates") +RootResponse = Union[dict[str, str], templates.TemplateResponse] # type: ignore -@router.get("/", include_in_schema=False) + +@router.get("/", include_in_schema=False, response_model=None) def root_path( request: Request, accept: Union[str, None] = Header(default="text/html") -): +) -> RootResponse: """Display an HTML template for a browser, JSON response otherwise.""" if accept and accept.split(",")[0] == "text/html": context = { diff --git a/app/resources/user.py b/app/resources/user.py index 1a6c4d7b..e3010291 100644 --- a/app/resources/user.py +++ b/app/resources/user.py @@ -1,5 +1,7 @@ """Routes for User listing and control.""" -from typing import List, Optional, Union + +from collections.abc import Sequence +from typing import Optional, Union from fastapi import APIRouter, Depends, Request, status from sqlalchemy.ext.asyncio import AsyncSession @@ -18,9 +20,11 @@ @router.get( "/", dependencies=[Depends(oauth2_schema), Depends(is_admin)], - response_model=Union[UserResponse, List[UserResponse]], + response_model=Union[UserResponse, list[UserResponse]], ) -async def get_users(user_id: Optional[int] = None, db=Depends(get_database)): +async def get_users( + user_id: Optional[int] = None, db: AsyncSession = Depends(get_database) +) -> Union[Sequence[User], User]: """Get all users or a specific user by their ID. user_id is optional, and if omitted then all Users are returned. @@ -40,7 +44,7 @@ async def get_users(user_id: Optional[int] = None, db=Depends(get_database)): ) async def get_my_user( request: Request, db: AsyncSession = Depends(get_database) -): +) -> User: """Get the current user's data only.""" my_user: int = request.state.user.id return await UserManager.get_user_by_id(my_user, db) @@ -51,7 +55,9 @@ async def get_my_user( dependencies=[Depends(oauth2_schema), Depends(is_admin)], status_code=status.HTTP_204_NO_CONTENT, ) -async def make_admin(user_id: int, db: AsyncSession = Depends(get_database)): +async def make_admin( + user_id: int, db: AsyncSession = Depends(get_database) +) -> None: """Make the User with this ID an Admin.""" await UserManager.change_role(RoleType.admin, user_id, db) @@ -65,7 +71,7 @@ async def change_password( user_id: int, user_data: UserChangePasswordRequest, db: AsyncSession = Depends(get_database), -): +) -> None: """Change the password for the specified user. Can only be done by an Admin, or the specific user that matches the user_id. @@ -80,7 +86,7 @@ async def change_password( ) async def ban_user( request: Request, user_id: int, db: AsyncSession = Depends(get_database) -): +) -> None: """Ban the specific user Id. Admins only. The Admin cannot ban their own ID! @@ -95,7 +101,7 @@ async def ban_user( ) async def unban_user( request: Request, user_id: int, db: AsyncSession = Depends(get_database) -): +) -> None: """Ban the specific user Id. Admins only. @@ -113,7 +119,7 @@ async def edit_user( user_id: int, user_data: UserEditRequest, db: AsyncSession = Depends(get_database), -): +) -> Union[User, None]: """Update the specified User's data. Available for the specific requesting User, or an Admin. @@ -127,7 +133,9 @@ async def edit_user( dependencies=[Depends(oauth2_schema), Depends(is_admin)], status_code=status.HTTP_204_NO_CONTENT, ) -async def delete_user(user_id: int, db: AsyncSession = Depends(get_database)): +async def delete_user( + user_id: int, db: AsyncSession = Depends(get_database) +) -> None: """Delete the specified User by user_id. Admin only. diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py index e69de29b..166fce54 100644 --- a/app/schemas/__init__.py +++ b/app/schemas/__init__.py @@ -0,0 +1 @@ +"""Module containing the schemas for the API.""" diff --git a/app/schemas/email.py b/app/schemas/email.py index 6cd288c0..c4e2aca4 100644 --- a/app/schemas/email.py +++ b/app/schemas/email.py @@ -1,5 +1,5 @@ """Define email Connection Schema.""" -from typing import Any, Dict, List +from typing import Any from pydantic import BaseModel, EmailStr @@ -7,7 +7,7 @@ class EmailSchema(BaseModel): """Define the Email Schema.""" - recipients: List[EmailStr] + recipients: list[EmailStr] subject: str body: str @@ -15,7 +15,7 @@ class EmailSchema(BaseModel): class EmailTemplateSchema(BaseModel): """Define the Email Schema.""" - recipients: List[EmailStr] + recipients: list[EmailStr] subject: str - body: Dict[str, Any] + body: dict[str, Any] template_name: str diff --git a/app/schemas/examples.py b/app/schemas/examples.py index 53ac8220..0e46a21e 100644 --- a/app/schemas/examples.py +++ b/app/schemas/examples.py @@ -8,7 +8,7 @@ class ExampleUser: first_name = "John" last_name = "Doe" email = "user@example.com" - password = "My S3cur3 P@ssw0rd" # nosec + password = "My S3cur3 P@ssw0rd" # noqa: S105 role = "user" banned = False verified = True diff --git a/app/schemas/request/__init__.py b/app/schemas/request/__init__.py index e69de29b..aa57b0fd 100644 --- a/app/schemas/request/__init__.py +++ b/app/schemas/request/__init__.py @@ -0,0 +1 @@ +"""Module to define the 'request' schemas for the API.""" diff --git a/app/schemas/response/__init__.py b/app/schemas/response/__init__.py index e69de29b..332d3a68 100644 --- a/app/schemas/response/__init__.py +++ b/app/schemas/response/__init__.py @@ -0,0 +1 @@ +"""Module to define the 'response' schemas for the API.""" diff --git a/docs/contributing.md b/docs/contributing.md index b1545d3e..a03c8935 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -43,13 +43,13 @@ poe pre If you have added or changed functionality, please Update the documentation also. **This is a pre-req to having a PR merged**. See -[Documentation](../development/documentation/) for instructions +[Documentation](development/documentation.md) for instructions ## Ensure the tests Pass Ensure that any new code has relevant PASSING Unit and (if applicable) Integration Tests. New code should have full coverage and overall coverage -should not drop! See [Running Tests](../development/local/#run-tests) for more +should not drop! See [Running Tests](development/local.md/#run-tests) for more information. Note that there is a GitHub Action set up which will run all tests for each diff --git a/docs/customization/meta.md b/docs/customization/meta.md index 023c0230..26c75a51 100644 --- a/docs/customization/meta.md +++ b/docs/customization/meta.md @@ -1,3 +1,5 @@ +# Metadata + ## Customize the Metadata By default the Template Title, Description, Author and similar is set to my diff --git a/docs/customization/templates.md b/docs/customization/templates.md index 5e3f4a77..0277f7ca 100644 --- a/docs/customization/templates.md +++ b/docs/customization/templates.md @@ -1,4 +1,4 @@ -## Customize the Templates +# Customize the Templates There are several HTML templates used at this time, all are stored in the `templates/` folder or a subfolder of this. diff --git a/docs/deployment/deployment.md b/docs/deployment/deployment.md index dfcd9968..5917d22e 100644 --- a/docs/deployment/deployment.md +++ b/docs/deployment/deployment.md @@ -34,7 +34,7 @@ For AWS Elastic Beanstalk there is a very comprehensive tutorial at Deploy a Docker container or Kubernetes cluster. There are many services which allow this including AWS, Google Cloud and more. See [Develop with -Docker](/development/docker) in this documentation for how to containerize +Docker](../development/docker.md) in this documentation for how to containerize this project, and the [Offical FastAPI docs section][fastapi-docker]{: target="_blank"} for more information. diff --git a/docs/future.md b/docs/future.md index 7cf674f9..60546cf3 100644 --- a/docs/future.md +++ b/docs/future.md @@ -1 +1,2 @@ + --8<-- "TODO.md" diff --git a/docs/how-to-guides.md b/docs/how-to-guides.md index c1087601..93e4c817 100644 --- a/docs/how-to-guides.md +++ b/docs/how-to-guides.md @@ -1,2 +1,4 @@ +# Guides and How-to's + !!! note "How-to Guides" To be Written diff --git a/docs/known-issues.md b/docs/known-issues.md index 71b0404b..2252414a 100644 --- a/docs/known-issues.md +++ b/docs/known-issues.md @@ -1 +1,2 @@ + --8<-- "BUGS.md" diff --git a/docs/project-organization.md b/docs/project-organization.md index e8f1e1cc..d79c3afb 100644 --- a/docs/project-organization.md +++ b/docs/project-organization.md @@ -29,7 +29,7 @@ actual work needed for the routes. Check out the `managers/auth.py` and **migrations/** - We use [Alembic](https://github.com/sqlalchemy/alembic){:target="_blank"} to handle the database migrations. Check out their pages for more info. See instructions under -[Development](../usage/configuration/dot-env) for more info. +[Development](usage/configuration/dot-env.md) for more info. **models/** - Any database models used should be defined here along with supporting files (eq the `models/enums.py`) used here. Models are diff --git a/docs/reference/api.md b/docs/reference/api.md index 4537a4cd..cd83884a 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -1,2 +1,4 @@ +# API Reference + !!! note "API Reference" To be Written diff --git a/docs/tutorials.md b/docs/tutorials.md index 1c131f82..fea4e6f3 100644 --- a/docs/tutorials.md +++ b/docs/tutorials.md @@ -1,2 +1,4 @@ +# Tutorials + !!! note "Tutorials" To be Written diff --git a/docs/usage/configuration/database.md b/docs/usage/configuration/database.md index 08ed27e7..11d8b4c9 100644 --- a/docs/usage/configuration/database.md +++ b/docs/usage/configuration/database.md @@ -2,7 +2,7 @@ ## Migrate the Database -Make sure you have [configured](../dot-env) the database. Then +Make sure you have [configured](dot-env.md) the database. Then run the following command to set it up, applying all the required migrations: ```console diff --git a/docs/usage/configuration/dot-env.md b/docs/usage/configuration/dot-env.md index 2dec9508..20b44954 100644 --- a/docs/usage/configuration/dot-env.md +++ b/docs/usage/configuration/dot-env.md @@ -9,7 +9,7 @@ Database setup and JWT Secret Key. See the `.env.example` file for how to use. !!! info The Database and User must already exist in your Postgres database! - **Note that if you are using the [Docker](../../../development/docker/) container, + **Note that if you are using the [Docker](../../development/docker.md) container, this is done automatically.** ## Set the Base URL diff --git a/docs/usage/installation.md b/docs/usage/installation.md index dfdd9a48..0a94eb56 100644 --- a/docs/usage/installation.md +++ b/docs/usage/installation.md @@ -4,7 +4,7 @@ If you make changes to the existing code, why not create a feature-branch and a Pull-Request to have it included into the Template? 😊 - See [Contributing](../../contributing) for more info. + See [Contributing](../contributing.md) for more info. ## As a GitHub template diff --git a/docs/usage/user-control.md b/docs/usage/user-control.md index 97781f4f..e0115df0 100644 --- a/docs/usage/user-control.md +++ b/docs/usage/user-control.md @@ -13,7 +13,7 @@ command-line. ## Add a User -This is described in the [previous page](../add-user). It is important to +This is described in the [previous page](add-user.md). It is important to note that any user added this was will be **automatically verified**. ## List All Users diff --git a/mkdocs.yml b/mkdocs.yml index c2aeeffc..b4416219 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -46,8 +46,8 @@ markdown_extensions: auto_title: false - attr_list - pymdownx.emoji: - emoji_index: !!python/name:materialx.emoji.twemoji - emoji_generator: !!python/name:materialx.emoji.to_svg + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg nav: - FastAPI Template: index.md @@ -66,7 +66,7 @@ nav: - With a Local Server: development/local.md - With Docker: development/docker.md - Documentation: development/documentation.md - - How-to Guides: how-to-guides.md + - Guides and How-to's: how-to-guides.md - Project Organization: project-organization.md - Tutorials: tutorials.md - Deployment: deployment/deployment.md diff --git a/poetry.lock b/poetry.lock index b0830e99..5a95f60a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.7.0 and should not be changed by hand. [[package]] name = "aiosmtpd" @@ -97,18 +97,20 @@ test = ["anyio[trio]", "coverage[toml] (>=7)", "hypothesis (>=4.0)", "psutil (>= trio = ["trio (>=0.22)"] [[package]] -name = "astroid" -version = "3.0.1" -description = "An abstract syntax tree for Python with inference support." +name = "application-properties" +version = "0.8.1" +description = "A simple, easy to use, unified manner of accessing program properties." optional = false python-versions = ">=3.8.0" files = [ - {file = "astroid-3.0.1-py3-none-any.whl", hash = "sha256:7d5895c9825e18079c5aeac0572bc2e4c83205c95d416e0b4fee8bc361d2d9ca"}, - {file = "astroid-3.0.1.tar.gz", hash = "sha256:86b0bb7d7da0be1a7c4aedb7974e391b32d4ed89e33de6ed6902b4b15c97577e"}, + {file = "application_properties-0.8.1-py3-none-any.whl", hash = "sha256:f442e403ab7c8f97b048cf0ab92845057d595989653f65ced48579ad7cf35607"}, + {file = "application_properties-0.8.1.tar.gz", hash = "sha256:101667125383940651e72a2a9b3aa32225f359050c3be45b4ef4b51009930bb4"}, ] [package.dependencies] -typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.11\""} +pyyaml = ">=6.0" +tomli = ">=2.0.1" +typing-extensions = ">=4.5.0" [[package]] name = "async-timeout" @@ -192,6 +194,21 @@ async-timeout = {version = ">=4.0.3", markers = "python_version < \"3.12.0\""} docs = ["Sphinx (>=5.3.0,<5.4.0)", "sphinx-rtd-theme (>=1.2.2)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] test = ["flake8 (>=6.1,<7.0)", "uvloop (>=0.15.3)"] +[[package]] +name = "asyncpg-stubs" +version = "0.29.1" +description = "asyncpg stubs" +optional = false +python-versions = ">=3.8,<4.0" +files = [ + {file = "asyncpg_stubs-0.29.1-py3-none-any.whl", hash = "sha256:cce994d5a19394249e74ae8d252bde3c77cee0ddfc776cc708b724fdb4adebb6"}, + {file = "asyncpg_stubs-0.29.1.tar.gz", hash = "sha256:686afcc0af3a2f3c8e393cd850e0de430e5a139ce82b2f28ef8f693ecdf918bf"}, +] + +[package.dependencies] +asyncpg = ">=0.29,<0.30" +typing-extensions = ">=4.7.0,<5.0.0" + [[package]] name = "atpublic" version = "4.0" @@ -235,29 +252,6 @@ files = [ [package.extras] dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"] -[[package]] -name = "bandit" -version = "1.7.5" -description = "Security oriented static analyser for python code." -optional = false -python-versions = ">=3.7" -files = [ - {file = "bandit-1.7.5-py3-none-any.whl", hash = "sha256:75665181dc1e0096369112541a056c59d1c5f66f9bb74a8d686c3c362b83f549"}, - {file = "bandit-1.7.5.tar.gz", hash = "sha256:bdfc739baa03b880c2d15d0431b31c658ffc348e907fe197e54e0389dd59e11e"}, -] - -[package.dependencies] -colorama = {version = ">=0.3.9", markers = "platform_system == \"Windows\""} -GitPython = ">=1.0.1" -PyYAML = ">=5.3.1" -rich = "*" -stevedore = ">=1.20.0" - -[package.extras] -test = ["beautifulsoup4 (>=4.8.0)", "coverage (>=4.5.4)", "fixtures (>=3.0.0)", "flake8 (>=4.0.0)", "pylint (==1.9.4)", "stestr (>=2.5.0)", "testscenarios (>=0.5.0)", "testtools (>=2.3.0)", "tomli (>=1.1.0)"] -toml = ["tomli (>=1.1.0)"] -yaml = ["PyYAML"] - [[package]] name = "bcrypt" version = "4.0.1" @@ -310,48 +304,6 @@ soupsieve = ">1.2" html5lib = ["html5lib"] lxml = ["lxml"] -[[package]] -name = "black" -version = "23.10.1" -description = "The uncompromising code formatter." -optional = false -python-versions = ">=3.8" -files = [ - {file = "black-23.10.1-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:ec3f8e6234c4e46ff9e16d9ae96f4ef69fa328bb4ad08198c8cee45bb1f08c69"}, - {file = "black-23.10.1-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:1b917a2aa020ca600483a7b340c165970b26e9029067f019e3755b56e8dd5916"}, - {file = "black-23.10.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c74de4c77b849e6359c6f01987e94873c707098322b91490d24296f66d067dc"}, - {file = "black-23.10.1-cp310-cp310-win_amd64.whl", hash = "sha256:7b4d10b0f016616a0d93d24a448100adf1699712fb7a4efd0e2c32bbb219b173"}, - {file = "black-23.10.1-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:b15b75fc53a2fbcac8a87d3e20f69874d161beef13954747e053bca7a1ce53a0"}, - {file = "black-23.10.1-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:e293e4c2f4a992b980032bbd62df07c1bcff82d6964d6c9496f2cd726e246ace"}, - {file = "black-23.10.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d56124b7a61d092cb52cce34182a5280e160e6aff3137172a68c2c2c4b76bcb"}, - {file = "black-23.10.1-cp311-cp311-win_amd64.whl", hash = "sha256:3f157a8945a7b2d424da3335f7ace89c14a3b0625e6593d21139c2d8214d55ce"}, - {file = "black-23.10.1-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:cfcce6f0a384d0da692119f2d72d79ed07c7159879d0bb1bb32d2e443382bf3a"}, - {file = "black-23.10.1-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:33d40f5b06be80c1bbce17b173cda17994fbad096ce60eb22054da021bf933d1"}, - {file = "black-23.10.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:840015166dbdfbc47992871325799fd2dc0dcf9395e401ada6d88fe11498abad"}, - {file = "black-23.10.1-cp38-cp38-win_amd64.whl", hash = "sha256:037e9b4664cafda5f025a1728c50a9e9aedb99a759c89f760bd83730e76ba884"}, - {file = "black-23.10.1-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:7cb5936e686e782fddb1c73f8aa6f459e1ad38a6a7b0e54b403f1f05a1507ee9"}, - {file = "black-23.10.1-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:7670242e90dc129c539e9ca17665e39a146a761e681805c54fbd86015c7c84f7"}, - {file = "black-23.10.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ed45ac9a613fb52dad3b61c8dea2ec9510bf3108d4db88422bacc7d1ba1243d"}, - {file = "black-23.10.1-cp39-cp39-win_amd64.whl", hash = "sha256:6d23d7822140e3fef190734216cefb262521789367fbdc0b3f22af6744058982"}, - {file = "black-23.10.1-py3-none-any.whl", hash = "sha256:d431e6739f727bb2e0495df64a6c7a5310758e87505f5f8cde9ff6c0f2d7e4fe"}, - {file = "black-23.10.1.tar.gz", hash = "sha256:1f8ce316753428ff68749c65a5f7844631aa18c8679dfd3ca9dc1a289979c258"}, -] - -[package.dependencies] -click = ">=8.0.0" -mypy-extensions = ">=0.4.3" -packaging = ">=22.0" -pathspec = ">=0.9.0" -platformdirs = ">=2" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} -typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} - -[package.extras] -colorama = ["colorama (>=0.4.3)"] -d = ["aiohttp (>=3.7.4)"] -jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] -uvloop = ["uvloop (>=0.15.2)"] - [[package]] name = "blinker" version = "1.6.3" @@ -593,17 +545,6 @@ files = [ {file = "charset_normalizer-3.3.0-py3-none-any.whl", hash = "sha256:e46cd37076971c1040fc8c41273a8b3e2c624ce4f2be3f5dfcb7a430c1d3acc2"}, ] -[[package]] -name = "classify-imports" -version = "4.2.0" -description = "Utilities for refactoring imports in python-like syntax." -optional = false -python-versions = ">=3.7" -files = [ - {file = "classify_imports-4.2.0-py2.py3-none-any.whl", hash = "sha256:dbbc264b70a470ed8c6c95976a11dfb8b7f63df44ed1af87328bbed2663f5161"}, - {file = "classify_imports-4.2.0.tar.gz", hash = "sha256:7abfb7ea92149b29d046bd34573d247ba6e68cc28100c801eba4af17964fc40e"}, -] - [[package]] name = "cleo" version = "2.1.0" @@ -644,6 +585,21 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "columnar" +version = "1.4.1" +description = "A tool for printing data in a columnar format." +optional = false +python-versions = "*" +files = [ + {file = "Columnar-1.4.1-py3-none-any.whl", hash = "sha256:8efb692a7e6ca07dcc8f4ea889960421331a5dffa8e5af81f0a67ad8ea1fc798"}, + {file = "Columnar-1.4.1.tar.gz", hash = "sha256:c3cb57273333b2ff9cfaafc86f09307419330c97faa88dcfe23df05e6fbb9c72"}, +] + +[package.dependencies] +toolz = "*" +wcwidth = "*" + [[package]] name = "coverage" version = "7.3.2" @@ -777,20 +733,6 @@ files = [ {file = "csscompressor-0.9.5.tar.gz", hash = "sha256:afa22badbcf3120a4f392e4d22f9fff485c044a1feda4a950ecc5eba9dd31a05"}, ] -[[package]] -name = "dill" -version = "0.3.7" -description = "serialize all of Python" -optional = false -python-versions = ">=3.7" -files = [ - {file = "dill-0.3.7-py3-none-any.whl", hash = "sha256:76b122c08ef4ce2eedcd4d1abd8e641114bfc6c2867f49f3c41facf65bf19f5e"}, - {file = "dill-0.3.7.tar.gz", hash = "sha256:cc1c8b182eb3013e24bd475ff2e9295af86c1a38eb1aff128dac8962a9ce3c03"}, -] - -[package.extras] -graph = ["objgraph (>=1.7.2)"] - [[package]] name = "distlib" version = "0.3.7" @@ -1023,94 +965,6 @@ docs = ["furo (>=2023.7.26)", "sphinx (>=7.1.2)", "sphinx-autodoc-typehints (>=1 testing = ["covdefaults (>=2.3)", "coverage (>=7.3)", "diff-cover (>=7.7)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)", "pytest-timeout (>=2.1)"] typing = ["typing-extensions (>=4.7.1)"] -[[package]] -name = "flake8" -version = "6.1.0" -description = "the modular source code checker: pep8 pyflakes and co" -optional = false -python-versions = ">=3.8.1" -files = [ - {file = "flake8-6.1.0-py2.py3-none-any.whl", hash = "sha256:ffdfce58ea94c6580c77888a86506937f9a1a227dfcd15f245d694ae20a6b6e5"}, - {file = "flake8-6.1.0.tar.gz", hash = "sha256:d5b3857f07c030bdb5bf41c7f53799571d75c4491748a3adcd47de929e34cd23"}, -] - -[package.dependencies] -mccabe = ">=0.7.0,<0.8.0" -pycodestyle = ">=2.11.0,<2.12.0" -pyflakes = ">=3.1.0,<3.2.0" - -[[package]] -name = "flake8-docstrings" -version = "1.7.0" -description = "Extension for flake8 which uses pydocstyle to check docstrings" -optional = false -python-versions = ">=3.7" -files = [ - {file = "flake8_docstrings-1.7.0-py2.py3-none-any.whl", hash = "sha256:51f2344026da083fc084166a9353f5082b01f72901df422f74b4d953ae88ac75"}, - {file = "flake8_docstrings-1.7.0.tar.gz", hash = "sha256:4c8cc748dc16e6869728699e5d0d685da9a10b0ea718e090b1ba088e67a941af"}, -] - -[package.dependencies] -flake8 = ">=3" -pydocstyle = ">=2.1" - -[[package]] -name = "flake8-plugin-utils" -version = "1.3.3" -description = "The package provides base classes and utils for flake8 plugin writing" -optional = false -python-versions = ">=3.6,<4.0" -files = [ - {file = "flake8-plugin-utils-1.3.3.tar.gz", hash = "sha256:39f6f338d038b301c6fd344b06f2e81e382b68fa03c0560dff0d9b1791a11a2c"}, - {file = "flake8_plugin_utils-1.3.3-py3-none-any.whl", hash = "sha256:e4848c57d9d50f19100c2d75fa794b72df068666a9041b4b0409be923356a3ed"}, -] - -[[package]] -name = "flake8-pyproject" -version = "1.2.3" -description = "Flake8 plug-in loading the configuration from pyproject.toml" -optional = false -python-versions = ">= 3.6" -files = [ - {file = "flake8_pyproject-1.2.3-py3-none-any.whl", hash = "sha256:6249fe53545205af5e76837644dc80b4c10037e73a0e5db87ff562d75fb5bd4a"}, -] - -[package.dependencies] -Flake8 = ">=5" -TOMLi = {version = "*", markers = "python_version < \"3.11\""} - -[package.extras] -dev = ["pyTest", "pyTest-cov"] - -[[package]] -name = "flake8-pytest-style" -version = "1.7.2" -description = "A flake8 plugin checking common style issues or inconsistencies with pytest-based tests." -optional = false -python-versions = ">=3.7.2,<4.0.0" -files = [ - {file = "flake8_pytest_style-1.7.2-py3-none-any.whl", hash = "sha256:f5d2aa3219163a052dd92226589d45fab8ea027a3269922f0c4029f548ea5cd1"}, - {file = "flake8_pytest_style-1.7.2.tar.gz", hash = "sha256:b924197c99b951315949920b0e5547f34900b1844348432e67a44ab191582109"}, -] - -[package.dependencies] -flake8-plugin-utils = ">=1.3.2,<2.0.0" - -[[package]] -name = "flake8-type-checking" -version = "2.5.1" -description = "A flake8 plugin for managing type-checking imports & forward references" -optional = false -python-versions = ">=3.8" -files = [ - {file = "flake8_type_checking-2.5.1-py3-none-any.whl", hash = "sha256:1cd5cd9731f34921b33640751455643ca1cf7ee4a347a45cd94d3af328a3dd64"}, - {file = "flake8_type_checking-2.5.1.tar.gz", hash = "sha256:bfc51dd6e09a26662ab19191f44102f0606377ec0271a0e764ae993346a206d6"}, -] - -[package.dependencies] -classify-imports = "*" -flake8 = "*" - [[package]] name = "ghp-import" version = "2.1.0" @@ -1427,23 +1281,6 @@ files = [ {file = "installer-0.7.0.tar.gz", hash = "sha256:a26d3e3116289bb08216e0d0f7d925fcef0b0194eedfa0c944bcaaa106c4b631"}, ] -[[package]] -name = "isort" -version = "5.12.0" -description = "A Python utility / library to sort Python imports." -optional = false -python-versions = ">=3.8.0" -files = [ - {file = "isort-5.12.0-py3-none-any.whl", hash = "sha256:f84c2818376e66cf843d497486ea8fed8700b340f308f076c6fb1229dff318b6"}, - {file = "isort-5.12.0.tar.gz", hash = "sha256:8bef7dde241278824a6d83f44a544709b065191b95b6e50894bdc722fcba0504"}, -] - -[package.extras] -colors = ["colorama (>=0.4.3)"] -pipfile-deprecated-finder = ["pip-shims (>=0.5.2)", "pipreqs", "requirementslib"] -plugins = ["setuptools"] -requirements-deprecated-finder = ["pip-api", "pipreqs"] - [[package]] name = "itsdangerous" version = "2.1.2" @@ -1626,16 +1463,6 @@ files = [ {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"}, {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"}, {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-win32.whl", hash = "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"}, @@ -1668,17 +1495,6 @@ files = [ {file = "MarkupSafe-2.1.3.tar.gz", hash = "sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad"}, ] -[[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 = "mdurl" version = "0.1.2" @@ -2192,31 +2008,6 @@ files = [ {file = "pathspec-0.11.2.tar.gz", hash = "sha256:e0d8d0ac2f12da61956eb2306b69f9469b42f4deb0f3cb6ed47b9cce9996ced3"}, ] -[[package]] -name = "pbr" -version = "5.11.1" -description = "Python Build Reasonableness" -optional = false -python-versions = ">=2.6" -files = [ - {file = "pbr-5.11.1-py2.py3-none-any.whl", hash = "sha256:567f09558bae2b3ab53cb3c1e2e33e726ff3338e7bae3db5dc954b3a44eef12b"}, - {file = "pbr-5.11.1.tar.gz", hash = "sha256:aefc51675b0b533d56bb5fd1c8c6c0522fe31896679882e1c4c63d5e4a0fccb3"}, -] - -[[package]] -name = "pep8-naming" -version = "0.13.3" -description = "Check PEP-8 naming conventions, plugin for flake8" -optional = false -python-versions = ">=3.7" -files = [ - {file = "pep8-naming-0.13.3.tar.gz", hash = "sha256:1705f046dfcd851378aac3be1cd1551c7c1e5ff363bacad707d43007877fa971"}, - {file = "pep8_naming-0.13.3-py3-none-any.whl", hash = "sha256:1a86b8c71a03337c97181917e2b472f0f5e4ccb06844a0d6f0a33522549e7a80"}, -] - -[package.dependencies] -flake8 = ">=5.0.0" - [[package]] name = "pexpect" version = "4.8.0" @@ -2385,8 +2176,6 @@ files = [ {file = "psycopg2-2.9.9-cp310-cp310-win_amd64.whl", hash = "sha256:426f9f29bde126913a20a96ff8ce7d73fd8a216cfb323b1f04da402d452853c3"}, {file = "psycopg2-2.9.9-cp311-cp311-win32.whl", hash = "sha256:ade01303ccf7ae12c356a5e10911c9e1c51136003a9a1d92f7aa9d010fb98372"}, {file = "psycopg2-2.9.9-cp311-cp311-win_amd64.whl", hash = "sha256:121081ea2e76729acfb0673ff33755e8703d45e926e416cb59bae3a86c6a4981"}, - {file = "psycopg2-2.9.9-cp312-cp312-win32.whl", hash = "sha256:d735786acc7dd25815e89cc4ad529a43af779db2e25aa7c626de864127e5a024"}, - {file = "psycopg2-2.9.9-cp312-cp312-win_amd64.whl", hash = "sha256:a7653d00b732afb6fc597e29c50ad28087dcb4fbfb28e86092277a559ae4e693"}, {file = "psycopg2-2.9.9-cp37-cp37m-win32.whl", hash = "sha256:5e0d98cade4f0e0304d7d6f25bbfbc5bd186e07b38eac65379309c4ca3193efa"}, {file = "psycopg2-2.9.9-cp37-cp37m-win_amd64.whl", hash = "sha256:7e2dacf8b009a1c1e843b5213a87f7c544b2b042476ed7755be813eaf4e8347a"}, {file = "psycopg2-2.9.9-cp38-cp38-win32.whl", hash = "sha256:ff432630e510709564c01dafdbe996cb552e0b9f3f065eb89bdce5bd31fabf4c"}, @@ -2407,17 +2196,6 @@ files = [ {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"}, ] -[[package]] -name = "pycodestyle" -version = "2.11.1" -description = "Python style guide checker" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pycodestyle-2.11.1-py2.py3-none-any.whl", hash = "sha256:44fe31000b2d866f2e41841b18528a505fbd7fef9017b04eff4e2648a0fadc67"}, - {file = "pycodestyle-2.11.1.tar.gz", hash = "sha256:41ba0e7afc9752dfb53ced5489e89f8186be00e599e712660695b7a75ff2663f"}, -] - [[package]] name = "pycparser" version = "2.21" @@ -2599,31 +2377,14 @@ pydantic = ">=2.0.1" python-dotenv = ">=0.21.0" [[package]] -name = "pydocstyle" -version = "6.3.0" -description = "Python docstring style checker" +name = "pyfakefs" +version = "5.3.0" +description = "pyfakefs implements a fake file system that mocks the Python file system modules." optional = false -python-versions = ">=3.6" -files = [ - {file = "pydocstyle-6.3.0-py3-none-any.whl", hash = "sha256:118762d452a49d6b05e194ef344a55822987a462831ade91ec5c06fd2169d019"}, - {file = "pydocstyle-6.3.0.tar.gz", hash = "sha256:7ce43f0c0ac87b07494eb9c0b462c0b73e6ff276807f204d6b53edc72b7e44e1"}, -] - -[package.dependencies] -snowballstemmer = ">=2.2.0" - -[package.extras] -toml = ["tomli (>=1.2.3)"] - -[[package]] -name = "pyflakes" -version = "3.1.0" -description = "passive checker of Python programs" -optional = false -python-versions = ">=3.8" +python-versions = ">=3.7" files = [ - {file = "pyflakes-3.1.0-py2.py3-none-any.whl", hash = "sha256:4132f6d49cb4dae6819e5379898f2b8cce3c5f23994194c24b77d5da2e36f774"}, - {file = "pyflakes-3.1.0.tar.gz", hash = "sha256:a0aae034c444db0071aa077972ba4768d40c830d9539fd45bf4cd3f8f6992efc"}, + {file = "pyfakefs-5.3.0-py3-none-any.whl", hash = "sha256:33c1f891078c727beec465e75cb314120635e2298456493cc2cc0539e2130cbb"}, + {file = "pyfakefs-5.3.0.tar.gz", hash = "sha256:e3e35f65ce55ee8ecc5e243d55cfdbb5d0aa24938f6e04e19f0fab062f255020"}, ] [[package]] @@ -2658,34 +2419,20 @@ docs = ["sphinx (>=4.5.0,<5.0.0)", "sphinx-rtd-theme", "zope.interface"] tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] [[package]] -name = "pylint" -version = "3.0.2" -description = "python code static checker" +name = "pymarkdownlnt" +version = "0.9.14" +description = "A GitHub Flavored Markdown compliant Markdown linter." optional = false python-versions = ">=3.8.0" files = [ - {file = "pylint-3.0.2-py3-none-any.whl", hash = "sha256:60ed5f3a9ff8b61839ff0348b3624ceeb9e6c2a92c514d81c9cc273da3b6bcda"}, - {file = "pylint-3.0.2.tar.gz", hash = "sha256:0d4c286ef6d2f66c8bfb527a7f8a629009e42c99707dec821a03e1b51a4c1496"}, + {file = "pymarkdownlnt-0.9.14-py3-none-any.whl", hash = "sha256:7db466fe9170b8144a4101362171d111233dbd5c7710f896c525456e27559efb"}, + {file = "pymarkdownlnt-0.9.14.tar.gz", hash = "sha256:3f75977a812a14b305773167a8774b6930ff30528cd7f75da8c4e865c19a504f"}, ] [package.dependencies] -astroid = ">=3.0.1,<=3.1.0-dev0" -colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} -dill = [ - {version = ">=0.2", markers = "python_version < \"3.11\""}, - {version = ">=0.3.7", markers = "python_version >= \"3.12\""}, - {version = ">=0.3.6", markers = "python_version >= \"3.11\" and python_version < \"3.12\""}, -] -isort = ">=4.2.5,<6" -mccabe = ">=0.6,<0.8" -platformdirs = ">=2.2.0" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} -tomlkit = ">=0.10.1" -typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""} - -[package.extras] -spelling = ["pyenchant (>=3.2,<4.0)"] -testutils = ["gitpython (>3)"] +application-properties = ">=0.8.1" +columnar = ">=1.4.0" +typing-extensions = ">=4.7.0" [[package]] name = "pymdown-extensions" @@ -2842,6 +2589,21 @@ termcolor = ">=2.1.0" [package.extras] dev = ["black", "flake8", "pre-commit"] +[[package]] +name = "pytest-watcher" +version = "0.3.4" +description = "Automatically rerun your tests on file modifications" +optional = false +python-versions = ">=3.7.0,<4.0.0" +files = [ + {file = "pytest_watcher-0.3.4-py3-none-any.whl", hash = "sha256:edd2bd9c8a1fb14d48c9f4947234065eb9b4c1acedc0bf213b1f12501dfcffd3"}, + {file = "pytest_watcher-0.3.4.tar.gz", hash = "sha256:d39491ba15b589221bb9a78ef4bed3d5d1503aed08209b1a138aeb95b9117a18"}, +] + +[package.dependencies] +tomli = {version = ">=2.0.1,<3.0.0", markers = "python_version < \"3.11\""} +watchdog = ">=2.0.0" + [[package]] name = "python-dateutil" version = "2.8.2" @@ -2929,7 +2691,6 @@ files = [ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, - {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, @@ -2937,15 +2698,8 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, - {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, - {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, - {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, - {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, @@ -2962,7 +2716,6 @@ files = [ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, - {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, @@ -2970,7 +2723,6 @@ files = [ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, - {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, @@ -3259,6 +3011,32 @@ pygments = ">=2.13.0,<3.0.0" [package.extras] jupyter = ["ipywidgets (>=7.5.1,<9)"] +[[package]] +name = "ruff" +version = "0.1.5" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +files = [ + {file = "ruff-0.1.5-py3-none-macosx_10_7_x86_64.whl", hash = "sha256:32d47fc69261c21a4c48916f16ca272bf2f273eb635d91c65d5cd548bf1f3d96"}, + {file = "ruff-0.1.5-py3-none-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:171276c1df6c07fa0597fb946139ced1c2978f4f0b8254f201281729981f3c17"}, + {file = "ruff-0.1.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:17ef33cd0bb7316ca65649fc748acc1406dfa4da96a3d0cde6d52f2e866c7b39"}, + {file = "ruff-0.1.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b2c205827b3f8c13b4a432e9585750b93fd907986fe1aec62b2a02cf4401eee6"}, + {file = "ruff-0.1.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bb408e3a2ad8f6881d0f2e7ad70cddb3ed9f200eb3517a91a245bbe27101d379"}, + {file = "ruff-0.1.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:f20dc5e5905ddb407060ca27267c7174f532375c08076d1a953cf7bb016f5a24"}, + {file = "ruff-0.1.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aafb9d2b671ed934998e881e2c0f5845a4295e84e719359c71c39a5363cccc91"}, + {file = "ruff-0.1.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a4894dddb476597a0ba4473d72a23151b8b3b0b5f958f2cf4d3f1c572cdb7af7"}, + {file = "ruff-0.1.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a00a7ec893f665ed60008c70fe9eeb58d210e6b4d83ec6654a9904871f982a2a"}, + {file = "ruff-0.1.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a8c11206b47f283cbda399a654fd0178d7a389e631f19f51da15cbe631480c5b"}, + {file = "ruff-0.1.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fa29e67b3284b9a79b1a85ee66e293a94ac6b7bb068b307a8a373c3d343aa8ec"}, + {file = "ruff-0.1.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9b97fd6da44d6cceb188147b68db69a5741fbc736465b5cea3928fdac0bc1aeb"}, + {file = "ruff-0.1.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:721f4b9d3b4161df8dc9f09aa8562e39d14e55a4dbaa451a8e55bdc9590e20f4"}, + {file = "ruff-0.1.5-py3-none-win32.whl", hash = "sha256:f80c73bba6bc69e4fdc73b3991db0b546ce641bdcd5b07210b8ad6f64c79f1ab"}, + {file = "ruff-0.1.5-py3-none-win_amd64.whl", hash = "sha256:c21fe20ee7d76206d290a76271c1af7a5096bc4c73ab9383ed2ad35f852a0087"}, + {file = "ruff-0.1.5-py3-none-win_arm64.whl", hash = "sha256:82bfcb9927e88c1ed50f49ac6c9728dab3ea451212693fe40d08d314663e412f"}, + {file = "ruff-0.1.5.tar.gz", hash = "sha256:5cbec0ef2ae1748fb194f420fb03fb2c25c3258c86129af7172ff8f198f125ab"}, +] + [[package]] name = "secretstorage" version = "3.3.3" @@ -3345,17 +3123,6 @@ files = [ {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"}, ] -[[package]] -name = "snowballstemmer" -version = "2.2.0" -description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." -optional = false -python-versions = "*" -files = [ - {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, - {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, -] - [[package]] name = "soupsieve" version = "2.5" @@ -3472,20 +3239,6 @@ typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\"" [package.extras] full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart", "pyyaml"] -[[package]] -name = "stevedore" -version = "5.1.0" -description = "Manage dynamic plugins for Python applications" -optional = false -python-versions = ">=3.8" -files = [ - {file = "stevedore-5.1.0-py3-none-any.whl", hash = "sha256:8cc040628f3cea5d7128f2e76cf486b2251a4e543c7b938f58d9a377f6694a2d"}, - {file = "stevedore-5.1.0.tar.gz", hash = "sha256:a54534acf9b89bc7ed264807013b505bf07f74dbe4bcfa37d32bd063870b087c"}, -] - -[package.dependencies] -pbr = ">=2.0.0,<2.1.0 || >2.1.0" - [[package]] name = "termcolor" version = "2.3.0" @@ -3500,17 +3253,6 @@ files = [ [package.extras] tests = ["pytest", "pytest-cov"] -[[package]] -name = "toml" -version = "0.10.2" -description = "Python Library for Tom's Obvious, Minimal Language" -optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" -files = [ - {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, - {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, -] - [[package]] name = "tomli" version = "2.0.1" @@ -3544,6 +3286,17 @@ files = [ {file = "tomlkit-0.12.1.tar.gz", hash = "sha256:38e1ff8edb991273ec9f6181244a6a391ac30e9f5098e7535640ea6be97a7c86"}, ] +[[package]] +name = "toolz" +version = "0.12.0" +description = "List processing tools and functional utilities" +optional = false +python-versions = ">=3.5" +files = [ + {file = "toolz-0.12.0-py3-none-any.whl", hash = "sha256:2059bd4148deb1884bb0eb770a3cde70e7f954cfbbdc2285f1f2de01fd21eb6f"}, + {file = "toolz-0.12.0.tar.gz", hash = "sha256:88c570861c440ee3f2f6037c4654613228ff40c93a6c25e0eba70d17282c6194"}, +] + [[package]] name = "trove-classifiers" version = "2023.10.18" @@ -3555,23 +3308,6 @@ files = [ {file = "trove_classifiers-2023.10.18-py3-none-any.whl", hash = "sha256:20a3da8e3cb65587cc9f5d5b837bf74edeb480bba9bd8cd4f03ab056d6b06c4c"}, ] -[[package]] -name = "tryceratops" -version = "2.3.2" -description = "Prevent Exception Handling AntiPatterns" -optional = false -python-versions = ">=3.8.1,<4.0" -files = [ - {file = "tryceratops-2.3.2-py3-none-any.whl", hash = "sha256:032fa3cf3659c9865a07b59057edf9efe9e38631e6b977fdae04064888cb62ba"}, - {file = "tryceratops-2.3.2.tar.gz", hash = "sha256:e9d77811d8f7d886c4ceaeadccd2675c6f2d794344775463faf1cb969e49d865"}, -] - -[package.dependencies] -click = ">=7" -rich = ">=10.14.0" -toml = ">=0.10.2" -typing-extensions = {version = ">=4.5.0", markers = "python_version < \"3.11\""} - [[package]] name = "typer" version = "0.8.0" @@ -3595,6 +3331,17 @@ dev = ["autoflake (>=1.3.1,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)", "pre-commit (>=2 doc = ["cairosvg (>=2.5.2,<3.0.0)", "mdx-include (>=1.4.1,<2.0.0)", "mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "pillow (>=9.3.0,<10.0.0)"] test = ["black (>=22.3.0,<23.0.0)", "coverage (>=6.2,<7.0)", "isort (>=5.0.6,<6.0.0)", "mypy (==0.910)", "pytest (>=4.4.0,<8.0.0)", "pytest-cov (>=2.10.0,<5.0.0)", "pytest-sugar (>=0.9.4,<0.10.0)", "pytest-xdist (>=1.32.0,<4.0.0)", "rich (>=10.11.0,<14.0.0)", "shellingham (>=1.3.0,<2.0.0)"] +[[package]] +name = "types-passlib" +version = "1.7.7.13" +description = "Typing stubs for passlib" +optional = false +python-versions = "*" +files = [ + {file = "types-passlib-1.7.7.13.tar.gz", hash = "sha256:f152639f1f2103d7f59a56e2aec5f9398a75a80830991d0d68aac5c2b9c32a77"}, + {file = "types_passlib-1.7.7.13-py3-none-any.whl", hash = "sha256:414b5ee9c88313357c9261cfcf816509b1e8e4673f0796bd61e9ef249f6fe076"}, +] + [[package]] name = "typing-extensions" version = "4.8.0" @@ -3914,6 +3661,17 @@ files = [ [package.dependencies] anyio = ">=3.0.0" +[[package]] +name = "wcwidth" +version = "0.2.9" +description = "Measures the displayed width of unicode strings in a terminal" +optional = false +python-versions = "*" +files = [ + {file = "wcwidth-0.2.9-py2.py3-none-any.whl", hash = "sha256:9a929bd8380f6cd9571a968a9c8f4353ca58d7cd812a4822bba831f8d685b223"}, + {file = "wcwidth-0.2.9.tar.gz", hash = "sha256:a675d1a4a2d24ef67096a04b85b02deeecd8e226f57b5e3a72dbb9ed99d27da8"}, +] + [[package]] name = "websockets" version = "12.0" @@ -4097,4 +3855,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [metadata] lock-version = "2.0" python-versions = ">=3.9,<4.0" -content-hash = "1ca87834742a956531906bbdcf3f1b7830635fe72722c03ac09a54c05a834a37" +content-hash = "d15c10f30d4b0ab42cda8a67d2077bf546d65aba79348ebf9bdb48473b412240" diff --git a/pyproject.toml b/pyproject.toml index 0c030876..2e592887 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,7 @@ authors = ["Grant Ramsay (seapagan) "] license = "MIT" readme = "README.md" + [tool.poetry.dependencies] python = ">=3.9,<4.0" fastapi = { extras = ["all"], version = "^0.103.0" } @@ -42,22 +43,12 @@ version = "^1.7.4" [tool.poetry.group.dev.dependencies] aiosmtpd = "^1.4.4" aiosqlite = "^0.19.0" -bandit = "^1.7.5" -black = "^23.10.1" -flake8 = "^6.1.0" -flake8-docstrings = "^1.7.0" -flake8-pyproject = "^1.2.3" -flake8-pytest-style = "^1.7.2" -flake8-type-checking = "^2.5.1" httpx = "0.23.3" -isort = "^5.12.0" mypy = "^1.5.1" openapi-readme = "^0.2.4" -pep8-naming = "^0.13.3" -pylint = "^3.0.1" -pydocstyle = "^6.3.0" pre-commit = "^3.4.0" -tryceratops = "^2.3.2" +pymarkdownlnt = "^0.9.14" +ruff = "^0.1.5" # task runner poethepoet = "^0.24.2" @@ -65,6 +56,7 @@ poethepoet = "^0.24.2" # testing faker = "^19.13.0" mock = "^5.1.0" +pyfakefs = "^5.3.0" pytest = "^7.4.3" pytest-asyncio = "^0.21.1" pytest-cov = "^4.0.0" @@ -84,59 +76,131 @@ mkdocs-swagger-ui-tag = "^0.6.6" pymdown-extensions = "^10.3" pygments = "^2.16.1" +# for typing +types-passlib = "^1.7.7.13" +asyncpg-stubs = "^0.29.1" + # additional packages +pytest-watcher = "^0.3.4" [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" [tool.poe.tasks] -serve = "uvicorn main:app --reload" -pre = "pre-commit run --all-files" -lint = "pylint **/*.py" -_publish_docs = "mkdocs gh-deploy" -_build_docs = "mkdocs build" -openapi = "./api-admin docs openapi --prefix=docs/reference" -"docs:publish" = ["openapi", "_publish_docs"] -"docs:build" = ["openapi", "_build_docs"] -"docs:serve" = "mkdocs serve -a 0.0.0.0:9000" -try = "tryceratops **/*.py" - -[tool.flake8] -max-line-length = 80 -exclude = ["__init__.py", ".git", "app/migrations/versions/*", "old_tests/*"] -extend-ignore = ["E203", "W503"] -extend-select = ["TC", "TC1", "TRY"] -docstring-convention = "google" -type-checking-pydantic-enabled = true -classmethod-decorators = ["classmethod", "validator"] - -[tool.isort] -profile = "black" - -[tool.black] +# setup PoeThePoet tasks +pre.cmd = "pre-commit run --all-files" +pre.help = "Run pre-commit checks" +mypy.cmd = "mypy . --strict" +mypy.help = "Run mypy checks" +format.help = "Format code with Ruff" +format.cmd = "ruff format ." +ruff.help = "Run Ruff checks" +ruff.cmd = "ruff check ." +test.help = "Run tests using Pytest" +test.cmd = "pytest" +markdown.cmd = "pymarkdown --strict-config scan -r docs/**/*.md" +markdown.help = "Run markdown checks" + +# run all linting checks in sequence. we want to run them all, even if one fails +lint.ignore_fail = "return_non_zero" +lint.sequence = ["format", "ruff", "mypy", "markdown"] +lint.help = "Run all linting checks" + +# documentation tasks +"docs:publish".cmd = "mkdocs gh-deploy" +"docs:publish".help = "Publish documentation to GitHub Pages" +"docs:build".cmd = "mkdocs build" +"docs:build".help = "Build documentation locally to './site' folder" +"docs:serve".cmd = "mkdocs serve -w TODO.md -w CHANGELOG.md -w CONTRIBUTING.md" +"docs:serve".help = "Serve documentation locally" +"docs:serve:all".cmd = "mkdocs serve -w TODO.md -w CHANGELOG.md -w CONTRIBUTING.md -a 0.0.0.0:8000" +"docs:serve:all".help = "Serve documentation locally on all interfaces" + +changelog.cmd = "github-changelog-md" +changelog.help = "Generate the CHANGELOG.md file" + +# configure assorted tools and linters +[tool.pymarkdown] +plugins.md014.enabled = false +plugins.md046.enabled = false +plugins.md033.allowed_elements = "!--,![CDATA[,!DOCTYPE,swagger-ui" +plugins.md013.enabled = false + +[tool.ruff] line-length = 80 -exclude = ''' -/( - \.git - | \.mypy_cache - | \.tox - | venv - | \.venv - | _build - | build - | dist -)/ -''' - -[tool.bandit] -exclude_dirs = ["config/settings.py", "schemas/examples.py"] - -[tool.bandit.assert_used] -skips = ['*_test.py', '*/test_*.py'] - -[tool.pydocstyle] -add-ignore = ["D104"] +select = ["ALL"] # we are being very strict! +ignore = [ + "ANN101", + "PGH003", + "FBT002", + "FBT003", + "B006", +] # These rules are too strict even for us 😝 +extend-ignore = [ + "COM812", # ignored for ruff formatting + "ISC001", # ignored for ruff formatting + "T201", # temporary ignore for now, will remove when migrate to logging +] + +src = ["app"] +extend-exclude = [ + "app/migrations", # auto-generated by alembic so we don't need to check them + "tests/integration", # ignore for now until start fixing these +] +target-version = "py39" # minimum python version supported + +[tool.ruff.format] +indent-style = "space" +quote-style = "double" + +[tool.ruff.pep8-naming] +classmethod-decorators = ["pydantic.validator", "pydantic.root_validator"] + +[tool.ruff.pydocstyle] +convention = "google" + +[tool.ruff.extend-per-file-ignores] +"tests/**/*.py" = [ + "S101", # we can (and MUST!) use 'assert' in test files. + "ANN001", # annotations for fixtures are sometimes a pain for test files. + "ARG001", # sometimes fixtures are not physically used but have side-effects + "ARG002", # as above + "TD003", + "FIX002", +] +"app/managers/auth.py" = ["ERA001"] +"app/resources/auth.py" = ["ERA001"] + + +[tool.ruff.flake8-bugbear] +extend-immutable-calls = [ + "fastapi.Depends", + "fastapi.params.Depends", + "fastapi.Query", + "fastapi.params.Query", +] + +[tool.ruff.lint.flake8-builtins] +builtins-ignorelist = ["id"] + +[tool.ruff.isort] +known-first-party = ["app"] + +[tool.ruff.pyupgrade] +keep-runtime-typing = true + +[tool.mypy] +python_version = "3.9" +exclude = ["app/migrations/", "tests/integration/"] + +[[tool.mypy.overrides]] +disable_error_code = ["method-assign", "no-untyped-def", "attr-defined"] +module = "tests.*" + +[[tool.mypy.overrides]] +ignore_missing_imports = true +module = "decouple.*" [tool.pytest.ini_options] addopts = [ @@ -149,13 +213,11 @@ addopts = [ "html", ] filterwarnings = ["ignore:'crypt' is deprecated:DeprecationWarning"] -mock_use_standalone_module = true markers = ["unit: Unit tests", "integration: Integration tests"] testpaths = ["tests"] +mock_use_standalone_module = true [tool.coverage.run] # source = [] -omit = ["*/tests/*"] - -[tool.pymarkdown] -plugins.md014.enabled = false +source = ["app"] +omit = ["*/tests/*", "app/commands/*"] diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 00000000..72f6145c --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,169 @@ +aiosmtpd==1.4.4.post2 ; python_version >= "3.9" and python_version < "4.0" +aiosmtplib==2.0.2 ; python_version >= "3.9" and python_version < "4.0" +aiosqlite==0.19.0 ; python_version >= "3.9" and python_version < "4.0" +alembic==1.12.1 ; python_version >= "3.9" and python_version < "4.0" +annotated-types==0.6.0 ; python_version >= "3.9" and python_version < "4.0" +anyio==4.0.0 ; python_version >= "3.9" and python_version < "4.0" +application-properties==0.8.1 ; python_version >= "3.9" and python_version < "4.0" +async-timeout==4.0.3 ; python_version >= "3.9" and python_version < "3.12.0" +asyncclick==8.1.3.4 ; python_version >= "3.9" and python_version < "4.0" +asyncpg-stubs==0.29.1 ; python_version >= "3.9" and python_version < "4.0" +asyncpg==0.29.0 ; python_version >= "3.9" and python_version < "4.0" +atpublic==4.0 ; python_version >= "3.9" and python_version < "4.0" +attrs==23.1.0 ; python_version >= "3.9" and python_version < "4.0" +babel==2.13.0 ; python_version >= "3.9" and python_version < "4.0" +bcrypt==4.0.1 ; python_version >= "3.9" and python_version < "4.0" +beautifulsoup4==4.12.2 ; python_version >= "3.9" and python_version < "4.0" +blinker==1.6.3 ; python_version >= "3.9" and python_version < "4.0" +build==1.0.3 ; python_version >= "3.9" and python_version < "4.0" +cachecontrol[filecache]==0.13.1 ; python_version >= "3.9" and python_version < "4.0" +certifi==2023.7.22 ; python_version >= "3.9" and python_version < "4.0" +cffi==1.16.0 ; python_version >= "3.9" and python_version < "4.0" and (sys_platform == "darwin" or sys_platform == "linux") +cfgv==3.4.0 ; python_version >= "3.9" and python_version < "4.0" +charset-normalizer==3.3.0 ; python_version >= "3.9" and python_version < "4.0" +cleo==2.1.0 ; python_version >= "3.9" and python_version < "4.0" +click==8.1.7 ; python_version >= "3.9" and python_version < "4.0" +colorama==0.4.6 ; python_version >= "3.9" and python_version < "4.0" +columnar==1.4.1 ; python_version >= "3.9" and python_version < "4.0" +coverage[toml]==7.3.2 ; python_version >= "3.9" and python_version < "4.0" +crashtest==0.4.1 ; python_version >= "3.9" and python_version < "4.0" +cryptography==41.0.4 ; python_version >= "3.9" and python_version < "4.0" and sys_platform == "linux" +csscompressor==0.9.5 ; python_version >= "3.9" and python_version < "4.0" +distlib==0.3.7 ; python_version >= "3.9" and python_version < "4.0" +dnspython==2.4.2 ; python_version >= "3.9" and python_version < "4.0" +dulwich==0.21.6 ; python_version >= "3.9" and python_version < "4.0" +email-validator==2.1.0.post1 ; python_version >= "3.9" and python_version < "4.0" +exceptiongroup==1.1.3 ; python_version >= "3.9" and python_version < "3.11" +faker==19.13.0 ; python_version >= "3.9" and python_version < "4.0" +fastapi-mail[httpx]==1.4.1 ; python_version >= "3.9" and python_version < "4.0" +fastapi[all]==0.103.0 ; python_version >= "3.9" and python_version < "4.0" +fastjsonschema==2.18.1 ; python_version >= "3.9" and python_version < "4.0" +filelock==3.12.4 ; python_version >= "3.9" and python_version < "4.0" +ghp-import==2.1.0 ; python_version >= "3.9" and python_version < "4.0" +gitdb==4.0.11 ; python_version >= "3.9" and python_version < "4.0" +gitpython==3.1.40 ; python_version >= "3.9" and python_version < "4.0" +greenlet==3.0.0 ; python_version >= "3.9" and python_version < "4.0" +griffe==0.36.7 ; python_version >= "3.9" and python_version < "4.0" +h11==0.14.0 ; python_version >= "3.9" and python_version < "4.0" +htmlmin2==0.1.13 ; python_version >= "3.9" and python_version < "4.0" +httpcore==0.16.3 ; python_version >= "3.9" and python_version < "4.0" +httptools==0.6.1 ; python_version >= "3.9" and python_version < "4.0" +httpx==0.23.3 ; python_version >= "3.9" and python_version < "4.0" +httpx[httpx]==0.23.3 ; python_version >= "3.9" and python_version < "4.0" +identify==2.5.30 ; python_version >= "3.9" and python_version < "4.0" +idna==3.4 ; python_version >= "3.9" and python_version < "4.0" +importlib-metadata==6.8.0 ; python_version >= "3.9" and python_version < "3.12" +iniconfig==2.0.0 ; python_version >= "3.9" and python_version < "4.0" +installer==0.7.0 ; python_version >= "3.9" and python_version < "4.0" +itsdangerous==2.1.2 ; python_version >= "3.9" and python_version < "4.0" +jaraco-classes==3.3.0 ; python_version >= "3.9" and python_version < "4.0" +jeepney==0.8.0 ; python_version >= "3.9" and python_version < "4.0" and sys_platform == "linux" +jinja2==3.1.2 ; python_version >= "3.9" and python_version < "4.0" +jsmin==3.0.1 ; python_version >= "3.9" and python_version < "4.0" +keyring==24.2.0 ; python_version >= "3.9" and python_version < "4.0" +mako==1.2.4 ; python_version >= "3.9" and python_version < "4.0" +markdown-it-py==3.0.0 ; python_version >= "3.9" and python_version < "4.0" +markdown==3.5 ; python_version >= "3.9" and python_version < "4.0" +markupsafe==2.1.3 ; python_version >= "3.9" and python_version < "4.0" +mdurl==0.1.2 ; python_version >= "3.9" and python_version < "4.0" +mergedeep==1.3.4 ; python_version >= "3.9" and python_version < "4.0" +mkdocs-autorefs==0.5.0 ; python_version >= "3.9" and python_version < "4.0" +mkdocs-git-revision-date-localized-plugin==1.2.1 ; python_version >= "3.9" and python_version < "4.0" +mkdocs-latest-git-tag-plugin==0.1.2 ; python_version >= "3.9" and python_version < "4.0" +mkdocs-material-extensions==1.3 ; python_version >= "3.9" and python_version < "4.0" +mkdocs-material==9.4.8 ; python_version >= "3.9" and python_version < "4.0" +mkdocs-minify-plugin==0.7.1 ; python_version >= "3.9" and python_version < "4.0" +mkdocs-swagger-ui-tag==0.6.6 ; python_version >= "3.9" and python_version < "4.0" +mkdocs==1.5.3 ; python_version >= "3.9" and python_version < "4.0" +mkdocstrings-python==1.7.3 ; python_version >= "3.9" and python_version < "4.0" +mkdocstrings==0.23.0 ; python_version >= "3.9" and python_version < "4.0" +mkdocstrings[python]==0.23.0 ; python_version >= "3.9" and python_version < "4.0" +mock==5.1.0 ; python_version >= "3.9" and python_version < "4.0" +more-itertools==10.1.0 ; python_version >= "3.9" and python_version < "4.0" +msgpack==1.0.7 ; python_version >= "3.9" and python_version < "4.0" +mypy-extensions==1.0.0 ; python_version >= "3.9" and python_version < "4.0" +mypy==1.6.1 ; python_version >= "3.9" and python_version < "4.0" +nodeenv==1.8.0 ; python_version >= "3.9" and python_version < "4.0" +openapi-readme==0.2.4 ; python_version >= "3.9" and python_version < "4.0" +orjson==3.9.9 ; python_version >= "3.9" and python_version < "4.0" +packaging==23.2 ; python_version >= "3.9" and python_version < "4.0" +paginate==0.5.6 ; python_version >= "3.9" and python_version < "4.0" +passlib[bcrypt]==1.7.4 ; python_version >= "3.9" and python_version < "4.0" +pastel==0.2.1 ; python_version >= "3.9" and python_version < "4.0" +pathspec==0.11.2 ; python_version >= "3.9" and python_version < "4.0" +pexpect==4.8.0 ; python_version >= "3.9" and python_version < "4.0" +pkginfo==1.9.6 ; python_version >= "3.9" and python_version < "4.0" +platformdirs==3.11.0 ; python_version >= "3.9" and python_version < "4.0" +pluggy==1.3.0 ; python_version >= "3.9" and python_version < "4.0" +poethepoet==0.24.2 ; python_version >= "3.9" and python_version < "4.0" +poetry-core==1.8.1 ; python_version >= "3.9" and python_version < "4.0" +poetry-plugin-export==1.6.0 ; python_version >= "3.9" and python_version < "4.0" +poetry==1.7.0 ; python_version >= "3.9" and python_version < "4.0" +pre-commit==3.5.0 ; python_version >= "3.9" and python_version < "4.0" +psycopg2==2.9.9 ; python_version >= "3.9" and python_version < "4.0" +ptyprocess==0.7.0 ; python_version >= "3.9" and python_version < "4.0" +pycparser==2.21 ; python_version >= "3.9" and python_version < "4.0" and (sys_platform == "darwin" or sys_platform == "linux") +pydantic-core==2.10.1 ; python_version >= "3.9" and python_version < "4.0" +pydantic-extra-types==2.1.0 ; python_version >= "3.9" and python_version < "4.0" +pydantic-settings==2.0.3 ; python_version >= "3.9" and python_version < "4.0" +pydantic==2.4.2 ; python_version >= "3.9" and python_version < "4.0" +pyfakefs==5.3.0 ; python_version >= "3.9" and python_version < "4.0" +pygments==2.16.1 ; python_version >= "3.9" and python_version < "4.0" +pyjwt==2.8.0 ; python_version >= "3.9" and python_version < "4.0" +pymarkdownlnt==0.9.14 ; python_version >= "3.9" and python_version < "4.0" +pymdown-extensions==10.3.1 ; python_version >= "3.9" and python_version < "4.0" +pyproject-hooks==1.0.0 ; python_version >= "3.9" and python_version < "4.0" +pytest-asyncio==0.21.1 ; python_version >= "3.9" and python_version < "4.0" +pytest-cov==4.1.0 ; python_version >= "3.9" and python_version < "4.0" +pytest-mock==3.12.0 ; python_version >= "3.9" and python_version < "4.0" +pytest-randomly==3.15.0 ; python_version >= "3.9" and python_version < "4.0" +pytest-reverse==1.7.0 ; python_version >= "3.9" and python_version < "4.0" +pytest-sugar==0.9.7 ; python_version >= "3.9" and python_version < "4.0" +pytest-watcher==0.3.4 ; python_version >= "3.9" and python_version < "4.0" +pytest==7.4.3 ; python_version >= "3.9" and python_version < "4.0" +python-dateutil==2.8.2 ; python_version >= "3.9" and python_version < "4.0" +python-decouple==3.8 ; python_version >= "3.9" and python_version < "4.0" +python-dotenv==1.0.0 ; python_version >= "3.9" and python_version < "4.0" +python-multipart==0.0.6 ; python_version >= "3.9" and python_version < "4.0" +pytz==2023.3.post1 ; python_version >= "3.9" and python_version < "4.0" +pywin32-ctypes==0.2.2 ; python_version >= "3.9" and python_version < "4.0" and sys_platform == "win32" +pyyaml-env-tag==0.1 ; python_version >= "3.9" and python_version < "4.0" +pyyaml==6.0.1 ; python_version >= "3.9" and python_version < "4.0" +rapidfuzz==3.5.2 ; python_version >= "3.9" and python_version < "4.0" +regex==2023.10.3 ; python_version >= "3.9" and python_version < "4.0" +requests-toolbelt==1.0.0 ; python_version >= "3.9" and python_version < "4.0" +requests==2.31.0 ; python_version >= "3.9" and python_version < "4.0" +rfc3986[idna2008]==1.5.0 ; python_version >= "3.9" and python_version < "4.0" +rich==13.6.0 ; python_version >= "3.9" and python_version < "4.0" +ruff==0.1.5 ; python_version >= "3.9" and python_version < "4.0" +secretstorage==3.3.3 ; python_version >= "3.9" and python_version < "4.0" and sys_platform == "linux" +setuptools==68.2.2 ; python_version >= "3.9" and python_version < "4.0" +shellingham==1.5.3 ; python_version >= "3.9" and python_version < "4.0" +single-source==0.3.0 ; python_version >= "3.9" and python_version < "4.0" +six==1.16.0 ; python_version >= "3.9" and python_version < "4.0" +smmap==5.0.1 ; python_version >= "3.9" and python_version < "4.0" +sniffio==1.3.0 ; python_version >= "3.9" and python_version < "4.0" +soupsieve==2.5 ; python_version >= "3.9" and python_version < "4.0" +sqlalchemy==2.0.23 ; python_version >= "3.9" and python_version < "4.0" +sqlalchemy[asyncio]==2.0.23 ; python_version >= "3.9" and python_version < "4.0" +starlette==0.27.0 ; python_version >= "3.9" and python_version < "4.0" +termcolor==2.3.0 ; python_version >= "3.9" and python_version < "4.0" +tomli-w==1.0.0 ; python_version >= "3.9" and python_version < "4.0" +tomli==2.0.1 ; python_version >= "3.9" and python_version < "4.0" +tomlkit==0.12.1 ; python_version >= "3.9" and python_version < "4.0" +toolz==0.12.0 ; python_version >= "3.9" and python_version < "4.0" +trove-classifiers==2023.10.18 ; python_version >= "3.9" and python_version < "4.0" +typer[all]==0.8.0 ; python_version >= "3.9" and python_version < "4.0" +types-passlib==1.7.7.13 ; python_version >= "3.9" and python_version < "4.0" +typing-extensions==4.8.0 ; python_version >= "3.9" and python_version < "4.0" +ujson==5.8.0 ; python_version >= "3.9" and python_version < "4.0" +urllib3==2.0.7 ; python_version >= "3.9" and python_version < "4.0" +uvicorn[standard]==0.24.0.post1 ; python_version >= "3.9" and python_version < "4.0" +uvloop==0.18.0 ; (sys_platform != "win32" and sys_platform != "cygwin") and platform_python_implementation != "PyPy" and python_version >= "3.9" and python_version < "4.0" +virtualenv==20.24.5 ; python_version >= "3.9" and python_version < "4.0" +watchdog==3.0.0 ; python_version >= "3.9" and python_version < "4.0" +watchfiles==0.21.0 ; python_version >= "3.9" and python_version < "4.0" +wcwidth==0.2.9 ; python_version >= "3.9" and python_version < "4.0" +websockets==12.0 ; python_version >= "3.9" and python_version < "4.0" +xattr==0.10.1 ; python_version >= "3.9" and python_version < "4.0" and sys_platform == "darwin" +zipp==3.17.0 ; python_version >= "3.9" and python_version < "3.12" diff --git a/tests/__init__.py b/tests/__init__.py index e69de29b..630d0319 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""This module contains all the tests for the project.""" diff --git a/tests/conftest.py b/tests/conftest.py index 98693bc3..a37d7f35 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,9 @@ """Fixtures and configuration for the test suite.""" +from __future__ import annotations + import asyncio import os -from typing import Any, AsyncGenerator, Generator +from typing import TYPE_CHECKING, Any import pytest import pytest_asyncio @@ -18,6 +20,10 @@ from app.main import app from app.managers.email import EmailManager +if TYPE_CHECKING: + from collections.abc import AsyncGenerator, Generator + + if os.getenv("GITHUB_ACTIONS"): DATABASE_URL = ( "postgresql+asyncpg://postgres:postgres" @@ -61,17 +67,15 @@ async def reset_db() -> None: # Override the database connection to use the test database async def get_database_override() -> AsyncGenerator[AsyncSession, Any]: """Return the database connection for testing.""" - async with async_test_session() as session: - async with session.begin(): - yield session + async with async_test_session() as session, session.begin(): + yield session @pytest_asyncio.fixture() async def test_db() -> AsyncGenerator[AsyncSession, Any]: """Fixture to yield a database connection for testing.""" - async with async_test_session() as session: - async with session.begin(): - yield session + async with async_test_session() as session, session.begin(): + yield session @pytest_asyncio.fixture() diff --git a/tests/integration/test_auth_routes.py b/tests/integration/test_auth_routes.py index c5abe3e6..747b3ca1 100644 --- a/tests/integration/test_auth_routes.py +++ b/tests/integration/test_auth_routes.py @@ -2,7 +2,7 @@ import logging # from copy import deepcopy -from typing import Dict, Union +from typing import Union import pytest from httpx import AsyncClient @@ -31,7 +31,7 @@ class TestAuthRoutes: register_path = "/register/" login_path = "/login/" - test_user: Dict[str, Union[str, bool]] = { + test_user: dict[str, Union[str, bool]] = { "email": "testuser@usertest.com", "first_name": "Test", "last_name": "User", diff --git a/tests/integration/test_protected_user_routes.py b/tests/integration/test_protected_user_routes.py index 9c41fa2a..4f292760 100644 --- a/tests/integration/test_protected_user_routes.py +++ b/tests/integration/test_protected_user_routes.py @@ -1,6 +1,7 @@ """Integration tests for user routes.""" import pytest +from fastapi import status from app.managers.user import pwd_context @@ -43,7 +44,7 @@ async def test_routes_no_auth(self, client, route): fn = getattr(client, method) response = await fn(route_name) - assert response.status_code == 403 + assert response.status_code == status.HTTP_403_FORBIDDEN assert response.json() == {"detail": "Not authenticated"} @pytest.mark.asyncio() @@ -59,5 +60,5 @@ async def test_routes_bad_auth(self, client, route): route_name, headers={"Authorization": "Bearer BADBEEF"} ) - assert response.status_code == 401 + assert response.status_code == status.HTTP_401_UNAUTHORIZED assert response.json() == {"detail": "That token is Invalid"} diff --git a/tests/integration/test_startup.py b/tests/integration/test_startup.py index b535d47c..32ee4cca 100644 --- a/tests/integration/test_startup.py +++ b/tests/integration/test_startup.py @@ -3,7 +3,11 @@ If the database is not configured properly, the application should delete all routes and enable the config_error route. """ +from collections.abc import AsyncGenerator +from typing import Any + import pytest +from fastapi import status from sqlalchemy.ext.asyncio import ( AsyncEngine, AsyncSession, @@ -36,18 +40,17 @@ class TestStartup: ) bad_db: AsyncSession = bad_session() - async def db_error(self): + async def db_error(self) -> AsyncGenerator[AsyncSession, None]: """Return a bad database connection.""" - async with self.bad_session() as session: - async with session.begin(): - yield session + async with self.bad_session() as session, session.begin(): + yield session @pytest.mark.asyncio() @pytest.mark.parametrize( "route", ["/", "/users/", "/login/", "/register/"], ) - async def test_startup_fails_no_db(self, client, capfd, route): + async def test_startup_fails_no_db(self, client, capfd, route) -> None: """Test fail with bad or missing database settings. We test a number of routes to ensure that the error handler is working @@ -56,7 +59,7 @@ async def test_startup_fails_no_db(self, client, capfd, route): response = await client.get(route) - assert response.status_code == 500 + assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR out, _ = capfd.readouterr() assert "ERROR" in out assert "Have you set up your .env file??" in out diff --git a/tests/integration/test_user_routes.py b/tests/integration/test_user_routes.py index ba88204c..8041d710 100644 --- a/tests/integration/test_user_routes.py +++ b/tests/integration/test_user_routes.py @@ -1,5 +1,6 @@ """Define tests for the 'User' routes of the application.""" +from typing import Any import pytest from faker import Faker from httpx import AsyncClient @@ -24,7 +25,7 @@ class TestUserRoutes: mock_request_path = "app.resources.user.Request.state" - def get_test_user(self, hashed=True): + def get_test_user(self, hashed=True) -> dict[str, Any]: """Return one or more test users.""" fake = Faker() diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py index e69de29b..66974bd3 100644 --- a/tests/unit/__init__.py +++ b/tests/unit/__init__.py @@ -0,0 +1 @@ +"""This contains all the UNIT tests for the app.""" diff --git a/tests/unit/test_auth_manager.py b/tests/unit/test_auth_manager.py index d20f1226..5d456576 100644 --- a/tests/unit/test_auth_manager.py +++ b/tests/unit/test_auth_manager.py @@ -1,9 +1,9 @@ """Test the AuthManager class.""" -from datetime import datetime +from datetime import datetime, timezone import jwt import pytest -from fastapi import BackgroundTasks, HTTPException +from fastapi import BackgroundTasks, HTTPException, status from app.config.settings import get_settings from app.managers.auth import AuthManager, ResponseMessages @@ -17,7 +17,7 @@ class TestAuthManager: """Test the AuthManager class methods.""" - test_user = { + test_user = { # noqa: RUF012 "email": "testuser@usertest.com", "password": "test12345!", "first_name": "Test", @@ -27,9 +27,9 @@ class TestAuthManager: # ------------------------------------------------------------------------ # # test encoding tokens # # ------------------------------------------------------------------------ # - def test_encode_token(self): + def test_encode_token(self) -> None: """Ensure we can correctly encode a token.""" - time_now = datetime.utcnow() + time_now = datetime.now(tz=timezone.utc) token = AuthManager.encode_token(User(id=1)) payload = jwt.decode( @@ -37,28 +37,21 @@ def test_encode_token(self): ) assert payload["sub"] == 1 assert isinstance(payload["exp"], int) - # todo: better comparison to ensure the exp is in the future but close - # to the expected expiry time taking into account the setting for token - # expiry + # TODO(seapagan): better comparison to ensure the exp is in the future + # but close to the expected expiry time taking into account the setting + # for token expiry assert payload["exp"] > time_now.timestamp() - def test_encode_token_no_data(self): - """Test the encode_token method with no data.""" - with pytest.raises( - HTTPException, match=ResponseMessages.CANT_GENERATE_JWT - ): - AuthManager.encode_token({}) - - def test_encode_token_bad_data(self): + def test_encode_token_bad_data(self) -> None: """Test the encode_token method with bad data.""" with pytest.raises( HTTPException, match=ResponseMessages.CANT_GENERATE_JWT ): - AuthManager.encode_token("bad_data") + AuthManager.encode_token("bad_data") # type: ignore - def test_encode_refresh_token(self): + def test_encode_refresh_token(self) -> None: """Ensure we can correctly encode a refresh token.""" - time_now = datetime.utcnow() + time_now = datetime.now(tz=timezone.utc) refresh_token = AuthManager.encode_refresh_token(User(id=1)) payload = jwt.decode( @@ -67,28 +60,21 @@ def test_encode_refresh_token(self): assert payload["sub"] == 1 assert isinstance(payload["exp"], int) - # todo: better comparison to ensure the exp is in the future but close - # to the expected expiry time taking into account the expiry for these - # is 30 days + # TODO(seapagan): better comparison to ensure the exp is in the future + # but close to the expected expiry time taking into account the expiry + # for these is 30 days assert payload["exp"] > time_now.timestamp() - def test_encode_refresh_token_no_data(self): - """Test the encode_refresh_token method with no data.""" - with pytest.raises( - HTTPException, match=ResponseMessages.CANT_GENERATE_REFRESH - ): - AuthManager.encode_refresh_token({}) - - def test_encode_refresh_token_bad_data(self): + def test_encode_refresh_token_bad_data(self) -> None: """Test the encode_refresh_token method with bad data.""" with pytest.raises( HTTPException, match=ResponseMessages.CANT_GENERATE_REFRESH ): - AuthManager.encode_refresh_token("bad_data") + AuthManager.encode_refresh_token("bad_data") # type: ignore - def test_encode_verify_token(self): + def test_encode_verify_token(self) -> None: """Ensure we can correctly encode a verify token.""" - time_now = datetime.utcnow() + time_now = datetime.now(tz=timezone.utc) verify_token = AuthManager.encode_verify_token(User(id=1)) payload = jwt.decode( @@ -98,30 +84,23 @@ def test_encode_verify_token(self): assert payload["sub"] == 1 assert payload["typ"] == "verify" assert isinstance(payload["exp"], int) - # todo: better comparison to ensure the exp is in the future but close - # to the expected expiry time taking into account the expiry for these - # is 10 minutes + # TODO(seapagan): better comparison to ensure the exp is in the future + # but closeto the expected expiry time taking into account the expiry + # for these is 10 minutes assert payload["exp"] > time_now.timestamp() - def test_encode_verify_token_no_data(self): - """Test the encode_verify_token method with no data.""" - with pytest.raises( - HTTPException, match=ResponseMessages.CANT_GENERATE_VERIFY - ): - AuthManager.encode_verify_token({}) - - def test_encode_verify_token_bad_data(self): + def test_encode_verify_token_bad_data(self) -> None: """Test the encode_verify_token method with bad data.""" with pytest.raises( HTTPException, match=ResponseMessages.CANT_GENERATE_VERIFY ): - AuthManager.encode_verify_token("bad_data") + AuthManager.encode_verify_token("bad_data") # type: ignore # ------------------------------------------------------------------------ # # test refresh token # # ------------------------------------------------------------------------ # @pytest.mark.asyncio() - async def test_refresh(self, test_db): + async def test_refresh(self, test_db) -> None: """Test the refresh method returns a new token.""" _, refresh = await UserManager.register(self.test_user, test_db) new_token = await AuthManager.refresh( @@ -131,7 +110,7 @@ async def test_refresh(self, test_db): assert isinstance(new_token, str) @pytest.mark.asyncio() - async def test_refresh_bad_token(self, test_db): + async def test_refresh_bad_token(self, test_db) -> None: """Test the refresh method with a bad refresh token.""" await UserManager.register(self.test_user, test_db) new_token = None @@ -139,41 +118,45 @@ async def test_refresh_bad_token(self, test_db): new_token = await AuthManager.refresh( TokenRefreshRequest(refresh="horrible_bad_token"), test_db ) - assert exc_info.value.status_code == 401 + assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED assert exc_info.value.detail == ResponseMessages.INVALID_TOKEN assert new_token is None @pytest.mark.asyncio() - async def test_refresh_expired_token(self, test_db, mocker): + async def test_refresh_expired_token(self, test_db, mocker) -> None: """Test the refresh method with an expired refresh token.""" expired_refresh = get_token( - sub=1, exp=datetime.utcnow().timestamp() - 1, typ="refresh" + sub=1, + exp=datetime.now(tz=timezone.utc).timestamp() - 1, + typ="refresh", ) with pytest.raises(HTTPException) as exc_info: await AuthManager.refresh( TokenRefreshRequest(refresh=expired_refresh), test_db ) - assert exc_info.value.status_code == 401 + assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED assert exc_info.value.detail == ResponseMessages.EXPIRED_TOKEN @pytest.mark.asyncio() - async def test_refresh_wrong_token(self, test_db, mocker): + async def test_refresh_wrong_token(self, test_db, mocker) -> None: """Test the refresh method with the wrong token 'typ'.""" await UserManager.register(self.test_user, test_db) wrong_token = get_token( - sub=1, exp=datetime.utcnow().timestamp() + 10000, typ="verify" + sub=1, + exp=datetime.now(tz=timezone.utc).timestamp() + 10000, + typ="verify", ) with pytest.raises(HTTPException) as exc_info: await AuthManager.refresh( TokenRefreshRequest(refresh=wrong_token), test_db ) - assert exc_info.value.status_code == 401 + assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED assert exc_info.value.detail == ResponseMessages.INVALID_TOKEN @pytest.mark.asyncio() - async def test_refresh_empty_refresh_token(self, test_db): + async def test_refresh_empty_refresh_token(self, test_db) -> None: """Test the refresh method with no refresh token.""" await UserManager.register(self.test_user, test_db) new_token = None @@ -181,12 +164,12 @@ async def test_refresh_empty_refresh_token(self, test_db): new_token = await AuthManager.refresh( TokenRefreshRequest(refresh=""), test_db ) - assert exc_info.value.status_code == 401 + assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED assert exc_info.value.detail == ResponseMessages.INVALID_TOKEN assert new_token is None @pytest.mark.asyncio() - async def test_refresh_no_user(self, test_db): + async def test_refresh_no_user(self, test_db) -> None: """Test the refresh method when user does not exist.""" no_user_refresh = AuthManager.encode_refresh_token(User(id=999)) new_token = None @@ -194,12 +177,12 @@ async def test_refresh_no_user(self, test_db): new_token = await AuthManager.refresh( TokenRefreshRequest(refresh=no_user_refresh), test_db ) - assert exc_info.value.status_code == 404 + assert exc_info.value.status_code == status.HTTP_404_NOT_FOUND assert exc_info.value.detail == ResponseMessages.USER_NOT_FOUND assert new_token is None @pytest.mark.asyncio() - async def test_refresh_banned_user(self, test_db): + async def test_refresh_banned_user(self, test_db) -> None: """Test the refresh method with a banned user.""" await UserManager.register(self.test_user, test_db) await UserManager.set_ban_status(1, True, 666, test_db) @@ -209,7 +192,7 @@ async def test_refresh_banned_user(self, test_db): new_token = await AuthManager.refresh( TokenRefreshRequest(refresh=banned_user_refresh), test_db ) - assert exc_info.value.status_code == 401 + assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED assert exc_info.value.detail == ResponseMessages.INVALID_TOKEN assert new_token is None @@ -217,85 +200,93 @@ async def test_refresh_banned_user(self, test_db): # test verify token # # ------------------------------------------------------------------------ # @pytest.mark.asyncio() - async def test_verify(self, test_db): + async def test_verify(self, test_db) -> None: """Test the verify method.""" background_tasks = BackgroundTasks() await UserManager.register(self.test_user, test_db, background_tasks) verify_token = AuthManager.encode_verify_token(User(id=1)) with pytest.raises(HTTPException) as exc_info: await AuthManager.verify(verify_token, test_db) - assert exc_info.value.status_code == 200 + assert exc_info.value.status_code == status.HTTP_200_OK assert exc_info.value.detail == ResponseMessages.VERIFICATION_SUCCESS user_data = await UserManager.get_user_by_id(1, test_db) assert user_data.verified is True @pytest.mark.asyncio() - async def test_verify_missing_user(self, test_db): + async def test_verify_missing_user(self, test_db) -> None: """Test the verify method with a missing user.""" verify_token = AuthManager.encode_verify_token(User(id=1)) with pytest.raises(HTTPException) as exc_info: await AuthManager.verify(verify_token, test_db) - assert exc_info.value.status_code == 404 + assert exc_info.value.status_code == status.HTTP_404_NOT_FOUND assert exc_info.value.detail == ResponseMessages.USER_NOT_FOUND @pytest.mark.asyncio() - async def test_verify_wrong_token(self, test_db): + async def test_verify_wrong_token(self, test_db) -> None: """Test the verify method with a bad token type.""" background_tasks = BackgroundTasks() await UserManager.register(self.test_user, test_db, background_tasks) wrong_token = get_token( - sub=1, exp=datetime.utcnow().timestamp() + 10000, typ="refresh" + sub=1, + exp=datetime.now(tz=timezone.utc).timestamp() + 10000, + typ="refresh", ) with pytest.raises(HTTPException) as exc_info: await AuthManager.verify(wrong_token, test_db) - assert exc_info.value.status_code == 401 + assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED assert exc_info.value.detail == ResponseMessages.INVALID_TOKEN @pytest.mark.asyncio() - async def test_verify_banned_user(self, test_db): + async def test_verify_banned_user(self, test_db) -> None: """Test the verify method with a banned user.""" background_tasks = BackgroundTasks() await UserManager.register(self.test_user, test_db, background_tasks) await UserManager.set_ban_status(1, True, 666, test_db) verify_token = get_token( - sub=1, exp=datetime.utcnow().timestamp() + 10000, typ="verify" + sub=1, + exp=datetime.now(tz=timezone.utc).timestamp() + 10000, + typ="verify", ) with pytest.raises(HTTPException) as exc_info: await AuthManager.verify(verify_token, test_db) - assert exc_info.value.status_code == 401 + assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED assert exc_info.value.detail == ResponseMessages.INVALID_TOKEN @pytest.mark.asyncio() - async def test_verify_user_already_verified(self, test_db): + async def test_verify_user_already_verified(self, test_db) -> None: """Test the verify method with a banned user.""" await UserManager.register(self.test_user, test_db) verify_token = get_token( - sub=1, exp=datetime.utcnow().timestamp() + 10000, typ="verify" + sub=1, + exp=datetime.now(tz=timezone.utc).timestamp() + 10000, + typ="verify", ) with pytest.raises(HTTPException) as exc_info: await AuthManager.verify(verify_token, test_db) - assert exc_info.value.status_code == 401 + assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED assert exc_info.value.detail == ResponseMessages.INVALID_TOKEN @pytest.mark.asyncio() - async def test_verify_user_invalid_token(self, test_db): + async def test_verify_user_invalid_token(self, test_db) -> None: """Test the verify method with an invalid token.""" background_tasks = BackgroundTasks() await UserManager.register(self.test_user, test_db, background_tasks) with pytest.raises(HTTPException) as exc_info: await AuthManager.verify("very_bad_token", test_db) - assert exc_info.value.status_code == 401 + assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED assert exc_info.value.detail == ResponseMessages.INVALID_TOKEN @pytest.mark.asyncio() - async def test_verify_user_expired_token(self, test_db): + async def test_verify_user_expired_token(self, test_db) -> None: """Test the verify method with an expired token.""" expired_verify = get_token( - sub=1, exp=datetime.utcnow().timestamp() - 1, typ="verify" + sub=1, + exp=datetime.now(tz=timezone.utc).timestamp() - 1, + typ="verify", ) with pytest.raises(HTTPException) as exc_info: await AuthManager.verify(expired_verify, test_db) - assert exc_info.value.status_code == 401 + assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED assert exc_info.value.detail == ResponseMessages.EXPIRED_TOKEN diff --git a/tests/unit/test_auth_manager_bearer_class.py b/tests/unit/test_auth_manager_bearer_class.py index e18d9056..4d441ac8 100644 --- a/tests/unit/test_auth_manager_bearer_class.py +++ b/tests/unit/test_auth_manager_bearer_class.py @@ -1,8 +1,8 @@ """Test the CustomHTTPBearer class in the auth_manager module.""" -from datetime import datetime +from datetime import datetime, timezone import pytest -from fastapi import BackgroundTasks, HTTPException +from fastapi import BackgroundTasks, HTTPException, status from app.managers.auth import CustomHTTPBearer, ResponseMessages from app.managers.user import UserManager @@ -17,14 +17,14 @@ class TestCustomHTTPBearer: mock_request_path = "app.managers.auth.Request" - test_user = { + test_user = { # noqa: RUF012 "email": "testuser@usertest.com", "password": "test12345!", "first_name": "Test", "last_name": "User", } - async def test_custom_bearer_class(self, test_db, mocker): + async def test_custom_bearer_class(self, test_db, mocker) -> None: """Test with valid user and token.""" token, _ = await UserManager.register(self.test_user, test_db) mock_req = mocker.patch(self.mock_request_path) @@ -37,7 +37,9 @@ async def test_custom_bearer_class(self, test_db, mocker): assert result.email == self.test_user["email"] assert result.id == 1 - async def test_custom_bearer_class_invalid_token(self, test_db, mocker): + async def test_custom_bearer_class_invalid_token( + self, test_db, mocker + ) -> None: """Test with an invalid token.""" mock_req = mocker.patch(self.mock_request_path) mock_req.headers = {"Authorization": "Bearer badtoken"} @@ -46,10 +48,12 @@ async def test_custom_bearer_class_invalid_token(self, test_db, mocker): with pytest.raises(HTTPException) as exc: await bearer(request=mock_req, db=test_db) - assert exc.value.status_code == 401 + assert exc.value.status_code == status.HTTP_401_UNAUTHORIZED assert exc.value.detail == ResponseMessages.INVALID_TOKEN - async def test_custom_bearer_class_no_auth_header(self, test_db, mocker): + async def test_custom_bearer_class_no_auth_header( + self, test_db, mocker + ) -> None: """Test with an empty token.""" mock_req = mocker.patch(self.mock_request_path) mock_req.headers = {} @@ -58,10 +62,12 @@ async def test_custom_bearer_class_no_auth_header(self, test_db, mocker): with pytest.raises(HTTPException) as exc: await bearer(request=mock_req, db=test_db) - assert exc.value.status_code == 403 + assert exc.value.status_code == status.HTTP_403_FORBIDDEN assert exc.value.detail == "Not authenticated" - async def test_custom_bearer_class_banned_user(self, test_db, mocker): + async def test_custom_bearer_class_banned_user( + self, test_db, mocker + ) -> None: """Test with a banned user.""" token, _ = await UserManager.register(self.test_user, test_db) await UserManager.set_ban_status(1, True, 666, test_db) @@ -73,10 +79,12 @@ async def test_custom_bearer_class_banned_user(self, test_db, mocker): with pytest.raises(HTTPException) as exc: await bearer(request=mock_req, db=test_db) - assert exc.value.status_code == 401 + assert exc.value.status_code == status.HTTP_401_UNAUTHORIZED assert exc.value.detail == ResponseMessages.INVALID_TOKEN - async def test_custom_bearer_class_unverified_user(self, test_db, mocker): + async def test_custom_bearer_class_unverified_user( + self, test_db, mocker + ) -> None: """Test with a banned user.""" background_tasks = BackgroundTasks() token, _ = await UserManager.register( @@ -91,13 +99,15 @@ async def test_custom_bearer_class_unverified_user(self, test_db, mocker): with pytest.raises(HTTPException) as exc: await bearer(request=mock_req, db=test_db) - assert exc.value.status_code == 401 + assert exc.value.status_code == status.HTTP_401_UNAUTHORIZED assert exc.value.detail == ResponseMessages.INVALID_TOKEN - async def test_custom_bearer_expired_token(self, test_db, mocker): + async def test_custom_bearer_expired_token(self, test_db, mocker) -> None: """Test with an expired token.""" expired_token = get_token( - sub=1, exp=datetime.utcnow().timestamp() - 1, typ="verify" + sub=1, + exp=datetime.now(tz=timezone.utc).timestamp() - 1, + typ="verify", ) mock_req = mocker.patch(self.mock_request_path) @@ -107,5 +117,5 @@ async def test_custom_bearer_expired_token(self, test_db, mocker): with pytest.raises(HTTPException) as exc: await bearer(request=mock_req, db=test_db) - assert exc.value.status_code == 401 + assert exc.value.status_code == status.HTTP_401_UNAUTHORIZED assert exc.value.detail == ResponseMessages.EXPIRED_TOKEN diff --git a/tests/unit/test_auth_manager_helpers.py b/tests/unit/test_auth_manager_helpers.py index d553467e..4c4eff82 100644 --- a/tests/unit/test_auth_manager_helpers.py +++ b/tests/unit/test_auth_manager_helpers.py @@ -1,13 +1,15 @@ """Test the AuthManager class.""" import pytest from fastapi import HTTPException +from pytest_mock import MockerFixture +from pytest_mock.plugin import MockType from app.managers.auth import can_edit_user, is_admin, is_banned from app.models.enums import RoleType @pytest.fixture() -def mock_req(mocker): +def mock_req(mocker: MockerFixture) -> MockType: """Fixture to return a mocked Request object.""" request_mock_path = "app.managers.auth.Request" return mocker.patch(request_mock_path) @@ -18,49 +20,49 @@ class TestAuthManagerHelpers: """Test the AuthManager class.""" # ----------------- test the dependency_injector helpers ----------------- # - def test_is_admin_allow_admin(self, mock_req): + def test_is_admin_allow_admin(self, mock_req) -> None: """Test the is_admin method returns no exception for admin users.""" mock_req.state.user.role = RoleType.admin - assert is_admin(mock_req) is None + is_admin(mock_req) - def test_is_admin_block_non_admin(self, mock_req): + def test_is_admin_block_non_admin(self, mock_req) -> None: """Test the is_admin method returns an exception for non-admin users.""" mock_req.state.user.role = RoleType.user with pytest.raises(HTTPException, match="Forbidden"): is_admin(mock_req) - def test_is_banned_blocks_banned_user(self, mock_req): + def test_is_banned_blocks_banned_user(self, mock_req) -> None: """Test the is_banned method blocks banned users.""" mock_req.state.user.banned = True with pytest.raises(HTTPException, match="Banned!"): is_banned(mock_req) - def test_is_banned_ignores_valid_user(self, mock_req): + def test_is_banned_ignores_valid_user(self, mock_req) -> None: """Test the is_banned method allows non-banned users through.""" mock_req.state.user.banned = False - assert is_banned(mock_req) is None + is_banned(mock_req) - def test_can_edit_user_allow_admin(self, mock_req): + def test_can_edit_user_allow_admin(self, mock_req) -> None: """Test the can_edit_user method returns no exception for admin.""" mock_req.state.user.role = RoleType.admin mock_req.state.user.id = 2 mock_req.path_params = {"user_id": 1} - assert can_edit_user(mock_req) is None + can_edit_user(mock_req) - def test_can_edit_user_allow_owner(self, mock_req): + def test_can_edit_user_allow_owner(self, mock_req) -> None: """Test the can_edit_user method returns no exception for the owner.""" mock_req.state.user.role = RoleType.admin mock_req.state.user.id = 1 mock_req.path_params = {"user_id": 1} - assert can_edit_user(mock_req) is None + can_edit_user(mock_req) - def test_can_edit_user_block_non_admin(self, mock_req): + def test_can_edit_user_block_non_admin(self, mock_req) -> None: """Test the can_edit_user method returns an exception for non-admin.""" mock_req.state.user.role = RoleType.user mock_req.state.user.id = 2 diff --git a/tests/unit/test_config_helpers.py b/tests/unit/test_config_helpers.py index af69bb03..94514dce 100644 --- a/tests/unit/test_config_helpers.py +++ b/tests/unit/test_config_helpers.py @@ -1,6 +1,5 @@ """Test config/helpers.py.""" from pathlib import Path -from typing import List import pytest @@ -20,7 +19,7 @@ class TestConfigHelpers: mock_load_tomli = "app.config.helpers.tomli.load" - def test_get_toml_path(self, mocker): + def test_get_toml_path(self, mocker) -> None: """Test we get the correct toml path.""" mocker.patch( "app.config.helpers.os.path.realpath", @@ -28,7 +27,7 @@ def test_get_toml_path(self, mocker): ) assert get_toml_path() == Path("/test/path/pyproject.toml") - def test_get_config_path(self, mocker): + def test_get_config_path(self, mocker) -> None: """Test we get the correct config path.""" mocker.patch( "app.config.helpers.os.path.realpath", @@ -36,7 +35,7 @@ def test_get_config_path(self, mocker): ) assert get_config_path() == Path("/test/path/app/config/metadata.py") - def test_get_api_version(self, mocker): + def test_get_api_version(self, mocker) -> None: """Test we get the API version.""" mocker.patch( self.mock_load_tomli, @@ -44,7 +43,7 @@ def test_get_api_version(self, mocker): ) assert get_api_version() == "1.2.3" - def test_get_api_version_missing_toml(self, mocker, capfd): + def test_get_api_version_missing_toml(self, mocker, capfd) -> None: """Test we exit when the toml file is missing.""" mocker.patch(self.mock_load_tomli, side_effect=FileNotFoundError) with pytest.raises(SystemExit, match="2"): @@ -52,7 +51,7 @@ def test_get_api_version_missing_toml(self, mocker, capfd): out, _ = capfd.readouterr() assert "Cannot read the pyproject.toml file" in out - def test_get_api_version_missing_version(self, mocker, capfd): + def test_get_api_version_missing_version(self, mocker, capfd) -> None: """Test we exit when the version is missing.""" mocker.patch( self.mock_load_tomli, @@ -63,7 +62,7 @@ def test_get_api_version_missing_version(self, mocker, capfd): out, _ = capfd.readouterr() assert "Cannot find the API version in the pyproject.toml file" in out - def test_get_api_version_missing_key(self, mocker, capfd): + def test_get_api_version_missing_key(self, mocker, capfd) -> None: """Test we exit when the key is missing.""" mocker.patch( self.mock_load_tomli, @@ -74,7 +73,7 @@ def test_get_api_version_missing_key(self, mocker, capfd): out, _ = capfd.readouterr() assert "Cannot find the API version in the pyproject.toml file" in out - def test_get_api_details(self, mocker, capfd): + def test_get_api_details(self, mocker, capfd) -> None: """Test we get the API details.""" mocker.patch( self.mock_load_tomli, @@ -93,7 +92,7 @@ def test_get_api_details(self, mocker, capfd): assert isinstance(details, tuple) assert details == ("test_name", "test_desc", ["test_authors"]) - def test_get_api_details_authors_is_list(self, mocker): + def test_get_api_details_authors_is_list(self, mocker) -> None: """Authors should be converted to a list if not already.""" mocker.patch( self.mock_load_tomli, @@ -118,7 +117,9 @@ def test_get_api_details_authors_is_list(self, mocker): {"name": "test_name", "description": "test_desc"}, ], ) - def test_get_api_details_missing_key(self, mocker, capfd, missing_keys): + def test_get_api_details_missing_key( + self, mocker, capfd, missing_keys + ) -> None: """We should return an Error if any details are missing.""" mocker.patch( self.mock_load_tomli, @@ -133,7 +134,7 @@ def test_get_api_details_missing_key(self, mocker, capfd, missing_keys): out, _ = capfd.readouterr() assert "Missing name/description or authors" in out - def test_get_api_details_missing_toml(self, mocker, capfd): + def test_get_api_details_missing_toml(self, mocker, capfd) -> None: """Test we exit when the toml file is missing.""" mocker.patch(self.mock_load_tomli, side_effect=FileNotFoundError) with pytest.raises(SystemExit, match="2"): @@ -141,9 +142,9 @@ def test_get_api_details_missing_toml(self, mocker, capfd): out, _ = capfd.readouterr() assert "Cannot read the pyproject.toml file" in out - def test_licences_structure(self): + def test_licences_structure(self) -> None: """Test the licences structure.""" - assert isinstance(LICENCES, List) + assert isinstance(LICENCES, list) for licence in LICENCES: assert isinstance(licence, dict) @@ -151,7 +152,7 @@ def test_licences_structure(self): assert all(isinstance(value, str) for value in licence.values()) - def test_template_structure(self): + def test_template_structure(self) -> None: """Test the template structure. Just a basic test to ensure the template is a string and not empty. diff --git a/tests/unit/test_database_db.py b/tests/unit/test_database_db.py deleted file mode 100644 index 71193d3f..00000000 --- a/tests/unit/test_database_db.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Test the database module.""" -# from typing import AsyncGenerator - -# import pytest -# import sqlalchemy - -# from app.database.db import get_database - - -# @pytest.mark.skip(reason="Functionality has changed") -# @pytest.mark.unit() -# class TestDatabaseDB: -# """Test the database module.""" - -# @pytest.mark.asyncio() -# async def test_get_database(self, mocker): -# """Test the get_database function. - -# This is an Async Generator, so we need to test it as such. We also -# make sure that the database is connected when we get it, and -# disconnected when we're done with it. - -# We need to mock the Database object, otherwise it trys to connect to -# the configured production database (usually PostgreSQL which is not -# set up for GH Actions) -# """ -# mocker.patch( -# "app.database.db.database", -# databases.Database("sqlite:///./test.db"), -# ) -# db_generator = get_database() -# assert isinstance(db_generator, AsyncGenerator) - -# db_instance = await db_generator.__anext__() -# assert db_instance.is_connected - -# with pytest.raises(StopAsyncIteration): -# await db_generator.__anext__() -# assert not db_instance.is_connected - -# def test_metadata(self): -# """Test the metadata object.""" -# assert isinstance(metadata, sqlalchemy.MetaData) - -# def test_database(self): -# """Test the database object.""" -# assert isinstance(database, databases.Database) diff --git a/tests/unit/test_email_manager.py b/tests/unit/test_email_manager.py index 168a2fb4..5ad1a9f5 100644 --- a/tests/unit/test_email_manager.py +++ b/tests/unit/test_email_manager.py @@ -1,17 +1,26 @@ """Test the EmailManager class.""" import json +from typing import TypedDict import pytest +from fastapi import status from app.config.settings import get_settings from app.schemas.email import EmailSchema, EmailTemplateSchema +class EmailData(TypedDict): + """Type definition for email data.""" + + subject: str + recipients: list[str] + + @pytest.mark.unit() class TestEmailManager: """Test the EmailManager class.""" - email_data = { + email_data: EmailData = { # noqa: RUF012 "subject": "Test Subject", "recipients": ["test_recipient@testing.com"], } @@ -24,22 +33,22 @@ class TestEmailManager: background_tasks_mock_path = "app.managers.email.BackgroundTasks" - def test_init(self, email_manager): + def test_init(self, email_manager) -> None: """Test the EmailManager constructor.""" - assert email_manager.conf.MAIL_USERNAME == get_settings().mail_username - assert email_manager.conf.MAIL_PASSWORD == get_settings().mail_password - assert email_manager.conf.MAIL_FROM == get_settings().mail_from + assert get_settings().mail_username == email_manager.conf.MAIL_USERNAME + assert get_settings().mail_password == email_manager.conf.MAIL_PASSWORD + assert get_settings().mail_from == email_manager.conf.MAIL_FROM assert email_manager.conf.SUPPRESS_SEND == 1 @pytest.mark.asyncio() - async def test_simple_send(self, email_manager): + async def test_simple_send(self, email_manager) -> None: """Test the simple_send method.""" response = await email_manager.simple_send(email_data=self.email_schema) - assert response.status_code == 200 + assert response.status_code == status.HTTP_200_OK assert json.loads(response.body)["message"] == "email has been sent" - def test_background_send(self, email_manager, mocker): + def test_background_send(self, email_manager, mocker) -> None: """Test the background_send method.""" mock_backgroundtasks = mocker.patch(self.background_tasks_mock_path) response = email_manager.background_send( @@ -47,10 +56,10 @@ def test_background_send(self, email_manager, mocker): ) assert response is None mock_backgroundtasks.add_task.assert_called_once() - # TODO: investigate how to ensure the task is called with the correct - # args mock_backgroundtasks.add_task.assert_called_once_with(...) + # TODO(seapgan): investigate how to ensure the task is called with the + # correct args - def test_template_send(self, email_manager, mocker): + def test_template_send(self, email_manager, mocker) -> None: """Test the template_send method.""" mock_backgroundtasks = mocker.patch(self.background_tasks_mock_path) response = email_manager.template_send( @@ -58,4 +67,4 @@ def test_template_send(self, email_manager, mocker): ) assert response is None mock_backgroundtasks.add_task.assert_called_once() - # TODO: again see if we can get more granular with the assert + # TODO(seapgan): again see if we can get more granular with the assert diff --git a/tests/unit/test_user_manager.py b/tests/unit/test_user_manager.py index 99183a52..56ad8ea8 100644 --- a/tests/unit/test_user_manager.py +++ b/tests/unit/test_user_manager.py @@ -1,5 +1,4 @@ """Test the UserManager class.""" -from typing import List import pytest from fastapi import BackgroundTasks, HTTPException @@ -16,7 +15,7 @@ class TestUserManager: # pylint: disable=too-many-public-methods """Test the UserManager class.""" - test_user = { + test_user = { # noqa: RUF012 "email": "testuser@usertest.com", "password": "test12345!", "first_name": "Test", @@ -24,7 +23,7 @@ class TestUserManager: # pylint: disable=too-many-public-methods } # ------------------------- Test register method ------------------------- # - async def test_create_user(self, test_db): + async def test_create_user(self, test_db) -> None: """Test creating a user.""" await UserManager.register(self.test_user, test_db) new_user = await test_db.get(User, 1) @@ -36,7 +35,7 @@ async def test_create_user(self, test_db): assert pwd_context.verify(self.test_user["password"], new_user.password) - async def test_create_user_with_bad_email(self, test_db): + async def test_create_user_with_bad_email(self, test_db) -> None: """Ensure you cant create a user with a bad email.""" with pytest.raises(HTTPException, match=ErrorMessages.EMAIL_INVALID): await UserManager.register( @@ -78,24 +77,26 @@ async def test_create_user_with_bad_email(self, test_db): }, ], ) - async def test_create_user_missing_values(self, test_db, create_data): + async def test_create_user_missing_values( + self, test_db, create_data + ) -> None: """Test creating a user with missing values.""" with pytest.raises(HTTPException, match=ErrorMessages.EMPTY_FIELDS): await UserManager.register(create_data, test_db) - async def test_create_duplicate_user(self, test_db): + async def test_create_duplicate_user(self, test_db) -> None: """Test creating a duplicate user.""" await UserManager.register(self.test_user, test_db) with pytest.raises(IntegrityError): await UserManager.register(self.test_user, test_db) - async def test_create_user_returns_tokens(self, test_db): + async def test_create_user_returns_tokens(self, test_db) -> None: """Test creating a user.""" result = await UserManager.register(self.test_user, test_db) assert isinstance(result, tuple) - assert len(result) == 2 + assert len(result) == 2 # noqa: PLR2004 token, refresh = result assert isinstance(token, str) @@ -103,7 +104,7 @@ async def test_create_user_returns_tokens(self, test_db): async def test_register_user_verified_when_no_background_tasks_specified( self, test_db - ): + ) -> None: """Test user is automatically verified when no 'background_tasks'.""" await UserManager.register(self.test_user, test_db) user = await test_db.get(User, 1) @@ -113,7 +114,7 @@ async def test_register_user_verified_when_no_background_tasks_specified( async def test_register_user_not_verified_when_background_tasks_specified( self, test_db, - ): + ) -> None: """Test user is not verified when 'background_tasks' IS provided.""" background_tasks = BackgroundTasks() await UserManager.register( @@ -124,32 +125,32 @@ async def test_register_user_not_verified_when_background_tasks_specified( assert user.verified is False # --------------------------- test login method -------------------------- # - async def test_login_user(self, test_db): + async def test_login_user(self, test_db) -> None: """Test logging in a user.""" await UserManager.register(self.test_user, test_db) result = await UserManager.login(self.test_user, test_db) assert isinstance(result, tuple) - assert len(result) == 2 + assert len(result) == 2 # noqa: PLR2004 token, refresh = result assert isinstance(token, str) assert isinstance(refresh, str) - async def test_login_user_not_found(self, test_db): + async def test_login_user_not_found(self, test_db) -> None: """Test logging in a user that doesn't exist.""" with pytest.raises(HTTPException, match=ErrorMessages.AUTH_INVALID): await UserManager.login(self.test_user, test_db) - async def test_login_user_wrong_password(self, test_db): + async def test_login_user_wrong_password(self, test_db) -> None: """Test logging in a user with the wrong password.""" await UserManager.register(self.test_user, test_db) bad_user = self.test_user.copy() - bad_user["password"] = "wrongpassword" # nosec + bad_user["password"] = "wrongpassword" # noqa: S105 with pytest.raises(HTTPException, match=ErrorMessages.AUTH_INVALID): await UserManager.login(bad_user, test_db) - async def test_login_user_not_verified(self, test_db): + async def test_login_user_not_verified(self, test_db) -> None: """Test logging in a user that isn't verified. We can do this easily by creating a user and specify a @@ -162,7 +163,7 @@ async def test_login_user_not_verified(self, test_db): with pytest.raises(HTTPException, match=ErrorMessages.NOT_VERIFIED): await UserManager.login(self.test_user, test_db) - async def test_login_user_banned(self, test_db): + async def test_login_user_banned(self, test_db) -> None: """Test logging in a user that is banned.""" await UserManager.register(self.test_user, test_db) await UserManager.set_ban_status(1, True, 666, test_db) @@ -170,7 +171,7 @@ async def test_login_user_banned(self, test_db): await UserManager.login(self.test_user, test_db) # -------------------------- test delete method -------------------------- # - async def test_delete_user(self, test_db): + async def test_delete_user(self, test_db) -> None: """Test deleting a user.""" await UserManager.register(self.test_user, test_db) await UserManager.delete_user(1, test_db) @@ -178,13 +179,13 @@ async def test_delete_user(self, test_db): user = await test_db.get(User, 1) assert user is None - async def test_delete_user_not_found(self, test_db): + async def test_delete_user_not_found(self, test_db) -> None: """Test deleting a user that doesn't exist.""" with pytest.raises(HTTPException, match=ErrorMessages.USER_INVALID): await UserManager.delete_user(1, test_db) # -------------------------- test update method -------------------------- # - async def test_update_user(self, test_db): + async def test_update_user(self, test_db) -> None: """Test updating a user.""" await UserManager.register(self.test_user, test_db) edited_user = self.test_user.copy() @@ -197,7 +198,7 @@ async def test_update_user(self, test_db): assert edited_user.first_name == "Edited" - async def test_update_user_not_found(self, test_db): + async def test_update_user_not_found(self, test_db) -> None: """Test updating a user that doesn't exist.""" with pytest.raises(HTTPException, match=ErrorMessages.USER_INVALID): await UserManager.update_user( @@ -205,29 +206,29 @@ async def test_update_user_not_found(self, test_db): ) # ------------------------ test changing password ------------------------ # - async def test_change_password(self, test_db): + async def test_change_password(self, test_db) -> None: """Test changing a user's password.""" await UserManager.register(self.test_user, test_db) await UserManager.change_password( 1, - UserChangePasswordRequest(password="updated_password"), # nosec + UserChangePasswordRequest(password="updated_password"), # noqa: S106 test_db, ) user = await test_db.get(User, 1) assert user.password != self.test_user["password"] - async def test_change_password_not_found(self, test_db): + async def test_change_password_not_found(self, test_db) -> None: """Test changing a user's password that doesn't exist.""" with pytest.raises(HTTPException, match=ErrorMessages.USER_INVALID): await UserManager.change_password( 1, - UserChangePasswordRequest(password="updated_password"), # nosec + UserChangePasswordRequest(password="updated_password"), # noqa: S106 test_db, ) # -------------------------- test set ban status ------------------------- # - async def test_ban_user(self, test_db): + async def test_ban_user(self, test_db) -> None: """Test we can ban or unban a user.""" await UserManager.register(self.test_user, test_db) await UserManager.set_ban_status(1, True, 666, test_db) @@ -235,7 +236,7 @@ async def test_ban_user(self, test_db): banned_user = await test_db.get(User, 1) assert banned_user.banned is True - async def test_unban_user(self, test_db): + async def test_unban_user(self, test_db) -> None: """Test we can ban or unban a user.""" await UserManager.register(self.test_user, test_db) # set this user as banned @@ -246,13 +247,13 @@ async def test_unban_user(self, test_db): banned_user = await test_db.get(User, 1) assert banned_user.banned is False - async def test_ban_user_not_found(self, test_db): + async def test_ban_user_not_found(self, test_db) -> None: """Test we can't ban a user that doesn't exist.""" with pytest.raises(HTTPException, match=ErrorMessages.USER_INVALID): await UserManager.set_ban_status(1, True, 666, test_db) @pytest.mark.parametrize("state", [True, False]) - async def test_cant_ban_user_already_banned(self, test_db, state): + async def test_cant_ban_user_already_banned(self, test_db, state) -> None: """Test we can't ban a user that is already banned/unbanned.""" await UserManager.register(self.test_user, test_db) if state: @@ -263,14 +264,14 @@ async def test_cant_ban_user_already_banned(self, test_db, state): ): await UserManager.set_ban_status(1, state, 666, test_db) - async def test_cant_ban_self(self, test_db): + async def test_cant_ban_self(self, test_db) -> None: """Test we can't ban ourselves.""" await UserManager.register(self.test_user, test_db) with pytest.raises(HTTPException, match=ErrorMessages.CANT_SELF_BAN): await UserManager.set_ban_status(1, True, 1, test_db) # ------------------------- test change user role ------------------------ # - async def test_change_user_role_to_admin(self, test_db): + async def test_change_user_role_to_admin(self, test_db) -> None: """Test we can change a user's role to admin.""" await UserManager.register(self.test_user, test_db) await UserManager.change_role(RoleType.admin, 1, test_db) @@ -280,7 +281,7 @@ async def test_change_user_role_to_admin(self, test_db): assert user_data.role == RoleType.admin # ----------------------- test the helper functions ---------------------- # - async def test_get_user_by_id(self, test_db): + async def test_get_user_by_id(self, test_db) -> None: """Ensure we can get a user by their id.""" await UserManager.register(self.test_user, test_db) user_data = await UserManager.get_user_by_id(1, test_db) @@ -288,12 +289,12 @@ async def test_get_user_by_id(self, test_db): assert user_data is not None assert user_data.id == 1 - async def test_get_user_by_id_not_found(self, test_db): + async def test_get_user_by_id_not_found(self, test_db) -> None: """Ensure we get None if the user doesn't exist.""" with pytest.raises(HTTPException, match=ErrorMessages.USER_INVALID): await UserManager.get_user_by_id(1, test_db) - async def test_get_user_by_email(self, test_db): + async def test_get_user_by_email(self, test_db) -> None: """Ensure we can get a user by their email.""" await UserManager.register(self.test_user, test_db) @@ -305,26 +306,27 @@ async def test_get_user_by_email(self, test_db): assert user_data.email == self.test_user["email"] assert user_data.id == 1 - async def test_get_user_by_email_not_found(self, test_db): + async def test_get_user_by_email_not_found(self, test_db) -> None: """Ensure we get None if the user with email doesn't exist.""" with pytest.raises(HTTPException, match=ErrorMessages.USER_INVALID): await UserManager.get_user_by_email( self.test_user["email"], test_db ) - async def test_get_all_users(self, test_db): + async def test_get_all_users(self, test_db) -> None: """Test getting all users.""" user = self.test_user.copy() - for i in range(5): + number_of_users = 5 + for i in range(number_of_users): user["email"] = f"user{i}@test.com" await UserManager.register(user, test_db) users = await UserManager.get_all_users(test_db) - assert isinstance(users, List) - assert len(users) == 5 + assert isinstance(users, list) + assert len(users) == number_of_users - async def test_get_all_users_empty(self, test_db): + async def test_get_all_users_empty(self, test_db) -> None: """Test getting all users when there are none.""" users = await UserManager.get_all_users(test_db)