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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:
run: uv sync --locked --group dev

- name: Check code
run: uv run make check-ci
run: make check-ci

- name: Test with Docker
env:
Expand Down
4 changes: 2 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ repos:
hooks:
- id: code-check
name: code-check (local)
entry: uv run make check
entry: make check
language: system
pass_filenames: false
- id: pip-audit-local
name: pip-audit (local)
entry: uv run make pip-audit
entry: make pip-audit
language: system
pass_filenames: false
verbose: true
Expand Down
50 changes: 24 additions & 26 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -72,36 +72,34 @@ slotscheck:
$(SLOTSCHECK) src

lint:
ruff check --fix
ruff format
tombi format
tombi lint
deptry
uv run ruff check --fix
uv run ruff format
uv run tombi format
uv run tombi lint
uv run deptry
$(MAKE) slotscheck
lint-imports
mypy
uv run lint-imports
uv run mypy

test:
pytest -v \
uv run pytest -v \
$(PYTEST_PATHS_LIGHT) \
$(PYTEST_ARGS_COV)

check: lint test
coverage html
uv run coverage html

check-ci:
ruff check
ruff format --check
tombi format --check
tombi lint
deptry
uv run ruff check
uv run ruff format --check
uv run tombi format --check
uv run tombi lint
uv run deptry
$(MAKE) slotscheck
lint-imports
mypy
pytest -v \
$(PYTEST_PATHS_LIGHT) \
$(PYTEST_ARGS_COV)
coverage html
uv run lint-imports
uv run mypy
$(MAKE) test
uv run coverage html

# Docker compose
.PHONY: docker-env local-env upd up upd-local up-local down stop-all
Expand Down Expand Up @@ -129,6 +127,10 @@ down:
stop-all:
docker ps -q | xargs -r docker stop

.PHONY: prune
prune:
$(DOCKER_PRUNE)

# Migrations
.PHONY: migration
migration: local-env
Expand Down Expand Up @@ -179,13 +181,9 @@ test-docker-migrations: docker-env
test-docker:
$(MAKE) test-docker-app
$(MAKE) test-docker-migrations
coverage html --data-file=.coverage.docker -d htmlcov-docker && \
uv run coverage html --data-file=.coverage.docker -d htmlcov-docker && \
echo "Coverage HTML report: htmlcov-docker/index.html" || true

.PHONY: prune
prune:
$(DOCKER_PRUNE)

# Project structure visualization
.PHONY: pycache-del tree plot-data
pycache-del:
Expand All @@ -195,4 +193,4 @@ tree: pycache-del
tree

