diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d33b9cb2..d80a999c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -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: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d9a97c98..bcb97f28 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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 diff --git a/Makefile b/Makefile index 00db6590..26d67d35 100644 --- a/Makefile +++ b/Makefile @@ -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 @@ -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 @@ -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: @@ -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) diff --git a/pyproject.toml b/pyproject.toml index fb9af5d9..a4e33559 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", @@ -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", @@ -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 @@ -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 @@ -274,3 +281,6 @@ exclude-modules = ''' [tool.typos.files] extend-exclude = [] + +[tool.uv] +default-groups = ["dev"] diff --git a/scripts/makefile/migration.sh b/scripts/makefile/migration.sh index e0f70a8c..e615b449 100755 --- a/scripts/makefile/migration.sh +++ b/scripts/makefile/migration.sh @@ -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 diff --git a/scripts/makefile/pip_audit.sh b/scripts/makefile/pip_audit.sh index 7717fcb1..bd46f4a4 100755 --- a/scripts/makefile/pip_audit.sh +++ b/scripts/makefile/pip_audit.sh @@ -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 diff --git a/scripts/makefile/slotscheck.sh b/scripts/makefile/slotscheck.sh index 2cc83dda..99fe0bce 100755 --- a/scripts/makefile/slotscheck.sh +++ b/scripts/makefile/slotscheck.sh @@ -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])' diff --git a/src/app/core/common/exceptions.py b/src/app/core/common/exceptions.py index eaca1520..35ba0f2c 100644 --- a/src/app/core/common/exceptions.py +++ b/src/app/core/common/exceptions.py @@ -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): diff --git a/src/app/core/common/value_objects/utc_datetime.py b/src/app/core/common/value_objects/utc_datetime.py index 58e5b6bd..6b258827 100644 --- a/src/app/core/common/value_objects/utc_datetime.py +++ b/src/app/core/common/value_objects/utc_datetime.py @@ -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 @@ -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 diff --git a/src/app/core/queries/query_support/offset_pagination.py b/src/app/core/queries/query_support/offset_pagination.py index 5e6e8db7..29d39b79 100644 --- a/src/app/core/queries/query_support/offset_pagination.py +++ b/src/app/core/queries/query_support/offset_pagination.py @@ -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 @@ -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}") diff --git a/src/app/core/queries/query_support/sorting.py b/src/app/core/queries/query_support/sorting.py index 4dc5ec80..46068c62 100644 --- a/src/app/core/queries/query_support/sorting.py +++ b/src/app/core/queries/query_support/sorting.py @@ -3,8 +3,8 @@ class SortingOrder(StrEnum): - ASC = "ASC" - DESC = "DESC" + ASC = "asc" + DESC = "desc" @dataclass(frozen=True, slots=True, kw_only=True) diff --git a/src/app/inbound/http/account/change_password.py b/src/app/inbound/http/account/change_password.py index 3aabc852..87a7094a 100644 --- a/src/app/inbound/http/account/change_password.py +++ b/src/app/inbound/http/account/change_password.py @@ -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 @@ -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/", @@ -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), diff --git a/src/app/inbound/http/account/log_in.py b/src/app/inbound/http/account/log_in.py index d937ef34..ceb19c18 100644 --- a/src/app/inbound/http/account/log_in.py +++ b/src/app/inbound/http/account/log_in.py @@ -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 @@ -16,7 +16,7 @@ def make_log_in_router() -> APIRouter: - router = ErrorAwareRouter() + router = make_error_aware_router(on_error=log_info) @router.post( "/login/", @@ -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), ) diff --git a/src/app/inbound/http/account/log_out.py b/src/app/inbound/http/account/log_out.py index 62105a57..c9da4f18 100644 --- a/src/app/inbound/http/account/log_out.py +++ b/src/app/inbound/http/account/log_out.py @@ -4,10 +4,10 @@ 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 @@ -15,7 +15,7 @@ def make_log_out_router(*, cookie_name: str) -> APIRouter: - router = ErrorAwareRouter() + router = make_error_aware_router(on_error=log_info) @router.delete( "/logout/", @@ -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), diff --git a/src/app/inbound/http/account/sign_up.py b/src/app/inbound/http/account/sign_up.py index 7d6c8817..ab1b4699 100644 --- a/src/app/inbound/http/account/sign_up.py +++ b/src/app/inbound/http/account/sign_up.py @@ -3,12 +3,12 @@ from dishka import FromDishka from dishka.integrations.fastapi import inject from fastapi import APIRouter, status -from fastapi_error_map import ErrorAwareRouter from app.core.commands.exceptions import UsernameAlreadyExistsError 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 @@ -17,7 +17,7 @@ def make_sign_up_router() -> APIRouter: - router = ErrorAwareRouter() + router = make_error_aware_router(on_error=log_info) @router.post( "/signup/", @@ -29,7 +29,6 @@ def make_sign_up_router() -> APIRouter: PasswordHasherBusyError: HTTP_503_SERVICE_UNAVAILABLE_RULE, UsernameAlreadyExistsError: status.HTTP_409_CONFLICT, }, - default_on_error=log_info, status_code=status.HTTP_204_NO_CONTENT, description=getdoc(SignUp), ) diff --git a/src/app/inbound/http/api_v1_router.py b/src/app/inbound/http/api_v1_router.py index a3323ced..613b5797 100644 --- a/src/app/inbound/http/api_v1_router.py +++ b/src/app/inbound/http/api_v1_router.py @@ -1,11 +1,12 @@ from fastapi import APIRouter from app.inbound.http.account.router import make_account_router +from app.inbound.http.errors.openapi_responses import SERVER_ERROR_RESPONSES from app.inbound.http.users.router import make_users_router def make_v1_router(*, cookie_name: str) -> APIRouter: - router = APIRouter(prefix="/api/v1") + router = APIRouter(prefix="/api/v1", responses=SERVER_ERROR_RESPONSES) router.include_router(make_account_router(cookie_name=cookie_name)) router.include_router(make_users_router(cookie_name=cookie_name)) return router diff --git a/src/app/inbound/http/errors/internal_server_error.py b/src/app/inbound/http/errors/internal_server_error.py new file mode 100644 index 00000000..3d1605b3 --- /dev/null +++ b/src/app/inbound/http/errors/internal_server_error.py @@ -0,0 +1,7 @@ +from fastapi_error_map import StructuredErrorResponse, Translator, structured +from starlette import status + + +def internal_server_error(exc: Exception) -> StructuredErrorResponse: + translate: Translator[StructuredErrorResponse] = structured()(status.HTTP_500_INTERNAL_SERVER_ERROR) + return translate(exc) diff --git a/src/app/inbound/http/errors/openapi_responses.py b/src/app/inbound/http/errors/openapi_responses.py new file mode 100644 index 00000000..eed75944 --- /dev/null +++ b/src/app/inbound/http/errors/openapi_responses.py @@ -0,0 +1,10 @@ +from typing import Any, Final + +from fastapi_error_map import StructuredErrorResponse +from starlette import status + +type OpenApiResponses = dict[int | str, dict[str, Any]] + +SERVER_ERROR_RESPONSES: Final[OpenApiResponses] = { + status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": StructuredErrorResponse}, +} diff --git a/src/app/inbound/http/errors/router.py b/src/app/inbound/http/errors/router.py new file mode 100644 index 00000000..7b58bed7 --- /dev/null +++ b/src/app/inbound/http/errors/router.py @@ -0,0 +1,19 @@ +from typing import Any + +from fastapi_error_map import ErrorAwareRouter, ErrorMap, OnError, structured + + +def make_error_aware_router( + *, + error_map: ErrorMap | None = None, + on_error: OnError | None = None, + warn_on_unmapped: bool = True, + **kwargs: Any, +) -> ErrorAwareRouter: + return ErrorAwareRouter( + translator_factory=structured(), + error_map=error_map, + on_error=on_error, + warn_on_unmapped=warn_on_unmapped, + **kwargs, + ) diff --git a/src/app/inbound/http/errors/rules.py b/src/app/inbound/http/errors/rules.py index 6662739e..63276853 100644 --- a/src/app/inbound/http/errors/rules.py +++ b/src/app/inbound/http/errors/rules.py @@ -1,11 +1,11 @@ from typing import Final -from fastapi_error_map.rules import Rule, rule +from fastapi_error_map import Rule, rule, structured from starlette import status -from app.inbound.http.errors.translators import ServiceUnavailableTranslator +SERVICE_UNAVAILABLE_MESSAGE: Final[str] = "Service temporarily unavailable. Please try again later." HTTP_503_SERVICE_UNAVAILABLE_RULE: Final[Rule] = rule( status=status.HTTP_503_SERVICE_UNAVAILABLE, - translator=ServiceUnavailableTranslator(), + translator=structured(server_message=SERVICE_UNAVAILABLE_MESSAGE)(status.HTTP_503_SERVICE_UNAVAILABLE), ) diff --git a/src/app/inbound/http/errors/translators.py b/src/app/inbound/http/errors/translators.py deleted file mode 100644 index 221b0b53..00000000 --- a/src/app/inbound/http/errors/translators.py +++ /dev/null @@ -1,10 +0,0 @@ -from fastapi_error_map import ErrorTranslator, SimpleErrorResponseModel - - -class ServiceUnavailableTranslator(ErrorTranslator[SimpleErrorResponseModel]): - @property - def error_response_model_cls(self) -> type[SimpleErrorResponseModel]: - return SimpleErrorResponseModel - - def from_error(self, err: Exception) -> SimpleErrorResponseModel: - return SimpleErrorResponseModel(error="Service temporarily unavailable. Please try again later.") diff --git a/src/app/inbound/http/users/activate_user.py b/src/app/inbound/http/users/activate_user.py index 4264aa1c..9b95afac 100644 --- a/src/app/inbound/http/users/activate_user.py +++ b/src/app/inbound/http/users/activate_user.py @@ -5,19 +5,19 @@ from dishka import FromDishka from dishka.integrations.fastapi import inject from fastapi import APIRouter, Path, status -from fastapi_error_map import ErrorAwareRouter from app.core.commands.activate_user import ActivateUser, ActivateUserRequest from app.core.commands.exceptions import UserNotFoundError 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.exceptions import StorageError def make_activate_user_router() -> APIRouter: - router = ErrorAwareRouter() + router = make_error_aware_router(on_error=log_info) @router.put( "/{user_id}/activation/", @@ -27,7 +27,6 @@ def make_activate_user_router() -> APIRouter: AuthorizationError: status.HTTP_403_FORBIDDEN, UserNotFoundError: status.HTTP_404_NOT_FOUND, }, - default_on_error=log_info, status_code=status.HTTP_204_NO_CONTENT, description=getdoc(ActivateUser), ) diff --git a/src/app/inbound/http/users/create_user.py b/src/app/inbound/http/users/create_user.py index 069f3f49..548780d9 100644 --- a/src/app/inbound/http/users/create_user.py +++ b/src/app/inbound/http/users/create_user.py @@ -3,7 +3,6 @@ from dishka import FromDishka from dishka.integrations.fastapi import inject from fastapi import APIRouter -from fastapi_error_map import ErrorAwareRouter from starlette import status from app.core.commands.create_user import CreateUser, CreateUserRequest, CreateUserResponse @@ -13,6 +12,7 @@ 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 AuthenticationError @@ -20,7 +20,7 @@ def make_create_user_router() -> APIRouter: - router = ErrorAwareRouter() + router = make_error_aware_router(on_error=log_info) @router.post( "/", @@ -32,7 +32,6 @@ def make_create_user_router() -> APIRouter: PasswordHasherBusyError: HTTP_503_SERVICE_UNAVAILABLE_RULE, UsernameAlreadyExistsError: status.HTTP_409_CONFLICT, }, - default_on_error=log_info, status_code=status.HTTP_201_CREATED, description=getdoc(CreateUser), ) diff --git a/src/app/inbound/http/users/deactivate_user.py b/src/app/inbound/http/users/deactivate_user.py index 19541543..a2c10aac 100644 --- a/src/app/inbound/http/users/deactivate_user.py +++ b/src/app/inbound/http/users/deactivate_user.py @@ -5,19 +5,19 @@ from dishka import FromDishka from dishka.integrations.fastapi import inject from fastapi import APIRouter, Path, status -from fastapi_error_map import ErrorAwareRouter from app.core.commands.deactivate_user import DeactivateUser, DeactivateUserRequest from app.core.commands.exceptions import UserNotFoundError 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.exceptions import StorageError def make_deactivate_user_router() -> APIRouter: - router = ErrorAwareRouter() + router = make_error_aware_router(on_error=log_info) @router.delete( "/{user_id}/activation/", @@ -27,7 +27,6 @@ def make_deactivate_user_router() -> APIRouter: AuthorizationError: status.HTTP_403_FORBIDDEN, UserNotFoundError: status.HTTP_404_NOT_FOUND, }, - default_on_error=log_info, status_code=status.HTTP_204_NO_CONTENT, description=getdoc(DeactivateUser), ) diff --git a/src/app/inbound/http/users/grant_admin.py b/src/app/inbound/http/users/grant_admin.py index c473e8a9..e4bdd9b5 100644 --- a/src/app/inbound/http/users/grant_admin.py +++ b/src/app/inbound/http/users/grant_admin.py @@ -5,19 +5,19 @@ from dishka import FromDishka from dishka.integrations.fastapi import inject from fastapi import APIRouter, Path, status -from fastapi_error_map import ErrorAwareRouter from app.core.commands.exceptions import UserNotFoundError from app.core.commands.grant_admin import GrantAdmin, GrantAdminRequest 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.exceptions import StorageError def make_grant_admin_router() -> APIRouter: - router = ErrorAwareRouter() + router = make_error_aware_router(on_error=log_info) @router.put( "/{user_id}/roles/admin/", @@ -27,7 +27,6 @@ def make_grant_admin_router() -> APIRouter: AuthorizationError: status.HTTP_403_FORBIDDEN, UserNotFoundError: status.HTTP_404_NOT_FOUND, }, - default_on_error=log_info, status_code=status.HTTP_204_NO_CONTENT, description=getdoc(GrantAdmin), ) diff --git a/src/app/inbound/http/users/list_users.py b/src/app/inbound/http/users/list_users.py index 6994cdd4..ec9f09bb 100644 --- a/src/app/inbound/http/users/list_users.py +++ b/src/app/inbound/http/users/list_users.py @@ -4,7 +4,6 @@ from dishka import FromDishka from dishka.integrations.fastapi import inject from fastapi import APIRouter, Depends -from fastapi_error_map import ErrorAwareRouter from pydantic import BaseModel, ConfigDict, Field from starlette import status @@ -15,6 +14,7 @@ from app.core.queries.query_support.offset_pagination import OffsetPaginationParams from app.core.queries.query_support.sorting import SortingOrder 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.exceptions import ReaderError, StorageError @@ -35,7 +35,7 @@ class ListUsersRequestSchema(BaseModel): def make_list_users_router() -> APIRouter: - router = ErrorAwareRouter() + router = make_error_aware_router(on_error=log_info) @router.get( "/", @@ -46,7 +46,6 @@ def make_list_users_router() -> APIRouter: PaginationError: status.HTTP_400_BAD_REQUEST, ReaderError: HTTP_503_SERVICE_UNAVAILABLE_RULE, }, - default_on_error=log_info, status_code=status.HTTP_200_OK, description=getdoc(ListUsers), ) diff --git a/src/app/inbound/http/users/revoke_admin.py b/src/app/inbound/http/users/revoke_admin.py index 8ba0d36c..d3106d83 100644 --- a/src/app/inbound/http/users/revoke_admin.py +++ b/src/app/inbound/http/users/revoke_admin.py @@ -5,19 +5,19 @@ from dishka import FromDishka from dishka.integrations.fastapi import inject from fastapi import APIRouter, Path, status -from fastapi_error_map import ErrorAwareRouter from app.core.commands.exceptions import UserNotFoundError from app.core.commands.revoke_admin import RevokeAdmin, RevokeAdminRequest 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.exceptions import StorageError def make_revoke_admin_router() -> APIRouter: - router = ErrorAwareRouter() + router = make_error_aware_router(on_error=log_info) @router.delete( "/{user_id}/roles/admin/", @@ -27,7 +27,6 @@ def make_revoke_admin_router() -> APIRouter: AuthorizationError: status.HTTP_403_FORBIDDEN, UserNotFoundError: status.HTTP_404_NOT_FOUND, }, - default_on_error=log_info, status_code=status.HTTP_204_NO_CONTENT, description=getdoc(RevokeAdmin), ) diff --git a/src/app/inbound/http/users/set_user_password.py b/src/app/inbound/http/users/set_user_password.py index febe18b9..ded19f7c 100644 --- a/src/app/inbound/http/users/set_user_password.py +++ b/src/app/inbound/http/users/set_user_password.py @@ -5,7 +5,6 @@ from dishka import FromDishka from dishka.integrations.fastapi import inject from fastapi import APIRouter, Path -from fastapi_error_map import ErrorAwareRouter from pydantic import BaseModel, ConfigDict from starlette import status @@ -14,6 +13,7 @@ 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 AuthenticationError @@ -32,7 +32,7 @@ class SetUserPasswordRequestSchema(BaseModel): def make_set_user_password_router() -> APIRouter: - router = ErrorAwareRouter() + router = make_error_aware_router(on_error=log_info) @router.put( "/{user_id}/password/", @@ -44,7 +44,6 @@ def make_set_user_password_router() -> APIRouter: UserNotFoundError: status.HTTP_404_NOT_FOUND, PasswordHasherBusyError: HTTP_503_SERVICE_UNAVAILABLE_RULE, }, - default_on_error=log_info, status_code=status.HTTP_204_NO_CONTENT, description=getdoc(SetUserPassword), ) diff --git a/src/app/main/setup.py b/src/app/main/setup.py index 4b947c73..49074e38 100644 --- a/src/app/main/setup.py +++ b/src/app/main/setup.py @@ -1,8 +1,12 @@ import logging from fastapi import FastAPI +from starlette import status +from starlette.requests import Request +from starlette.responses import JSONResponse from app.inbound.http.auth_cookie_middleware import AuthCookieMiddleware +from app.inbound.http.errors.internal_server_error import internal_server_error from app.main.config.logging_ import DATEFMT, FMT, LoggingLevel from app.main.config.settings import CookieSettings @@ -31,6 +35,13 @@ def setup_middlewares(app: FastAPI, cookie_settings: CookieSettings) -> None: logger.info("Middlewares are set up") -def setup_global_exception_handlers(_app: FastAPI) -> None: - # A place to register global exception handlers +def setup_global_exception_handlers(app: FastAPI) -> None: + @app.exception_handler(Exception) + async def handle_unexpected(_request: Request, exc: Exception) -> JSONResponse: + logger.exception("Unhandled exception") + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content=internal_server_error(exc), + ) + logger.info("Global exception handlers are set up") diff --git a/src/app/outbound/adapters/sqla_user_reader.py b/src/app/outbound/adapters/sqla_user_reader.py index 69188c88..a8ae7909 100644 --- a/src/app/outbound/adapters/sqla_user_reader.py +++ b/src/app/outbound/adapters/sqla_user_reader.py @@ -23,7 +23,7 @@ async def list_users( ) -> ListUsersQm: sorting_column = users_table.c.get(sorting.field) if sorting_column is None: - raise SortingError(f"Invalid sorting field: '{sorting.field}'") + raise SortingError("Invalid sorting field") order_by_expression = sorting_column.asc() if sorting.order == SortingOrder.ASC else sorting_column.desc() secondary_order_by = users_table.c.id.asc() if sorting.order == SortingOrder.ASC else users_table.c.id.desc() stmt = ( diff --git a/src/app/outbound/auth_ctx/utc_timer.py b/src/app/outbound/auth_ctx/utc_timer.py index 41244f49..9c38de6c 100644 --- a/src/app/outbound/auth_ctx/utc_timer.py +++ b/src/app/outbound/auth_ctx/utc_timer.py @@ -22,7 +22,7 @@ def expiration_from_now(self) -> UtcDatetime: return UtcDatetime(self.now.value + self._ttl) def is_expired(self, session: AuthSession) -> bool: - return session.expiration.value <= self.now.value + return session.expiration <= self.now def needs_refresh(self, session: AuthSession) -> bool: remaining = session.expiration.value - self.now.value diff --git a/tests/integration/with_infra/account/test_change_password.py b/tests/integration/with_infra/account/test_change_password.py index c2484ec0..b4d3aacf 100644 --- a/tests/integration/with_infra/account/test_change_password.py +++ b/tests/integration/with_infra/account/test_change_password.py @@ -1,4 +1,4 @@ -import httpx +import httpx2 from sqlalchemy.ext.asyncio import AsyncSession from app.core.common.services.user import UserService @@ -9,7 +9,7 @@ async def test_returns_204_and_changes_password( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, it_session: AsyncSession, it_user_service: UserService, ) -> None: @@ -29,7 +29,7 @@ async def test_returns_204_and_changes_password( async def test_returns_400_when_new_password_is_too_short( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, it_session: AsyncSession, it_user_service: UserService, ) -> None: @@ -46,7 +46,7 @@ async def test_returns_400_when_new_password_is_too_short( async def test_returns_400_when_new_password_equals_current( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, it_session: AsyncSession, it_user_service: UserService, ) -> None: @@ -63,7 +63,7 @@ async def test_returns_400_when_new_password_equals_current( async def test_returns_401_when_not_authenticated( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, ) -> None: payload = {"current_password": create_raw_password(), "new_password": create_raw_password()} @@ -73,7 +73,7 @@ async def test_returns_401_when_not_authenticated( async def test_returns_403_when_current_password_is_wrong( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, it_session: AsyncSession, it_user_service: UserService, ) -> None: diff --git a/tests/integration/with_infra/account/test_log_in.py b/tests/integration/with_infra/account/test_log_in.py index 87b03e2b..89e6e3b3 100644 --- a/tests/integration/with_infra/account/test_log_in.py +++ b/tests/integration/with_infra/account/test_log_in.py @@ -1,4 +1,4 @@ -import httpx +import httpx2 from sqlalchemy.ext.asyncio import AsyncSession from app.core.common.services.user import UserService @@ -14,7 +14,7 @@ async def test_returns_204_and_sets_cookie( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, it_session: AsyncSession, it_user_service: UserService, ) -> None: @@ -31,7 +31,7 @@ async def test_returns_204_and_sets_cookie( async def test_returns_400_when_username_is_too_short( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, ) -> None: payload = {"username": "x" * (Username.MIN_LEN - 1), "password": create_raw_password()} @@ -41,7 +41,7 @@ async def test_returns_400_when_username_is_too_short( async def test_returns_400_when_password_is_too_short( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, ) -> None: payload = {"username": create_raw_username(), "password": "x" * (RawPassword.MIN_LEN - 1)} @@ -51,7 +51,7 @@ async def test_returns_400_when_password_is_too_short( async def test_returns_401_when_user_does_not_exist( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, ) -> None: payload = {"username": create_raw_username(), "password": create_raw_password()} @@ -61,7 +61,7 @@ async def test_returns_401_when_user_does_not_exist( async def test_returns_401_when_password_is_wrong( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, it_session: AsyncSession, it_user_service: UserService, ) -> None: @@ -76,7 +76,7 @@ async def test_returns_401_when_password_is_wrong( async def test_returns_401_when_user_is_inactive( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, it_session: AsyncSession, it_user_service: UserService, ) -> None: @@ -92,7 +92,7 @@ async def test_returns_401_when_user_is_inactive( async def test_returns_403_when_already_authenticated( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, it_session: AsyncSession, it_user_service: UserService, ) -> None: diff --git a/tests/integration/with_infra/account/test_log_out.py b/tests/integration/with_infra/account/test_log_out.py index 02ad78fb..78f826b7 100644 --- a/tests/integration/with_infra/account/test_log_out.py +++ b/tests/integration/with_infra/account/test_log_out.py @@ -1,4 +1,4 @@ -import httpx +import httpx2 from sqlalchemy.ext.asyncio import AsyncSession from app.core.common.services.user import UserService @@ -8,7 +8,7 @@ async def test_returns_204_and_clears_cookie( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, it_session: AsyncSession, it_user_service: UserService, ) -> None: @@ -25,7 +25,7 @@ async def test_returns_204_and_clears_cookie( async def test_returns_401_when_not_authenticated( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, ) -> None: r = await it_client.delete(LOG_OUT_ENDPOINT) diff --git a/tests/integration/with_infra/account/test_sign_up.py b/tests/integration/with_infra/account/test_sign_up.py index 5386beec..ebf481a8 100644 --- a/tests/integration/with_infra/account/test_sign_up.py +++ b/tests/integration/with_infra/account/test_sign_up.py @@ -1,4 +1,4 @@ -import httpx +import httpx2 from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -19,7 +19,7 @@ async def test_returns_204_and_creates_user( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, it_session: AsyncSession, ) -> None: username = create_raw_username() @@ -37,7 +37,7 @@ async def test_returns_204_and_creates_user( async def test_returns_400_when_username_is_too_short( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, ) -> None: payload = {"username": "x" * (Username.MIN_LEN - 1), "password": create_raw_password()} @@ -47,7 +47,7 @@ async def test_returns_400_when_username_is_too_short( async def test_returns_400_when_password_is_too_short( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, ) -> None: payload = {"username": create_raw_username(), "password": "x" * (RawPassword.MIN_LEN - 1)} @@ -57,7 +57,7 @@ async def test_returns_400_when_password_is_too_short( async def test_returns_409_when_username_already_exists( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, it_session: AsyncSession, it_user_service: UserService, ) -> None: @@ -76,7 +76,7 @@ async def test_returns_409_when_username_already_exists( async def test_returns_403_when_already_authenticated( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, it_session: AsyncSession, it_user_service: UserService, ) -> None: diff --git a/tests/integration/with_infra/authentication.py b/tests/integration/with_infra/authentication.py index 005ba14b..590bb4cf 100644 --- a/tests/integration/with_infra/authentication.py +++ b/tests/integration/with_infra/authentication.py @@ -1,8 +1,8 @@ -import httpx +import httpx2 from tests.integration.with_infra.account.constants import LOG_IN_ENDPOINT -async def authenticate(client: httpx.AsyncClient, username: str, password: str) -> None: +async def authenticate(client: httpx2.AsyncClient, username: str, password: str) -> None: r = await client.post(LOG_IN_ENDPOINT, json={"username": username, "password": password}) assert r.status_code == 204 diff --git a/tests/integration/with_infra/conftest.py b/tests/integration/with_infra/conftest.py index c961f569..1d831954 100644 --- a/tests/integration/with_infra/conftest.py +++ b/tests/integration/with_infra/conftest.py @@ -2,7 +2,7 @@ from typing import Final, cast import asgi_lifespan -import httpx +import httpx2 import pytest from dishka import Provider from fastapi import FastAPI @@ -35,14 +35,14 @@ def it_fastapi_app(it_di_overrides: Sequence[Provider]) -> FastAPI: @pytest.fixture -async def it_client(it_fastapi_app: FastAPI) -> AsyncIterator[httpx.AsyncClient]: +async def it_client(it_fastapi_app: FastAPI) -> AsyncIterator[httpx2.AsyncClient]: async with ( asgi_lifespan.LifespanManager( it_fastapi_app, startup_timeout=LIFESPAN_MANAGER_STARTUP_TIMEOUT_S, ), - httpx.AsyncClient( - transport=httpx.ASGITransport(app=it_fastapi_app), + httpx2.AsyncClient( + transport=httpx2.ASGITransport(app=it_fastapi_app), base_url="http://test", ) as client, ): @@ -51,7 +51,7 @@ async def it_client(it_fastapi_app: FastAPI) -> AsyncIterator[httpx.AsyncClient] @pytest.fixture async def it_sessionmaker( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, it_fastapi_app: FastAPI, ) -> async_sessionmaker[AsyncSession]: container = it_fastapi_app.state.dishka_container @@ -86,7 +86,7 @@ async def it_session( @pytest.fixture async def it_user_service( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, it_fastapi_app: FastAPI, ) -> UserService: container = it_fastapi_app.state.dishka_container diff --git a/tests/integration/with_infra/users/conftest.py b/tests/integration/with_infra/users/conftest.py index b850a419..1f0b256b 100644 --- a/tests/integration/with_infra/users/conftest.py +++ b/tests/integration/with_infra/users/conftest.py @@ -1,4 +1,4 @@ -import httpx +import httpx2 import pytest from sqlalchemy.ext.asyncio import AsyncSession @@ -15,7 +15,7 @@ @pytest.fixture async def it_admin( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, it_session: AsyncSession, it_user_service: UserService, ) -> User: @@ -29,7 +29,7 @@ async def it_admin( @pytest.fixture async def it_super_admin( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, it_session: AsyncSession, it_user_service: UserService, ) -> User: diff --git a/tests/integration/with_infra/users/test_activate_user.py b/tests/integration/with_infra/users/test_activate_user.py index 9d3bdba9..61ac9d80 100644 --- a/tests/integration/with_infra/users/test_activate_user.py +++ b/tests/integration/with_infra/users/test_activate_user.py @@ -1,4 +1,4 @@ -import httpx +import httpx2 from sqlalchemy.ext.asyncio import AsyncSession from app.core.common.entities.types_ import UserRole @@ -15,7 +15,7 @@ async def test_returns_204_and_activates_user( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, it_session: AsyncSession, it_admin: User, it_user_service: UserService, @@ -32,7 +32,7 @@ async def test_returns_204_and_activates_user( async def test_returns_204_when_user_already_active( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, it_session: AsyncSession, it_admin: User, it_user_service: UserService, @@ -49,7 +49,7 @@ async def test_returns_204_when_user_already_active( async def test_returns_401_when_not_authenticated( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, ) -> None: r = await it_client.put(f"{USERS_ENDPOINT}{create_raw_user_id()}/activation/") @@ -57,7 +57,7 @@ async def test_returns_401_when_not_authenticated( async def test_returns_403_when_user_role( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, it_session: AsyncSession, it_user_service: UserService, ) -> None: @@ -74,7 +74,7 @@ async def test_returns_403_when_user_role( async def test_returns_403_when_admin_targets_admin( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, it_session: AsyncSession, it_admin: User, it_user_service: UserService, @@ -89,7 +89,7 @@ async def test_returns_403_when_admin_targets_admin( async def test_returns_404_when_user_not_found( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, it_admin: User, ) -> None: r = await it_client.put(f"{USERS_ENDPOINT}{create_raw_user_id()}/activation/") diff --git a/tests/integration/with_infra/users/test_create_user.py b/tests/integration/with_infra/users/test_create_user.py index 9350a296..d0bedbf3 100644 --- a/tests/integration/with_infra/users/test_create_user.py +++ b/tests/integration/with_infra/users/test_create_user.py @@ -1,4 +1,4 @@ -import httpx +import httpx2 from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -18,7 +18,7 @@ async def test_returns_201_and_creates_user( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, it_session: AsyncSession, it_admin: User, ) -> None: @@ -38,7 +38,7 @@ async def test_returns_201_and_creates_user( async def test_returns_201_and_super_admin_creates_admin( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, it_session: AsyncSession, it_super_admin: User, ) -> None: @@ -55,7 +55,7 @@ async def test_returns_201_and_super_admin_creates_admin( async def test_returns_400_when_username_is_too_short( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, it_admin: User, ) -> None: payload = {"username": "x" * (Username.MIN_LEN - 1), "password": create_raw_password(), "role": "user"} @@ -66,7 +66,7 @@ async def test_returns_400_when_username_is_too_short( async def test_returns_400_when_password_is_too_short( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, it_admin: User, ) -> None: payload = {"username": create_raw_username(), "password": "x" * (RawPassword.MIN_LEN - 1), "role": "user"} @@ -77,7 +77,7 @@ async def test_returns_400_when_password_is_too_short( async def test_returns_401_when_not_authenticated( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, ) -> None: payload = {"username": create_raw_username(), "password": create_raw_password(), "role": "user"} @@ -87,7 +87,7 @@ async def test_returns_401_when_not_authenticated( async def test_returns_403_when_user_role( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, it_session: AsyncSession, it_user_service: UserService, ) -> None: @@ -104,7 +104,7 @@ async def test_returns_403_when_user_role( async def test_returns_403_when_admin_creates_admin( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, it_admin: User, ) -> None: payload = {"username": create_raw_username(), "password": create_raw_password(), "role": "admin"} @@ -115,7 +115,7 @@ async def test_returns_403_when_admin_creates_admin( async def test_returns_409_when_username_already_exists( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, it_session: AsyncSession, it_admin: User, it_user_service: UserService, diff --git a/tests/integration/with_infra/users/test_deactivate_user.py b/tests/integration/with_infra/users/test_deactivate_user.py index 2c093399..f761bce8 100644 --- a/tests/integration/with_infra/users/test_deactivate_user.py +++ b/tests/integration/with_infra/users/test_deactivate_user.py @@ -1,4 +1,4 @@ -import httpx +import httpx2 from sqlalchemy.ext.asyncio import AsyncSession from app.core.common.entities.types_ import UserRole @@ -15,7 +15,7 @@ async def test_returns_204_and_deactivates_user( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, it_session: AsyncSession, it_admin: User, it_user_service: UserService, @@ -32,7 +32,7 @@ async def test_returns_204_and_deactivates_user( async def test_returns_204_when_user_already_inactive( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, it_session: AsyncSession, it_admin: User, it_user_service: UserService, @@ -49,7 +49,7 @@ async def test_returns_204_when_user_already_inactive( async def test_returns_401_when_not_authenticated( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, ) -> None: r = await it_client.delete(f"{USERS_ENDPOINT}{create_raw_user_id()}/activation/") @@ -57,7 +57,7 @@ async def test_returns_401_when_not_authenticated( async def test_returns_403_when_user_role( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, it_session: AsyncSession, it_user_service: UserService, ) -> None: @@ -74,7 +74,7 @@ async def test_returns_403_when_user_role( async def test_returns_403_when_admin_targets_admin( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, it_session: AsyncSession, it_admin: User, it_user_service: UserService, @@ -89,7 +89,7 @@ async def test_returns_403_when_admin_targets_admin( async def test_returns_404_when_user_not_found( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, it_admin: User, ) -> None: r = await it_client.delete(f"{USERS_ENDPOINT}{create_raw_user_id()}/activation/") diff --git a/tests/integration/with_infra/users/test_grant_admin.py b/tests/integration/with_infra/users/test_grant_admin.py index e962fb4d..04314fd5 100644 --- a/tests/integration/with_infra/users/test_grant_admin.py +++ b/tests/integration/with_infra/users/test_grant_admin.py @@ -1,4 +1,4 @@ -import httpx +import httpx2 from sqlalchemy.ext.asyncio import AsyncSession from app.core.common.entities.types_ import UserRole @@ -15,7 +15,7 @@ async def test_returns_204_and_grants_admin( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, it_session: AsyncSession, it_super_admin: User, it_user_service: UserService, @@ -32,7 +32,7 @@ async def test_returns_204_and_grants_admin( async def test_returns_204_when_user_already_admin( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, it_session: AsyncSession, it_super_admin: User, it_user_service: UserService, @@ -49,7 +49,7 @@ async def test_returns_204_when_user_already_admin( async def test_returns_401_when_not_authenticated( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, ) -> None: r = await it_client.put(f"{USERS_ENDPOINT}{create_raw_user_id()}/roles/admin/") @@ -57,7 +57,7 @@ async def test_returns_401_when_not_authenticated( async def test_returns_403_when_admin_role( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, it_session: AsyncSession, it_admin: User, it_user_service: UserService, @@ -72,7 +72,7 @@ async def test_returns_403_when_admin_role( async def test_returns_403_when_user_role( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, it_session: AsyncSession, it_user_service: UserService, ) -> None: @@ -89,7 +89,7 @@ async def test_returns_403_when_user_role( async def test_returns_404_when_user_not_found( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, it_super_admin: User, ) -> None: r = await it_client.put(f"{USERS_ENDPOINT}{create_raw_user_id()}/roles/admin/") diff --git a/tests/integration/with_infra/users/test_list_users.py b/tests/integration/with_infra/users/test_list_users.py index 3e4198e2..3ad23d44 100644 --- a/tests/integration/with_infra/users/test_list_users.py +++ b/tests/integration/with_infra/users/test_list_users.py @@ -1,6 +1,6 @@ from datetime import timedelta -import httpx +import httpx2 from sqlalchemy.ext.asyncio import AsyncSession from app.core.common.entities.types_ import UserRole @@ -17,7 +17,7 @@ async def test_returns_200_and_lists_single_user( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, it_admin: User, ) -> None: r = await it_client.get(USERS_ENDPOINT) @@ -33,7 +33,7 @@ async def test_returns_200_and_lists_single_user( async def test_returns_200_and_lists_multiple_users( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, it_session: AsyncSession, it_admin: User, it_user_service: UserService, @@ -52,7 +52,7 @@ async def test_returns_200_and_lists_multiple_users( async def test_returns_200_and_respects_pagination_params( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, it_session: AsyncSession, it_admin: User, it_user_service: UserService, @@ -72,7 +72,7 @@ async def test_returns_200_and_respects_pagination_params( async def test_returns_200_and_sorts_by_updated_at_desc( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, it_session: AsyncSession, it_user_service: UserService, ) -> None: @@ -98,7 +98,7 @@ async def test_returns_200_and_sorts_by_updated_at_desc( async def test_returns_200_and_sorts_by_username_asc( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, it_session: AsyncSession, it_user_service: UserService, ) -> None: @@ -112,7 +112,7 @@ async def test_returns_200_and_sorts_by_username_asc( await it_session.commit() await authenticate(it_client, admin.username.value, admin_password) - r = await it_client.get(USERS_ENDPOINT, params={"sorting_field": "username", "sorting_order": "ASC"}) + r = await it_client.get(USERS_ENDPOINT, params={"sorting_field": "username", "sorting_order": "asc"}) assert r.status_code == 200 payload = r.json() @@ -123,7 +123,7 @@ async def test_returns_200_and_sorts_by_username_asc( async def test_returns_401_when_not_authenticated( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, ) -> None: r = await it_client.get(USERS_ENDPOINT) @@ -131,7 +131,7 @@ async def test_returns_401_when_not_authenticated( async def test_returns_403_when_user_role( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, it_session: AsyncSession, it_user_service: UserService, ) -> None: diff --git a/tests/integration/with_infra/users/test_revoke_admin.py b/tests/integration/with_infra/users/test_revoke_admin.py index a01c6001..70b4c4c9 100644 --- a/tests/integration/with_infra/users/test_revoke_admin.py +++ b/tests/integration/with_infra/users/test_revoke_admin.py @@ -1,4 +1,4 @@ -import httpx +import httpx2 from sqlalchemy.ext.asyncio import AsyncSession from app.core.common.entities.types_ import UserRole @@ -15,7 +15,7 @@ async def test_returns_204_and_revokes_admin( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, it_session: AsyncSession, it_super_admin: User, it_user_service: UserService, @@ -32,7 +32,7 @@ async def test_returns_204_and_revokes_admin( async def test_returns_204_when_user_already_not_admin( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, it_session: AsyncSession, it_super_admin: User, it_user_service: UserService, @@ -49,7 +49,7 @@ async def test_returns_204_when_user_already_not_admin( async def test_returns_401_when_not_authenticated( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, ) -> None: r = await it_client.delete(f"{USERS_ENDPOINT}{create_raw_user_id()}/roles/admin/") @@ -57,7 +57,7 @@ async def test_returns_401_when_not_authenticated( async def test_returns_403_when_admin_role( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, it_session: AsyncSession, it_admin: User, it_user_service: UserService, @@ -72,7 +72,7 @@ async def test_returns_403_when_admin_role( async def test_returns_403_when_user_role( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, it_session: AsyncSession, it_user_service: UserService, ) -> None: @@ -89,7 +89,7 @@ async def test_returns_403_when_user_role( async def test_returns_404_when_user_not_found( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, it_super_admin: User, ) -> None: r = await it_client.delete(f"{USERS_ENDPOINT}{create_raw_user_id()}/roles/admin/") diff --git a/tests/integration/with_infra/users/test_set_user_password.py b/tests/integration/with_infra/users/test_set_user_password.py index 875b221a..11526a7e 100644 --- a/tests/integration/with_infra/users/test_set_user_password.py +++ b/tests/integration/with_infra/users/test_set_user_password.py @@ -1,4 +1,4 @@ -import httpx +import httpx2 from sqlalchemy.ext.asyncio import AsyncSession from app.core.common.entities.types_ import UserRole @@ -16,7 +16,7 @@ async def test_returns_204_and_sets_password( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, it_session: AsyncSession, it_admin: User, it_user_service: UserService, @@ -35,7 +35,7 @@ async def test_returns_204_and_sets_password( async def test_returns_400_when_password_is_too_short( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, it_session: AsyncSession, it_admin: User, it_user_service: UserService, @@ -51,7 +51,7 @@ async def test_returns_400_when_password_is_too_short( async def test_returns_401_when_not_authenticated( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, ) -> None: payload = {"password": create_raw_password()} @@ -61,7 +61,7 @@ async def test_returns_401_when_not_authenticated( async def test_returns_403_when_user_role( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, it_session: AsyncSession, it_user_service: UserService, ) -> None: @@ -79,7 +79,7 @@ async def test_returns_403_when_user_role( async def test_returns_403_when_admin_targets_admin( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, it_session: AsyncSession, it_admin: User, it_user_service: UserService, @@ -95,7 +95,7 @@ async def test_returns_403_when_admin_targets_admin( async def test_returns_404_when_user_not_found( - it_client: httpx.AsyncClient, + it_client: httpx2.AsyncClient, it_admin: User, ) -> None: payload = {"password": create_raw_password()} diff --git a/tests/smoke/conftest.py b/tests/smoke/conftest.py index 972b935c..d3b62444 100644 --- a/tests/smoke/conftest.py +++ b/tests/smoke/conftest.py @@ -1,6 +1,6 @@ from collections.abc import AsyncIterator -import httpx +import httpx2 import pytest from asgi_lifespan import LifespanManager from fastapi import FastAPI @@ -14,11 +14,11 @@ def smoke_app() -> FastAPI: @pytest.fixture -async def smoke_client(smoke_app: FastAPI) -> AsyncIterator[httpx.AsyncClient]: +async def smoke_client(smoke_app: FastAPI) -> AsyncIterator[httpx2.AsyncClient]: async with ( LifespanManager(smoke_app, startup_timeout=60) as manager, - httpx.AsyncClient( - transport=httpx.ASGITransport(app=manager.app), + httpx2.AsyncClient( + transport=httpx2.ASGITransport(app=manager.app), base_url="http://test", ) as client, ): diff --git a/tests/smoke/inbound/http/test_health_router.py b/tests/smoke/inbound/http/test_health_router.py index c02da325..4082e43c 100644 --- a/tests/smoke/inbound/http/test_health_router.py +++ b/tests/smoke/inbound/http/test_health_router.py @@ -1,16 +1,16 @@ -import httpx +import httpx2 import pytest from fastapi import FastAPI, status -async def test_liveness_probe(smoke_client: httpx.AsyncClient) -> None: +async def test_liveness_probe(smoke_client: httpx2.AsyncClient) -> None: r = await smoke_client.get("/livez/") assert r.status_code == status.HTTP_200_OK assert r.json() == "OK" -async def test_readiness_probe(smoke_client: httpx.AsyncClient) -> None: +async def test_readiness_probe(smoke_client: httpx2.AsyncClient) -> None: r = await smoke_client.get("/healthz/") assert r.status_code == status.HTTP_200_OK @@ -18,7 +18,7 @@ async def test_readiness_probe(smoke_client: httpx.AsyncClient) -> None: async def test_error_handling_prod_contract( - smoke_client: httpx.AsyncClient, + smoke_client: httpx2.AsyncClient, smoke_app: FastAPI, ) -> None: if smoke_app.debug: diff --git a/uv.lock b/uv.lock index 70cab258..f53d4851 100644 --- a/uv.lock +++ b/uv.lock @@ -430,7 +430,7 @@ dev = [ { name = "asgi-lifespan" }, { name = "coverage" }, { name = "deptry" }, - { name = "httpx" }, + { name = "httpx2" }, { name = "import-linter" }, { name = "line-profiler" }, { name = "mypy" }, @@ -450,7 +450,7 @@ requires-dist = [ { name = "bcrypt", specifier = "==5.0.0" }, { name = "dishka", specifier = "==1.10.1" }, { name = "fastapi", specifier = "==0.136.1" }, - { name = "fastapi-error-map", specifier = "==0.9.10" }, + { name = "fastapi-error-map", specifier = "==1.0.0" }, { name = "psycopg", extras = ["binary"], specifier = "==3.3.4" }, { name = "pydantic-settings", specifier = "==2.14.1" }, { name = "pyjwt", extras = ["crypto"], specifier = "==2.12.1" }, @@ -464,7 +464,7 @@ dev = [ { name = "asgi-lifespan", specifier = "==2.1.0" }, { name = "coverage", specifier = "==7.14.0" }, { name = "deptry", specifier = "==0.25.1" }, - { name = "httpx", specifier = "==0.28.1" }, + { name = "httpx2", specifier = "==2.5.0" }, { name = "import-linter", specifier = "==2.11" }, { name = "line-profiler", specifier = "==5.0.2" }, { name = "mypy", specifier = "==2.1.0" }, @@ -480,15 +480,15 @@ dev = [ [[package]] name = "fastapi-error-map" -version = "0.9.10" +version = "1.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fastapi" }, - { name = "packaging" }, + { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/62/64/a48e0da4a9f07ac4893335f64e2ceccb44baeb3e296095f9f8d572d993b6/fastapi_error_map-0.9.10.tar.gz", hash = "sha256:38a62f48981515e5e861691d70c06675bb021e1c5be347d03518b09699d27ad4", size = 381321, upload-time = "2026-02-24T15:58:07.37Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/b7/275fdf248a4bd67f66c77200f5db039d6073b319f7724db7444afa68f593/fastapi_error_map-1.0.0.tar.gz", hash = "sha256:7799954314684b7106e326d7a0c2c2ba362fdfea0ddff7ec3e089dc10946744f", size = 337739, upload-time = "2026-06-24T00:15:33.698Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ac/1e/a4406d8f5dcf900be04bd4a0410d37487f5a1c1de6ba9a1c258e28414e32/fastapi_error_map-0.9.10-py3-none-any.whl", hash = "sha256:5bf727bf7de3a4aabdcb5107e29281b812060c6c198a16264e7fee20aa3c4c89", size = 21422, upload-time = "2026-02-24T15:58:06.315Z" }, + { url = "https://files.pythonhosted.org/packages/44/4f/071b43fc51580ed22e6cbf4f77a171be2d5866812c36e0bb007dcd4bbf4e/fastapi_error_map-1.0.0-py3-none-any.whl", hash = "sha256:bf8e2d6c8c43c1db96b28bc5eb34431576c176a2c35754c659fe88e17f26d7fe", size = 25572, upload-time = "2026-06-24T00:15:32.463Z" }, ] [[package]] @@ -561,31 +561,31 @@ wheels = [ ] [[package]] -name = "httpcore" -version = "1.0.9" +name = "httpcore2" +version = "2.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "certifi" }, { name = "h11" }, + { name = "truststore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/06/5c12df521b5322fb1114a83d46911b2fbcb8855ddb3a635f11c01a214af5/httpcore2-2.5.0.tar.gz", hash = "sha256:88aa170137c17328d5ac44234f9fd10706466d5fb347f3edac4d39b91137b09d", size = 64808, upload-time = "2026-06-25T14:16:56.472Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a1/7564199d1a8728fe737b0a72e5b3f8d92dfe085a74ddf7cdd83bce5f206d/httpcore2-2.5.0-py3-none-any.whl", hash = "sha256:5ce35188de461d31e8d000bfb8ef8bf22c6c16587a211e5571deaa5e9bdf842a", size = 80330, upload-time = "2026-06-25T14:16:53.634Z" }, ] [[package]] -name = "httpx" -version = "0.28.1" +name = "httpx2" +version = "2.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, + { name = "httpcore2" }, { name = "idna" }, + { name = "truststore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/e2/b5dedc0cf35aa65de5f541ccd30d2bc1fd7f1d43c9ab09f8ed9a7342317b/httpx2-2.5.0.tar.gz", hash = "sha256:e2df9cb4611021527ff8a675b1c320b610a2ec397acc8d6fe6e91df2d9b33c29", size = 83121, upload-time = "2026-06-25T14:16:57.491Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, + { url = "https://files.pythonhosted.org/packages/31/22/859d8252dad9bc9adee34b52e62cde621ece07b042ccb2ab4da1be46695f/httpx2-2.5.0-py3-none-any.whl", hash = "sha256:3d2d4d9cf4b61f1a1f46a95947cfdb47e80cb56a2f91c6256ac8f58e4891df41", size = 76652, upload-time = "2026-06-25T14:16:55.23Z" }, ] [[package]] @@ -599,11 +599,11 @@ wheels = [ [[package]] name = "idna" -version = "3.14" +version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/05/b1/efac073e0c297ecf2fb33c346989a529d4e19164f1759102dee5953ee17e/idna-3.14.tar.gz", hash = "sha256:466d810d7a2cc1022bea9b037c39728d51ae7dad40d480fc9b7d7ecf98ba8ee3", size = 198272, upload-time = "2026-05-10T20:32:15.935Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6c/3c/3f62dee257eb3d6b2c1ef2a09d36d9793c7111156a73b5654d2c2305e5ce/idna-3.14-py3-none-any.whl", hash = "sha256:e677eaf072e290f7b725f9acf0b3a2bd55f9fd6f7c70abe5f0e34823d0accf69", size = 72184, upload-time = "2026-05-10T20:32:14.295Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] @@ -1332,6 +1332,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0"