diff --git a/alembic/env.py b/alembic/env.py index 8051754..0c5af85 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -1,10 +1,8 @@ from logging.config import fileConfig -from sqlalchemy import engine_from_config -from sqlalchemy import pool +from sqlalchemy import engine_from_config, pool from alembic import context - from foxnouns.db import Base from foxnouns.db.sync import SYNC_DATABASE_URL @@ -70,9 +68,7 @@ def run_migrations_online() -> None: ) with connectable.connect() as connection: - context.configure( - connection=connection, target_metadata=target_metadata - ) + context.configure(connection=connection, target_metadata=target_metadata) with context.begin_transaction(): context.run_migrations() diff --git a/alembic/versions/1709998348_init.py b/alembic/versions/1709998348_init.py index 6cb19d7..e448234 100644 --- a/alembic/versions/1709998348_init.py +++ b/alembic/versions/1709998348_init.py @@ -5,11 +5,12 @@ Revises: Create Date: 2024-03-09 16:32:28.590145 """ + from typing import Sequence, Union -from alembic import op import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision: str = "b39613fd7327" diff --git a/alembic/versions/1710345710_add_tokens.py b/alembic/versions/1710345710_add_tokens.py index a49d360..b92f0db 100644 --- a/alembic/versions/1710345710_add_tokens.py +++ b/alembic/versions/1710345710_add_tokens.py @@ -5,12 +5,14 @@ Revises: b39613fd7327 Create Date: 2024-03-13 17:01:50.434602 """ + from typing import Sequence, Union -from alembic import op import sqlalchemy as sa from sqlalchemy.dialects import postgresql +from alembic import op + # revision identifiers, used by Alembic. revision: str = "0b63f7c8ab96" down_revision: Union[str, None] = "b39613fd7327" diff --git a/alembic/versions/1710945368_add_names_pronouns_fields.py b/alembic/versions/1710945368_add_names_pronouns_fields.py index 3c5dbcf..f679376 100644 --- a/alembic/versions/1710945368_add_names_pronouns_fields.py +++ b/alembic/versions/1710945368_add_names_pronouns_fields.py @@ -5,12 +5,14 @@ Revises: 0b63f7c8ab96 Create Date: 2024-03-20 15:36:08.756635 """ + from typing import Sequence, Union -from alembic import op import sqlalchemy as sa from sqlalchemy.dialects import postgresql +from alembic import op + # revision identifiers, used by Alembic. revision: str = "1d8f8443a7f5" down_revision: Union[str, None] = "0b63f7c8ab96" diff --git a/alembic/versions/1710946859_add_members.py b/alembic/versions/1710946859_add_members.py index aa63228..21bd20f 100644 --- a/alembic/versions/1710946859_add_members.py +++ b/alembic/versions/1710946859_add_members.py @@ -5,37 +5,43 @@ Revises: 1d8f8443a7f5 Create Date: 2024-03-20 16:00:59.251354 """ + from typing import Sequence, Union -from alembic import op import sqlalchemy as sa from sqlalchemy.dialects import postgresql +from alembic import op + # revision identifiers, used by Alembic. -revision: str = '17cc8cb77be5' -down_revision: Union[str, None] = '1d8f8443a7f5' +revision: str = "17cc8cb77be5" +down_revision: Union[str, None] = "1d8f8443a7f5" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.create_table('members', - sa.Column('id', sa.BigInteger(), nullable=False), - sa.Column('name', sa.Text(), nullable=False), - sa.Column('display_name', sa.Text(), nullable=True), - sa.Column('bio', sa.Text(), nullable=True), - sa.Column('names', postgresql.JSONB(astext_type=sa.Text()), nullable=False), - sa.Column('pronouns', postgresql.JSONB(astext_type=sa.Text()), nullable=False), - sa.Column('fields', postgresql.JSONB(astext_type=sa.Text()), nullable=False), - sa.Column('user_id', sa.BigInteger(), nullable=False), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), - sa.PrimaryKeyConstraint('id') + op.create_table( + "members", + sa.Column("id", sa.BigInteger(), nullable=False), + sa.Column("name", sa.Text(), nullable=False), + sa.Column("display_name", sa.Text(), nullable=True), + sa.Column("bio", sa.Text(), nullable=True), + sa.Column("names", postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column("pronouns", postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column("fields", postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column("user_id", sa.BigInteger(), nullable=False), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("id"), ) # ### end Alembic commands ### def downgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('members') + op.drop_table("members") # ### end Alembic commands ### diff --git a/alembic/versions/1711032729_add_unique_index_to_members.py b/alembic/versions/1711032729_add_unique_index_to_members.py index 782af20..7fbf802 100644 --- a/alembic/versions/1711032729_add_unique_index_to_members.py +++ b/alembic/versions/1711032729_add_unique_index_to_members.py @@ -5,11 +5,12 @@ Revises: 17cc8cb77be5 Create Date: 2024-03-21 15:52:09.403257 """ + from typing import Sequence, Union -from alembic import op import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision: str = "a000d800f45f" diff --git a/foxnouns/app.py b/foxnouns/app.py index 94e5371..ca5bb7f 100644 --- a/foxnouns/app.py +++ b/foxnouns/app.py @@ -1,7 +1,7 @@ -from quart import Quart, request, g +from quart import Quart, g, request from quart_schema import QuartSchema, RequestSchemaValidationError -from .blueprints import users_blueprint, members_blueprint +from .blueprints import members_blueprint, users_blueprint from .db.aio import async_session from .db.util import validate_token from .exceptions import ExpectedError diff --git a/foxnouns/auth.py b/foxnouns/auth.py index be240e3..83d2765 100644 --- a/foxnouns/auth.py +++ b/foxnouns/auth.py @@ -1,14 +1,15 @@ from functools import wraps + from quart import g -from foxnouns.exceptions import ForbiddenError, ErrorCode +from foxnouns.exceptions import ErrorCode, ForbiddenError def require_auth(*, scope: str | None = None): def decorator(func): @wraps(func) async def wrapper(*args, **kwargs): - if not ("user" in g) or not ("token" in g): + if "user" not in g or "token" not in g: raise ForbiddenError("Not authenticated", type=ErrorCode.Forbidden) if scope and not g.token.has_scope(scope): diff --git a/foxnouns/blueprints/__init__.py b/foxnouns/blueprints/__init__.py index 56fa71e..976766c 100644 --- a/foxnouns/blueprints/__init__.py +++ b/foxnouns/blueprints/__init__.py @@ -1,2 +1,4 @@ -from .v2.users import bp as users_blueprint from .v2.members import bp as members_blueprint +from .v2.users import bp as users_blueprint + +__all__ = [users_blueprint, members_blueprint] diff --git a/foxnouns/blueprints/v2/members.py b/foxnouns/blueprints/v2/members.py index c832b09..7b4d21a 100644 --- a/foxnouns/blueprints/v2/members.py +++ b/foxnouns/blueprints/v2/members.py @@ -1,13 +1,12 @@ from pydantic import Field from quart import Blueprint, g -from quart_schema import validate_response, validate_request -from sqlalchemy import select +from quart_schema import validate_request, validate_response from foxnouns.auth import require_auth from foxnouns.db import Member from foxnouns.db.aio import async_session -from foxnouns.db.util import user_from_ref, is_self -from foxnouns.exceptions import NotFoundError, ErrorCode +from foxnouns.db.util import user_from_ref +from foxnouns.exceptions import ErrorCode, NotFoundError from foxnouns.models.member import FullMemberModel, MemberPatchModel from foxnouns.settings import BASE_DOMAIN @@ -50,6 +49,9 @@ async def create_member(data: MemberCreateModel): session.add(member) await session.commit() + # This has to be fetched before we can pass the model to Pydantic. + # In a normal SELECT this is automatically fetched, but because we just created the object, + # we have to do it manually. await member.awaitable_attrs.user return FullMemberModel.model_validate(member) diff --git a/foxnouns/blueprints/v2/users.py b/foxnouns/blueprints/v2/users.py index a86c594..bb649b5 100644 --- a/foxnouns/blueprints/v2/users.py +++ b/foxnouns/blueprints/v2/users.py @@ -1,16 +1,16 @@ from pydantic import Field, field_validator from quart import Blueprint, g -from quart_schema import validate_response, validate_request +from quart_schema import validate_request, validate_response from sqlalchemy import select from foxnouns.auth import require_auth from foxnouns.db import User from foxnouns.db.aio import async_session from foxnouns.db.snowflake import Snowflake -from foxnouns.db.util import user_from_ref, is_self, create_token, generate_token -from foxnouns.exceptions import NotFoundError, ErrorCode +from foxnouns.db.util import create_token, generate_token, is_self, user_from_ref +from foxnouns.exceptions import ErrorCode, NotFoundError from foxnouns.models import BasePatchModel -from foxnouns.models.user import UserModel, SelfUserModel, check_username +from foxnouns.models.user import SelfUserModel, UserModel, check_username from foxnouns.settings import BASE_DOMAIN bp = Blueprint("users_v2", __name__) diff --git a/foxnouns/db/__init__.py b/foxnouns/db/__init__.py index d40cf30..224bb4c 100644 --- a/foxnouns/db/__init__.py +++ b/foxnouns/db/__init__.py @@ -1,3 +1,5 @@ from .base import Base -from .user import User, Token, AuthMethod, FediverseApp from .member import Member +from .user import AuthMethod, FediverseApp, Token, User + +__all__ = [Base, User, Token, AuthMethod, FediverseApp, Member] diff --git a/foxnouns/db/aio.py b/foxnouns/db/aio.py index c43ae47..4d733ae 100644 --- a/foxnouns/db/aio.py +++ b/foxnouns/db/aio.py @@ -1,5 +1,5 @@ from sqlalchemy import URL -from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from foxnouns.settings import DATABASE, ECHO_SQL diff --git a/foxnouns/db/member.py b/foxnouns/db/member.py index 3e27621..b3b30cd 100644 --- a/foxnouns/db/member.py +++ b/foxnouns/db/member.py @@ -1,6 +1,6 @@ from typing import Any -from sqlalchemy import Text, BigInteger, ForeignKey, Index, func, text +from sqlalchemy import BigInteger, ForeignKey, Index, Text, func, text from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column, relationship diff --git a/foxnouns/db/user.py b/foxnouns/db/user.py index 0649b9a..ad65237 100644 --- a/foxnouns/db/user.py +++ b/foxnouns/db/user.py @@ -1,8 +1,8 @@ -from datetime import datetime import enum +from datetime import datetime from typing import Any -from sqlalchemy import Text, Integer, BigInteger, ForeignKey, DateTime +from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, Text from sqlalchemy.dialects.postgresql import ARRAY, JSONB from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -38,7 +38,9 @@ class User(Base): return f"User(id={self.id!r}, username={self.username!r})" -from .member import Member +# Import Member here--it's needed for the back reference in User, but Member references User, so we can only import it +# after User is initialized. +from .member import Member # noqa: E402 class Token(Base): diff --git a/foxnouns/db/util.py b/foxnouns/db/util.py index 152902f..2712b61 100644 --- a/foxnouns/db/util.py +++ b/foxnouns/db/util.py @@ -2,16 +2,17 @@ import datetime from itsdangerous import BadSignature from itsdangerous.url_safe import URLSafeTimedSerializer -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, insert -from sqlalchemy.orm import selectinload from quart import g +from sqlalchemy import insert, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload -from .user import User, Token -from .member import Member -from foxnouns.exceptions import ForbiddenError, ErrorCode +from foxnouns.exceptions import ErrorCode, ForbiddenError from foxnouns.settings import SECRET_KEY +from .member import Member +from .user import Token, User + async def user_from_ref(session: AsyncSession, user_ref: str): """Returns a user from a `user_ref` value. If `user_ref` is `@me`, returns the current user. @@ -25,7 +26,7 @@ async def user_from_ref(session: AsyncSession, user_ref: str): query = query.where(User.id == g.user.id) else: raise ForbiddenError( - f"Missing scope 'user.read'", type=ErrorCode.MissingScope + "Missing scope 'user.read'", type=ErrorCode.MissingScope ) else: raise ForbiddenError("Not authenticated") @@ -63,7 +64,7 @@ async def create_token(session: AsyncSession, user: User, scopes: list[str] = [" return await session.scalar(query) -async def validate_token(session: AsyncSession, header: str) -> (Token, User): +async def validate_token(session: AsyncSession, header: str) -> tuple[Token, User]: try: token_id = serializer.loads(header) except BadSignature: diff --git a/foxnouns/models/user.py b/foxnouns/models/user.py index 8e896d5..4ea3fd8 100644 --- a/foxnouns/models/user.py +++ b/foxnouns/models/user.py @@ -1,7 +1,7 @@ from pydantic import Field from . import BaseSnowflakeModel -from .fields import ProfileField, FieldEntry, PronounEntry +from .fields import FieldEntry, ProfileField, PronounEntry class BaseUserModel(BaseSnowflakeModel): diff --git a/poetry.lock b/poetry.lock index b1682a4..37dfcd3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1010,6 +1010,33 @@ async-timeout = {version = ">=4.0.3", markers = "python_full_version < \"3.11.3\ hiredis = ["hiredis (>=1.0.0)"] ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==20.0.1)", "requests (>=2.26.0)"] +[[package]] +name = "ruff" +version = "0.3.4" +description = "An extremely fast Python linter and code formatter, written in Rust." +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "ruff-0.3.4-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:60c870a7d46efcbc8385d27ec07fe534ac32f3b251e4fc44b3cbfd9e09609ef4"}, + {file = "ruff-0.3.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6fc14fa742e1d8f24910e1fff0bd5e26d395b0e0e04cc1b15c7c5e5fe5b4af91"}, + {file = "ruff-0.3.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3ee7880f653cc03749a3bfea720cf2a192e4f884925b0cf7eecce82f0ce5854"}, + {file = "ruff-0.3.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cf133dd744f2470b347f602452a88e70dadfbe0fcfb5fd46e093d55da65f82f7"}, + {file = "ruff-0.3.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3f3860057590e810c7ffea75669bdc6927bfd91e29b4baa9258fd48b540a4365"}, + {file = "ruff-0.3.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:986f2377f7cf12efac1f515fc1a5b753c000ed1e0a6de96747cdf2da20a1b369"}, + {file = "ruff-0.3.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4fd98e85869603e65f554fdc5cddf0712e352fe6e61d29d5a6fe087ec82b76c"}, + {file = "ruff-0.3.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:64abeed785dad51801b423fa51840b1764b35d6c461ea8caef9cf9e5e5ab34d9"}, + {file = "ruff-0.3.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:df52972138318bc7546d92348a1ee58449bc3f9eaf0db278906eb511889c4b50"}, + {file = "ruff-0.3.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:98e98300056445ba2cc27d0b325fd044dc17fcc38e4e4d2c7711585bd0a958ed"}, + {file = "ruff-0.3.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:519cf6a0ebed244dce1dc8aecd3dc99add7a2ee15bb68cf19588bb5bf58e0488"}, + {file = "ruff-0.3.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:bb0acfb921030d00070539c038cd24bb1df73a2981e9f55942514af8b17be94e"}, + {file = "ruff-0.3.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cf187a7e7098233d0d0c71175375c5162f880126c4c716fa28a8ac418dcf3378"}, + {file = "ruff-0.3.4-py3-none-win32.whl", hash = "sha256:af27ac187c0a331e8ef91d84bf1c3c6a5dea97e912a7560ac0cef25c526a4102"}, + {file = "ruff-0.3.4-py3-none-win_amd64.whl", hash = "sha256:de0d5069b165e5a32b3c6ffbb81c350b1e3d3483347196ffdf86dc0ef9e37dd6"}, + {file = "ruff-0.3.4-py3-none-win_arm64.whl", hash = "sha256:6810563cc08ad0096b57c717bd78aeac888a1bfd38654d9113cb3dc4d3f74232"}, + {file = "ruff-0.3.4.tar.gz", hash = "sha256:f0f4484c6541a99862b693e13a151435a279b271cff20e37101116a21e2a1ad1"}, +] + [[package]] name = "six" version = "1.16.0" @@ -1213,4 +1240,4 @@ h11 = ">=0.9.0,<1" [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "c4719965b47a0a85c480b6a26e0c7fd84d61e281a4bb012049fac9c61cd813df" +content-hash = "a04cf866b1b5efd1f9484114b0e5924947a4044c7b7e70127f042778246c0ce6" diff --git a/pyproject.toml b/pyproject.toml index c82eaa7..bfa2bca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,12 @@ asyncpg = "^0.29.0" environs = "^11.0.0" alembic = "^1.13.1" +[tool.poetry.group.dev] +optional = true + +[tool.poetry.group.dev.dependencies] +ruff = "^0.3.4" + [tool.poetry.group.test] optional = true @@ -30,11 +36,23 @@ optional = true pytest = "^8.0.2" pytest-asyncio = "^0.23.5.post1" +[tool.poe.tasks.dev] +help = "Run a development server with auto-reload" +cmd = "env QUART_APP=foxnouns.app:app quart --debug run --reload" + +[tool.poe.tasks.server] +help = "Run a production server" +cmd = "uvicorn 'foxnouns.app:app'" + +[tool.poe.tasks.migrate] +help = "Migrate the database to the latest revision" +cmd = "alembic upgrade head" + [tool.poe.tasks] -dev = "env QUART_APP=foxnouns.app:app quart --debug run --reload" -server = "uvicorn 'foxnouns.app:app'" -migrate = "alembic upgrade head" test = "pytest" +lint = "ruff check" +format = "ruff format" +"sort-imports" = "ruff check --select I --fix " [tool.pytest.ini_options] addopts = ["--import-mode=importlib"] diff --git a/tests/conftest.py b/tests/conftest.py index 1fd8593..6009ab2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,11 +1,10 @@ import pytest import pytest_asyncio -from sqlalchemy import text, delete +from sqlalchemy import delete, text from foxnouns.db import Base from foxnouns.settings import DATABASE - # Override the database name to the testing database DATABASE["NAME"] = f"{DATABASE['NAME']}_test" @@ -25,8 +24,8 @@ def pytest_collection_modifyitems(items): def setup(): """Migrate the testing database to the latest migration, and once the tests complete, clear the database again.""" - from foxnouns.db.sync import engine from alembic import command, config + from foxnouns.db.sync import engine cfg = config.Config("alembic.ini") cfg.attributes["connection"] = engine.connect()