plot-data:
APP_LOGGING_LEVEL=CRITICAL python $(DISHKA_PLOT_DATA)
APP_LOGGING_LEVEL=CRITICAL uv run python $(DISHKA_PLOT_DATA)
20 changes: 15 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ dependencies = [
"bcrypt==5.0.0",
"dishka==1.10.1",
"fastapi==0.136.1",
"fastapi-error-map==0.9.10",
"fastapi-error-map==1.0.0",
"psycopg[binary]==3.3.4",
"pydantic-settings==2.14.1",
"pyjwt[crypto]==2.12.1",
Expand All @@ -27,7 +27,7 @@ dev = [
"asgi-lifespan==2.1.0",
"coverage==7.14.0",
"deptry==0.25.1",
"httpx==0.28.1",
"httpx2==2.5.0",
"import-linter==2.11",
"line-profiler==5.0.2",
"mypy==2.1.0",
Expand Down Expand Up @@ -207,19 +207,20 @@ select = [
"FLY", # flynt
"FURB", # refurb
"G", # flake8-logging-format
"INP", # flake8-no-pep420
"I", # isort
"INT", # flake8-gettext
"ISC", # flake8-implicit-str-concat
"LOG", # flake8-logging
"N", # pep8-naming
"PERF", # Perflint
"PL", # pylint
"PLC", # Pylint (convention)
"PLE", # Pylint (error)
"PLR5501", # Pylint Refactor: collapsible-else-if
"PT", # flake8-pytest-style
"PTH", # flake8-use-pathlib
"Q", # flake8-quotes
"RSE", # flake8-raise (RSE)
"RSE", # flake8-raise
"RUF", # Ruff-specific rules
"S", # flake8-bandit
"SIM", # flake8-simplify
Expand All @@ -246,13 +247,19 @@ ignore = [
"TC006", # runtime-cast-value
]

[tool.ruff.lint.flake8-tidy-imports.banned-api]
"typing.TYPE_CHECKING".msg = "TYPE_CHECKING is banned: keep imports at module level; resolve cycles by refactoring."

[tool.ruff.lint.isort]
combine-as-imports = true
force-wrap-aliases = true
split-on-trailing-comma = true

[tool.ruff.lint.per-file-ignores]
"scripts/dishka/plot_dependencies_data.py" = ["T201"] # print
"scripts/**" = [
"INP001", # runnable scripts, not an importable package
"T20", # print is legitimate output here
]
"src/app/outbound/persistence_sqla/alembic/**" = ["ALL"]
"tests/**" = [
"ARG002", # unused-method-argument
Expand All @@ -274,3 +281,6 @@ exclude-modules = '''

[tool.typos.files]
extend-exclude = []

[tool.uv]
default-groups = ["dev"]
6 changes: 3 additions & 3 deletions scripts/makefile/migration.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ MIGRATION_PROJECT="$(basename "$PWD")-migration"
trap 'docker compose -p "$MIGRATION_PROJECT" down -v --remove-orphans >/dev/null' EXIT

docker compose -p "$MIGRATION_PROJECT" up -d --build --wait --wait-timeout 180 "$MIGRATION_DB_SERVICE"
alembic upgrade head
alembic revision --autogenerate -m "$slug"
uv run alembic upgrade head
uv run alembic revision --autogenerate -m "$slug"

if [ -n "${STAIRWAY_TEST:-}" ]; then
ALLOW_DESTRUCTIVE_TEST_CLEANUP=1 pytest -s -vv "$STAIRWAY_TEST"
ALLOW_DESTRUCTIVE_TEST_CLEANUP=1 uv run pytest -s -vv "$STAIRWAY_TEST"
fi
2 changes: 1 addition & 1 deletion scripts/makefile/pip_audit.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@ set -eu -o pipefail

tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
uv -qq export --format pylock.toml -o "$tmp/pylock.toml"
pip-audit --locked "$tmp" \
uv run pip-audit --locked "$tmp" \
|| echo "WARNING: pip-audit found vulnerabilities (non-blocking)" >&2
4 changes: 2 additions & 2 deletions scripts/makefile/slotscheck.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@
# The script is called from Makefile
set -eu -o pipefail

slotscheck "$1" 2>&1 | tee /dev/stderr \
uv run slotscheck "$1" 2>&1 | tee /dev/stderr \
| { grep -m1 "Failed to import" || true; } | cut -d"'" -f2 \
| xargs -r -n1 python -c 'import importlib,sys; importlib.import_module(sys.argv[1])'
| xargs -r -n1 uv run python -c 'import importlib,sys; importlib.import_module(sys.argv[1])'
12 changes: 10 additions & 2 deletions src/app/core/common/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
from typing import ClassVar
from typing import Any, ClassVar


class BaseError(Exception):
default_message: ClassVar[str | None] = None

def __init__(self, message: str | None = None) -> None:
def __init__(
self,
message: str | None = None,
*,
code: str | None = None,
details: dict[str, Any] | None = None,
) -> None:
msg = message if message is not None else self.default_message
super().__init__() if msg is None else super().__init__(msg)
self.code = code
self.details = details


class BusinessTypeError(BaseError):
Expand Down
9 changes: 8 additions & 1 deletion src/app/core/common/value_objects/utc_datetime.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
from dataclasses import dataclass
from datetime import UTC, datetime
from functools import total_ordering

from app.core.common.exceptions import BusinessTypeError
from app.core.common.value_objects.base import ValueObject


@total_ordering
@dataclass(frozen=True, slots=True, repr=False)
class UtcDatetime(ValueObject):
value: datetime
Expand All @@ -16,8 +18,13 @@ def __post_init__(self) -> None:
@classmethod
def _ensure_is_tz_aware(cls, dt: datetime) -> None:
if dt.tzinfo is None or dt.utcoffset() is None:
raise BusinessTypeError(f"{cls.__name__}: timezone-aware datetime required, got {dt!r}")
raise BusinessTypeError(f"{cls.__name__}: timezone-aware datetime required")

@classmethod
def _normalize(cls, dt: datetime) -> datetime:
return dt.astimezone(UTC)

def __lt__(self, other: object) -> bool:
if not isinstance(other, UtcDatetime):
return NotImplemented
return self.value < other.value
14 changes: 8 additions & 6 deletions src/app/core/queries/query_support/offset_pagination.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

@dataclass(frozen=True, slots=True, kw_only=True)
class OffsetPaginationParams:
MIN_LIMIT: ClassVar[int] = 1
MIN_OFFSET: ClassVar[int] = 0
MAX_INT32: ClassVar[int] = 2**31 - 1

limit: int
Expand All @@ -16,11 +18,11 @@ def __post_init__(self) -> None:

@classmethod
def _validate(cls, limit: int, offset: int) -> None:
if limit <= 0:
raise PaginationError(f"Limit must be greater than 0, got {limit}")
if limit < cls.MIN_LIMIT:
raise PaginationError(f"Limit must be at least {cls.MIN_LIMIT}")
if limit > cls.MAX_INT32:
raise PaginationError(f"Limit cannot be greater than {cls.MAX_INT32}, got {limit}")
if offset < 0:
raise PaginationError(f"Offset must be non-negative, got {offset}")
raise PaginationError(f"Limit must be at most {cls.MAX_INT32}")
if offset < cls.MIN_OFFSET:
raise PaginationError(f"Offset must be at least {cls.MIN_OFFSET}")
if offset > cls.MAX_INT32:
raise PaginationError(f"Offset cannot be greater than {cls.MAX_INT32}, got {offset}")
raise PaginationError(f"Offset must be at most {cls.MAX_INT32}")
4 changes: 2 additions & 2 deletions src/app/core/queries/query_support/sorting.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@


class SortingOrder(StrEnum):
ASC = "ASC"
DESC = "DESC"
ASC = "asc"
DESC = "desc"


@dataclass(frozen=True, slots=True, kw_only=True)
Expand Down
5 changes: 2 additions & 3 deletions src/app/inbound/http/account/change_password.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@
from dishka.integrations.fastapi import inject
from fastapi import APIRouter, Depends, status
from fastapi.security import APIKeyCookie
from fastapi_error_map import ErrorAwareRouter
from pydantic import BaseModel, ConfigDict

from app.core.common.authorization.exceptions import AuthorizationError
from app.core.common.exceptions import BusinessTypeError
from app.inbound.http.errors.callbacks import log_info
from app.inbound.http.errors.router import make_error_aware_router
from app.inbound.http.errors.rules import HTTP_503_SERVICE_UNAVAILABLE_RULE
from app.outbound.adapters.exceptions import PasswordHasherBusyError
from app.outbound.auth_ctx.exceptions import AuthenticationChangeError, AuthenticationError, ReAuthenticationError
Expand All @@ -30,7 +30,7 @@ class ChangePasswordRequestSchema(BaseModel):


def make_change_password_router(*, cookie_name: str) -> APIRouter:
router = ErrorAwareRouter()
router = make_error_aware_router(on_error=log_info)

@router.put(
"/password/",
Expand All @@ -43,7 +43,6 @@ def make_change_password_router(*, cookie_name: str) -> APIRouter:
ReAuthenticationError: status.HTTP_403_FORBIDDEN,
PasswordHasherBusyError: HTTP_503_SERVICE_UNAVAILABLE_RULE,
},
default_on_error=log_info,
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(APIKeyCookie(name=cookie_name))],
description=getdoc(ChangePassword),
Expand Down
5 changes: 2 additions & 3 deletions src/app/inbound/http/account/log_in.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
from dishka import FromDishka
from dishka.integrations.fastapi import inject
from fastapi import APIRouter, status
from fastapi_error_map import ErrorAwareRouter

from app.core.common.authorization.exceptions import AuthorizationError
from app.core.common.exceptions import BusinessTypeError
from app.inbound.http.errors.callbacks import log_info
from app.inbound.http.errors.router import make_error_aware_router
from app.inbound.http.errors.rules import HTTP_503_SERVICE_UNAVAILABLE_RULE
from app.outbound.adapters.exceptions import PasswordHasherBusyError
from app.outbound.auth_ctx.exceptions import AlreadyAuthenticatedError, AuthenticationError
Expand All @@ -16,7 +16,7 @@


def make_log_in_router() -> APIRouter:
router = ErrorAwareRouter()
router = make_error_aware_router(on_error=log_info)

@router.post(
"/login/",
Expand All @@ -28,7 +28,6 @@ def make_log_in_router() -> APIRouter:
AuthenticationError: status.HTTP_401_UNAUTHORIZED,
PasswordHasherBusyError: HTTP_503_SERVICE_UNAVAILABLE_RULE,
},
default_on_error=log_info,
status_code=status.HTTP_204_NO_CONTENT,
description=getdoc(LogIn),
)
Expand Down
5 changes: 2 additions & 3 deletions src/app/inbound/http/account/log_out.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,18 @@
from dishka.integrations.fastapi import inject
from fastapi import APIRouter, Depends, status
from fastapi.security import APIKeyCookie
from fastapi_error_map import ErrorAwareRouter

from app.core.common.authorization.exceptions import AuthorizationError
from app.inbound.http.errors.callbacks import log_info
from app.inbound.http.errors.router import make_error_aware_router
from app.inbound.http.errors.rules import HTTP_503_SERVICE_UNAVAILABLE_RULE
from app.outbound.auth_ctx.exceptions import AuthenticationError
from app.outbound.auth_ctx.handlers.log_out import LogOut
from app.outbound.exceptions import StorageError


def make_log_out_router(*, cookie_name: str) -> APIRouter:
router = ErrorAwareRouter()
router = make_error_aware_router(on_error=log_info)

@router.delete(
"/logout/",
Expand All @@ -24,7 +24,6 @@ def make_log_out_router(*, cookie_name: str) -> APIRouter:
StorageError: HTTP_503_SERVICE_UNAVAILABLE_RULE,
AuthorizationError: status.HTTP_403_FORBIDDEN,
},
default_on_error=log_info,
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(APIKeyCookie(name=cookie_name))],
description=getdoc(LogOut),
Expand Down
Loading