Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@
"version": "latest"
}
},
"image": "mcr.microsoft.com/devcontainers/python:0-3.11-bullseye",
"postCreateCommand": "pip install --user -r requirements.txt"
"image": "mcr.microsoft.com/devcontainers/python:1-3.14-bookworm",
"postCreateCommand": "pip install uv && uv sync"
}
21 changes: 21 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
.venv
venv
.git
.github
.idea
.run
.devcontainer
htmlcov
.mypy_cache
.ruff_cache
.pytest_cache
__pycache__
*.db
app/infrastructure/databases/*.db
.coverage
coverage.xml
.DS_Store
docs
node_modules
openspec
*.md
2 changes: 1 addition & 1 deletion .env.development
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
APP_DATABASE_URL=sqlite:///app/infrastructure/databases/development.db
APP_ASYNC_DATABASE_URL=sqlite+aiosqlite:///app/infrastructure/databases/development.db
APP_JWT_SECRET_KEY=secret
APP_JWT_SECRET_KEY=development-jwt-secret-key-not-secure-for-local-use-only
2 changes: 1 addition & 1 deletion .env.test
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
APP_DATABASE_URL=sqlite:///app/infrastructure/databases/test.db
APP_ASYNC_DATABASE_URL=sqlite+aiosqlite:///app/infrastructure/databases/test.db
APP_JWT_SECRET_KEY=test-secret
APP_JWT_SECRET_KEY=test-jwt-secret-key-not-secure-for-local-testing-only
14 changes: 7 additions & 7 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,18 @@ jobs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6

- uses: actions/setup-python@v5
- uses: astral-sh/setup-uv@v8.1.0
with:
python-version: 3.11.9
python-version: "3.14"
enable-cache: true

- run: make install
- run: uv sync --frozen

- run: make check-types
- run: make check-flake8
- run: make check-isort
- run: make check-black
- run: make lint
- run: make check-format

- run: make test
- run: make test-integration
Expand Down
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ app/infrastructure/databases/*.db
docs
htmlcov
node_modules
venv
.venv
.coverage
coverage.xml
README.local.md
1 change: 0 additions & 1 deletion .tool-versions

This file was deleted.

6 changes: 3 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
FROM python:3.11.9
FROM ghcr.io/astral-sh/uv:python3.14-bookworm-slim

WORKDIR /usr/src/app

COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY pyproject.toml uv.lock .python-version ./
RUN uv sync --frozen
73 changes: 22 additions & 51 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,70 +1,41 @@
VENV_DIR=venv
# Environment

venv:
python -m venv $(VENV_DIR)
@echo "\nUse '. $(VENV_DIR)/bin/activate' to activate"
install:
uv sync

deps-pre:
pip install --upgrade pip==24.1.2 pip-tools==7.4.1

deps-compile:
pip-compile requirements.in --output-file requirements.txt

deps-install:
pip-sync

deps: deps-pre deps-compile deps-install

install: deps-pre deps-install
lock:
uv lock

seed:
APP_ENV=development python -m app.seed
APP_ENV=development uv run python -m app.seed

server-dev:
APP_ENV=development uvicorn app:create_app --reload

# Linting
APP_ENV=development uv run uvicorn app:create_app --reload

format-isort:
isort .
# Linting & formatting (ruff + ty)

check-isort:
isort . --check-only
lint:
uv run ruff check .

format-black:
black .

check-black:
black . --check

check-flake8:
flake8 .
check-format:
uv run ruff format --check .

check-types:
python -m mypy .

lint-all: check-types check-flake8 check-isort check-black

# Formatting

format-autoflake:
autoflake --in-place --recursive \
--remove-all-unused-imports \
--remove-unused-variables \
app tests
uv run ty check

format-yesqa:
yesqa app/**/*.py tests/**/*.py
format:
uv run ruff format .
uv run ruff check . --fix

format: format-yesqa format-autoflake format-isort format-black
lint-all: check-types lint check-format

# Testing

test:
APP_ENV=test pytest app
APP_ENV=test uv run pytest app

test-cov:
APP_ENV=test pytest app --verbose \
APP_ENV=test uv run pytest app --verbose \
--cov-config=.coveragerc \
--cov=app \
--cov-report=term:skip-covered \
Expand All @@ -74,12 +45,12 @@ test-cov:
--cov-fail-under=60

test-watch:
APP_ENV=test ptw app
APP_ENV=test uv run ptw app

test-integration:
APP_ENV=test pytest tests/integration
APP_ENV=test uv run pytest tests/integration

test-end-to-end:
APP_ENV=test pytest tests/end-to-end
APP_ENV=test uv run pytest tests/end-to-end

test-all: test test-integration test-end-to-end
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@

## Preparing and running the app

* Use asdf to manage Python versions
* `make venv`
* `make install`
* Install [uv](https://docs.astral.sh/uv/) — it manages the Python 3.14 toolchain and dependencies
* `uv sync` (creates `.venv` from `uv.lock`, installing Python 3.14 if needed)
* `make seed`
* `make server-dev`

Expand All @@ -13,7 +12,8 @@ http://localhost:8000/api/docs

## Linting and testing

* `make lint`
* `make lint-all` (ruff lint + format check + ty type check)
* `make format` (ruff format + autofix)
* `make test`
* `make test-watch`

Expand All @@ -24,8 +24,8 @@ curl http://localhost:8000/api/projects/1/tasks --silent | jq

## Other useful commands

* `pip list --outdated`
* `libyear -r requirements.txt --sort`
* `uv pip list --outdated`
* `uv lock --upgrade` (refresh the lockfile to the latest compatible versions)

## Books that inspired me to create this project

Expand Down
10 changes: 4 additions & 6 deletions app/infrastructure/base_sql_query_handler.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,22 @@
import abc
from typing import Any, Generic, Optional, Sequence, TypeVar
from collections.abc import Sequence
from typing import Any

from sqlalchemy import Executable, Row
from sqlalchemy.ext.asyncio import AsyncEngine

from app.shared.query import Query

Q = TypeVar("Q", bound=Query)
QR = TypeVar("QR")


class BaseSQLQueryHandler(Generic[Q, QR], metaclass=abc.ABCMeta):
class BaseSQLQueryHandler[Q: Query, QR](metaclass=abc.ABCMeta):
def __init__(self, engine: AsyncEngine):
self._engine = engine

@abc.abstractmethod
async def __call__(self, query: Q) -> QR:
raise NotImplementedError

async def _first_from(self, stmt: Executable) -> Optional[Row[Any]]:
async def _first_from(self, stmt: Executable) -> Row[Any] | None:
async with self._engine.connect() as connection:
result = await connection.execute(stmt)
return result.first()
Expand Down
3 changes: 1 addition & 2 deletions app/modules/accounts/application/authentication_test.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
from datetime import datetime
from typing import Optional

import pytest

Expand All @@ -17,7 +16,7 @@ class FakeAuthenticationToken(AuthenticationToken):
def __init__(self, secret_key: str):
super().__init__(secret_key=secret_key)

def encode(self, user_id: UserID, now: Optional[datetime] = None) -> str:
def encode(self, user_id: UserID, now: datetime | None = None) -> str:
return f"token-{self._secret_key}-{user_id}"

def decode(self, token: str) -> UserID:
Expand Down
7 changes: 5 additions & 2 deletions app/modules/accounts/application/commands/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
from .change_user_email_address import ChangeUserEmailAddress, ChangeUserEmailAddressHandler
from .register_user import RegisterUser, RegisterUserHandler
from app.modules.accounts.application.commands.change_user_email_address import (
ChangeUserEmailAddress,
ChangeUserEmailAddressHandler,
)
from app.modules.accounts.application.commands.register_user import RegisterUser, RegisterUserHandler

__all__ = [
"ChangeUserEmailAddress",
Expand Down
4 changes: 2 additions & 2 deletions app/modules/accounts/entrypoints/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import pytest
from fastapi import FastAPI
from httpx import AsyncClient
from httpx2 import ASGITransport, AsyncClient

from app.modules.accounts.application.containers import AppContainer
from app.modules.accounts.entrypoints import routes
Expand Down Expand Up @@ -44,4 +44,4 @@ def app():

@pytest.fixture
def client(app):
return AsyncClient(app=app, base_url="http://test")
return AsyncClient(transport=ASGITransport(app=app), base_url="http://test")
3 changes: 2 additions & 1 deletion app/modules/accounts/entrypoints/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ def user_login_endpoint(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
authentication: Authentication = Depends(Provide[AppContainer.authentication]),
):
data = schemas.LoginUser(email=form_data.username, password=form_data.password)
# pydantic coerces str -> EmailAddress/Password; ty has no pydantic plugin, so it can't see it.
data = schemas.LoginUser(email=form_data.username, password=form_data.password) # ty: ignore[invalid-argument-type]
token = authentication.login(email=data.email, password=data.password)

return {
Expand Down
6 changes: 3 additions & 3 deletions app/modules/accounts/entrypoints/routes_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import pytest
from fastapi import FastAPI
from httpx import AsyncClient
from httpx2 import AsyncClient
from starlette import status

from app.modules.accounts.application.commands import RegisterUser
Expand Down Expand Up @@ -64,7 +64,7 @@ async def test_register_user_endpoint_returns_422(client: AsyncClient, email, pa
)

# Then
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT


@pytest.mark.asyncio
Expand Down Expand Up @@ -102,7 +102,7 @@ async def test_get_current_user_endpoint(
future = asyncio.Future[GetUser.Result]()
future.set_result(
GetUser.Result(
id=user_id,
id=user_id, # ty: ignore[invalid-argument-type] pydantic coerces UserID -> UUID
email="test@email.com",
projects=[
GetUser.Result.Project(id=1, name="Project One"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
class TestJWTImplementation:
@pytest.fixture
def jwt(self) -> JWTAuthentication:
return JWTAuthentication(secret_key="test-secret")
return JWTAuthentication(secret_key="test-secret-key-with-at-least-32-bytes!")

def test_encode_and_decode(self, jwt: JWTAuthentication):
user_id = UserID.generate()
Expand Down
20 changes: 14 additions & 6 deletions app/modules/accounts/infrastructure/adapters/password_hasher.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,26 @@
from passlib.context import CryptContext
import bcrypt

from app.modules.accounts.application.ports.abstract_password_hasher import AbstractPasswordHasher
from app.modules.accounts.domain.password import Password

# bcrypt only considers the first 72 bytes of a password and (since 4.x) raises on longer
# inputs. passlib used to silently truncate; we mirror that here in both hash() and verify()
# to preserve behavior and keep existing $2b$ hashes verifiable. Tightening this length-only
# policy is tracked as a separate security change.
_BCRYPT_MAX_BYTES = 72

class PasswordHasher(AbstractPasswordHasher):
def __init__(self):
self._crypt_context = CryptContext(schemes=["bcrypt"], deprecated="auto")

def _encode(password: Password) -> bytes:
return str(password).encode("utf-8")[:_BCRYPT_MAX_BYTES]


class PasswordHasher(AbstractPasswordHasher):
def hash(self, password: Password) -> str:
return self._crypt_context.hash(str(password))
hashed_password = bcrypt.hashpw(_encode(password), bcrypt.gensalt())
return hashed_password.decode("utf-8")

def verify(self, password: Password, hashed_password: str) -> bool:
try:
return self._crypt_context.verify(str(password), hashed_password)
return bcrypt.checkpw(_encode(password), hashed_password.encode("utf-8"))
except ValueError:
return False
3 changes: 2 additions & 1 deletion app/modules/accounts/infrastructure/adapters/unit_of_work.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from typing import Callable, Self
from collections.abc import Callable
from typing import Self

from sqlalchemy.orm import Session

Expand Down
Loading