diff --git a/.dockerignore b/.dockerignore
index 78b7c1de..71d3a94a 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -2,9 +2,10 @@
**
# Except
+!docker-entrypoint.sh
!pyproject.toml
-!uv.lock
!README.md
-!config/**
-!src/**
+!uv.lock
!alembic.ini
+!src/**
+!tests/**
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 37f9aa54..be82dffc 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -7,43 +7,24 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - name: Checkout
+ uses: actions/checkout@v6
- name: Set up Python
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
python-version: "3.13"
- name: Install uv
- uses: astral-sh/setup-uv@v6
+ uses: astral-sh/setup-uv@v7
- name: Install dependencies
run: uv sync --locked --group dev
- name: Check code
- run: uv run make code.check
+ run: uv run make check
- - name: Test Docker Compose setup
+ - name: Test with Docker
env:
- APP_ENV: local
- run: |
- uv run config/toml_config_manager.py
- cd config/local
- docker compose --env-file .env.local up -d --build
-
- - name: Verify Application Health
- run: |
- timeout 10s bash -s <<'BASH'
- while ! curl -sf http://127.0.0.1:9999/api/v1/health; do
- sleep 1
- done
- BASH
-
- - name: Test Signup Handler
- run: |
- curl -f --json @- http://127.0.0.1:9999/api/v1/account/signup <<'JSON'
- {
- "username": "string",
- "password": "string"
- }
- JSON
+ ALLOW_DESTRUCTIVE_TEST_CLEANUP: 1
+ run: make test-docker
diff --git a/.gitignore b/.gitignore
index c2da5faa..3726ba47 100644
--- a/.gitignore
+++ b/.gitignore
@@ -159,18 +159,16 @@ cython_debug/
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
.idea/
-# Config
-config/local/*
-!config/local/.gitkeep
-config/dev/*
-!config/dev/.gitkeep
-config/prod/*
-!config/prod/.gitkeep
-.secrets.*
-.env.*
-
-# IgnoreToDo
+# other
+.claude/
+.import_linter_cache/
+.ruff_cache/
+.vscode/
+htmlcov-docker/
todo/
-# ImportLinter
-.import_linter_cache/
\ No newline at end of file
+.constraints.in
+.secrets
+AGENTS.md
+CLAUDE.md
+pylock.toml
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 175d857c..d9a97c98 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,14 +1,70 @@
+default_language_version:
+ python: python3.13
+
+default_stages: [pre-commit]
+
repos:
- repo: local
hooks:
- - id: make-check
- name: source-code-check
- entry: make code.check
+ - id: code-check
+ name: code-check (local)
+ entry: uv run make check
+ language: system
+ pass_filenames: false
+ - id: pip-audit-local
+ name: pip-audit (local)
+ entry: uv run make pip-audit
+ language: system
+ pass_filenames: false
+ verbose: true
+ - id: test-docker
+ name: test-docker (local)
+ entry: make test-docker
language: system
+ stages: [pre-push]
pass_filenames: false
- always_run: true
+ verbose: true
+
+ - repo: https://github.com/pre-commit/pre-commit-hooks
+ rev: v6.0.0
+ hooks:
+ - id: check-ast
+ - id: check-case-conflict
+ - id: trailing-whitespace
+ - id: end-of-file-fixer
+ exclude: ^docs/
+ - id: check-added-large-files
+ - id: check-docstring-first
+ - id: check-json
+ - id: check-toml
+ - id: check-yaml
+ exclude: docker-compose.test.yml
+ - id: detect-private-key
+ - id: debug-statements
+ - id: check-merge-conflict
+ - id: mixed-line-ending
+ args: ["--fix=lf"]
+ - id: no-commit-to-branch
+ args: [--branch, develop, --branch, dev, --branch, master, --branch, main]
+
+ - repo: https://github.com/crate-ci/typos
+ rev: v1.40.0
+ hooks:
+ - id: typos
+ args: [--force-exclude]
- repo: https://github.com/google/yamlfmt
rev: v0.20.0
hooks:
- id: yamlfmt
+ name: YAML formatter
+ files: (^|/).*\.ya?ml$
+ args:
+ - "--conf"
+ - ".yamlfmt"
+
+ - repo: https://github.com/koalaman/shellcheck-precommit
+ rev: v0.11.0
+ hooks:
+ - id: shellcheck
+ args: ["--severity=warning"]
diff --git a/.yamlfmt b/.yamlfmt
index 9a6db598..3620e44b 100644
--- a/.yamlfmt
+++ b/.yamlfmt
@@ -4,4 +4,4 @@ formatter:
type: basic
line_ending: lf
retain_line_breaks: true
- scan_folded_as_literal: true
\ No newline at end of file
+ scan_folded_as_literal: true
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 00000000..a47ded36
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,41 @@
+ARG PYTHON_VERSION=3.13
+FROM ghcr.io/astral-sh/uv:python${PYTHON_VERSION}-trixie-slim
+
+ARG APP_VERSION=develop
+ARG ENVIRONMENT="prod"
+
+ENV APP_VERSION=${APP_VERSION}
+ENV PYTHONDONTWRITEBYTECODE=1
+ENV PYTHONUNBUFFERED=1
+ENV UV_HTTP_TIMEOUT=300
+ENV UV_LINK_MODE=copy
+ENV UV_PROJECT_ENVIRONMENT=/usr/local
+
+WORKDIR /code
+
+COPY pyproject.toml uv.lock README.md ./
+
+RUN if [ "${ENVIRONMENT}" = "prod" ]; then \
+ uv sync --frozen --no-cache --no-dev --no-install-project; \
+ else \
+ uv sync --frozen --dev --no-install-project; \
+ fi
+
+COPY . .
+
+RUN if [ "${ENVIRONMENT}" = "prod" ]; then \
+ uv sync --frozen --no-cache --no-dev; \
+ else \
+ uv sync --frozen --dev; \
+ fi
+
+RUN groupadd -r runner
+RUN useradd -r -g runner -m -s /usr/sbin/nologin runner
+RUN chown -R runner:root /code
+RUN chmod -R g=u /code
+
+USER runner
+
+EXPOSE 8000
+
+ENTRYPOINT [ "/code/docker-entrypoint.sh" ]
diff --git a/Makefile b/Makefile
index d099c00a..20fffccb 100644
--- a/Makefile
+++ b/Makefile
@@ -1,100 +1,169 @@
+# Shell / Make config
+SHELL := bash
+.SHELLFLAGS := -eu -o pipefail -c
+
+.SILENT:
+MAKEFLAGS += --no-print-directory
+
+# -----------------------------
+# User-configurable variables (edit this)
+# -----------------------------
+PROJECT_NAME ?= $(notdir $(abspath .))
+INFRA_SERVICES ?= db_pg
+
+# -----------------------------
+# Internal vars / aliases
+# -----------------------------
+PYTHON_BIN := python
+DOCKER_COMPOSE := docker compose -p $(PROJECT_NAME)
+DOCKER_COMPOSE_PRUNE := scripts/makefile/docker_prune.sh
+
+# Test stack is isolated by project name
+TEST_PROJECT ?= $(PROJECT_NAME)-test
+DC_TEST_DOCKER := docker compose \
+ -p $(TEST_PROJECT) \
+ -f docker-compose.yml \
+ -f docker-compose.test.yml
+TEST_RUNNER := $(TEST_PROJECT)-runner
+
+# Pytest paths
+PYTEST_PATHS_LIGHT := \
+ tests/sanity \
+ tests/unit \
+ tests/integration/no_infra
+PYTEST_PATHS_ALL := \
+ $(PYTEST_PATHS_LIGHT) \
+ tests/smoke \
+ tests/integration/with_infra
+
+# Pytest args
+PYTEST_ARGS_VERBOSE := -s -vv
+PYTEST_ARGS_COV := \
+ --cov=src \
+ --cov-report=term-missing \
+ --cov-report=html
+PYTEST_ARGS_COV_DOCKER := \
+ --cov=src \
+ --cov-report=term-missing
+
+# Safety
+.PHONY: pip-audit
+pip-audit:
+ tmp=$$(mktemp -d); trap 'rm -rf "$$tmp"' EXIT; \
+ uv -qq export --format pylock.toml -o "$$tmp/pylock.toml"; \
+ pip-audit --locked "$$tmp" \
+ || echo "WARNING: pip-audit found vulnerabilities (non-blocking)" >&2
+
# Code quality
-.PHONY: code.format code.lint code.test code.cov code.cov.html code.check
-code.format:
+.PHONY: slotscheck lint test check
+slotscheck:
+ slotscheck $(SLOTSCHECK_TARGET) 2>&1 | tee /dev/stderr \
+ | { grep -m1 "Failed to import" || true; } | cut -d"'" -f2 \
+ | xargs -r -n1 $(PYTHON_BIN) -c 'import importlib,sys; importlib.import_module(sys.argv[1])'
+
+lint:
+ ruff check --fix
ruff format
-
-code.lint: code.format
+ tombi format
+ tombi lint
deptry
- slotscheck src
+ $(MAKE) slotscheck SLOTSCHECK_TARGET=src
lint-imports
- ruff check --exit-non-zero-on-fix
mypy
-code.test:
- pytest -v
-
-code.cov:
- coverage run -m pytest
- coverage combine
- coverage report
+test:
+ pytest -v \
+ $(PYTEST_PATHS_LIGHT) \
+ $(PYTEST_ARGS_COV)
-code.cov.html:
- coverage run -m pytest
- coverage combine
+check: lint test
coverage html
-code.check: code.lint code.test
-
-# Environment
-PYTHON := python
-CONFIGS_DIG := config
-TOML_CONFIG_MANAGER := $(CONFIGS_DIG)/toml_config_manager.py
-
-.PHONY: guard-APP_ENV
-guard-APP_ENV:
- @if [ -z "$$APP_ENV" ]; then \
- echo "APP_ENV is not set. Set APP_ENV before running this command."; \
- exit 1; \
- fi
-
-.PHONY: env dotenv
-env:
- @echo APP_ENV=$(APP_ENV)
-
-dotenv: guard-APP_ENV
- @$(PYTHON) $(TOML_CONFIG_MANAGER) $(APP_ENV)
-
# Docker compose
-DOCKER_COMPOSE := docker compose
-DOCKER_COMPOSE_PRUNE := scripts/makefile/docker_prune.sh
-
-.PHONY: up.db up.db-echo up up.echo down down.total logs.db shell.db prune
-up.db: guard-APP_ENV
- @echo "APP_ENV=$(APP_ENV)"
- @cd $(CONFIGS_DIG)/$(APP_ENV) && $(DOCKER_COMPOSE) --env-file .env.$(APP_ENV) up -d web_app_db_pg
-
-up.db-echo: guard-APP_ENV
- @echo "APP_ENV=$(APP_ENV)"
- @cd $(CONFIGS_DIG)/$(APP_ENV) && $(DOCKER_COMPOSE) --env-file .env.$(APP_ENV) up web_app_db_pg
-
-up: guard-APP_ENV
- @echo "APP_ENV=$(APP_ENV)"
- @cd $(CONFIGS_DIG)/$(APP_ENV) && $(DOCKER_COMPOSE) --env-file .env.$(APP_ENV) up -d --build
-
-up.echo: guard-APP_ENV
- @echo "APP_ENV=$(APP_ENV)"
- @cd $(CONFIGS_DIG)/$(APP_ENV) && $(DOCKER_COMPOSE) --env-file .env.$(APP_ENV) up --build
-
-down.db: guard-APP_ENV
- @cd $(CONFIGS_DIG)/$(APP_ENV) && $(DOCKER_COMPOSE) --env-file .env.$(APP_ENV) down web_app_db_pg
-
-down: guard-APP_ENV
- @cd $(CONFIGS_DIG)/$(APP_ENV) && $(DOCKER_COMPOSE) --env-file .env.$(APP_ENV) down
-
-down.total: guard-APP_ENV
- @cd $(CONFIGS_DIG)/$(APP_ENV) && $(DOCKER_COMPOSE) --env-file .env.$(APP_ENV) down -v
-
-logs.db: guard-APP_ENV
- @cd $(CONFIGS_DIG)/$(APP_ENV) && $(DOCKER_COMPOSE) --env-file .env.$(APP_ENV) logs -f web_app_db_pg
-
-shell.db: guard-APP_ENV
- @cd $(CONFIGS_DIG)/$(APP_ENV) && $(DOCKER_COMPOSE) --env-file .env.$(APP_ENV) exec web_app_db_pg sh
-
+.PHONY: docker-env local-env upd up upd-local up-local down stop-all
+docker-env:
+ { \
+ echo "# This .env file is generated automatically for DOCKER environment by Makefile."; \
+ echo "# Do not edit it directly; edit env.example / .secrets and Makefile instead."; \
+ echo; \
+ cat env.example; \
+ if [ -f .secrets ]; then \
+ echo; \
+ echo "# --- secrets from .secrets (not committed) ---"; \
+ cat .secrets; \
+ fi; \
+ } > .env
+
+local-env:
+ { \
+ echo "# This .env file is generated automatically for LOCAL environment by Makefile."; \
+ echo "# Do not edit it directly; edit env.example / .secrets and Makefile instead."; \
+ echo; \
+ sed \
+ -e 's|^EXAMPLE_SERVICE_URL=.*|EXAMPLE_SERVICE_URL=http://127.0.0.1:51999|' \
+ -e 's|^POSTGRES_HOST=.*|POSTGRES_HOST=127.0.0.1|' \
+ env.example; \
+ if [ -f .secrets ]; then \
+ echo; \
+ echo "# --- secrets from .secrets (not committed) ---"; \
+ cat .secrets; \
+ fi; \
+ } > .env
+
+upd: docker-env
+ $(DOCKER_COMPOSE) up -d --build --force-recreate
+
+up: docker-env
+ $(DOCKER_COMPOSE) up --build --force-recreate
+
+upd-local: local-env
+ $(DOCKER_COMPOSE) up -d --build --force-recreate $(INFRA_SERVICES)
+
+up-local: local-env
+ $(DOCKER_COMPOSE) up --build --force-recreate $(INFRA_SERVICES)
+
+down:
+ $(DOCKER_COMPOSE) down
+
+stop-all:
+ docker ps -q | xargs -r docker stop
+
+# Tests (with infra)
+.PHONY: test-docker
+test-docker: docker-env
+ rc=0; \
+ $(DC_TEST_DOCKER) down -v --remove-orphans >/dev/null 2>&1 || true; \
+ if [ -n "$(strip $(INFRA_SERVICES))" ]; then \
+ $(DC_TEST_DOCKER) up -d --build --wait --wait-timeout 180 $(INFRA_SERVICES); \
+ else \
+ echo "INFRA_SERVICES is empty, skipping infra startup"; \
+ fi; \
+ $(DC_TEST_DOCKER) run --build --name $(TEST_RUNNER) app \
+ pytest $(PYTEST_ARGS_VERBOSE) \
+ $(PYTEST_PATHS_ALL) \
+ $(PYTEST_ARGS_COV_DOCKER) \
+ || rc=$$?; \
+ docker cp $(TEST_RUNNER):/tmp/.coverage ./.coverage.docker 2>/dev/null || true; \
+ docker rm $(TEST_RUNNER) >/dev/null 2>&1 || true; \
+ $(DC_TEST_DOCKER) down -v --remove-orphans; \
+ coverage html --data-file=.coverage.docker -d htmlcov-docker && \
+ echo "Coverage HTML report: htmlcov-docker/index.html" || true; \
+ exit $$rc
+
+.PHONY: prune
prune:
$(DOCKER_COMPOSE_PRUNE)
# Project structure visualization
+.PHONY: pycache-del tree plot-data
PYCACHE_DEL := scripts/makefile/pycache_del.sh
DISHKA_PLOT_DATA := scripts/dishka/plot_dependencies_data.py
-.PHONY: pycache-del tree plot-data
pycache-del:
@$(PYCACHE_DEL)
-# Clean tree
tree: pycache-del
@tree
-# Dishka
plot-data:
- @$(PYTHON) $(DISHKA_PLOT_DATA)
+ @$(PYTHON_BIN) $(DISHKA_PLOT_DATA)
diff --git a/README.md b/README.md
index 12a00f64..1c5ffbf3 100644
--- a/README.md
+++ b/README.md
@@ -1,771 +1,54 @@
-# Overview
+[](https://github.com/mjhea0/awesome-fastapi?tab=readme-ov-file#best-practices)
-π This FastAPI-based project and its documentation represent a practical interpretation of Clean Architecture and
-Command Query Responsibility Segregation (CQRS) principles with elements of Domain-Driven Design (DDD).
-Although it's not meant to serve as a comprehensive reference or a strict application of these methodologies, the
-project demonstrates how their core ideas can be effectively put into practice in Python.
-If they're new to you, refer to the [Useful Resources](#useful-resources) section.
+Stay tuned. Refactor in progress, see [`legacy-2025`](https://github.com/ivan-borovets/fastapi-clean-example/tree/legacy-2025) branch for architecture docs
-# Table of contents
+TODO:
+- [ ] Polish code where possible
+- [ ] Write integration tests, finally
+- [ ] Explain code and patterns in new README
+- [ ] Make template project
-1. [Overview](#overview)
-2. [Architecture Principles](#architecture-principles)
- 1. [Introduction](#introduction)
- 2. [Layered Approach](#layered-approach)
- 3. [Dependency Rule](#dependency-rule)
- 1. [Note on Adapters](#note-on-adapters)
- 4. [Layered Approach Continued](#layered-approach-continued)
- 5. [Dependency Inversion](#dependency-inversion)
- 6. [Dependency Injection](#dependency-injection)
- 7. [CQRS](#cqrs)
-3. [Project](#project)
- 1. [Dependency Graphs](#dependency-graphs)
- 2. [Structure](#structure)
- 3. [Technology Stack](#technology-stack)
- 4. [API](#api)
- 1. [General](#general)
- 2. [Account](#account-apiv1account)
- 3. [Users](#users-apiv1users)
- 5. [Configuration](#configuration)
- 1. [Files](#files)
- 2. [Flow](#flow)
- 3. [Local Environment](#local-environment)
- 4. [Other Environments](#other-environments-devprod)
- 5. [Adding New Environments](#adding-new-environments)
-4. [Useful Resources](#useful-resources)
-5. [Support the Project](#-support-the-project)
-6. [Acknowledgements](#acknowledgements)
-
-# Architecture Principles
-
-## Introduction
-
-This repository may be helpful for those seeking backend implementation in Python that is both framework-agnostic
-and storage-agnostic (unlike Django).
-Such flexibility can be achieved by using a web framework that doesn't impose strict software design (like FastAPI) and
-applying a layered architecture patterned after the one proposed by Robert Martin, which we'll explore further.
-
-The original explanation of Clean Architecture concepts can be
-found [here](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html).
-If you're still wondering why Clean Architecture matters, read the article β it only takes about 5 minutes.
-In essence, itβs about making your application independent of external systems and highly testable.
-
-
-
- Figure 1: Robert Martin's Clean Architecture Diagram
-
-
-> "A computer program is a detailed description of the **policy** by which inputs are transformed into outputs."
->
-> β Robert Martin
-
-The most abstract policies define core business rules, while the least abstract ones handle I/O operations.
-Being closer to implementation details, less abstract policies are more likely to change.
-**Layer** represents a collection of components expressing policies at the same level of abstraction.
-
-Concentric circles represent boundaries between different layers.
-The meaning of arrows in the diagram will be discussed [later](#dependency-rule).
-For now, we will focus on the purpose of the layers.
-
-## Layered Approach
-
- **Domain Layer**
-
-- **Domain model** is a set of concepts, rules and behaviors that define what business (context) is and how it operates.
- It is expressed in **ubiquitous language** β consistent terminology shared by developers and domain experts.
- Domain layer implements domain model in code; this implementation is often called domain model.
-- The strictest domain rules are **invariants** β conditions that must always hold true for the model.
- Enforcing invariants means maintaining data consistency in the model.
- This can be achieved through **encapsulation**, which hides internal state and couples data with behavior.
-- Building blocks of domain model are (not limited to these):
- - **value objects** β smart business types (no identity, immutable, equal by value).
- - **entities** β business objects (have identity and lifecycle, equal by identity).
- - **domain services** β containers for behavior that has no place in the components above.
-- Other domain model building blocks, unused in this project but important for deeper DDD:
- - **aggregates** β clusters of entities (1+) that must change together as a single unit,
- managed exclusively through their root, defining boundaries of transactional consistency.
- - **repositories** β abstractions emulating collections of aggregate roots.
-- Domain model lies on a spectrum from anemic to rich.
- - **anemic** β simple data types, entities are just data holders, rules and behaviors live outside.
- - **rich** β value objects and entities encapsulate data and rules;
- invariants are enforced internally, so the model itself prevents invalid states.
- For components: anemic means no behavior within, rich β the contrary.
-- Domain services originally represent operations that don't naturally belong to a specific entity or value object.
- But in projects with anemic entities, they can also contain logic that would otherwise be in those entities.
-- In early stages of development when the domain model is not yet clearly defined,
- I'd recommend keeping entities flat and anemic, even though the latter weakens encapsulation.
- Once domain logic is well established, some entities can, as aggregate roots, become non-flat and rich.
- This best enforces invariants but can be tricky to design once and for all.
-- Prefer rich value objects early, freeing entities and services from an excessive burden of local rules.
-- Consider domain layer the most important, stable, and independent part of a system.
-
- **Application Layer**
-
-- Business defines **use case** as specification of observable behavior that delivers value by achieving a goal.
-- Within use case, the behavior is enacted by **actor** β possibly a client of the software system.
-- Actor performs use case in steps, some of which require interaction with the system.
- These stepwise interactions with the system are handled at the application layer by **interactors**.
- In other words, each interactor handles a single business operation matching a step within use case.
-- Interactors are stateless and cannot call each other, unlike use cases.
- Each is invoked independently - typically by external drivers such as HTTP controllers, message consumers, or
- scheduled jobs.
-- Interactor orchestrates domain logic and external calls needed to perform the operation.
- Its primary responsibilities may include permission verification and transaction management.
- To access external systems, interactors rely on **interfaces (ports)** that abstract infrastructure details.
-- Interactor uses **DTOs (Data Transfer Objects)** to exchange serializable data with external layers.
- These are simple, behavior-free carriers - the cross-layer transport for external contracts.
-- If logic is reused across interactors: extract an application service when it falls under typical interactor
- responsibilities; otherwise, consider evolving the domain model to include it.
- Such evolution is a normal part of model enrichment.
-- Together, domain and application layers form the **core** of the system.
-
- **Infrastructure Layer**
-
-- This layer is responsible for adapting the core to external systems.
-- It consists of **adapters**: driving and driven.
- Driving adapters call into the core, translating external requests into interactor calls.
- Driven adapters (port implementations) are called by the core via ports, allowing the core to interact with external
- systems (databases, APIs, file systems, etc.) while keeping the business logic decoupled.
-- Related adapter logic can be grouped into **infrastructure service**.
-
-> [!IMPORTANT]
-> - Clean Architecture doesn't prescribe any particular number of layers.
- The key is to follow the Dependency Rule, which is explained in the next section.
-
-## Dependency Rule
-
-A dependency occurs when one software component relies on another to operate.
-If you were to split all blocks of code into separate modules, dependencies would manifest as imports between those
-modules.
-Typically, dependencies are graphically depicted in UML style in such a way that
-
-> [!IMPORTANT]
-> - `A -> B` (**A points to B**) means **A depends on B**.
-
-The key principle of Clean Architecture is the **Dependency Rule**.
-This rule states that **more abstract software components must not depend on more concrete ones.**
-In other words, dependencies must never point outwards.
-
-> [!IMPORTANT]
-> - Domain and application layers may import external tools and libraries to the extent necessary for describing
- business logic - those that extend the programming language's capabilities (math/numeric utilities, time zone
- conversion, object modeling, etc.). This trades some core stability for clarity and expressiveness. What is not
- acceptable are dependencies that bind business logic to implementation details (including frameworks) or to
- out-of-process systems (databases, brokers, file systems, cloud SDKs, etc.).
->
-> - Components within the same layer **can depend on each other.** For example, components in the Infrastructure layer
- can interact with one another without crossing into other layers.
->
-> - Components in any outer layer can depend on components in any inner layer, not necessarily the one closest to
- them. For example, components in the Presentation layer can directly depend on the Domain layer, bypassing the
- Application and Infrastructure layers.
->
-> - Avoid letting business logic leak into peripheral details, such as raising business-specific exceptions in the
- Infrastructure layer without re-raising them in the business logic or declaring domain rules outside the Domain
- layer.
->
-> - In specific cases where database constraints enforce business rules, the Infrastructure layer may raise
- domain-specific exceptions, such as `UsernameAlreadyExistsError` for a `UNIQUE CONSTRAINT` violation.
- Handling these exceptions in the Application layer ensures that any business logic implemented in adapters remains
- under control.
->
-> - Avoid introducing elements in inner layers that specifically exist to support outer layers.
- For example, you might be tempted to place something in the Application layer that exists solely to support a
- specific piece of infrastructure.
- At first glance, based on imports, it might seem that the Dependency Rule isn't violated. However, in reality,
- you've broken the core idea of the rule by embedding infrastructure concerns (more concrete) into the business logic
- (more abstract).
-
-### Note on Adapters
-
-The **Infrastructure layer** in Clean Architecture acts as the adapter layer β connecting the application to
-external systems.
-In this project, we treat both **Infrastructure** and **Presentation** as adapters, since both adapt the application to
-the outside world.
-Speaking of dependencies direction, the diagram by R. Martin in Figure 1 can, without significant loss, be replaced by a
-more concise and pragmatic one β where the adapter layer serves as a "bridge", depending both on the internal layers of
-the application and external components.
-This adjustment implies **reversing** the arrow from the blue layer to the green layer in R. Martin's diagram.
-
-The proposed solution is a **trade-off**.
-It doesn't strictly follow R. Martin's original concept but avoids introducing excessive abstractions with
-implementations outside the application's boundaries.
-Pursuing purity on the outermost layer is more likely to result in overengineering than in practical gains.
-
-My approach retains nearly all advantages of Clean Architecture while simplifying real-world development.
-When needed, adapters can be removed along with the external components they're written for, which isn't a
-significant issue.
-
-Letβs agree, for this project, to revise the principle:
-
-Original:
-> "Dependencies must never point outwards."
-
-Revised:
-> "Dependencies must never point outwards **within the core**."
-
-
-
-
-
-
- Figure 2: Revised Interpretation of Clean Architecture
- (diagrammed β original and alternative representation)
-
-
-
-## Layered Approach Continued
-
- **Presentation Layer**
-
-> [!NOTE]
-> In the original diagram, the Presentation layer isn't explicitly distinguished and is instead included within the
-> Interface Adapters layer. I chose to introduce it as a separate layer, marked in blue, as I see it as even more
-> external compared to typical adapters.
-
-- This layer handles external requests and includes **controllers** that validate inputs and pass them to the
- interactors in the Application layer. More abstract layers of the program assume that request data is already
- validated, allowing them to focus solely on their core logic.
-- Controllers must be as thin as possible, containing no logic beyond basic input validation and routing. Their
- role is to act as an intermediary between the application and external systems (e.g., FastAPI).
-
-> [!IMPORTANT]
-> - **_Basic_** validation, like checking whether the structure of the incoming request matches the structure of the
- defined request model (e.g., type safety and required fields) should be performed by controllers at this layer,
- while **_business rule_** validation (e.g., ensuring the email domain is allowed, verifying the uniqueness of
- username, or checking if a user meets the required age) belongs to the Domain or Application layer.
-> - Business rule validation often involves relationships between fields, such as ensuring that a discount applies only
- within a specific date range or a promotion code is valid for orders above a certain total.
-> - **Carefully** consider using Pydantic for business rule validation. While convenient, Pydantic models are slower
- than regular dataclasses and reduce application core stability by coupling business logic to an external library.
-> - If you choose Pydantic (or a similar tool bundled with web framework) for business model definitions, ensure that
- a Pydantic model in business layers is a separate model from the one in the Presentation layer, even if their
- structure appears identical. Mixing data presentation logic with business logic is a common mistake made early in
- development to save effort on creating separate models and field mapping, often due to not understanding that
- structural similarities are temporary.
-
- **External Layer**
-
-> [!NOTE]
-> In the original diagram, external components are included in the blue layer (Frameworks & Drivers).
-> I've marked them in gray to clearly distinguish them from layers within the application's boundaries.
-
-- This layer represents fully external components such as web frameworks (e.g. FastAPI itself), databases, third-party
- APIs, and other services.
-- These components operate outside the applicationβs core logic and can be easily replaced or modified without affecting
- the business rules, as they interact with the application only through the Presentation and Infrastructure layers.
-
-
-
- Figure 3: Basic Dependency Graph
-
-
-## Dependency Inversion
-
-The **dependency inversion** technique enables reversing dependencies **by introducing an interface** between
-components, allowing an inner layer to communicate with an outer layer while adhering to the Dependency Rule.
-
-
-
- Figure 4: Corrupted Dependency
-
-
-In this example, the Application component depends directly on the Infrastructure component, violating the Dependency
-Rule.
-This creates "corrupted" dependencies, where changes in the Infrastructure layer can propagate to and unintentionally
-affect the Application layer.
-
-
-
- Figure 5: Correct Dependency
-
-
-In the correct design, the Application layer component depends on an **abstraction (port)**, and the Infrastructure
-layer component **implements** the corresponding interface.
-This makes the Infrastructure component an adapter for the port, effectively turning it into a plugin for
-Application layer.
-Such a design adheres to the **Dependency Inversion Principle (DIP)**, minimizing the impact of infrastructure changes
-on core business logic.
-
-## Dependency Injection
-
-The idea behind **Dependency Injection** is that a component shouldn't create the dependencies it needs but rather
-receive them.
-From this definition, it's clear that one common way to implement DI is by passing dependencies as arguments to the
-`__init__` method or functions.
-
-But how exactly should these dependencies be initialized (and finalized)?
-
-**DI frameworks** offer an elegant solution by automatically creating necessary objects (while managing their
-**lifecycle**) and injecting them where needed.
-This makes the process of dependency injection much cleaner and easier to manage.
-
-
-
- Figure 6: Correct Dependency with DI
-
-
-FastAPI provides a built-in **DI mechanism** called [Depends](https://fastapi.tiangolo.com/tutorial/dependencies/),
-which tends to leak into different layers of the application. This creates tight coupling to FastAPI, violating the
-principles of Clean Architecture, where the web framework belongs to the outermost layer and should remain easily
-replaceable.
-
-Refactoring the codebase to remove `Depends` when switching frameworks can be unnecessarily costly. It also has [other
-limitations](https://dishka.readthedocs.io/en/stable/alternatives.html#why-not-fastapi) that are beyond the scope of
-this README. Personally, I prefer [**Dishka**](https://dishka.readthedocs.io/en/stable/index.html) β a solution that
-avoids these issues and remains framework-agnostic.
-
-## CQRS
-
-The project implements Command Query Responsibility Segregation (**CQRS**) β a pattern that separates read and write
-operations into distinct paths.
-
-- **Commands** (via interactors) handle write operations and business-critical reads using command gateways that work
- with entities and value objects.
-- **Queries** are implemented through query services (similar contract to interactors) that use query gateways to fetch
- data optimized for presentation as query models.
-
-This separation enables:
-
-- Efficient read operations through specialized query gates, avoiding loading complete entity models.
-- Performance optimization by tailoring data retrieval to specific view requirements.
-- Flexibility to combine data from multiple models in read operations with minimal field selection.
-
-# Project
-
-## Dependency Graphs
-
-
- Application Controller - Interactor
-
-
-
- Figure 7: Application Controller - Interactor
-
-
-In the presentation layer, a Pydantic model appears when working with FastAPI and detailed information needs to be
-displayed in OpenAPI documentation.
-You might also find it convenient to validate certain fields using Pydantic;
-however, be cautious to avoid leaking business rules into the presentation layer.
-
-For request data, a plain `dataclass` is often sufficient.
-Unlike lighter alternatives, it provides attribute access, which is more convenient for working in the application
-layer.
-However, such access is unnecessary for data returned to the client, where a `TypedDict` is sufficient (it's
-approximately twice as fast to create as a dataclass with slots, with comparable access times).
-
-
-
-
- Application Interactor
-
-
-
- Figure 8: Application Interactor
-
-
-
-
-
- Application Interactor - Adapter
-
-
-
- Figure 9: Application Interactor - Adapter
-
-
-
-
-
- Domain - Adapter
-
-
-
- Figure 10: Domain - Adapter
-
-
-
-
-
- Infrastructure Controller - Handler
-
-
- Figure 11: Infrastructure Controller - Handler
-
-
-An infrastructure handler may be required as a temporary solution in cases where a separate context exists but isn't
-physically separated into a distinct domain (e.g., not implemented as a standalone module within a monolithic
-application).
-In such cases, the handler operates as an application-level interactor but resides in the infrastructure layer.
-
-Initially, I called these handlers interactors, but the community reacted very negatively to the idea of interactors in
-the infrastructure layer, refusing to acknowledge that these essentially belong to another context.
-
-In this application, such handlers include those managing user accounts, such as registration, login, and logout.
-
-
-
-
- Infrastructure Handler
-
-
- Figure 12: Infrastructure Handler
-
-
-Ports in infrastructure are not commonly seen β typically, only concrete implementations are present.
-However, in this project, since we have a separate layer of adapters (presentation) located outside the infrastructure,
-ports are necessary to comply with the dependency rule.
-
-
-
-
-
-**Identity Provider (IdP)** abstracts authentication details, linking the main business context with the authentication
-context. In this example, the authentication context is not physically separated, making it an infrastructure detail.
-However, it can potentially evolve into a separate domain.
-
- Identity Provider
-
-
- Figure 13: Identity Provider
-
-
-Normally, IdP is expected to provide all information about current user.
-However, in this project, since roles are not stored in sessions or tokens, retrieving them in main context was more
-natural.
-
-
-
-## Structure
-
-```
-.
-βββ config/... # configuration files and scripts, includes Docker
-βββ Makefile # shortcuts for setup and common tasks
-βββ scripts/... # helper scripts
-βββ pyproject.toml # tooling and environment config (uv)
-βββ ...
-βββ src/
- βββ app/
- βββ domain/ # domain layer
- β βββ services/... # domain layer services
- β βββ entities/... # entities (have identity)
- β β βββ base.py # base declarations
- β β βββ ... # concrete entities
- β βββ value_objects/... # value objects (no identity)
- β β βββ base.py # base declarations
- β β βββ ... # concrete value objects
- β βββ ... # ports, enums, exceptions, etc.
- β
- βββ application/... # application layer
- β βββ commands/ # write ops, business-critical reads
- β β βββ create_user.py # interactor
- β β βββ ... # other interactors
- β βββ queries/ # optimized read operations
- β β βββ list_users.py # query service
- β β βββ ... # other query services
- β βββ common/ # common layer objects
- β βββ services/... # authorization, etc.
- β βββ ... # ports, exceptions, etc.
- β
- βββ infrastructure/... # infrastructure layer
- β βββ adapters/... # port adapters
- β βββ auth/... # auth context (session-based)
- β βββ ... # persistence, exceptions, etc.
- β
- βββ presentation/... # presentation layer
- β βββ http/ # http interface
- β βββ auth/... # web auth logic
- β βββ controllers/... # controllers and routers
- β βββ errors/... # error handling helpers
- β
- βββ setup/
- β βββ ioc/... # dependency injection setup
- β βββ config/... # app settings
- β βββ app_factory.py # app builder
- β
- βββ run.py # app entry point
-```
-
-## Technology Stack
-
-- **Python**: `3.13`
-- **Core**: `alembic`, `alembic-postgresql-enum`, `bcrypt`, `dishka`, `fastapi-error-map`, `fastapi`, `orjson`,
- `psycopg3[binary]`, `pyjwt[crypto]`, `sqlalchemy[mypy]`, `uuid-utils`, `uvicorn`, `uvloop`
-- **Development**: `deptry`, `import-linter`, `mypy`, `pre-commit`, `ruff`, `slotscheck`
-- **Testing**: `coverage`, `line-profiler`, `pytest`, `pytest-asyncio`
-
-## API
-
-
-
- Figure 14: Handlers
-
-
-### General
-
-- `/` (GET): Open to **everyone**.
- - Redirects to Swagger documentation.
-- `/api/v1/health` (GET): Open to **everyone**.
- - Returns `200 OK` if the API is alive.
-
-### Account (`/api/v1/account`)
-
-- `/signup` (POST): Open to **everyone**.
- - Registers a new user with validation and uniqueness checks.
- - Passwords are peppered, salted, and stored as hashes.
- - A logged-in user cannot sign up until the session expires or is terminated.
-- `/login` (POST): Open to **everyone**.
- - Authenticates registered user, sets a JWT access token with a session ID in cookies, and creates a session.
- - A logged-in user cannot log in again until the session expires or is terminated.
- - Authentication renews automatically when accessing protected routes before expiration.
- - If the JWT is invalid, expired, or the session is terminated, the user loses authentication. [^1]
-- `/password` (PUT): Open to **authenticated users**.
- - The current user can change their password.
- - New password must differ from current password.
-- `/logout` (DELETE): Open to **authenticated users**.
- - Logs the user out by deleting the JWT access token from cookies and removing the session from the database.
-
-### Users (`/api/v1/users`)
-
-- `/` (POST): Open to **admins**.
- - Creates a new user, including admins, if the username is unique.
- - Only super admins can create new admins.
-- `/` (GET): Open to **admins**.
- - Retrieves a paginated list of existing users with relevant information.
-- `/{user_id}/password` (PUT): Open to **admins**.
- - Admins can set passwords of subordinate users.
-- `/{user_id}/roles/admin` (PUT): Open to **super admins**.
- - Grants admin rights to a specified user.
- - Super admin rights cannot be changed.
-- `/{user_id}/roles/admin` (DELETE): Open to **super admins**.
- - Revokes admin rights from a specified user.
- - Super admin rights cannot be changed.
-- `/{user_id}/activation` (PUT): Open to **admins**.
- - Restores a previously soft-deleted user.
- - Only super admins can activate other admins.
-- `/{user_id}/activation` (DELETE): Open to **admins**.
- - Soft-deletes an existing user, making that user inactive.
- - Also deletes the user's sessions.
- - Only super admins can deactivate other admins.
- - Super admins cannot be soft-deleted.
-
-> [!NOTE]
-> - Super admin privileges must be initially granted manually (e.g., directly in the database), though the user
- account itself can be created through the API.
-
-## Configuration
-
-> [!WARNING]
-> - This part of documentation is **not** related to the architecture approach.
-> - Use any configuration method you prefer.
-
-### Files
-
-- **config.toml**: Main application settings organized in sections
-- **export.toml**: Lists fields to export to .env (`export.fields = ["postgres.USER", "postgres.PASSWORD", ...]`)
-- **.secrets.toml**: Optional sensitive data (same format as config.toml, merged with main config)
-
-> [!IMPORTANT]
-> - This project includes secret files for demonstration purposes only. In a real project, you **must** ensure that
- `.secrets.toml` and all `.env` files are not tracked by version control system to prevent exposing sensitive
- information. See this project's `.gitignore` for an example of how to properly exclude these sensitive files from
- Git.
-
-### Flow
-
-In this project I use my own configuration system based on TOML files as the single source of truth.
-The system generates `.env` files for Docker and infrastructure components while the application reads settings directly
-from the structured TOML files. More details are available at https://github.com/ivan-borovets/toml-config-manager
-
-
-
- Figure 15: Configuration flow
- Here, the arrows represent usage flow, not dependencies.
-
-
-### Local Environment
-
-1. Configure local environment
-
-* In this project, local configuration is already prepared in `config/local/`.
- Nothing needs to be created β adjust files only if you want to change defaults.
-* If you want to adjust settings, edit the existing TOML files in `config/local/` directly.
- `.env.local` will be generated automatically β **donβt** create or edit it manually.
-* Docker Compose in this project is already configured with `APP_ENV`.
- Just keep in mind this variable if you change the setup:
-
-```yaml
-services:
- app:
- # ...
- environment:
- APP_ENV: ${APP_ENV}
+Prerequisites
+```shell
+uv sync
+source .venv/bin/activate
+pre-commit install --hook-type pre-commit --hook-type pre-push
```
-2. Set environment variable
-
+Start in Docker
```shell
-export APP_ENV=local
-# export APP_ENV=dev
-# export APP_ENV=prod
+make upd
```
-3. Check it and generate `.env`
-
+Start locally
```shell
-# Probably you'll need Python 3.13 installed on your system to run these commands.
-# The next code section provides commands for its fast installation.
-make env # should print APP_ENV=local
-make dotenv # should tell you where .env.local was generated
+make upd-local
+alembic upgrade head
+uvicorn app.main.run:make_app --host 0.0.0.0 --port 8000 --reload
+# or `src/app/main/run.py` in IDE
```
+Full API access:
+- create user via sign up
+- set its role to `SUPER_ADMIN` manually in DB
+- log in as super admin
-4. Install `uv`
-
+Stop
```shell
-# sudo apt update
-# sudo apt install pipx
-# pipx ensurepath
-# pipx install uv
-# https://docs.astral.sh/uv/getting-started/installation/#shell-autocompletion
-# uv python install 3.13 # To install Python
+make down
```
-5. Set up virtual environment
-
+Test (light paths)
```shell
-uv sync --group dev
-source .venv/bin/activate
-
-# Alternatively,
-# uv v
-# source .venv/bin/activate # on Unix
-# .venv\Scripts\activate # on Windows
-# uv pip install -e . --group dev
+make check
```
-Don't forget to tell your IDE where the interpreter is located.
-
-Install pre-commit hooks:
-
+Test (all paths)
```shell
-# https://pre-commit.com/
-pre-commit install
+make test-docker
```
-6. Launch
-
-- To run only the database in Docker and use the app locally, use the following command:
-
- ```shell
- make up.db
- # make up.db-echo
- ```
-
-- Then, apply the migrations:
- ```shell
- alembic upgrade head
- ```
-
-- After applying the migrations, the database is ready, and you can launch the application locally (e.g., through your
- IDE). Remember to set the `APP_ENV` environment variable in your IDE's run configuration.
-
-- To run via Docker Compose:
-
- ```shell
- make up
- # make up.echo
- ```
-
- In this case, migrations will be applied automatically at startup.
-
-7. Shutdown
-
-- To stop the containers, use:
- ```shell
- make down
- ```
-
-### Other Environments (dev/prod)
-
-1. Use the instructions about [local environment](#local-environment) above
-
-* But make sure you've created similar structure in `config/dev` or `config/prod` with [files](#files):
- * `config.toml`
- * `.secrets.toml`
- * `export.toml`
- * `docker-compose.yaml` if needed
-* `.env.dev` or `.env.prod` to be generated later β **don't** create them manually
-
-### Adding New Environments
-
-1. Add new value to `ValidEnvs` enum in `config/toml_config_manager.py` (and maybe in your app settings)
-2. Update `ENV_TO_DIR_PATHS` mapping in the same file (and maybe in your app settings)
-3. Create corresponding directory in `config/` folder
-4. Add required configuration [files](#files)
-
-Environment directories can also contain other env-specific files like `docker-compose.yaml`, which will be used by
-Makefile commands.
-
-# Useful Resources
-
-## Layered Architecture
-
-- [Robert C. Martin. Clean Architecture: A Craftsman's Guide to Software Structure and Design. 2017](https://www.amazon.com/Clean-Architecture-Craftsmans-Software-Structure/dp/0134494164)
-
-- [Alistair Cockburn. Hexagonal Architecture Explained. 2024](https://www.amazon.com/Hexagonal-Architecture-Explained-Alistair-Cockburn-ebook/dp/B0D4JQJ8KD)
- (introduced in 2005)
-
-## Domain-Driven Design
-
-- [Vlad Khononov. Learning Domain-Driven Design: Aligning Software Architecture and Business Strategy. 2021](https://www.amazon.com/Learning-Domain-Driven-Design-Aligning-Architecture/dp/1098100131)
-
-- [Vaughn Vernon. Implementing Domain-Driven Design. 2013](https://www.amazon.com/Implementing-Domain-Driven-Design-Vaughn-Vernon/dp/0321834577)
-
-- [Eric Evans. Domain-Driven Design: Tackling Complexity in the Heart of Software. 2003](https://www.amazon.com/Domain-Driven-Design-Tackling-Complexity-Software/dp/0321125215)
-
-- [Martin Fowler. Patterns of Enterprise Application Architecture. 2002](https://www.amazon.com/Patterns-Enterprise-Application-Architecture-Martin/dp/0321127420)
-
-## Adjacent
-
-- [Vladimir Khorikov. Unit Testing Principles. 2020](https://www.amazon.com/Unit-Testing-Principles-Practices-Patterns/dp/1617296279)
-
-# β Support the Project
-
-If you find this project useful, please give it a star or share it!
-Your support means a lot.
-
-π Check out the amazing [fastapi-error-map](https://github.com/ivan-borovets/fastapi-error-map), used here to enable
-contextual, per-route error handling with automatic OpenAPI schema generation.
-
-π¬ Feel free to open issues, ask questions, or submit pull requests.
-
-# Acknowledgements
-
-I would like to express my sincere gratitude to the following individuals for their valuable ideas and support in
-satisfying my curiosity throughout the development of this project:
-[igoryuha](https://github.com/igoryuha),
-[tishka17](https://github.com/tishka17),
-[chessenjoyer17](https://github.com/chessenjoyer17),
-[PlzTrustMe](https://github.com/PlzTrustMe),
-[Krak3nDev](https://github.com/Krak3nDev),
-[Ivankirpichnikov](https://github.com/Ivankirpichnikov),
-[SamWarden](https://github.com/SamWarden),
-[nkhitrov](https://github.com/nkhitrov),
-[ApostolFet](https://github.com/ApostolFet),
-Lancetnik, Sehat1137, Maclovi.
-
-I also greatly appreciate the valuable insights shared by participants of the ASGI Community Telegram chat, despite
-frequent and lively communication challenges, as well as the βοΈ Reagento (adaptix/dishka)
-[Telegram chat](https://t.me/reagento_ru) for their thoughtful discussions and generous knowledge exchange.
-
-# Todo
+See [Makefile](Makefile) for more commands
-- [x] set up CI
-- [x] simplify settings
-- [x] simplify annotations
-- [ ] add integration tests
-- [ ] explain design choices
+Thanks for your patience and support
-[^1]: Session and token share the same expiry time, avoiding database reads if the token is expired.
-This scheme of using JWT **is not** related to OAuth 2.0 and is a custom micro-optimization.
+[Acknowledgements](https://github.com/ivan-borovets/fastapi-clean-example/tree/legacy-2025?tab=readme-ov-file#acknowledgements)
diff --git a/alembic.ini b/alembic.ini
index 809dc39b..5fa0c360 100644
--- a/alembic.ini
+++ b/alembic.ini
@@ -2,21 +2,28 @@
[alembic]
# path to migration scripts.
-# Use forward slashes (/) also on windows to provide an os agnostic path
+# this is typically a path given in POSIX (e.g. forward slashes)
+# format, relative to the token %(here)s which refers to the location of this
+# ini file
script_location = src/app/infrastructure/persistence_sqla/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
-file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
+# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
+# for all available tokens
+# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
+# Or organize into date-based subdirectories (requires recursive_version_locations = true)
+file_template = %%(year)d-%%(month).2d-%%(day).2d_%%(hour).2d%%(minute).2d%%(second).2d_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
-# defaults to the current working directory.
+# defaults to the current working directory. for multiple paths, the path separator
+# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
-# If specified, requires the python>=3.9 or backports.zoneinfo library.
-# Any required deps can installed by adding `alembic[tz]` to the pip requirements
+# If specified, requires the tzdata library which can be installed by adding
+# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
@@ -34,20 +41,38 @@ prepend_sys_path = .
# sourceless = false
# version location specification; This defaults
-# to src/app/infra/sqla_db/alembic/versions. When using multiple version
+# to /versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
-# The path separator used here should be the separator specified by "version_path_separator" below.
-# version_locations = %(here)s/bar:%(here)s/bat:src/app/infra/sqla_db/alembic/versions
-
-# version path separator; As mentioned above, this is the character used to split
-# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
-# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
-# Valid values for version_path_separator are:
+# The path separator used here should be the separator specified by "path_separator"
+# below.
+# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
+
+# path_separator; This indicates what character is used to split lists of file
+# paths, including version_locations and prepend_sys_path within configparser
+# files such as alembic.ini.
+# The default rendered in new alembic.ini files is "os", which uses os.pathsep
+# to provide os-dependent path splitting.
+#
+# Note that in order to support legacy alembic.ini files, this default does NOT
+# take place if path_separator is not present in alembic.ini. If this
+# option is omitted entirely, fallback logic is as follows:
+#
+# 1. Parsing of the version_locations option falls back to using the legacy
+# "version_path_separator" key, which if absent then falls back to the legacy
+# behavior of splitting on spaces and/or commas.
+# 2. Parsing of the prepend_sys_path option falls back to the legacy
+# behavior of splitting on spaces, commas, or colons.
+#
+# Valid values for path_separator are:
#
-# version_path_separator = :
-# version_path_separator = ;
-# version_path_separator = space
-version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
+# path_separator = :
+# path_separator = ;
+# path_separator = space
+# path_separator = newline
+#
+# Use os.pathsep. Default configuration used for new projects.
+path_separator = os
+
# set to 'true' to search source files recursively
# in each "version_locations" directory
@@ -58,6 +83,9 @@ version_path_separator = os # Use os.pathsep. Default configuration used for ne
# are written from script.py.mako
# output_encoding = utf-8
+# database URL. This is consumed by the user-maintained env.py script only.
+# other means of configuring database URLs may be customized within the env.py
+# file.
sqlalchemy.url = driver://user:pass@localhost/dbname
@@ -72,13 +100,20 @@ sqlalchemy.url = driver://user:pass@localhost/dbname
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
-# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
+# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
+# hooks = ruff
+# ruff.type = module
+# ruff.module = ruff
+# ruff.options = check --fix REVISION_SCRIPT_FILENAME
+
+# Alternatively, use the exec runner to execute a binary found on your PATH
hooks = ruff
ruff.type = exec
-ruff.executable = %(here)s/.venv/bin/ruff
-ruff.options = format REVISION_SCRIPT_FILENAME
+ruff.executable = ruff
+ruff.options = check --fix REVISION_SCRIPT_FILENAME
-# Logging configuration
+# Logging configuration. This is also consumed by the user-maintained
+# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
@@ -89,12 +124,12 @@ keys = console
keys = generic
[logger_root]
-level = WARN
+level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
-level = WARN
+level = WARNING
handlers =
qualname = sqlalchemy.engine
diff --git a/config/dev/.gitkeep b/config/dev/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/config/local/.env.local b/config/local/.env.local
deleted file mode 100644
index 27010d55..00000000
--- a/config/local/.env.local
+++ /dev/null
@@ -1,11 +0,0 @@
-# This .env file was automatically generated by toml_config_manager.
-# Do not edit directly. Make changes in config.toml or .secrets.toml instead.
-# Ensure values here match those in config files.
-# Environment: local
-# Generated: 2025-06-05T02:17:23.145056+00:00
-POSTGRES_USER=postgres
-POSTGRES_PASSWORD=changethis
-POSTGRES_DB=web_app_db_pg
-POSTGRES_PORT=5432
-UVICORN_HOST=0.0.0.0
-UVICORN_PORT=9999
diff --git a/config/local/.gitkeep b/config/local/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/config/local/.secrets.toml b/config/local/.secrets.toml
deleted file mode 100644
index 33ef77e2..00000000
--- a/config/local/.secrets.toml
+++ /dev/null
@@ -1,17 +0,0 @@
-# PostgreSQL
-[postgres]
-USER = "postgres"
-PASSWORD = "changethis"
-
-[security.auth]
-# Recommended: Use a cryptographically secure random generator to create a
-# string of at least 32 characters including numbers, letters, and symbols
-JWT_SECRET = "REPLACE_THIS_WITH_YOUR_OWN_SECRET_JWT_SECRET_VALUE"
-
-[security.password]
-# Critical: This value must be kept secret and should be changed in production
-# Losing or changing this value will invalidate all existing password hashes
-# IMPORTANT: Replace the placeholder below with your own secure random string
-# Recommended: Use a cryptographically secure random generator to create a
-# string of at least 32 characters including numbers, letters, and symbols
-PEPPER = "REPLACE_THIS_WITH_YOUR_OWN_SECRET_PEPPER_VALUE"
diff --git a/config/local/Dockerfile b/config/local/Dockerfile
deleted file mode 100644
index d1e9dc23..00000000
--- a/config/local/Dockerfile
+++ /dev/null
@@ -1,25 +0,0 @@
-FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder
-WORKDIR /app
-ENV UV_COMPILE_BYTECODE=1 \
- UV_LINK_MODE=copy
-COPY pyproject.toml uv.lock ./
-RUN --mount=type=cache,target=/root/.cache/uv \
- uv sync --locked --no-dev --no-install-project
-COPY . ./
-RUN --mount=type=cache,target=/root/.cache/uv \
- uv sync --locked --no-dev
-
-FROM python:3.13-slim-bookworm AS final
-ARG APP_UID=10001
-ARG APP_GID=10001
-RUN groupadd -g ${APP_GID} appgroup && \
- useradd -u ${APP_UID} -g ${APP_GID} -s /usr/sbin/nologin -M appuser
-WORKDIR /app
-ENV VIRTUAL_ENV="/app/.venv" \
- PATH="/app/.venv/bin:$PATH" \
- PYTHONDONTWRITEBYTECODE=1 \
- PYTHONUNBUFFERED=1
-COPY --from=builder --chown=${APP_UID}:${APP_GID} /app/ ./
-USER appuser
-EXPOSE 8888
-CMD ["uvicorn", "app.run:make_app", "--factory", "--host", "0.0.0.0", "--port", "8888", "--loop", "uvloop"]
diff --git a/config/local/config.toml b/config/local/config.toml
deleted file mode 100644
index d51ad188..00000000
--- a/config/local/config.toml
+++ /dev/null
@@ -1,43 +0,0 @@
-# PostgreSQL
-[postgres]
-DB = "web_app_db_pg"
-HOST = "localhost"
-PORT = 5432
-DRIVER = "psycopg"
-
-# Uvicorn
-[uvicorn]
-HOST = "0.0.0.0"
-PORT = 9999
-
-# SQLAlchemy
-[sqla]
-ECHO = false
-ECHO_POOL = false
-POOL_SIZE = 30
-MAX_OVERFLOW = 20
-
-# Logs
-[logs]
-# Can be set to "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"
-LEVEL = "DEBUG"
-
-[security.auth]
-# Can be set to "HS256", "HS384", "HS512", "RS256", "RS384", "RS512"
-JWT_ALGORITHM = "HS256"
-# Must be at least 1 (number of minutes)
-SESSION_TTL_MIN = 5
-# Must be a number (fraction, 0 < fraction < 1)
-SESSION_REFRESH_THRESHOLD = 0.2
-
-[security.cookies]
-# Choose `true` for production (secure=True, samesite="Strict")
-SECURE = false
-
-[security.password]
-# https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#introduction
-HASHER_WORK_FACTOR = 11
-# CPU-bound & GIL released: per-worker β max(1, floor(effective vCPUs / workers))
-HASHER_MAX_THREADS = 8
-# Fail-fast cap: max semaphore wait before timeout (start ~1 second, tune to peak)
-HASHER_SEMAPHORE_WAIT_TIMEOUT_S = 1.0
diff --git a/config/local/docker-compose.yaml b/config/local/docker-compose.yaml
deleted file mode 100644
index 80137f4b..00000000
--- a/config/local/docker-compose.yaml
+++ /dev/null
@@ -1,45 +0,0 @@
-services:
- web_app_db_pg:
- image: postgres:16-alpine
- shm_size: 128mb
- environment:
- - POSTGRES_USER=${POSTGRES_USER}
- - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- - POSTGRES_DB=${POSTGRES_DB}
- ports:
- - "127.0.0.1:${POSTGRES_PORT}:5432"
- volumes:
- - pgdata:/var/lib/postgresql/data
- healthcheck:
- test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
- interval: 5s
- timeout: 5s
- retries: 10
- start_period: 10s
-
- web_app:
- build:
- context: ../..
- dockerfile: config/${APP_ENV}/Dockerfile
- image: web_app:${APP_ENV}
- environment:
- APP_ENV: ${APP_ENV}
- UVICORN_HOST: ${UVICORN_HOST}
- UVICORN_PORT: ${UVICORN_PORT}
- POSTGRES_HOST: web_app_db_pg
- ports:
- - "127.0.0.1:${UVICORN_PORT}:${UVICORN_PORT}"
- depends_on:
- web_app_db_pg:
- condition: service_healthy
- command: >
- sh -c "
- echo 'Running alembic migrations...' &&
- alembic upgrade head &&
- echo 'Starting Uvicorn...' &&
- uvicorn app.run:make_app --factory --host ${UVICORN_HOST} --port ${UVICORN_PORT} --loop uvloop
- "
-
-volumes:
- pgdata:
- name: "web_app_pgdata_${APP_ENV}"
diff --git a/config/local/export.toml b/config/local/export.toml
deleted file mode 100644
index fa263745..00000000
--- a/config/local/export.toml
+++ /dev/null
@@ -1,13 +0,0 @@
-# Don't rename `export` or `fields`
-[export]
-fields = [
- # PostgreSQL from secrets
- "postgres.USER",
- "postgres.PASSWORD",
- # PostgreSQL from config
- "postgres.DB",
- "postgres.PORT",
- # Uvicorn from config
- "uvicorn.HOST",
- "uvicorn.PORT",
-]
diff --git a/config/prod/.gitkeep b/config/prod/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/config/toml_config_manager.py b/config/toml_config_manager.py
deleted file mode 100644
index d38169a6..00000000
--- a/config/toml_config_manager.py
+++ /dev/null
@@ -1,298 +0,0 @@
-import logging
-import os
-import tomllib
-from collections.abc import Mapping
-from datetime import UTC, datetime
-from enum import StrEnum
-from pathlib import Path
-from types import MappingProxyType
-from typing import Any, Final
-
-ConfigDict = dict[str, Any]
-ExportEnv = dict[str, str]
-
-log = logging.getLogger(__name__)
-
-
-# LOGGING
-
-
-LOG_LEVEL_VAR_NAME: Final[str] = "LOG_LEVEL"
-
-
-class LoggingLevel(StrEnum):
- DEBUG = "DEBUG"
- INFO = "INFO"
- WARNING = "WARNING"
- ERROR = "ERROR"
- CRITICAL = "CRITICAL"
-
-
-DEFAULT_LOG_LEVEL: Final[LoggingLevel] = LoggingLevel.INFO
-
-
-def validate_logging_level(*, level: str) -> LoggingLevel:
- try:
- return LoggingLevel(level)
- except ValueError as err:
- raise ValueError(f"Invalid log level: '{level}'.") from err
-
-
-FMT: Final[str] = (
- "[%(asctime)s.%(msecs)03d] "
- "[%(threadName)s] "
- "%(funcName)20s "
- "%(module)s:%(lineno)d "
- "%(levelname)-8s - "
- "%(message)s"
-)
-DATEFMT: Final[str] = "%Y-%m-%d %H:%M:%S"
-
-
-def configure_logging(
- *,
- level: LoggingLevel = DEFAULT_LOG_LEVEL,
-) -> None:
- logging.basicConfig(
- level=level,
- datefmt=DATEFMT,
- format=FMT,
- force=True,
- )
-
-
-# ENVIRONMENT & PATHS
-
-
-ENV_VAR_NAME: Final[str] = "APP_ENV"
-
-
-class ValidEnvs(StrEnum):
- """
- Values should reflect actual directory names.
- """
-
- LOCAL = "local"
- DEV = "dev"
- PROD = "prod"
-
-
-class DirContents(StrEnum):
- """
- Values should reflect actual file names.
- """
-
- CONFIG_NAME = "config.toml"
- SECRETS_NAME = ".secrets.toml"
- EXPORT_NAME = "export.toml"
- DOTENV_NAME = ".env"
-
-
-BASE_DIR_PATH: Final[Path] = Path(__file__).resolve().parents[1]
-CONFIG_PATH: Final[Path] = BASE_DIR_PATH / "config"
-
-ENV_TO_DIR_PATHS: Final[Mapping[ValidEnvs, Path]] = MappingProxyType({
- ValidEnvs.LOCAL: CONFIG_PATH / ValidEnvs.LOCAL,
- ValidEnvs.DEV: CONFIG_PATH / ValidEnvs.DEV,
- ValidEnvs.PROD: CONFIG_PATH / ValidEnvs.PROD,
-})
-
-
-def validate_env(env: str | None) -> ValidEnvs:
- if env is None:
- raise ValueError(f"{ENV_VAR_NAME} is not set.")
- try:
- return ValidEnvs(env)
- except ValueError as err:
- valid_values = ", ".join(f"'{e}'" for e in ValidEnvs)
- raise ValueError(
- f"Invalid {ENV_VAR_NAME}: '{env}'. Must be one of: {valid_values}.",
- ) from err
-
-
-def get_current_env() -> ValidEnvs:
- return validate_env(os.getenv(ENV_VAR_NAME))
-
-
-# CONFIG READING
-
-
-def load_full_config(
- env: ValidEnvs,
- dir_paths: Mapping[ValidEnvs, Path] = ENV_TO_DIR_PATHS,
- main_config: DirContents = DirContents.CONFIG_NAME,
- secrets_config: DirContents = DirContents.SECRETS_NAME,
-) -> ConfigDict:
- log.info("Reading config for environment: '%s'", env)
- config = read_config(env=env, config=main_config, dir_paths=dir_paths)
- try:
- secrets = read_config(env=env, config=secrets_config, dir_paths=dir_paths)
- except FileNotFoundError:
- log.warning("Secrets file not found. Full config will not contain secrets.")
- return config
- return merge_dicts(dict1=config, dict2=secrets)
-
-
-def read_config(
- env: ValidEnvs,
- dir_paths: Mapping[ValidEnvs, Path],
- config: DirContents,
-) -> ConfigDict:
- dir_path = dir_paths.get(env)
- if dir_path is None:
- raise FileNotFoundError(f"No directory path configured for environment: {env}")
- file_path = dir_path / config
- if not file_path.is_file():
- raise FileNotFoundError(
- f"The file does not exist at the specified path: {file_path}",
- )
- with file_path.open(mode="rb") as f:
- return tomllib.load(f)
-
-
-def merge_dicts(*, dict1: ConfigDict, dict2: ConfigDict) -> ConfigDict:
- result = dict1.copy()
- for key, value in dict2.items():
- if key in result and isinstance(result[key], dict) and isinstance(value, dict):
- result[key] = merge_dicts(dict1=result[key], dict2=value)
- else:
- result[key] = value
- return result
-
-
-# EXPORT PROCESSING
-
-
-EXPORT_SECTION: Final[str] = "export"
-EXPORT_FIELDS_KEY: Final[str] = "fields"
-
-
-def get_exported_env_variables(
- env: ValidEnvs,
- dir_paths: Mapping[ValidEnvs, Path] = ENV_TO_DIR_PATHS,
-) -> ExportEnv:
- config = load_full_config(env=env, dir_paths=dir_paths)
- export_fields = load_export_fields(env=env, dir_paths=dir_paths)
- return extract_export_fields_from_config(config=config, export_fields=export_fields)
-
-
-def load_export_fields(
- env: ValidEnvs,
- dir_paths: Mapping[ValidEnvs, Path],
-) -> list[str]:
- export_data = read_config(
- env=env,
- config=DirContents.EXPORT_NAME,
- dir_paths=dir_paths,
- )
-
- export_section = export_data.get(EXPORT_SECTION)
- if not isinstance(export_section, dict):
- raise ValueError(
- f"Invalid {DirContents.EXPORT_NAME}: missing [{EXPORT_SECTION}] section"
- )
-
- fields = export_section.get(EXPORT_FIELDS_KEY)
- if not isinstance(fields, list) or not all(isinstance(f, str) for f in fields):
- raise ValueError(
- f"Invalid {DirContents.EXPORT_NAME}: "
- f"'{EXPORT_FIELDS_KEY}' must be a list of strings"
- )
- if not fields:
- raise ValueError(
- f"Invalid {DirContents.EXPORT_NAME}: '{EXPORT_FIELDS_KEY}' cannot be empty"
- )
-
- return fields
-
-
-def extract_export_fields_from_config(
- config: ConfigDict,
- export_fields: list[str],
-) -> ExportEnv:
- result: ExportEnv = {}
- for field in export_fields:
- str_value = get_env_value_by_export_field(config=config, field=field)
- env_key = "_".join(part.upper() for part in field.split("."))
- result[env_key] = str_value
- return result
-
-
-def get_env_value_by_export_field(*, config: ConfigDict, field: str) -> str:
- node: Mapping[str, Any] = config
- value: Any = node
-
- parts = field.split(".")
- for idx, part in enumerate(parts):
- if part not in node:
- raise KeyError(f"Field '{field}' not found in config")
-
- value = node[part]
-
- if idx != len(parts) - 1:
- if not isinstance(value, Mapping):
- raise KeyError(f"Field '{field}' not found in config")
- node = value
-
- if isinstance(value, (dict, list)):
- raise ValueError(
- f"Field '{field}' cannot be converted to string: "
- f"got {type(value).__name__}",
- )
-
- return str(value)
-
-
-# DOTENV GENERATION
-
-
-def write_dotenv_file(
- *,
- env: ValidEnvs,
- exported_fields: ExportEnv,
- generated_at: datetime | None = None,
-) -> None:
- if generated_at is None:
- generated_at = datetime.now(UTC)
-
- dotenv_filename = f"{DirContents.DOTENV_NAME}.{env.value}"
- dotenv_path = ENV_TO_DIR_PATHS[env] / dotenv_filename
-
- header = [
- "# This .env file was automatically generated by toml_config_manager.",
- "# Do not edit directly. Make changes in config.toml or .secrets.toml instead.",
- "# Ensure values here match those in config files.",
- f"# Environment: {env}",
- f"# Generated: {generated_at.isoformat()}",
- ]
- body = [f"{key}={value}" for key, value in exported_fields.items()]
- body.append("")
-
- dotenv_path.write_text(
- data="\n".join(header + body),
- encoding="utf-8",
- newline="\n",
- )
-
- log.info(
- "Dotenv for environment '%s' was successfully generated at '%s'! β¨",
- env.value,
- str(dotenv_path.resolve()),
- )
-
-
-# ENTRY POINT
-
-
-def main() -> None:
- log_lvl_str = os.getenv(LOG_LEVEL_VAR_NAME, DEFAULT_LOG_LEVEL)
- log_lvl = validate_logging_level(level=log_lvl_str)
- configure_logging(level=log_lvl)
-
- env = get_current_env()
- exported_fields = get_exported_env_variables(env)
- write_dotenv_file(env=env, exported_fields=exported_fields)
-
-
-if __name__ == "__main__":
- main()
diff --git a/docker-compose.test.yml b/docker-compose.test.yml
new file mode 100644
index 00000000..a47a7f18
--- /dev/null
+++ b/docker-compose.test.yml
@@ -0,0 +1,8 @@
+services:
+ app:
+ ports: !reset []
+ environment:
+ ALLOW_DESTRUCTIVE_TEST_CLEANUP: "1"
+
+ db_pg:
+ ports: !reset []
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 00000000..40be82f6
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,36 @@
+services:
+ app:
+ build:
+ context: .
+ dockerfile: ./Dockerfile
+ args:
+ - ENVIRONMENT=dev
+ restart: on-failure
+ volumes:
+ - .:/code
+ ports:
+ - "127.0.0.1:${UVICORN_PORT}:8000"
+ depends_on:
+ db_pg:
+ condition: service_healthy
+ env_file:
+ - .env
+ environment:
+ - COVERAGE_FILE=/tmp/.coverage
+ command: ["start", "8000"]
+
+ db_pg:
+ image: postgres:18-alpine
+ shm_size: 128mb
+ ports:
+ - "127.0.0.1:${POSTGRES_PORT}:5432"
+ environment:
+ POSTGRES_DB: ${POSTGRES_DB?POSTGRES_DB is required}
+ POSTGRES_USER: ${POSTGRES_USER?POSTGRES_USER is required}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD?POSTGRES_PASSWORD is required}
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
+ interval: 2s
+ timeout: 2s
+ retries: 5
+ start_period: 1s
diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh
new file mode 100755
index 00000000..0aba861d
--- /dev/null
+++ b/docker-entrypoint.sh
@@ -0,0 +1,14 @@
+#!/bin/bash
+set -e
+
+PORT=${2:-8000}
+
+case "$1" in
+ start)
+ alembic upgrade head
+ exec uvicorn app.main.run:make_app --factory --host 0.0.0.0 --port "$PORT" --reload
+ ;;
+ *)
+ exec "$@"
+ ;;
+esac
diff --git a/docs/Robert_Martin_CA.png b/docs/Robert_Martin_CA.png
deleted file mode 100644
index 4ad25a84..00000000
Binary files a/docs/Robert_Martin_CA.png and /dev/null differ
diff --git a/docs/application_controller_interactor.svg b/docs/application_controller_interactor.svg
deleted file mode 100644
index b2dcd9e8..00000000
--- a/docs/application_controller_interactor.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-Business Logic Controllers
Request / Response Data (Dataclass / TypedDict)
Application Interactors / Services
\ No newline at end of file
diff --git a/docs/application_interactor.svg b/docs/application_interactor.svg
deleted file mode 100644
index c691c44d..00000000
--- a/docs/application_interactor.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-Application Interactors / Services
Request / Response Data (Dataclass / TypedDict)
\ No newline at end of file
diff --git a/docs/application_interactor_adapter.svg b/docs/application_interactor_adapter.svg
deleted file mode 100644
index e4602d6c..00000000
--- a/docs/application_interactor_adapter.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-Application Interactors / Services
Application
Other Β Adapters
Business Data Source / External System
\ No newline at end of file
diff --git a/docs/dep_graph_basic.svg b/docs/dep_graph_basic.svg
deleted file mode 100644
index 6ea56bc6..00000000
--- a/docs/dep_graph_basic.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-
\ No newline at end of file
diff --git a/docs/dep_graph_inv_correct.svg b/docs/dep_graph_inv_correct.svg
deleted file mode 100644
index 865bf52d..00000000
--- a/docs/dep_graph_inv_correct.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-
\ No newline at end of file
diff --git a/docs/dep_graph_inv_correct_di.svg b/docs/dep_graph_inv_correct_di.svg
deleted file mode 100644
index cc9b7146..00000000
--- a/docs/dep_graph_inv_correct_di.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-IoC container of DI framework
\ No newline at end of file
diff --git a/docs/dep_graph_inv_corrupted.svg b/docs/dep_graph_inv_corrupted.svg
deleted file mode 100644
index 3cc8eea3..00000000
--- a/docs/dep_graph_inv_corrupted.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-
\ No newline at end of file
diff --git a/docs/domain_adapter.svg b/docs/domain_adapter.svg
deleted file mode 100644
index 4f810f65..00000000
--- a/docs/domain_adapter.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-Business Data Source / External System
\ No newline at end of file
diff --git a/docs/draw.io/application_controller_interactor.drawio b/docs/draw.io/application_controller_interactor.drawio
deleted file mode 100644
index 0a6ab2d9..00000000
--- a/docs/draw.io/application_controller_interactor.drawio
+++ /dev/null
@@ -1,40 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/docs/draw.io/application_interactor.drawio b/docs/draw.io/application_interactor.drawio
deleted file mode 100644
index 3382519e..00000000
--- a/docs/draw.io/application_interactor.drawio
+++ /dev/null
@@ -1,53 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/docs/draw.io/application_interactor_adapter.drawio b/docs/draw.io/application_interactor_adapter.drawio
deleted file mode 100644
index 9c093fc0..00000000
--- a/docs/draw.io/application_interactor_adapter.drawio
+++ /dev/null
@@ -1,84 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/docs/draw.io/dep_graph_basic.drawio b/docs/draw.io/dep_graph_basic.drawio
deleted file mode 100644
index 7311c943..00000000
--- a/docs/draw.io/dep_graph_basic.drawio
+++ /dev/null
@@ -1,43 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/docs/draw.io/dep_graph_inv_correct.drawio b/docs/draw.io/dep_graph_inv_correct.drawio
deleted file mode 100644
index 66218ccc..00000000
--- a/docs/draw.io/dep_graph_inv_correct.drawio
+++ /dev/null
@@ -1,38 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/docs/draw.io/dep_graph_inv_correct_di.drawio b/docs/draw.io/dep_graph_inv_correct_di.drawio
deleted file mode 100644
index 462edb31..00000000
--- a/docs/draw.io/dep_graph_inv_correct_di.drawio
+++ /dev/null
@@ -1,75 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/docs/draw.io/dep_graph_inv_corrupted.drawio b/docs/draw.io/dep_graph_inv_corrupted.drawio
deleted file mode 100644
index 0eab550e..00000000
--- a/docs/draw.io/dep_graph_inv_corrupted.drawio
+++ /dev/null
@@ -1,24 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/docs/draw.io/domain_adapter.drawio b/docs/draw.io/domain_adapter.drawio
deleted file mode 100644
index 42b6eb1e..00000000
--- a/docs/draw.io/domain_adapter.drawio
+++ /dev/null
@@ -1,37 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/docs/draw.io/identity_provider.drawio b/docs/draw.io/identity_provider.drawio
deleted file mode 100644
index fed319c4..00000000
--- a/docs/draw.io/identity_provider.drawio
+++ /dev/null
@@ -1,70 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/docs/draw.io/infrastructure_controller_handler.drawio b/docs/draw.io/infrastructure_controller_handler.drawio
deleted file mode 100644
index 301f4875..00000000
--- a/docs/draw.io/infrastructure_controller_handler.drawio
+++ /dev/null
@@ -1,40 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/docs/draw.io/infrastructure_handler.drawio b/docs/draw.io/infrastructure_handler.drawio
deleted file mode 100644
index 5e84d560..00000000
--- a/docs/draw.io/infrastructure_handler.drawio
+++ /dev/null
@@ -1,61 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/docs/draw.io/toml_config_manager.drawio b/docs/draw.io/toml_config_manager.drawio
deleted file mode 100644
index df95f817..00000000
--- a/docs/draw.io/toml_config_manager.drawio
+++ /dev/null
@@ -1,73 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/docs/handlers.png b/docs/handlers.png
deleted file mode 100644
index 8653d557..00000000
Binary files a/docs/handlers.png and /dev/null differ
diff --git a/docs/identity_provider.svg b/docs/identity_provider.svg
deleted file mode 100644
index caf1f882..00000000
--- a/docs/identity_provider.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-Infrastructure Data & Services
Application Interactors / Services
\ No newline at end of file
diff --git a/docs/infrastructure_controller_handler.svg b/docs/infrastructure_controller_handler.svg
deleted file mode 100644
index 9180f590..00000000
--- a/docs/infrastructure_controller_handler.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-Infrastructure Logic Controllers
Request / Response DataΒ (Dataclass)
\ No newline at end of file
diff --git a/docs/infrastructure_handler.svg b/docs/infrastructure_handler.svg
deleted file mode 100644
index 9039a618..00000000
--- a/docs/infrastructure_handler.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-Infrastructure Handlers / Services
Infrastructure Data Mappers
Infrastructure Data Source
\ No newline at end of file
diff --git a/docs/onion_1.svg b/docs/onion_1.svg
deleted file mode 100644
index de0a4f81..00000000
--- a/docs/onion_1.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-
\ No newline at end of file
diff --git a/docs/onion_2.svg b/docs/onion_2.svg
deleted file mode 100644
index 690ac5b2..00000000
--- a/docs/onion_2.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-
\ No newline at end of file
diff --git a/docs/toml_config_manager.svg b/docs/toml_config_manager.svg
deleted file mode 100644
index ef5eaf5a..00000000
--- a/docs/toml_config_manager.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-config/dev/config.toml
config/dev/.secrets.toml
config/dev/docker-compose.yaml
... if this variable is set
\ No newline at end of file
diff --git a/env.example b/env.example
new file mode 100644
index 00000000..51a11b27
--- /dev/null
+++ b/env.example
@@ -0,0 +1,21 @@
+# Example
+EXAMPLE_SERVICE_URL=http://example_service:51888
+
+# Uvicorn
+UVICORN_PORT=8000
+
+# App
+APP_LOGGING_LEVEL=DEBUG
+
+# Jwt
+JWT_SECRET=REPLACE_THIS_WITH_YOUR_OWN_SECRET_JWT_SECRET_VALUE
+
+# Password
+PASSWORD_PEPPER=REPLACE_THIS_WITH_YOUR_OWN_SECRET_PEPPER_VALUE
+
+# Postgres
+POSTGRES_DB=clean-example
+POSTGRES_HOST=db_pg
+POSTGRES_PORT=5432
+POSTGRES_USER=postgres
+POSTGRES_PASSWORD=password
diff --git a/pyproject.toml b/pyproject.toml
index e92e4faf..87f96f58 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,92 +1,92 @@
-[build-system]
-requires = ["hatchling>=1.13"]
-build-backend = "hatchling.build"
-
-[tool.hatch.build]
-sources = ["src"]
-
-[tool.hatch.build.targets.wheel]
-packages = ["src/app"]
-
[project]
name = "fastapi-clean-example"
-version = "0.1"
+version = "0.2"
description = "Practical Clean Architecture backend example built with FastAPI"
readme = "README.md"
+requires-python = "==3.13.*"
license = "MIT"
authors = [
- { name = "Ivan Borovets", email = "ivan.r.borovets@gmail.com" },
+ { name = "Ivan Borovets", email = "ivan.r.borovets@gmail.com" },
]
-requires-python = "==3.13.*"
dependencies = [
- "alembic==1.17.1",
- "alembic-postgresql-enum==1.8.0",
- "bcrypt==5.0.0",
- "dishka==1.7.2",
- "fastapi-error-map==0.9.8",
- "fastapi==0.121.0",
- "orjson==3.11.4",
- "psycopg[binary]==3.2.12",
- "pyjwt[crypto]==2.10.1",
- "sqlalchemy[mypy]==2.0.44",
- "uvicorn==0.38.0",
- "uvloop==0.22.1",
- "uuid-utils==0.11.1",
+ "alembic==1.18.4",
+ "alembic-postgresql-enum==1.10.0",
+ "bcrypt==5.0.0",
+ "dishka==1.9.1",
+ "fastapi==0.133.1",
+ "fastapi-error-map==0.9.10",
+ "psycopg[binary]==3.3.3",
+ "pydantic-settings==2.13.1",
+ "pyjwt[crypto]==2.11.0",
+ "sqlalchemy[mypy]==2.0.47",
+ "uuid-utils==0.14.1",
+ "uvicorn==0.41.0",
]
[dependency-groups]
dev = [
- "deptry==0.24.0",
- "import-linter==2.9",
- "mypy==1.17.0",
- "pre-commit==4.2.0",
- "ruff==0.12.5",
- "slotscheck==0.19.1",
- { include-group = "test" },
-]
-test = [
- "coverage==7.10.0",
- "line-profiler==5.0.0",
- "pytest==8.4.1",
- "pytest-asyncio==1.1.0"
+ "asgi-lifespan==2.1.0",
+ "coverage==7.13.4",
+ "deptry==0.24.0",
+ "httpx==0.28.1",
+ "import-linter==2.10",
+ "line-profiler==5.0.2",
+ "mypy==1.19.1",
+ "pip-audit==2.10.0",
+ "pre-commit==4.5.1",
+ "pytest==9.0.2",
+ "pytest-asyncio==1.3.0",
+ "pytest-cov==7.0.0",
+ "ruff==0.15.4",
+ "slotscheck==0.19.1",
+ "tombi==0.7.33",
]
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
[tool.coverage.report]
show_missing = true
skip_empty = true
exclude_lines = [
- "if __name__ == .__main__.:",
- "pass",
- "\\.\\.\\.",
- "from .* import .*",
- "import .*",
- "logging\\..*",
- "log\\..*",
- "@(abc\\.)?abstractmethod",
- ".* = NewType\\(.*\\)",
- ".* = Literal\\[.*\\]",
+ ".* = Literal\\[.*\\]",
+ ".* = NewType\\(.*\\)",
+ "@abc\\.abstractmethod",
+ "\\.\\.\\.",
+ "^\\s*raise\\s*$",
+ "from .* import .*",
+ "if TYPE_CHECKING:",
+ "if __name__ == .__main__.:",
+ "import .*",
+ "logger\\..*",
+ "logging\\..*",
+ "pass",
]
[tool.coverage.run]
+relative_files = true
source = ["src"]
omit = [
- "**/__init__.py",
- "**/alembic/**",
-]
-concurrency = [
- "multiprocessing",
- "thread",
+ "**/__init__.py",
+ "src/app/config/logging_.py",
+ "src/app/main/**",
+ "**/health/**",
+ "**/alembic/**",
]
-parallel = true
+concurrency = ["thread"]
branch = true
[tool.deptry]
root = ["src"]
ignore = [
- "DEP002", # Unused dependencies
- "DEP003", # Transitive dependencies
+ "DEP002", # Unused dependencies
+ "DEP003", # Transitive dependencies
]
+[tool.hatch.build.targets.wheel]
+packages = ["src/app"]
+
[tool.importlinter]
root_package = "app"
exclude_type_checking_imports = true
@@ -97,24 +97,58 @@ name = "inner must not import outer"
type = "layers"
containers = ["app"]
layers = [
- "(run)",
- "(setup)",
- "presentation",
- "infrastructure",
- "application",
- "domain",
+ "(main)",
+ "(config)",
+ "presentation",
+ "infrastructure",
+ "core",
]
ignore_imports = [
- "app.infrastructure.persistence_sqla.alembic.env -> app.setup.config.settings",
+ "app.infrastructure.persistence_sqla.alembic.env -> app.config.loader",
+ "app.infrastructure.persistence_sqla.alembic.env -> app.config.settings",
]
+[[tool.importlinter.contracts]]
+id = "cqrs-common-must-not-import-commands"
+name = "cqrs: common must not import commands"
+type = "forbidden"
+source_modules = ["app.core.common"]
+forbidden_modules = ["app.core.commands"]
+
+[[tool.importlinter.contracts]]
+id = "cqrs-common-must-not-import-queries"
+name = "cqrs: common must not import queries"
+type = "forbidden"
+source_modules = ["app.core.common"]
+forbidden_modules = ["app.core.queries"]
+
+[[tool.importlinter.contracts]]
+id = "cqrs-commands-must-not-import-queries"
+name = "cqrs: commands must not import queries"
+type = "forbidden"
+source_modules = ["app.core.commands"]
+forbidden_modules = ["app.core.queries"]
+
+[[tool.importlinter.contracts]]
+id = "cqrs-queries-must-not-import-commands"
+name = "cqrs: queries must not import commands"
+type = "forbidden"
+source_modules = ["app.core.queries"]
+forbidden_modules = ["app.core.commands"]
+
+[[tool.importlinter.contracts]]
+id = "auth-ctx"
+name = "auth-ctx must use its own adapters"
+type = "forbidden"
+source_modules = ["app.infrastructure.auth_ctx"]
+forbidden_modules = ["app.infrastructure.adapters"]
+
[tool.mypy]
mypy_path = ["src"]
files = [
- "config",
- "scripts",
- "src",
- "tests",
+ "scripts",
+ "src",
+ "tests",
]
exclude = "^.*alembic.*$"
python_version = "3.13"
@@ -127,8 +161,8 @@ no_implicit_optional = true
warn_no_return = true
warn_unreachable = true
plugins = [
- "pydantic.mypy",
- "sqlalchemy.ext.mypy.plugin",
+ "pydantic.mypy",
+ "sqlalchemy.ext.mypy.plugin",
]
[tool.pydantic-mypy]
@@ -136,70 +170,77 @@ warn_required_dynamic_aliases = true
warn_untyped_fields = true
init_typed = true
-[tool.pytest.ini_options]
-testpaths = ["tests"]
+[tool.pytest]
+addopts = ["--color=yes", "-mnot(slow)", "-pno:cacheprovider"]
+filterwarnings = []
markers = ["slow"]
-addopts = "-m 'not slow'"
+testpaths = ["tests"]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
-filterwarnings = []
[tool.ruff]
-line-length = 88
+line-length = 120
target-version = "py313"
-preview = true # experimental
+fix = true
+unsafe-fixes = true
[tool.ruff.format]
+quote-style = "double"
+indent-style = "space"
skip-magic-trailing-comma = false
[tool.ruff.lint]
select = [
- "A", # flake8-builtins https://docs.astral.sh/ruff/rules/#flake8-builtins-a
- "ARG", # flake8-unused-arguments https://docs.astral.sh/ruff/rules/#flake8-unused-arguments-arg
- "ASYNC", # flake8-async https://docs.astral.sh/ruff/rules/#flake8-async-async
- "B", # flake8-bugbear https://docs.astral.sh/ruff/rules/#flake8-bugbear-b
- "C4", # flake8-comprehensions https://docs.astral.sh/ruff/rules/#flake8-comprehensions-c4
- "C90", # mccabe https://docs.astral.sh/ruff/rules/#mccabe-c90
- # "COM", # flake8-commas https://docs.astral.sh/ruff/rules/#flake8-commas-com
- # Incompatible with ruff formatter, but can be useful (uncomment once, then review changes)
- # See: https://github.com/astral-sh/ruff/issues/9216
- "DTZ", # flake8-datetimez https://docs.astral.sh/ruff/rules/#flake8-datetimez-dtz
- "E", # pycodestyle-error https://docs.astral.sh/ruff/rules/#error-e
- "ERA001", # commented-out-code https://docs.astral.sh/ruff/rules/#eradicate-era
- "F", # pyflakes https://docs.astral.sh/ruff/rules/#pyflakes-f
- "FLY", # flynt https://docs.astral.sh/ruff/rules/#flynt-fly
- "I", # isort https://docs.astral.sh/ruff/rules/#isort-i
- "LOG", # flake8-logging https://docs.astral.sh/ruff/rules/#flake8-logging-log
- "N", # pep8-naming https://docs.astral.sh/ruff/rules/#pep8-naming-n
- "PERF", # Perflint https://docs.astral.sh/ruff/rules/#perflint-perf
- "PL", # pylint https://docs.astral.sh/ruff/rules/#pylint-pl
- "PT", # flake8-pytest-style https://docs.astral.sh/ruff/rules/#flake8-pytest-style-pt
- "PTH", # flake8-use-pathlib https://docs.astral.sh/ruff/rules/#flake8-use-pathlib-pth
- "Q", # flake8-quotes https://docs.astral.sh/ruff/rules/#flake8-quotes-q
- "RET", # flake8-return (RET) https://docs.astral.sh/ruff/rules/#flake8-return-ret
- "RSE", # flake8-raise (RSE) https://docs.astral.sh/ruff/rules/#flake8-raise-rse
- "RUF", # Ruff-specific rules https://docs.astral.sh/ruff/rules/#ruff-specific-rules-ruf
- "S", # flake8-bandit https://docs.astral.sh/ruff/rules/#flake8-bandit-s
- "SIM", # flake8-simplify https://docs.astral.sh/ruff/rules/#flake8-simplify-sim
- "SLF", # flake8-self (SLF) https://docs.astral.sh/ruff/rules/#flake8-self-slf
- "SLOT", # flake8-slots https://docs.astral.sh/ruff/rules/#flake8-slots-slot
- "T20", # flake8-print https://docs.astral.sh/ruff/rules/#flake8-print-t20
- "TCH", # flake8-type-checking https://docs.astral.sh/ruff/rules/#flake8-type-checking-tch
- "TID", # flake8-tidy-imports https://docs.astral.sh/ruff/rules/#flake8-tidy-imports-tid
- "UP", # pyupgrade https://docs.astral.sh/ruff/rules/#pyupgrade-up
- "W", # pycodestyle-warning https://docs.astral.sh/ruff/rules/#warning-w
+ "A", # flake8-builtins
+ "ASYNC", # flake8-async
+ "B", # flake8-bugbear
+ "C4", # flake8-comprehensions
+ "C90", # mccabe
+ "DTZ", # flake8-datetimez
+ "E", # pycodestyle (Error)
+ "EXE", # flake8-executable
+ "F", # Pyflakes
+ "FAST", # FastAPI
+ "FLY", # flynt
+ "FURB", # refurb
+ "G", # flake8-logging-format
+ "I", # isort
+ "INT", # flake8-gettext
+ "ISC", # flake8-implicit-str-concat
+ "LOG", # flake8-logging
+ "N", # pep8-naming
+ "PERF", # Perflint
+ "PL", # pylint
+ "PLE", # Pylint (error)
+ "PLR5501", # Pylint Refactor: collapsible-else-if
+ "PT", # flake8-pytest-style
+ "PTH", # flake8-use-pathlib
+ "Q", # flake8-quotes
+ "RSE", # flake8-raise (RSE)
+ "RUF", # Ruff-specific rules
+ "S", # flake8-bandit
+ "SIM", # flake8-simplify
+ "SLF", # flake8-self (SLF)
+ "SLOT", # flake8-slots
+ "T10", # flake8-debugger
+ "T20", # flake8-print
+ "TC", # flake8-type-checking
+ "TID", # flake8-tidy-imports
+ "UP", # pyupgrade
+ "W", # pycodestyle (Warning)
]
ignore = [
- "N818", # error-suffix-on-exception-name
- "PLR0913", # too-many-arguments
- "PLR0917", # too-many-positional-arguments
- "PLR6301", # no-self-use
- "PTH123", # builtin-open
- "TC001", # typing-only-first-party-import
- "TC002", # typing-only-third-party-import
- "TC003", # typing-only-standard-library-import
- "TC006", # runtime-cast-value
- "UP015", # redundant-open-modes
+ "PLR0913", # too-many-arguments
+ "PLR0917", # too-many-positional-arguments
+ "PLR6301", # no-self-use
+ "RUF001", # ambiguous-unicode-character-string (cyrillic)
+ "RUF002", # ambiguous-unicode-character-docstring (cyrillic)
+ "RUF003", # ambiguous-unicode-character-comment (cyrillic)
+ "S311", # suspicious-non-cryptographic-random-usage
+ "TC001", # typing-only-first-party-import
+ "TC002", # typing-only-third-party-import
+ "TC003", # typing-only-standard-library-import
+ "TC006", # runtime-cast-value
]
[tool.ruff.lint.isort]
@@ -208,25 +249,25 @@ force-wrap-aliases = true
split-on-trailing-comma = true
[tool.ruff.lint.per-file-ignores]
+"scripts/dishka/plot_dependencies_data.py" = ["T201"] # print
"src/app/infrastructure/persistence_sqla/alembic/**" = ["ALL"]
"tests/**" = [
- "ARG002", # unused-method-argument
- "PLC2801", # unnecessary-dunder-call
- "PLR2004", # magic-value-comparison
- "PT011", # pytest-raises-too-broad
- "S101", # assert
- "S106", # hardcoded-password-func-arg
- "S107", # hardcoded-password-default
+ "ARG002", # unused-method-argument
+ "PLC2801", # unnecessary-dunder-call
+ "PLR2004", # magic-value-comparison
+ "PT011", # pytest-raises-too-broad
+ "S101", # assert
+ "S105", # hardcoded-password-string
+ "S106", # hardcoded-password-func-arg
+ "S107", # hardcoded-password-default
+ "SIM300", # yoda-conditions
]
-#
-"src/app/infrastructure/adapters/password_hasher_bcrypt.py" = ["E501"] # line-too-long
-"src/app/infrastructure/auth/handlers/constants.py" = ["S105"] # hardcoded-password-string
-"src/app/presentation/http/auth/constants.py" = ["S105"] # hardcoded-password-string
-"src/app/presentation/http/errors/translators.py" = ["ARG002"] # unused-method-argument
-"scripts/dishka/plot_dependencies_data.py" = ["T201"] # print
[tool.slotscheck]
strict-imports = true
exclude-modules = '''
^app\.infrastructure\.persistence_sqla\.alembic
'''
+
+[tool.typos.files]
+extend-exclude = []
diff --git a/scripts/dishka/plot_dependencies_data.py b/scripts/dishka/plot_dependencies_data.py
index 83d6d3fa..b8f28c17 100644
--- a/scripts/dishka/plot_dependencies_data.py
+++ b/scripts/dishka/plot_dependencies_data.py
@@ -1,13 +1,18 @@
-import dishka.plotter
-import uvloop
-from dishka import AsyncContainer, make_async_container
-
-from app.setup.config.settings import AppSettings, load_settings
-from app.setup.ioc.provider_registry import get_providers
+import asyncio
+import dishka.plotter
+from dishka import AsyncContainer
-def make_plot_data_container(settings: AppSettings) -> AsyncContainer:
- return make_async_container(*get_providers(), context={AppSettings: settings})
+from app.config.loader import (
+ load_app_settings,
+ load_cookie_settings,
+ load_jwt_settings,
+ load_password_hasher_settings,
+ load_postgres_settings,
+ load_session_settings,
+ load_sqla_settings,
+)
+from app.main.run import make_ioc_container
def generate_dependency_graph_d2(container: AsyncContainer) -> str:
@@ -19,11 +24,18 @@ def generate_dependency_graph_d2(container: AsyncContainer) -> str:
async def main() -> None:
- settings: AppSettings = load_settings()
- async with make_plot_data_container(settings)() as container:
+ async with make_ioc_container(
+ app_settings=load_app_settings(),
+ postgres_settings=load_postgres_settings(),
+ sqla_settings=load_sqla_settings(),
+ password_hasher_settings=load_password_hasher_settings(),
+ jwt_settings=load_jwt_settings(),
+ session_settings=load_session_settings(),
+ cookie_settings=load_cookie_settings(),
+ )() as container:
print(generate_dependency_graph_d2(container))
await container.close()
if __name__ == "__main__":
- uvloop.run(main())
+ asyncio.run(main())
diff --git a/scripts/makefile/pycache_del.sh b/scripts/makefile/pycache_del.sh
index f98bcda7..cf70c768 100755
--- a/scripts/makefile/pycache_del.sh
+++ b/scripts/makefile/pycache_del.sh
@@ -1,2 +1,3 @@
#!/bin/bash
-find . | grep -E "(/__pycache__$|\.pyc$|\.pyo$)" | xargs rm -rf
+ find . -type d -name '__pycache__' -prune -exec rm -rf {} +; \
+ find . -type f \( -name '*.pyc' -o -name '*.pyo' \) -delete
diff --git a/src/app/application/commands/activate_user.py b/src/app/application/commands/activate_user.py
deleted file mode 100644
index 7e880b56..00000000
--- a/src/app/application/commands/activate_user.py
+++ /dev/null
@@ -1,93 +0,0 @@
-import logging
-from dataclasses import dataclass
-from uuid import UUID
-
-from app.application.common.ports.transaction_manager import (
- TransactionManager,
-)
-from app.application.common.ports.user_command_gateway import UserCommandGateway
-from app.application.common.services.authorization.authorize import (
- authorize,
-)
-from app.application.common.services.authorization.permissions import (
- CanManageRole,
- CanManageSubordinate,
- RoleManagementContext,
- UserManagementContext,
-)
-from app.application.common.services.current_user import CurrentUserService
-from app.domain.entities.user import User
-from app.domain.enums.user_role import UserRole
-from app.domain.exceptions.user import (
- UserNotFoundByIdError,
-)
-from app.domain.services.user import UserService
-from app.domain.value_objects.user_id import UserId
-
-log = logging.getLogger(__name__)
-
-
-@dataclass(frozen=True, slots=True)
-class ActivateUserRequest:
- user_id: UUID
-
-
-class ActivateUserInteractor:
- """
- - Open to admins.
- - Restores a previously soft-deleted user.
- - Only super admins can activate other admins.
- """
-
- def __init__(
- self,
- current_user_service: CurrentUserService,
- user_command_gateway: UserCommandGateway,
- user_service: UserService,
- transaction_manager: TransactionManager,
- ) -> None:
- self._current_user_service = current_user_service
- self._user_command_gateway = user_command_gateway
- self._user_service = user_service
- self._transaction_manager = transaction_manager
-
- async def execute(self, request_data: ActivateUserRequest) -> None:
- """
- :raises AuthenticationError:
- :raises DataMapperError:
- :raises AuthorizationError:
- :raises UserNotFoundByIdError:
- :raises ActivationChangeNotPermittedError:
- """
- log.info("Activate user: started. Target user ID: '%s'.", request_data.user_id)
-
- current_user = await self._current_user_service.get_current_user()
-
- authorize(
- CanManageRole(),
- context=RoleManagementContext(
- subject=current_user,
- target_role=UserRole.USER,
- ),
- )
-
- user_id = UserId(request_data.user_id)
- user: User | None = await self._user_command_gateway.read_by_id(
- user_id,
- for_update=True,
- )
- if user is None:
- raise UserNotFoundByIdError(user_id)
-
- authorize(
- CanManageSubordinate(),
- context=UserManagementContext(
- subject=current_user,
- target=user,
- ),
- )
-
- if self._user_service.toggle_user_activation(user, is_active=True):
- await self._transaction_manager.commit()
-
- log.info("Activate user: done. Target user ID: '%s'.", user.id_.value)
diff --git a/src/app/application/commands/create_user.py b/src/app/application/commands/create_user.py
deleted file mode 100644
index 0d75514b..00000000
--- a/src/app/application/commands/create_user.py
+++ /dev/null
@@ -1,98 +0,0 @@
-import logging
-from dataclasses import dataclass
-from typing import TypedDict
-from uuid import UUID
-
-from app.application.common.ports.flusher import Flusher
-from app.application.common.ports.transaction_manager import (
- TransactionManager,
-)
-from app.application.common.ports.user_command_gateway import UserCommandGateway
-from app.application.common.services.authorization.authorize import (
- authorize,
-)
-from app.application.common.services.authorization.permissions import (
- CanManageRole,
- RoleManagementContext,
-)
-from app.application.common.services.current_user import CurrentUserService
-from app.domain.enums.user_role import UserRole
-from app.domain.exceptions.user import UsernameAlreadyExistsError
-from app.domain.services.user import UserService
-from app.domain.value_objects.raw_password import RawPassword
-from app.domain.value_objects.username import Username
-
-log = logging.getLogger(__name__)
-
-
-@dataclass(frozen=True, slots=True, kw_only=True)
-class CreateUserRequest:
- username: str
- password: str
- role: UserRole
-
-
-class CreateUserResponse(TypedDict):
- id: UUID
-
-
-class CreateUserInteractor:
- """
- - Open to admins.
- - Creates a new user, including admins, if the username is unique.
- - Only super admins can create new admins.
- """
-
- def __init__(
- self,
- current_user_service: CurrentUserService,
- user_service: UserService,
- user_command_gateway: UserCommandGateway,
- flusher: Flusher,
- transaction_manager: TransactionManager,
- ) -> None:
- self._current_user_service = current_user_service
- self._user_service = user_service
- self._user_command_gateway = user_command_gateway
- self._flusher = flusher
- self._transaction_manager = transaction_manager
-
- async def execute(self, request_data: CreateUserRequest) -> CreateUserResponse:
- """
- :raises AuthenticationError:
- :raises DataMapperError:
- :raises AuthorizationError:
- :raises DomainTypeError:
- :raises PasswordHasherBusyError:
- :raises RoleAssignmentNotPermittedError:
- :raises UsernameAlreadyExistsError:
- """
- log.info("Create user: started. Target username: '%s'.", request_data.username)
-
- current_user = await self._current_user_service.get_current_user()
-
- authorize(
- CanManageRole(),
- context=RoleManagementContext(
- subject=current_user,
- target_role=request_data.role,
- ),
- )
-
- username = Username(request_data.username)
- password = RawPassword(request_data.password)
- user = await self._user_service.create_user(
- username, password, request_data.role
- )
-
- self._user_command_gateway.add(user)
-
- try:
- await self._flusher.flush()
- except UsernameAlreadyExistsError:
- raise
-
- await self._transaction_manager.commit()
-
- log.info("Create user: done. Target username: '%s'.", user.username.value)
- return CreateUserResponse(id=user.id_.value)
diff --git a/src/app/application/commands/deactivate_user.py b/src/app/application/commands/deactivate_user.py
deleted file mode 100644
index 791ad75f..00000000
--- a/src/app/application/commands/deactivate_user.py
+++ /dev/null
@@ -1,103 +0,0 @@
-import logging
-from dataclasses import dataclass
-from uuid import UUID
-
-from app.application.common.ports.access_revoker import AccessRevoker
-from app.application.common.ports.transaction_manager import (
- TransactionManager,
-)
-from app.application.common.ports.user_command_gateway import UserCommandGateway
-from app.application.common.services.authorization.authorize import (
- authorize,
-)
-from app.application.common.services.authorization.permissions import (
- CanManageRole,
- CanManageSubordinate,
- RoleManagementContext,
- UserManagementContext,
-)
-from app.application.common.services.current_user import CurrentUserService
-from app.domain.entities.user import User
-from app.domain.enums.user_role import UserRole
-from app.domain.exceptions.user import (
- UserNotFoundByIdError,
-)
-from app.domain.services.user import UserService
-from app.domain.value_objects.user_id import UserId
-
-log = logging.getLogger(__name__)
-
-
-@dataclass(frozen=True, slots=True)
-class DeactivateUserRequest:
- user_id: UUID
-
-
-class DeactivateUserInteractor:
- """
- - Open to admins.
- - Soft-deletes an existing user, making that user inactive.
- - Also deletes the user's sessions.
- - Only super admins can deactivate other admins.
- - Super admins cannot be soft-deleted.
- """
-
- def __init__(
- self,
- current_user_service: CurrentUserService,
- user_command_gateway: UserCommandGateway,
- user_service: UserService,
- transaction_manager: TransactionManager,
- access_revoker: AccessRevoker,
- ) -> None:
- self._current_user_service = current_user_service
- self._user_command_gateway = user_command_gateway
- self._user_service = user_service
- self._transaction_manager = transaction_manager
- self._access_revoker = access_revoker
-
- async def execute(self, request_data: DeactivateUserRequest) -> None:
- """
- :raises AuthenticationError:
- :raises DataMapperError:
- :raises AuthorizationError:
- :raises UserNotFoundByIdError:
- :raises ActivationChangeNotPermittedError:
- """
- log.info(
- "Deactivate user: started. Target user ID: '%s'.",
- request_data.user_id,
- )
-
- current_user = await self._current_user_service.get_current_user()
-
- authorize(
- CanManageRole(),
- context=RoleManagementContext(
- subject=current_user,
- target_role=UserRole.USER,
- ),
- )
-
- user_id = UserId(request_data.user_id)
- user: User | None = await self._user_command_gateway.read_by_id(
- user_id,
- for_update=True,
- )
- if user is None:
- raise UserNotFoundByIdError(user_id)
-
- authorize(
- CanManageSubordinate(),
- context=UserManagementContext(
- subject=current_user,
- target=user,
- ),
- )
-
- if self._user_service.toggle_user_activation(user, is_active=False):
- await self._transaction_manager.commit()
-
- await self._access_revoker.remove_all_user_access(user.id_)
-
- log.info("Deactivate user: done. Target user ID: '%s'.", user.id_.value)
diff --git a/src/app/application/commands/grant_admin.py b/src/app/application/commands/grant_admin.py
deleted file mode 100644
index 645ee55d..00000000
--- a/src/app/application/commands/grant_admin.py
+++ /dev/null
@@ -1,83 +0,0 @@
-import logging
-from dataclasses import dataclass
-from uuid import UUID
-
-from app.application.common.ports.transaction_manager import (
- TransactionManager,
-)
-from app.application.common.ports.user_command_gateway import UserCommandGateway
-from app.application.common.services.authorization.authorize import (
- authorize,
-)
-from app.application.common.services.authorization.permissions import (
- CanManageRole,
- RoleManagementContext,
-)
-from app.application.common.services.current_user import CurrentUserService
-from app.domain.entities.user import User
-from app.domain.enums.user_role import UserRole
-from app.domain.exceptions.user import (
- UserNotFoundByIdError,
-)
-from app.domain.services.user import UserService
-from app.domain.value_objects.user_id import UserId
-
-log = logging.getLogger(__name__)
-
-
-@dataclass(frozen=True, slots=True)
-class GrantAdminRequest:
- user_id: UUID
-
-
-class GrantAdminInteractor:
- """
- - Open to super admins.
- - Grants admin rights to a specified user.
- - Super admin rights cannot be changed.
- """
-
- def __init__(
- self,
- current_user_service: CurrentUserService,
- user_command_gateway: UserCommandGateway,
- user_service: UserService,
- transaction_manager: TransactionManager,
- ) -> None:
- self._current_user_service = current_user_service
- self._user_command_gateway = user_command_gateway
- self._user_service = user_service
- self._transaction_manager = transaction_manager
-
- async def execute(self, request_data: GrantAdminRequest) -> None:
- """
- :raises AuthenticationError:
- :raises DataMapperError:
- :raises AuthorizationError:
- :raises UserNotFoundByIdError:
- :raises RoleChangeNotPermittedError:
- """
- log.info("Grant admin: started. Target user ID: '%s'.", request_data.user_id)
-
- current_user = await self._current_user_service.get_current_user()
-
- authorize(
- CanManageRole(),
- context=RoleManagementContext(
- subject=current_user,
- target_role=UserRole.ADMIN,
- ),
- )
-
- user_id = UserId(request_data.user_id)
- user: User | None = await self._user_command_gateway.read_by_id(
- user_id,
- for_update=True,
- )
- if user is None:
- raise UserNotFoundByIdError(user_id)
-
- if self._user_service.toggle_user_admin_role(user, is_admin=True):
- await self._transaction_manager.commit()
-
- log.info("Grant admin: done. Target user ID: '%s'.", user.id_.value)
diff --git a/src/app/application/commands/revoke_admin.py b/src/app/application/commands/revoke_admin.py
deleted file mode 100644
index eb1670e2..00000000
--- a/src/app/application/commands/revoke_admin.py
+++ /dev/null
@@ -1,81 +0,0 @@
-import logging
-from dataclasses import dataclass
-from uuid import UUID
-
-from app.application.common.ports.transaction_manager import (
- TransactionManager,
-)
-from app.application.common.ports.user_command_gateway import UserCommandGateway
-from app.application.common.services.authorization.authorize import authorize
-from app.application.common.services.authorization.permissions import (
- CanManageRole,
- RoleManagementContext,
-)
-from app.application.common.services.current_user import CurrentUserService
-from app.domain.entities.user import User
-from app.domain.enums.user_role import UserRole
-from app.domain.exceptions.user import (
- UserNotFoundByIdError,
-)
-from app.domain.services.user import UserService
-from app.domain.value_objects.user_id import UserId
-
-log = logging.getLogger(__name__)
-
-
-@dataclass(frozen=True, slots=True)
-class RevokeAdminRequest:
- user_id: UUID
-
-
-class RevokeAdminInteractor:
- """
- - Open to super admins.
- - Revokes admin rights from a specified user.
- - Super admin rights cannot be changed
- """
-
- def __init__(
- self,
- current_user_service: CurrentUserService,
- user_command_gateway: UserCommandGateway,
- user_service: UserService,
- transaction_manager: TransactionManager,
- ) -> None:
- self._current_user_service = current_user_service
- self._user_command_gateway = user_command_gateway
- self._user_service = user_service
- self._transaction_manager = transaction_manager
-
- async def execute(self, request_data: RevokeAdminRequest) -> None:
- """
- :raises AuthenticationError:
- :raises DataMapperError:
- :raises AuthorizationError:
- :raises UserNotFoundByIdError:
- :raises RoleChangeNotPermittedError:
- """
- log.info("Revoke admin: started. Target user ID: '%s'.", request_data.user_id)
-
- current_user = await self._current_user_service.get_current_user()
-
- authorize(
- CanManageRole(),
- context=RoleManagementContext(
- subject=current_user,
- target_role=UserRole.ADMIN,
- ),
- )
-
- user_id = UserId(request_data.user_id)
- user: User | None = await self._user_command_gateway.read_by_id(
- user_id,
- for_update=True,
- )
- if user is None:
- raise UserNotFoundByIdError(user_id)
-
- if self._user_service.toggle_user_admin_role(user, is_admin=False):
- await self._transaction_manager.commit()
-
- log.info("Revoke admin: done. Target user ID: '%s'.", user.id_.value)
diff --git a/src/app/application/commands/set_user_password.py b/src/app/application/commands/set_user_password.py
deleted file mode 100644
index 78c96399..00000000
--- a/src/app/application/commands/set_user_password.py
+++ /dev/null
@@ -1,99 +0,0 @@
-import logging
-from dataclasses import dataclass
-from uuid import UUID
-
-from app.application.common.ports.transaction_manager import (
- TransactionManager,
-)
-from app.application.common.ports.user_command_gateway import UserCommandGateway
-from app.application.common.services.authorization.authorize import (
- authorize,
-)
-from app.application.common.services.authorization.permissions import (
- CanManageRole,
- CanManageSubordinate,
- RoleManagementContext,
- UserManagementContext,
-)
-from app.application.common.services.current_user import CurrentUserService
-from app.domain.entities.user import User
-from app.domain.enums.user_role import UserRole
-from app.domain.exceptions.user import (
- UserNotFoundByIdError,
-)
-from app.domain.services.user import UserService
-from app.domain.value_objects.raw_password import RawPassword
-from app.domain.value_objects.user_id import UserId
-
-log = logging.getLogger(__name__)
-
-
-@dataclass(frozen=True, slots=True, kw_only=True)
-class SetUserPasswordRequest:
- user_id: UUID
- password: str
-
-
-class SetUserPasswordInteractor:
- """
- - Open to admins.
- - Admins can set passwords of subordinate users.
- """
-
- def __init__(
- self,
- current_user_service: CurrentUserService,
- user_command_gateway: UserCommandGateway,
- user_service: UserService,
- transaction_manager: TransactionManager,
- ) -> None:
- self._current_user_service = current_user_service
- self._user_command_gateway = user_command_gateway
- self._user_service = user_service
- self._transaction_manager = transaction_manager
-
- async def execute(self, request_data: SetUserPasswordRequest) -> None:
- """
- :raises AuthenticationError:
- :raises DataMapperError:
- :raises AuthorizationError:
- :raises DomainTypeError:
- :raises UserNotFoundByIdError:
- :raises PasswordHasherBusyError:
- """
- log.info(
- "Set user password: started. Target user ID: '%s'.",
- request_data.user_id,
- )
-
- current_user = await self._current_user_service.get_current_user()
-
- authorize(
- CanManageRole(),
- context=RoleManagementContext(
- subject=current_user,
- target_role=UserRole.USER,
- ),
- )
-
- user_id = UserId(request_data.user_id)
- password = RawPassword(request_data.password)
- user: User | None = await self._user_command_gateway.read_by_id(
- user_id,
- for_update=True,
- )
- if user is None:
- raise UserNotFoundByIdError(user_id)
-
- authorize(
- CanManageSubordinate(),
- context=UserManagementContext(
- subject=current_user,
- target=user,
- ),
- )
-
- await self._user_service.change_password(user, password)
- await self._transaction_manager.commit()
-
- log.info("Set user password: done. Target user ID: '%s'.", user.id_.value)
diff --git a/src/app/application/common/exceptions/authorization.py b/src/app/application/common/exceptions/authorization.py
deleted file mode 100644
index 2a0a2a0f..00000000
--- a/src/app/application/common/exceptions/authorization.py
+++ /dev/null
@@ -1,5 +0,0 @@
-from app.application.common.exceptions.base import ApplicationError
-
-
-class AuthorizationError(ApplicationError):
- pass
diff --git a/src/app/application/common/exceptions/base.py b/src/app/application/common/exceptions/base.py
deleted file mode 100644
index 5f0f4986..00000000
--- a/src/app/application/common/exceptions/base.py
+++ /dev/null
@@ -1,2 +0,0 @@
-class ApplicationError(Exception):
- pass
diff --git a/src/app/application/common/exceptions/query.py b/src/app/application/common/exceptions/query.py
deleted file mode 100644
index 6449dfd7..00000000
--- a/src/app/application/common/exceptions/query.py
+++ /dev/null
@@ -1,9 +0,0 @@
-from app.application.common.exceptions.base import ApplicationError
-
-
-class PaginationError(ApplicationError):
- pass
-
-
-class SortingError(ApplicationError):
- pass
diff --git a/src/app/application/common/ports/flusher.py b/src/app/application/common/ports/flusher.py
deleted file mode 100644
index 6f25c27b..00000000
--- a/src/app/application/common/ports/flusher.py
+++ /dev/null
@@ -1,17 +0,0 @@
-from abc import abstractmethod
-from typing import Protocol
-
-
-class Flusher(Protocol):
- """
- Interface for flushing intermediate changes during a business transaction.
- """
-
- @abstractmethod
- async def flush(self) -> None:
- """
- :raises DataMapperError:
- :raises UsernameAlreadyExists:
-
- Flush pending changes to validate constraints or trigger side effects.
- """
diff --git a/src/app/application/common/ports/identity_provider.py b/src/app/application/common/ports/identity_provider.py
deleted file mode 100644
index b0a7b9f5..00000000
--- a/src/app/application/common/ports/identity_provider.py
+++ /dev/null
@@ -1,10 +0,0 @@
-from abc import abstractmethod
-from typing import Protocol
-
-from app.domain.value_objects.user_id import UserId
-
-
-class IdentityProvider(Protocol):
- @abstractmethod
- async def get_current_user_id(self) -> UserId:
- """:raises AuthenticationError:"""
diff --git a/src/app/application/common/ports/user_command_gateway.py b/src/app/application/common/ports/user_command_gateway.py
deleted file mode 100644
index 7593322b..00000000
--- a/src/app/application/common/ports/user_command_gateway.py
+++ /dev/null
@@ -1,28 +0,0 @@
-from abc import abstractmethod
-from typing import Protocol
-
-from app.domain.entities.user import User
-from app.domain.value_objects.user_id import UserId
-from app.domain.value_objects.username import Username
-
-
-class UserCommandGateway(Protocol):
- @abstractmethod
- def add(self, user: User) -> None:
- """:raises DataMapperError:"""
-
- @abstractmethod
- async def read_by_id(
- self,
- user_id: UserId,
- for_update: bool = False,
- ) -> User | None:
- """:raises DataMapperError:"""
-
- @abstractmethod
- async def read_by_username(
- self,
- username: Username,
- for_update: bool = False,
- ) -> User | None:
- """:raises DataMapperError:"""
diff --git a/src/app/application/common/ports/user_query_gateway.py b/src/app/application/common/ports/user_query_gateway.py
deleted file mode 100644
index 0ca0985c..00000000
--- a/src/app/application/common/ports/user_query_gateway.py
+++ /dev/null
@@ -1,32 +0,0 @@
-from abc import abstractmethod
-from typing import Protocol, TypedDict
-from uuid import UUID
-
-from app.application.common.query_params.offset_pagination import OffsetPaginationParams
-from app.application.common.query_params.sorting import SortingParams
-from app.domain.enums.user_role import UserRole
-
-
-class UserQueryModel(TypedDict):
- id_: UUID
- username: str
- role: UserRole
- is_active: bool
-
-
-class ListUsersQM(TypedDict):
- users: list[UserQueryModel]
- total: int
-
-
-class UserQueryGateway(Protocol):
- @abstractmethod
- async def read_all(
- self,
- pagination: OffsetPaginationParams,
- sorting: SortingParams,
- ) -> ListUsersQM:
- """
- :raises SortingError:
- :raises ReaderError:
- """
diff --git a/src/app/application/common/query_params/offset_pagination.py b/src/app/application/common/query_params/offset_pagination.py
deleted file mode 100644
index b0335bbc..00000000
--- a/src/app/application/common/query_params/offset_pagination.py
+++ /dev/null
@@ -1,20 +0,0 @@
-from dataclasses import dataclass
-
-from app.application.common.exceptions.query import PaginationError
-
-
-@dataclass(frozen=True, slots=True, kw_only=True)
-class OffsetPaginationParams:
- """
- raises PaginationError
- """
-
- limit: int
- offset: int
-
- def __post_init__(self) -> None:
- """:raises PaginationError:"""
- if self.limit <= 0:
- raise PaginationError(f"Limit must be greater than 0, got {self.limit}")
- if self.offset < 0:
- raise PaginationError(f"Offset must be non-negative, got {self.offset}")
diff --git a/src/app/application/common/services/authorization/authorize.py b/src/app/application/common/services/authorization/authorize.py
deleted file mode 100644
index 5305c716..00000000
--- a/src/app/application/common/services/authorization/authorize.py
+++ /dev/null
@@ -1,16 +0,0 @@
-from app.application.common.exceptions.authorization import AuthorizationError
-from app.application.common.services.authorization.base import (
- Permission,
- PermissionContext,
-)
-from app.application.common.services.constants import AUTHZ_NOT_AUTHORIZED
-
-
-def authorize[PC: PermissionContext](
- permission: Permission[PC],
- *,
- context: PC,
-) -> None:
- """:raises AuthorizationError:"""
- if not permission.is_satisfied_by(context):
- raise AuthorizationError(AUTHZ_NOT_AUTHORIZED)
diff --git a/src/app/application/common/services/constants.py b/src/app/application/common/services/constants.py
deleted file mode 100644
index 7cb6a652..00000000
--- a/src/app/application/common/services/constants.py
+++ /dev/null
@@ -1,6 +0,0 @@
-from typing import Final
-
-AUTHZ_NOT_AUTHORIZED: Final[str] = "Not authorized."
-AUTHZ_NO_CURRENT_USER: Final[str] = (
- "Failed to retrieve current user. Removing all access."
-)
diff --git a/src/app/application/common/services/current_user.py b/src/app/application/common/services/current_user.py
deleted file mode 100644
index 28517c30..00000000
--- a/src/app/application/common/services/current_user.py
+++ /dev/null
@@ -1,43 +0,0 @@
-import logging
-
-from app.application.common.exceptions.authorization import AuthorizationError
-from app.application.common.ports.access_revoker import AccessRevoker
-from app.application.common.ports.identity_provider import IdentityProvider
-from app.application.common.ports.user_command_gateway import UserCommandGateway
-from app.application.common.services.constants import (
- AUTHZ_NO_CURRENT_USER,
- AUTHZ_NOT_AUTHORIZED,
-)
-from app.domain.entities.user import User
-
-log = logging.getLogger(__name__)
-
-
-class CurrentUserService:
- def __init__(
- self,
- identity_provider: IdentityProvider,
- user_command_gateway: UserCommandGateway,
- access_revoker: AccessRevoker,
- ) -> None:
- self._identity_provider = identity_provider
- self._user_command_gateway = user_command_gateway
- self._access_revoker = access_revoker
-
- async def get_current_user(self, for_update: bool = False) -> User:
- """
- :raises AuthenticationError:
- :raises DataMapperError:
- :raises AuthorizationError:
- """
- current_user_id = await self._identity_provider.get_current_user_id()
- user: User | None = await self._user_command_gateway.read_by_id(
- current_user_id,
- for_update=for_update,
- )
- if user is None or not user.is_active:
- log.warning("%s ID: %s.", AUTHZ_NO_CURRENT_USER, current_user_id)
- await self._access_revoker.remove_all_user_access(current_user_id)
- raise AuthorizationError(AUTHZ_NOT_AUTHORIZED)
-
- return user
diff --git a/src/app/application/queries/list_users.py b/src/app/application/queries/list_users.py
deleted file mode 100644
index 35aa4c14..00000000
--- a/src/app/application/queries/list_users.py
+++ /dev/null
@@ -1,81 +0,0 @@
-import logging
-from dataclasses import dataclass
-
-from app.application.common.ports.user_query_gateway import (
- ListUsersQM,
- UserQueryGateway,
-)
-from app.application.common.query_params.offset_pagination import OffsetPaginationParams
-from app.application.common.query_params.sorting import SortingOrder, SortingParams
-from app.application.common.services.authorization.authorize import (
- authorize,
-)
-from app.application.common.services.authorization.permissions import (
- CanManageRole,
- RoleManagementContext,
-)
-from app.application.common.services.current_user import CurrentUserService
-from app.domain.enums.user_role import UserRole
-
-log = logging.getLogger(__name__)
-
-
-@dataclass(frozen=True, slots=True, kw_only=True)
-class ListUsersRequest:
- limit: int
- offset: int
- sorting_field: str
- sorting_order: SortingOrder
-
-
-class ListUsersQueryService:
- """
- - Open to admins.
- - Retrieves a paginated list of existing users with relevant information.
- """
-
- def __init__(
- self,
- current_user_service: CurrentUserService,
- user_query_gateway: UserQueryGateway,
- ) -> None:
- self._current_user_service = current_user_service
- self._user_query_gateway = user_query_gateway
-
- async def execute(self, request_data: ListUsersRequest) -> ListUsersQM:
- """
- :raises AuthenticationError:
- :raises DataMapperError:
- :raises AuthorizationError:
- :raises PaginationError:
- :raises SortingError:
- :raises ReaderError:
- """
- log.info("List users: started.")
-
- current_user = await self._current_user_service.get_current_user()
-
- authorize(
- CanManageRole(),
- context=RoleManagementContext(
- subject=current_user,
- target_role=UserRole.USER,
- ),
- )
-
- log.debug("Retrieving list of users.")
- pagination = OffsetPaginationParams(
- limit=request_data.limit,
- offset=request_data.offset,
- )
- sorting = SortingParams(
- field=request_data.sorting_field,
- order=request_data.sorting_order,
- )
- response = await self._user_query_gateway.read_all(
- pagination=pagination,
- sorting=sorting,
- )
-
- log.info("List users: done.")
- return response
diff --git a/config/__init__.py b/src/app/config/__init__.py
similarity index 100%
rename from config/__init__.py
rename to src/app/config/__init__.py
diff --git a/src/app/config/loader.py b/src/app/config/loader.py
new file mode 100644
index 00000000..30e69a68
--- /dev/null
+++ b/src/app/config/loader.py
@@ -0,0 +1,82 @@
+from pathlib import Path
+from typing import Final
+
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+from app.config.settings import (
+ AppSettings,
+ CookieSettings,
+ JwtSettings,
+ PasswordHasherSettings,
+ PostgresSettings,
+ SessionSettings,
+ SqlaSettings,
+)
+
+BASE_DIR: Final[Path] = Path(__file__).resolve().parents[3]
+_ENV_FILE: Final[Path] = BASE_DIR.joinpath(".env")
+_DEFAULT_CONFIG_DICT: Final[SettingsConfigDict] = SettingsConfigDict(
+ env_file=_ENV_FILE,
+ env_file_encoding="utf-8",
+ extra="ignore",
+)
+
+
+def _load_settings[E: BaseSettings](env_cls: type[E]) -> E:
+ return env_cls()
+
+
+class AppEnvConfig(BaseSettings, AppSettings):
+ model_config = _DEFAULT_CONFIG_DICT | SettingsConfigDict(env_prefix="APP_")
+
+
+class PostgresEnvConfig(BaseSettings, PostgresSettings):
+ model_config = _DEFAULT_CONFIG_DICT | SettingsConfigDict(env_prefix="POSTGRES_")
+
+
+class SqlaEnvConfig(BaseSettings, SqlaSettings):
+ model_config = _DEFAULT_CONFIG_DICT | SettingsConfigDict(env_prefix="SQLA_")
+
+
+class PasswordHasherEnvConfig(BaseSettings, PasswordHasherSettings):
+ model_config = _DEFAULT_CONFIG_DICT | SettingsConfigDict(env_prefix="PASSWORD_")
+
+
+class JwtEnvConfig(BaseSettings, JwtSettings):
+ model_config = _DEFAULT_CONFIG_DICT | SettingsConfigDict(env_prefix="JWT_")
+
+
+class SessionEnvConfig(BaseSettings, SessionSettings):
+ model_config = _DEFAULT_CONFIG_DICT | SettingsConfigDict(env_prefix="SESSION_")
+
+
+class CookieEnvConfig(BaseSettings, CookieSettings):
+ model_config = _DEFAULT_CONFIG_DICT | SettingsConfigDict(env_prefix="COOKIE_")
+
+
+def load_app_settings() -> AppSettings:
+ return _load_settings(AppEnvConfig)
+
+
+def load_postgres_settings() -> PostgresSettings:
+ return _load_settings(PostgresEnvConfig)
+
+
+def load_sqla_settings() -> SqlaSettings:
+ return _load_settings(SqlaEnvConfig)
+
+
+def load_password_hasher_settings() -> PasswordHasherSettings:
+ return _load_settings(PasswordHasherEnvConfig)
+
+
+def load_jwt_settings() -> JwtSettings:
+ return _load_settings(JwtEnvConfig)
+
+
+def load_session_settings() -> SessionSettings:
+ return _load_settings(SessionEnvConfig)
+
+
+def load_cookie_settings() -> CookieSettings:
+ return _load_settings(CookieEnvConfig)
diff --git a/src/app/config/logging_.py b/src/app/config/logging_.py
new file mode 100644
index 00000000..4fa6d96a
--- /dev/null
+++ b/src/app/config/logging_.py
@@ -0,0 +1,20 @@
+from enum import StrEnum
+from typing import Final
+
+# fmt: off
+FMT: Final[str] = (
+ "[%(asctime)s.%(msecs)03d] [%(threadName)s] "
+ "%(funcName)20s "
+ "%(module)s:%(lineno)d "
+ "%(levelname)-8s - %(message)s"
+)
+# fmt: on
+DATEFMT: Final[str] = "%Y-%m-%d %H:%M:%S"
+
+
+class LoggingLevel(StrEnum):
+ DEBUG = "DEBUG"
+ INFO = "INFO"
+ WARNING = "WARNING"
+ ERROR = "ERROR"
+ CRITICAL = "CRITICAL"
diff --git a/src/app/config/settings.py b/src/app/config/settings.py
new file mode 100644
index 00000000..def10f83
--- /dev/null
+++ b/src/app/config/settings.py
@@ -0,0 +1,79 @@
+from datetime import timedelta
+from typing import Literal
+
+from pydantic import BaseModel, Field, PostgresDsn
+
+from app.config.logging_ import LoggingLevel
+from app.infrastructure.auth_ctx.jwt_types import JwtAlgorithm
+
+
+class AppSettings(BaseModel):
+ SERVICE_NAME: str = "clean-example"
+ VERSION: str = "development"
+ ROOT_PATH: str = "/"
+ DEBUG_MODE: bool = False
+ LOGGING_LEVEL: LoggingLevel = LoggingLevel.INFO
+
+
+class PostgresSettings(BaseModel):
+ DB: str
+ HOST: str
+ PORT: int
+ USER: str
+ PASSWORD: str
+
+ @property
+ def dsn(self) -> str:
+ return str(
+ PostgresDsn.build(
+ scheme="postgresql+psycopg",
+ username=self.USER,
+ password=self.PASSWORD,
+ host=self.HOST,
+ port=self.PORT,
+ path=self.DB,
+ ),
+ )
+
+
+class SqlaSettings(BaseModel):
+ ECHO: bool = False
+ ECHO_POOL: bool = False
+ POOL_SIZE: int = 15
+ MAX_OVERFLOW: int = 0
+
+
+class PasswordHasherSettings(BaseModel):
+ # https://www.ietf.org/archive/id/draft-ietf-kitten-password-storage-04.html#section-4.2
+ PEPPER: str = Field(min_length=32)
+
+ # https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#introduction
+ WORK_FACTOR: int = 11
+ # CPU-bound & GIL released: per-worker β max(1, floor(effective vCPUs / workers))
+ MAX_THREADS: int = 8
+ # Fail-fast cap: max semaphore wait before timeout (start ~1 second, tune to peak)
+ SEMAPHORE_WAIT_TIMEOUT_S: float = 1.0
+
+
+class JwtSettings(BaseModel):
+ # Min length 32 for 256-bit: https://www.rfc-editor.org/rfc/rfc7518#section-3.2
+ SECRET: str = Field(min_length=32)
+
+ ALGORITHM: JwtAlgorithm = "HS256"
+
+
+class SessionSettings(BaseModel):
+ TTL_MIN: int = Field(ge=1, default=5)
+ REFRESH_THRESHOLD_RATIO: float = Field(gt=0, lt=1, default=0.2)
+
+ @property
+ def ttl(self) -> timedelta:
+ return timedelta(minutes=self.TTL_MIN)
+
+
+class CookieSettings(BaseModel):
+ NAME: str = "auth_token"
+ PATH: str = "/"
+ HTTPONLY: bool = True
+ SECURE: bool = False
+ SAMESITE: Literal["lax", "strict", "none"] = "lax"
diff --git a/src/app/application/__init__.py b/src/app/core/__init__.py
similarity index 100%
rename from src/app/application/__init__.py
rename to src/app/core/__init__.py
diff --git a/src/app/application/commands/__init__.py b/src/app/core/commands/__init__.py
similarity index 100%
rename from src/app/application/commands/__init__.py
rename to src/app/core/commands/__init__.py
diff --git a/src/app/core/commands/activate_user.py b/src/app/core/commands/activate_user.py
new file mode 100644
index 00000000..62a1df18
--- /dev/null
+++ b/src/app/core/commands/activate_user.py
@@ -0,0 +1,82 @@
+import logging
+from dataclasses import dataclass
+from uuid import UUID
+
+from app.core.commands.exceptions import UserNotFoundError
+from app.core.commands.ports.transaction_manager import TransactionManager
+from app.core.commands.ports.user_tx_storage import UserTxStorage
+from app.core.commands.ports.utc_timer import UtcTimer
+from app.core.common.authorization.authorize import authorize
+from app.core.common.authorization.current_user_service import CurrentUserService
+from app.core.common.authorization.permissions import (
+ CanManageRole,
+ CanManageSubordinate,
+ RoleManagementContext,
+ UserManagementContext,
+)
+from app.core.common.entities.types_ import UserId, UserRole
+from app.core.common.services.user import UserService
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass(frozen=True, slots=True)
+class ActivateUserRequest:
+ user_id: UUID
+
+
+class ActivateUser:
+ """
+ - Open to admins.
+ - Restores previously soft-deleted user.
+ - Only super admins can activate other admins.
+ """
+
+ def __init__(
+ self,
+ current_user_service: CurrentUserService,
+ user_tx_storage: UserTxStorage,
+ user_service: UserService,
+ utc_timer: UtcTimer,
+ transaction_manager: TransactionManager,
+ ) -> None:
+ self._current_user_service = current_user_service
+ self._user_tx_storage = user_tx_storage
+ self._user_service = user_service
+ self._utc_timer = utc_timer
+ self._transaction_manager = transaction_manager
+
+ async def execute(self, request: ActivateUserRequest) -> None:
+ logger.info("Activate user: started.")
+
+ current_user = await self._current_user_service.get_current_user()
+ authorize(
+ CanManageRole(),
+ context=RoleManagementContext(
+ subject=current_user,
+ target_role=UserRole.USER,
+ ),
+ )
+ user_id = UserId(request.user_id)
+ user = await self._user_tx_storage.get_by_id(
+ user_id,
+ for_update=True,
+ )
+ if user is None:
+ raise UserNotFoundError
+
+ authorize(
+ CanManageSubordinate(),
+ context=UserManagementContext(
+ subject=current_user,
+ target=user,
+ ),
+ )
+ if self._user_service.set_activation(
+ user,
+ now=self._utc_timer.now,
+ is_active=True,
+ ):
+ await self._transaction_manager.commit()
+
+ logger.info("Activate user: done.")
diff --git a/src/app/core/commands/create_user.py b/src/app/core/commands/create_user.py
new file mode 100644
index 00000000..ccffd9de
--- /dev/null
+++ b/src/app/core/commands/create_user.py
@@ -0,0 +1,98 @@
+import logging
+from dataclasses import dataclass
+from datetime import datetime
+from enum import StrEnum
+from typing import TypedDict
+from uuid import UUID
+
+from app.core.commands.exceptions import UsernameAlreadyExistsError
+from app.core.commands.ports.flusher import Flusher
+from app.core.commands.ports.transaction_manager import TransactionManager
+from app.core.commands.ports.user_tx_storage import UserTxStorage
+from app.core.commands.ports.utc_timer import UtcTimer
+from app.core.common.authorization.authorize import authorize
+from app.core.common.authorization.current_user_service import CurrentUserService
+from app.core.common.authorization.permissions import CanManageRole, RoleManagementContext
+from app.core.common.entities.types_ import UserRole
+from app.core.common.factories.id_factory import create_user_id
+from app.core.common.services.user import UserService
+from app.core.common.value_objects.raw_password import RawPassword
+from app.core.common.value_objects.username import Username
+
+logger = logging.getLogger(__name__)
+
+
+class UserRoleRequestEnum(StrEnum):
+ USER = "user"
+ ADMIN = "admin"
+
+
+@dataclass(frozen=True, slots=True, kw_only=True)
+class CreateUserRequest:
+ username: str
+ password: str
+ role: UserRoleRequestEnum
+
+
+class CreateUserResponse(TypedDict):
+ id: UUID
+ created_at: datetime
+
+
+class CreateUser:
+ """
+ - Open to admins.
+ - Creates new user, including admins, if the username is unique.
+ - Only super admins can create new admins.
+ """
+
+ def __init__(
+ self,
+ current_user_service: CurrentUserService,
+ user_service: UserService,
+ utc_timer: UtcTimer,
+ user_tx_storage: UserTxStorage,
+ flusher: Flusher,
+ transaction_manager: TransactionManager,
+ ) -> None:
+ self._current_user_service = current_user_service
+ self._user_service = user_service
+ self._utc_timer = utc_timer
+ self._user_tx_storage = user_tx_storage
+ self._flusher = flusher
+ self._transaction_manager = transaction_manager
+
+ async def execute(self, request: CreateUserRequest) -> CreateUserResponse:
+ logger.info("Create user: started.")
+
+ current_user = await self._current_user_service.get_current_user()
+ role = UserRole(request.role)
+ authorize(
+ CanManageRole(),
+ context=RoleManagementContext(
+ subject=current_user,
+ target_role=role,
+ ),
+ )
+ username = Username(request.username)
+ password = RawPassword(request.password)
+ user = await self._user_service.create_user_with_raw_password(
+ user_id=create_user_id(),
+ username=username,
+ raw_password=password,
+ now=self._utc_timer.now,
+ role=role,
+ )
+ self._user_tx_storage.add(user)
+ try:
+ await self._flusher.flush()
+ except UsernameAlreadyExistsError:
+ raise
+
+ await self._transaction_manager.commit()
+
+ logger.info("Create user: done.")
+ return CreateUserResponse(
+ id=user.id_,
+ created_at=user.created_at.value,
+ )
diff --git a/src/app/core/commands/deactivate_user.py b/src/app/core/commands/deactivate_user.py
new file mode 100644
index 00000000..3d546157
--- /dev/null
+++ b/src/app/core/commands/deactivate_user.py
@@ -0,0 +1,89 @@
+import logging
+from dataclasses import dataclass
+from uuid import UUID
+
+from app.core.commands.exceptions import UserNotFoundError
+from app.core.commands.ports.transaction_manager import TransactionManager
+from app.core.commands.ports.user_tx_storage import UserTxStorage
+from app.core.commands.ports.utc_timer import UtcTimer
+from app.core.common.authorization.authorize import authorize
+from app.core.common.authorization.current_user_service import CurrentUserService
+from app.core.common.authorization.permissions import (
+ CanManageRole,
+ CanManageSubordinate,
+ RoleManagementContext,
+ UserManagementContext,
+)
+from app.core.common.entities.types_ import UserId, UserRole
+from app.core.common.ports.access_revoker import AccessRevoker
+from app.core.common.services.user import UserService
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass(frozen=True, slots=True)
+class DeactivateUserRequest:
+ user_id: UUID
+
+
+class DeactivateUser:
+ """
+ - Open to admins.
+ - Soft-deletes existing user, making that user inactive.
+ - Also deletes user's sessions.
+ - Only super admins can deactivate other admins.
+ - Super admins cannot be soft-deleted.
+ """
+
+ def __init__(
+ self,
+ current_user_service: CurrentUserService,
+ user_tx_storage: UserTxStorage,
+ user_service: UserService,
+ utc_timer: UtcTimer,
+ transaction_manager: TransactionManager,
+ access_revoker: AccessRevoker,
+ ) -> None:
+ self._current_user_service = current_user_service
+ self._user_tx_storage = user_tx_storage
+ self._user_service = user_service
+ self._utc_timer = utc_timer
+ self._transaction_manager = transaction_manager
+ self._access_revoker = access_revoker
+
+ async def execute(self, request: DeactivateUserRequest) -> None:
+ logger.info("Deactivate user: started.")
+
+ current_user = await self._current_user_service.get_current_user()
+ authorize(
+ CanManageRole(),
+ context=RoleManagementContext(
+ subject=current_user,
+ target_role=UserRole.USER,
+ ),
+ )
+ user_id = UserId(request.user_id)
+ user = await self._user_tx_storage.get_by_id(
+ user_id,
+ for_update=True,
+ )
+ if user is None:
+ raise UserNotFoundError
+
+ authorize(
+ CanManageSubordinate(),
+ context=UserManagementContext(
+ subject=current_user,
+ target=user,
+ ),
+ )
+ if self._user_service.set_activation(
+ user,
+ now=self._utc_timer.now,
+ is_active=False,
+ ):
+ await self._transaction_manager.commit()
+
+ await self._access_revoker.remove_all_user_access(user.id_)
+
+ logger.info("Deactivate user: done.")
diff --git a/src/app/core/commands/exceptions.py b/src/app/core/commands/exceptions.py
new file mode 100644
index 00000000..aa979fb2
--- /dev/null
+++ b/src/app/core/commands/exceptions.py
@@ -0,0 +1,11 @@
+from typing import ClassVar
+
+from app.core.common.exceptions import BaseError
+
+
+class UsernameAlreadyExistsError(BaseError):
+ default_message: ClassVar[str] = "Username already exists."
+
+
+class UserNotFoundError(BaseError):
+ default_message: ClassVar[str] = "User not found."
diff --git a/src/app/core/commands/grant_admin.py b/src/app/core/commands/grant_admin.py
new file mode 100644
index 00000000..f1d2e3d8
--- /dev/null
+++ b/src/app/core/commands/grant_admin.py
@@ -0,0 +1,82 @@
+import logging
+from dataclasses import dataclass
+from uuid import UUID
+
+from app.core.commands.exceptions import UserNotFoundError
+from app.core.commands.ports.transaction_manager import TransactionManager
+from app.core.commands.ports.user_tx_storage import UserTxStorage
+from app.core.commands.ports.utc_timer import UtcTimer
+from app.core.common.authorization.authorize import authorize
+from app.core.common.authorization.current_user_service import CurrentUserService
+from app.core.common.authorization.permissions import (
+ CanManageRole,
+ CanManageSubordinate,
+ RoleManagementContext,
+ UserManagementContext,
+)
+from app.core.common.entities.types_ import UserId, UserRole
+from app.core.common.services.user import UserService
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass(frozen=True, slots=True)
+class GrantAdminRequest:
+ user_id: UUID
+
+
+class GrantAdmin:
+ """
+ - Open to super admins.
+ - Grants admin rights to specified user.
+ - Super admin rights cannot be changed.
+ """
+
+ def __init__(
+ self,
+ current_user_service: CurrentUserService,
+ user_tx_storage: UserTxStorage,
+ user_service: UserService,
+ utc_timer: UtcTimer,
+ transaction_manager: TransactionManager,
+ ) -> None:
+ self._current_user_service = current_user_service
+ self._user_tx_storage = user_tx_storage
+ self._user_service = user_service
+ self._utc_timer = utc_timer
+ self._transaction_manager = transaction_manager
+
+ async def execute(self, request: GrantAdminRequest) -> None:
+ logger.info("Grant admin: started.")
+
+ current_user = await self._current_user_service.get_current_user()
+ authorize(
+ CanManageRole(),
+ context=RoleManagementContext(
+ subject=current_user,
+ target_role=UserRole.ADMIN,
+ ),
+ )
+ user_id = UserId(request.user_id)
+ user = await self._user_tx_storage.get_by_id(
+ user_id,
+ for_update=True,
+ )
+ if user is None:
+ raise UserNotFoundError
+
+ authorize(
+ CanManageSubordinate(),
+ context=UserManagementContext(
+ subject=current_user,
+ target=user,
+ ),
+ )
+ if self._user_service.set_role(
+ user,
+ now=self._utc_timer.now,
+ is_admin=True,
+ ):
+ await self._transaction_manager.commit()
+
+ logger.info("Grant admin: done.")
diff --git a/src/app/application/common/__init__.py b/src/app/core/commands/ports/__init__.py
similarity index 100%
rename from src/app/application/common/__init__.py
rename to src/app/core/commands/ports/__init__.py
diff --git a/src/app/core/commands/ports/flusher.py b/src/app/core/commands/ports/flusher.py
new file mode 100644
index 00000000..0eb77c6e
--- /dev/null
+++ b/src/app/core/commands/ports/flusher.py
@@ -0,0 +1,10 @@
+from abc import abstractmethod
+from typing import Protocol
+
+
+class Flusher(Protocol):
+ """Interface for flushing intermediate changes during a business transaction."""
+
+ @abstractmethod
+ async def flush(self) -> None:
+ """Flush pending changes to validate constraints or trigger side effects."""
diff --git a/src/app/application/common/ports/transaction_manager.py b/src/app/core/commands/ports/transaction_manager.py
similarity index 73%
rename from src/app/application/common/ports/transaction_manager.py
rename to src/app/core/commands/ports/transaction_manager.py
index 72f9cccd..3193ad31 100644
--- a/src/app/application/common/ports/transaction_manager.py
+++ b/src/app/core/commands/ports/transaction_manager.py
@@ -11,8 +11,4 @@ class TransactionManager(Protocol):
@abstractmethod
async def commit(self) -> None:
- """
- :raises DataMapperError:
-
- Commit the successful outcome of a business transaction.
- """
+ """Commit the successful outcome of a business transaction."""
diff --git a/src/app/core/commands/ports/user_tx_storage.py b/src/app/core/commands/ports/user_tx_storage.py
new file mode 100644
index 00000000..fe65da86
--- /dev/null
+++ b/src/app/core/commands/ports/user_tx_storage.py
@@ -0,0 +1,20 @@
+from abc import abstractmethod
+from typing import Protocol
+
+from app.core.common.entities.types_ import UserId
+from app.core.common.entities.user import User
+
+
+class UserTxStorage(Protocol):
+ """Transactional: commit required."""
+
+ @abstractmethod
+ def add(self, user: User) -> None: ...
+
+ @abstractmethod
+ async def get_by_id(
+ self,
+ user_id: UserId,
+ *,
+ for_update: bool = False,
+ ) -> User | None: ...
diff --git a/src/app/core/commands/ports/utc_timer.py b/src/app/core/commands/ports/utc_timer.py
new file mode 100644
index 00000000..6dff8549
--- /dev/null
+++ b/src/app/core/commands/ports/utc_timer.py
@@ -0,0 +1,10 @@
+from abc import abstractmethod
+from typing import Protocol
+
+from app.core.common.value_objects.utc_datetime import UtcDatetime
+
+
+class UtcTimer(Protocol):
+ @property
+ @abstractmethod
+ def now(self) -> UtcDatetime: ...
diff --git a/src/app/core/commands/revoke_admin.py b/src/app/core/commands/revoke_admin.py
new file mode 100644
index 00000000..2e2a7b6c
--- /dev/null
+++ b/src/app/core/commands/revoke_admin.py
@@ -0,0 +1,82 @@
+import logging
+from dataclasses import dataclass
+from uuid import UUID
+
+from app.core.commands.exceptions import UserNotFoundError
+from app.core.commands.ports.transaction_manager import TransactionManager
+from app.core.commands.ports.user_tx_storage import UserTxStorage
+from app.core.commands.ports.utc_timer import UtcTimer
+from app.core.common.authorization.authorize import authorize
+from app.core.common.authorization.current_user_service import CurrentUserService
+from app.core.common.authorization.permissions import (
+ CanManageRole,
+ CanManageSubordinate,
+ RoleManagementContext,
+ UserManagementContext,
+)
+from app.core.common.entities.types_ import UserId, UserRole
+from app.core.common.services.user import UserService
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass(frozen=True, slots=True)
+class RevokeAdminRequest:
+ user_id: UUID
+
+
+class RevokeAdmin:
+ """
+ - Open to super admins.
+ - Revokes admin rights from specified user.
+ - Super admin rights cannot be changed
+ """
+
+ def __init__(
+ self,
+ current_user_service: CurrentUserService,
+ user_tx_storage: UserTxStorage,
+ user_service: UserService,
+ utc_timer: UtcTimer,
+ transaction_manager: TransactionManager,
+ ) -> None:
+ self._current_user_service = current_user_service
+ self._user_tx_storage = user_tx_storage
+ self._user_service = user_service
+ self._utc_timer = utc_timer
+ self._transaction_manager = transaction_manager
+
+ async def execute(self, request: RevokeAdminRequest) -> None:
+ logger.info("Revoke admin: started.")
+
+ current_user = await self._current_user_service.get_current_user()
+ authorize(
+ CanManageRole(),
+ context=RoleManagementContext(
+ subject=current_user,
+ target_role=UserRole.ADMIN,
+ ),
+ )
+ user_id = UserId(request.user_id)
+ user = await self._user_tx_storage.get_by_id(
+ user_id,
+ for_update=True,
+ )
+ if user is None:
+ raise UserNotFoundError
+
+ authorize(
+ CanManageSubordinate(),
+ context=UserManagementContext(
+ subject=current_user,
+ target=user,
+ ),
+ )
+ if self._user_service.set_role(
+ user,
+ now=self._utc_timer.now,
+ is_admin=False,
+ ):
+ await self._transaction_manager.commit()
+
+ logger.info("Revoke admin: done.")
diff --git a/src/app/core/commands/set_user_password.py b/src/app/core/commands/set_user_password.py
new file mode 100644
index 00000000..fbc18387
--- /dev/null
+++ b/src/app/core/commands/set_user_password.py
@@ -0,0 +1,85 @@
+import logging
+from dataclasses import dataclass
+from uuid import UUID
+
+from app.core.commands.exceptions import UserNotFoundError
+from app.core.commands.ports.transaction_manager import TransactionManager
+from app.core.commands.ports.user_tx_storage import UserTxStorage
+from app.core.commands.ports.utc_timer import UtcTimer
+from app.core.common.authorization.authorize import authorize
+from app.core.common.authorization.current_user_service import CurrentUserService
+from app.core.common.authorization.permissions import (
+ CanManageRole,
+ CanManageSubordinate,
+ RoleManagementContext,
+ UserManagementContext,
+)
+from app.core.common.entities.types_ import UserId, UserRole
+from app.core.common.services.user import UserService
+from app.core.common.value_objects.raw_password import RawPassword
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass(frozen=True, slots=True)
+class SetUserPasswordRequest:
+ user_id: UUID
+ password: str
+
+
+class SetUserPassword:
+ """
+ - Open to admins.
+ - Admins can set passwords of subordinate users.
+ """
+
+ def __init__(
+ self,
+ current_user_service: CurrentUserService,
+ user_tx_storage: UserTxStorage,
+ user_service: UserService,
+ utc_timer: UtcTimer,
+ transaction_manager: TransactionManager,
+ ) -> None:
+ self._current_user_service = current_user_service
+ self._user_tx_storage = user_tx_storage
+ self._user_service = user_service
+ self._utc_timer = utc_timer
+ self._transaction_manager = transaction_manager
+
+ async def execute(self, request: SetUserPasswordRequest) -> None:
+ logger.info("Set user password: started.")
+
+ current_user = await self._current_user_service.get_current_user()
+ authorize(
+ CanManageRole(),
+ context=RoleManagementContext(
+ subject=current_user,
+ target_role=UserRole.USER,
+ ),
+ )
+ user_id = UserId(request.user_id)
+ password = RawPassword(request.password)
+ user = await self._user_tx_storage.get_by_id(
+ user_id,
+ for_update=True,
+ )
+ if user is None:
+ raise UserNotFoundError
+
+ authorize(
+ CanManageSubordinate(),
+ context=UserManagementContext(
+ subject=current_user,
+ target=user,
+ ),
+ )
+
+ await self._user_service.change_password(
+ user,
+ password,
+ now=self._utc_timer.now,
+ )
+ await self._transaction_manager.commit()
+
+ logger.info("Set user password: done.")
diff --git a/src/app/application/common/exceptions/__init__.py b/src/app/core/common/__init__.py
similarity index 100%
rename from src/app/application/common/exceptions/__init__.py
rename to src/app/core/common/__init__.py
diff --git a/src/app/application/common/ports/__init__.py b/src/app/core/common/authorization/__init__.py
similarity index 100%
rename from src/app/application/common/ports/__init__.py
rename to src/app/core/common/authorization/__init__.py
diff --git a/src/app/core/common/authorization/authorize.py b/src/app/core/common/authorization/authorize.py
new file mode 100644
index 00000000..61aea7dd
--- /dev/null
+++ b/src/app/core/common/authorization/authorize.py
@@ -0,0 +1,11 @@
+from app.core.common.authorization.base import Permission, PermissionContext
+from app.core.common.authorization.exceptions import AuthorizationError
+
+
+def authorize[PC: PermissionContext](
+ permission: Permission[PC],
+ *,
+ context: PC,
+) -> None:
+ if not permission.is_satisfied_by(context):
+ raise AuthorizationError
diff --git a/src/app/application/common/services/authorization/base.py b/src/app/core/common/authorization/base.py
similarity index 86%
rename from src/app/application/common/services/authorization/base.py
rename to src/app/core/common/authorization/base.py
index a824a9c5..5b08c226 100644
--- a/src/app/application/common/services/authorization/base.py
+++ b/src/app/core/common/authorization/base.py
@@ -2,7 +2,7 @@
from dataclasses import dataclass
-@dataclass(frozen=True)
+@dataclass(frozen=True, slots=True)
class PermissionContext:
pass
diff --git a/src/app/application/common/services/authorization/composite.py b/src/app/core/common/authorization/composite.py
similarity index 72%
rename from src/app/application/common/services/authorization/composite.py
rename to src/app/core/common/authorization/composite.py
index 3ac1dae4..8c72daa4 100644
--- a/src/app/application/common/services/authorization/composite.py
+++ b/src/app/core/common/authorization/composite.py
@@ -1,7 +1,4 @@
-from app.application.common.services.authorization.base import (
- Permission,
- PermissionContext,
-)
+from app.core.common.authorization.base import Permission, PermissionContext
class AnyOf[PC: PermissionContext](Permission[PC]):
diff --git a/src/app/core/common/authorization/current_user_service.py b/src/app/core/common/authorization/current_user_service.py
new file mode 100644
index 00000000..5bcd2bc6
--- /dev/null
+++ b/src/app/core/common/authorization/current_user_service.py
@@ -0,0 +1,33 @@
+import logging
+from typing import Final
+
+from app.core.common.authorization.exceptions import AuthorizationError
+from app.core.common.authorization.ports import AuthzUserFinder
+from app.core.common.entities.user import User
+from app.core.common.ports.access_revoker import AccessRevoker
+from app.core.common.ports.identity_provider import IdentityProvider
+
+AUTHZ_NO_CURRENT_USER: Final[str] = "Failed to retrieve current user. Removing all access."
+
+logger = logging.getLogger(__name__)
+
+
+class CurrentUserService:
+ def __init__(
+ self,
+ identity_provider: IdentityProvider,
+ authz_user_finder: AuthzUserFinder,
+ access_revoker: AccessRevoker,
+ ) -> None:
+ self._identity_provider = identity_provider
+ self._authz_user_finder = authz_user_finder
+ self._access_revoker = access_revoker
+
+ async def get_current_user(self, *, for_update: bool = False) -> User:
+ current_user_id = await self._identity_provider.get_current_user_id()
+ user = await self._authz_user_finder.get_by_id(current_user_id, for_update=for_update)
+ if user is None or not user.is_active:
+ logger.warning("%s ID: %s.", AUTHZ_NO_CURRENT_USER, current_user_id)
+ await self._access_revoker.remove_all_user_access(current_user_id)
+ raise AuthorizationError
+ return user
diff --git a/src/app/core/common/authorization/exceptions.py b/src/app/core/common/authorization/exceptions.py
new file mode 100644
index 00000000..df6eb334
--- /dev/null
+++ b/src/app/core/common/authorization/exceptions.py
@@ -0,0 +1,7 @@
+from typing import ClassVar
+
+from app.core.common.exceptions import BaseError
+
+
+class AuthorizationError(BaseError):
+ default_message: ClassVar[str] = "Not authorized."
diff --git a/src/app/application/common/services/authorization/permissions.py b/src/app/core/common/authorization/permissions.py
similarity index 62%
rename from src/app/application/common/services/authorization/permissions.py
rename to src/app/core/common/authorization/permissions.py
index 8e302f97..66a348ea 100644
--- a/src/app/application/common/services/authorization/permissions.py
+++ b/src/app/core/common/authorization/permissions.py
@@ -1,18 +1,13 @@
from collections.abc import Mapping
from dataclasses import dataclass
-from app.application.common.services.authorization.base import (
- Permission,
- PermissionContext,
-)
-from app.application.common.services.authorization.role_hierarchy import (
- SUBORDINATE_ROLES,
-)
-from app.domain.entities.user import User
-from app.domain.enums.user_role import UserRole
-
-
-@dataclass(frozen=True, kw_only=True)
+from app.core.common.authorization.base import Permission, PermissionContext
+from app.core.common.authorization.role_hierarchy import ROLE_HIERARCHY
+from app.core.common.entities.types_ import UserRole
+from app.core.common.entities.user import User
+
+
+@dataclass(frozen=True, slots=True, kw_only=True)
class UserManagementContext(PermissionContext):
subject: User
target: User
@@ -24,10 +19,7 @@ def is_satisfied_by(self, context: UserManagementContext) -> bool:
class CanManageSubordinate(Permission[UserManagementContext]):
- def __init__(
- self,
- role_hierarchy: Mapping[UserRole, set[UserRole]] = SUBORDINATE_ROLES,
- ) -> None:
+ def __init__(self, role_hierarchy: Mapping[UserRole, set[UserRole]] = ROLE_HIERARCHY) -> None:
self._role_hierarchy = role_hierarchy
def is_satisfied_by(self, context: UserManagementContext) -> bool:
@@ -35,17 +27,14 @@ def is_satisfied_by(self, context: UserManagementContext) -> bool:
return context.target.role in allowed_roles
-@dataclass(frozen=True, kw_only=True)
+@dataclass(frozen=True, slots=True, kw_only=True)
class RoleManagementContext(PermissionContext):
subject: User
target_role: UserRole
class CanManageRole(Permission[RoleManagementContext]):
- def __init__(
- self,
- role_hierarchy: Mapping[UserRole, set[UserRole]] = SUBORDINATE_ROLES,
- ) -> None:
+ def __init__(self, role_hierarchy: Mapping[UserRole, set[UserRole]] = ROLE_HIERARCHY) -> None:
self._role_hierarchy = role_hierarchy
def is_satisfied_by(self, context: RoleManagementContext) -> bool:
diff --git a/src/app/core/common/authorization/ports.py b/src/app/core/common/authorization/ports.py
new file mode 100644
index 00000000..0bb7c60b
--- /dev/null
+++ b/src/app/core/common/authorization/ports.py
@@ -0,0 +1,10 @@
+from abc import abstractmethod
+from typing import Protocol
+
+from app.core.common.entities.types_ import UserId
+from app.core.common.entities.user import User
+
+
+class AuthzUserFinder(Protocol):
+ @abstractmethod
+ async def get_by_id(self, user_id: UserId, *, for_update: bool = False) -> User | None: ...
diff --git a/src/app/application/common/services/authorization/role_hierarchy.py b/src/app/core/common/authorization/role_hierarchy.py
similarity index 62%
rename from src/app/application/common/services/authorization/role_hierarchy.py
rename to src/app/core/common/authorization/role_hierarchy.py
index cc14fd45..93eb270f 100644
--- a/src/app/application/common/services/authorization/role_hierarchy.py
+++ b/src/app/core/common/authorization/role_hierarchy.py
@@ -1,9 +1,9 @@
from collections.abc import Mapping
from typing import Final
-from app.domain.enums.user_role import UserRole
+from app.core.common.entities.types_ import UserRole
-SUBORDINATE_ROLES: Final[Mapping[UserRole, set[UserRole]]] = {
+ROLE_HIERARCHY: Final[Mapping[UserRole, set[UserRole]]] = {
UserRole.SUPER_ADMIN: {UserRole.ADMIN, UserRole.USER},
UserRole.ADMIN: {UserRole.USER},
UserRole.USER: set(),
diff --git a/src/app/application/common/query_params/__init__.py b/src/app/core/common/entities/__init__.py
similarity index 100%
rename from src/app/application/common/query_params/__init__.py
rename to src/app/core/common/entities/__init__.py
diff --git a/src/app/domain/entities/base.py b/src/app/core/common/entities/base.py
similarity index 100%
rename from src/app/domain/entities/base.py
rename to src/app/core/common/entities/base.py
diff --git a/src/app/core/common/entities/types_.py b/src/app/core/common/entities/types_.py
new file mode 100644
index 00000000..195a22e3
--- /dev/null
+++ b/src/app/core/common/entities/types_.py
@@ -0,0 +1,16 @@
+from enum import StrEnum
+from typing import NewType
+from uuid import UUID
+
+UserId = NewType("UserId", UUID)
+UserPasswordHash = NewType("UserPasswordHash", bytes)
+
+
+class UserRole(StrEnum):
+ SUPER_ADMIN = "super_admin"
+ ADMIN = "admin"
+ USER = "user"
+
+ @property
+ def is_system(self) -> bool:
+ return self == UserRole.SUPER_ADMIN
diff --git a/src/app/core/common/entities/user.py b/src/app/core/common/entities/user.py
new file mode 100644
index 00000000..5651ce6f
--- /dev/null
+++ b/src/app/core/common/entities/user.py
@@ -0,0 +1,29 @@
+from app.core.common.entities.base import Entity
+from app.core.common.entities.types_ import UserId, UserPasswordHash, UserRole
+from app.core.common.value_objects.username import Username
+from app.core.common.value_objects.utc_datetime import UtcDatetime
+
+
+class User(Entity[UserId]):
+ def __init__(
+ self,
+ *,
+ id_: UserId,
+ username: Username,
+ password_hash: UserPasswordHash,
+ role: UserRole,
+ is_active: bool,
+ created_at: UtcDatetime,
+ updated_at: UtcDatetime,
+ ) -> None:
+ super().__init__(id_=id_)
+ self.username = username
+ self.password_hash = password_hash
+ self.role = role
+ self.is_active = is_active
+ self._created_at = created_at
+ self.updated_at = updated_at
+
+ @property
+ def created_at(self) -> UtcDatetime:
+ return self._created_at
diff --git a/src/app/core/common/exceptions.py b/src/app/core/common/exceptions.py
new file mode 100644
index 00000000..eaca1520
--- /dev/null
+++ b/src/app/core/common/exceptions.py
@@ -0,0 +1,25 @@
+from typing import ClassVar
+
+
+class BaseError(Exception):
+ default_message: ClassVar[str | None] = None
+
+ def __init__(self, message: str | None = None) -> None:
+ msg = message if message is not None else self.default_message
+ super().__init__() if msg is None else super().__init__(msg)
+
+
+class BusinessTypeError(BaseError):
+ """Invalid construction of business logic types (Value Objects)."""
+
+
+class RoleAssignmentNotPermittedError(BaseError):
+ default_message: ClassVar[str] = "Assignment of role is not permitted."
+
+
+class ActivationChangeNotPermittedError(BaseError):
+ default_message: ClassVar[str] = "Activation change is not permitted."
+
+
+class RoleChangeNotPermittedError(BaseError):
+ default_message: ClassVar[str] = "Role change is not permitted."
diff --git a/src/app/application/common/services/__init__.py b/src/app/core/common/factories/__init__.py
similarity index 100%
rename from src/app/application/common/services/__init__.py
rename to src/app/core/common/factories/__init__.py
diff --git a/src/app/core/common/factories/id_factory.py b/src/app/core/common/factories/id_factory.py
new file mode 100644
index 00000000..c5f0974d
--- /dev/null
+++ b/src/app/core/common/factories/id_factory.py
@@ -0,0 +1,9 @@
+from uuid import UUID
+
+from uuid_utils import compat as uuid_utils
+
+from app.core.common.entities.types_ import UserId
+
+
+def create_user_id(value: UUID | None = None) -> UserId:
+ return UserId(value if value is not None else uuid_utils.uuid7())
diff --git a/src/app/application/common/services/authorization/__init__.py b/src/app/core/common/ports/__init__.py
similarity index 100%
rename from src/app/application/common/services/authorization/__init__.py
rename to src/app/core/common/ports/__init__.py
diff --git a/src/app/application/common/ports/access_revoker.py b/src/app/core/common/ports/access_revoker.py
similarity index 64%
rename from src/app/application/common/ports/access_revoker.py
rename to src/app/core/common/ports/access_revoker.py
index 9dba834c..9752a839 100644
--- a/src/app/application/common/ports/access_revoker.py
+++ b/src/app/core/common/ports/access_revoker.py
@@ -1,10 +1,9 @@
from abc import abstractmethod
from typing import Protocol
-from app.domain.value_objects.user_id import UserId
+from app.core.common.entities.types_ import UserId
class AccessRevoker(Protocol):
@abstractmethod
- async def remove_all_user_access(self, user_id: UserId) -> None:
- """:raises DataMapperError:"""
+ async def remove_all_user_access(self, user_id: UserId) -> None: ...
diff --git a/src/app/core/common/ports/identity_provider.py b/src/app/core/common/ports/identity_provider.py
new file mode 100644
index 00000000..f292219e
--- /dev/null
+++ b/src/app/core/common/ports/identity_provider.py
@@ -0,0 +1,9 @@
+from abc import abstractmethod
+from typing import Protocol
+
+from app.core.common.entities.types_ import UserId
+
+
+class IdentityProvider(Protocol):
+ @abstractmethod
+ async def get_current_user_id(self) -> UserId: ...
diff --git a/src/app/core/common/ports/password_hasher.py b/src/app/core/common/ports/password_hasher.py
new file mode 100644
index 00000000..4da35ac3
--- /dev/null
+++ b/src/app/core/common/ports/password_hasher.py
@@ -0,0 +1,13 @@
+from abc import abstractmethod
+from typing import Protocol
+
+from app.core.common.entities.types_ import UserPasswordHash
+from app.core.common.value_objects.raw_password import RawPassword
+
+
+class PasswordHasher(Protocol):
+ @abstractmethod
+ async def hash(self, raw_password: RawPassword) -> UserPasswordHash: ...
+
+ @abstractmethod
+ async def verify(self, raw_password: RawPassword, hashed_password: UserPasswordHash) -> bool: ...
diff --git a/src/app/application/queries/__init__.py b/src/app/core/common/services/__init__.py
similarity index 100%
rename from src/app/application/queries/__init__.py
rename to src/app/core/common/services/__init__.py
diff --git a/src/app/core/common/services/user.py b/src/app/core/common/services/user.py
new file mode 100644
index 00000000..29fa3ba3
--- /dev/null
+++ b/src/app/core/common/services/user.py
@@ -0,0 +1,105 @@
+from app.core.common.entities.types_ import UserId, UserPasswordHash, UserRole
+from app.core.common.entities.user import User
+from app.core.common.exceptions import (
+ ActivationChangeNotPermittedError,
+ RoleAssignmentNotPermittedError,
+ RoleChangeNotPermittedError,
+)
+from app.core.common.ports.password_hasher import PasswordHasher
+from app.core.common.value_objects.raw_password import RawPassword
+from app.core.common.value_objects.username import Username
+from app.core.common.value_objects.utc_datetime import UtcDatetime
+
+
+class UserService:
+ def __init__(self, password_hasher: PasswordHasher) -> None:
+ self._password_hasher = password_hasher
+
+ def create_user(
+ self,
+ user_id: UserId,
+ username: Username,
+ password_hash: UserPasswordHash,
+ *,
+ now: UtcDatetime,
+ role: UserRole = UserRole.USER,
+ is_active: bool = True,
+ ) -> User:
+ if role.is_system:
+ raise RoleAssignmentNotPermittedError
+ return User(
+ id_=user_id,
+ username=username,
+ password_hash=password_hash,
+ role=role,
+ is_active=is_active,
+ created_at=now,
+ updated_at=now,
+ )
+
+ async def create_user_with_raw_password(
+ self,
+ user_id: UserId,
+ username: Username,
+ raw_password: RawPassword,
+ *,
+ now: UtcDatetime,
+ role: UserRole = UserRole.USER,
+ is_active: bool = True,
+ ) -> User:
+ password_hash = await self._password_hasher.hash(raw_password)
+ return self.create_user(
+ user_id,
+ username,
+ password_hash,
+ now=now,
+ role=role,
+ is_active=is_active,
+ )
+
+ async def is_password_valid(self, user: User, raw_password: RawPassword) -> bool:
+ return await self._password_hasher.verify(
+ raw_password=raw_password,
+ hashed_password=user.password_hash,
+ )
+
+ async def change_password(
+ self,
+ user: User,
+ raw_password: RawPassword,
+ *,
+ now: UtcDatetime,
+ ) -> None:
+ user.password_hash = await self._password_hasher.hash(raw_password)
+ user.updated_at = now
+
+ def set_role(
+ self,
+ user: User,
+ *,
+ now: UtcDatetime,
+ is_admin: bool,
+ ) -> bool:
+ if user.role.is_system:
+ raise RoleChangeNotPermittedError
+ target_role = UserRole.ADMIN if is_admin else UserRole.USER
+ if user.role == target_role:
+ return False
+ user.role = target_role
+ user.updated_at = now
+ return True
+
+ def set_activation(
+ self,
+ user: User,
+ *,
+ now: UtcDatetime,
+ is_active: bool,
+ ) -> bool:
+ if user.role.is_system:
+ raise ActivationChangeNotPermittedError
+ if user.is_active == is_active:
+ return False
+ user.is_active = is_active
+ user.updated_at = now
+ return True
diff --git a/src/app/domain/__init__.py b/src/app/core/common/value_objects/__init__.py
similarity index 100%
rename from src/app/domain/__init__.py
rename to src/app/core/common/value_objects/__init__.py
diff --git a/src/app/domain/value_objects/base.py b/src/app/core/common/value_objects/base.py
similarity index 68%
rename from src/app/domain/value_objects/base.py
rename to src/app/core/common/value_objects/base.py
index ab02c8ac..f07295a4 100644
--- a/src/app/domain/value_objects/base.py
+++ b/src/app/core/common/value_objects/base.py
@@ -5,7 +5,7 @@
@dataclass(frozen=True, slots=True, repr=False)
class ValueObject:
"""
- Base class for immutable value objects (VO) in domain.
+ Base class for immutable value objects (VO).
Subclassing is optional; any implementation honoring this contract is valid.
Defined by instance attributes only; these must be immutable.
For simple type tagging, consider `typing.NewType` instead of subclassing.
@@ -14,16 +14,12 @@ class ValueObject:
fields with `repr=False` are omitted to avoid leaking secrets.
If no fields have `repr=True`, '' is shown.
- Typing/runtime mismatch when working with class constants:
- By current typing rules, `Final` should wrap `ClassVar` β `Final[ClassVar[T]]`.
- At runtime, `dataclasses.fields()` includes it as an instance field (and with
- `__slots__` it becomes a `member_descriptor`). Use `ClassVar[Final[T]]`
- (or `ClassVar[T]`) so class constants are not treated as instance attributes.
- As of now, mypy does not enforce `Final` inside `ClassVar`; reassignment is
- allowed, so `ClassVar[Final[T]]` is effectively `ClassVar[T]`. We keep `Final`
- for forward-compatibility, expecting future enforcement.
- https://github.com/python/cpython/issues/89547
- https://github.com/python/mypy/issues/19607
+ Typing/runtime note for class constants:
+ Don't combine `ClassVar` with `Final` in any order for safety reasons.
+ Python 3.12 doesn't support this at runtime, Python 3.13 has compatibility issues.
+
+ Dataclasses exclude only `ClassVar` attributes from instance fields, so use `ClassVar[T]`
+ for class-level constants and enforce non-reassignment via convention/linting/type-checker.
"""
def __new__(cls, *_args: Any, **_kwargs: Any) -> Self:
@@ -34,7 +30,10 @@ def __new__(cls, *_args: Any, **_kwargs: Any) -> Self:
return object.__new__(cls)
def __post_init__(self) -> None:
- """Hook for additional initialization and ensuring invariants."""
+ """
+ Hook for additional initialization and ensuring invariants.
+ If this hook ever becomes non-empty, subclasses must call `super().__post_init__()`.
+ """
def __repr__(self) -> str:
"""
diff --git a/src/app/core/common/value_objects/raw_password.py b/src/app/core/common/value_objects/raw_password.py
new file mode 100644
index 00000000..2b643d3e
--- /dev/null
+++ b/src/app/core/common/value_objects/raw_password.py
@@ -0,0 +1,21 @@
+from dataclasses import dataclass, field
+from typing import ClassVar
+
+from app.core.common.exceptions import BusinessTypeError
+from app.core.common.value_objects.base import ValueObject
+
+
+@dataclass(frozen=True, slots=True, repr=False)
+class RawPassword(ValueObject):
+ MIN_LEN: ClassVar[int] = 6
+
+ value: bytes = field(init=False, repr=False)
+
+ def __init__(self, value: str) -> None:
+ self._validate(value)
+ object.__setattr__(self, "value", value.encode())
+
+ @classmethod
+ def _validate(cls, value: str) -> None:
+ if len(value) < cls.MIN_LEN:
+ raise BusinessTypeError(f"Password must be at least {cls.MIN_LEN} characters long.")
diff --git a/src/app/core/common/value_objects/username.py b/src/app/core/common/value_objects/username.py
new file mode 100644
index 00000000..2c9a94f7
--- /dev/null
+++ b/src/app/core/common/value_objects/username.py
@@ -0,0 +1,41 @@
+import re
+from dataclasses import dataclass
+from typing import ClassVar
+
+from app.core.common.exceptions import BusinessTypeError
+from app.core.common.value_objects.base import ValueObject
+
+
+@dataclass(frozen=True, slots=True, repr=False)
+class Username(ValueObject):
+ MIN_LEN: ClassVar[int] = 5
+ MAX_LEN: ClassVar[int] = 20
+
+ # 1) allowed alphabet only
+ PATTERN_ALLOWED_CHARS: ClassVar[re.Pattern[str]] = re.compile(r"^[A-Za-z0-9._-]+$")
+ # 2) start / end must be alnum
+ PATTERN_START: ClassVar[re.Pattern[str]] = re.compile(r"^[A-Za-z0-9]")
+ PATTERN_END: ClassVar[re.Pattern[str]] = re.compile(r"[A-Za-z0-9]$")
+ # 3) no consecutive specials
+ PATTERN_CONSECUTIVE_SPECIALS: ClassVar[re.Pattern[str]] = re.compile(r"[._-]{2,}")
+
+ value: str
+
+ def __post_init__(self) -> None:
+ self._validate(self.value)
+
+ @classmethod
+ def _validate(cls, value: str) -> None:
+ if len(value) < cls.MIN_LEN or len(value) > cls.MAX_LEN:
+ raise BusinessTypeError(f"{cls.__name__} must be between {cls.MIN_LEN} and {cls.MAX_LEN} characters.")
+ if not cls.PATTERN_ALLOWED_CHARS.fullmatch(value):
+ raise BusinessTypeError(
+ f"{cls.__name__} can only contain letters (A-Z, a-z), digits (0-9), "
+ "dots (.), hyphens (-), and underscores (_)."
+ )
+ if not cls.PATTERN_START.match(value):
+ raise BusinessTypeError(f"{cls.__name__} must start with a letter (A-Z, a-z) or a digit (0-9).")
+ if not cls.PATTERN_END.search(value):
+ raise BusinessTypeError(f"{cls.__name__} must end with a letter (A-Z, a-z) or a digit (0-9).")
+ if cls.PATTERN_CONSECUTIVE_SPECIALS.search(value):
+ raise BusinessTypeError(f"{cls.__name__} cannot contain consecutive special characters like .., --, or __.")
diff --git a/src/app/core/common/value_objects/utc_datetime.py b/src/app/core/common/value_objects/utc_datetime.py
new file mode 100644
index 00000000..58e5b6bd
--- /dev/null
+++ b/src/app/core/common/value_objects/utc_datetime.py
@@ -0,0 +1,23 @@
+from dataclasses import dataclass
+from datetime import UTC, datetime
+
+from app.core.common.exceptions import BusinessTypeError
+from app.core.common.value_objects.base import ValueObject
+
+
+@dataclass(frozen=True, slots=True, repr=False)
+class UtcDatetime(ValueObject):
+ value: datetime
+
+ def __post_init__(self) -> None:
+ self._ensure_is_tz_aware(self.value)
+ object.__setattr__(self, "value", self._normalize(self.value))
+
+ @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}")
+
+ @classmethod
+ def _normalize(cls, dt: datetime) -> datetime:
+ return dt.astimezone(UTC)
diff --git a/src/app/domain/entities/__init__.py b/src/app/core/queries/__init__.py
similarity index 100%
rename from src/app/domain/entities/__init__.py
rename to src/app/core/queries/__init__.py
diff --git a/src/app/core/queries/list_users.py b/src/app/core/queries/list_users.py
new file mode 100644
index 00000000..9950fd48
--- /dev/null
+++ b/src/app/core/queries/list_users.py
@@ -0,0 +1,71 @@
+import logging
+from dataclasses import dataclass
+from enum import StrEnum
+
+from app.core.common.authorization.authorize import authorize
+from app.core.common.authorization.current_user_service import CurrentUserService
+from app.core.common.authorization.permissions import CanManageRole, RoleManagementContext
+from app.core.common.entities.types_ import UserRole
+from app.core.queries.ports.user_reader import ListUsersQm, UserReader
+from app.core.queries.query_support.offset_pagination import OffsetPaginationParams
+from app.core.queries.query_support.sorting import SortingOrder, SortingParams
+
+logger = logging.getLogger(__name__)
+
+
+class UserSortingField(StrEnum):
+ USERNAME = "username"
+ ROLE = "role"
+ IS_ACTIVE = "is_active"
+ CREATED_AT = "created_at"
+ UPDATED_AT = "updated_at"
+
+
+@dataclass(frozen=True, slots=True, kw_only=True)
+class ListUsersRequest:
+ limit: int
+ offset: int
+ sorting_field: UserSortingField
+ sorting_order: SortingOrder
+
+
+class ListUsers:
+ """
+ - Open to admins.
+ - Retrieves paginated list of existing users with relevant info.
+ """
+
+ def __init__(
+ self,
+ current_user_service: CurrentUserService,
+ user_reader: UserReader,
+ ) -> None:
+ self._current_user_service = current_user_service
+ self._user_reader = user_reader
+
+ async def execute(self, request: ListUsersRequest) -> ListUsersQm:
+ logger.info("List users: started.")
+
+ current_user = await self._current_user_service.get_current_user()
+ authorize(
+ CanManageRole(),
+ context=RoleManagementContext(
+ subject=current_user,
+ target_role=UserRole.USER,
+ ),
+ )
+ pagination = OffsetPaginationParams(
+ limit=request.limit,
+ offset=request.offset,
+ )
+ sorting = SortingParams(
+ field=request.sorting_field,
+ order=request.sorting_order,
+ )
+ users = await self._user_reader.list_users(
+ pagination=pagination,
+ sorting=sorting,
+ )
+
+ logger.info("List users: done.")
+ return users
diff --git a/src/app/domain/enums/__init__.py b/src/app/core/queries/models/__init__.py
similarity index 100%
rename from src/app/domain/enums/__init__.py
rename to src/app/core/queries/models/__init__.py
diff --git a/src/app/core/queries/models/user.py b/src/app/core/queries/models/user.py
new file mode 100644
index 00000000..c5df7e6c
--- /dev/null
+++ b/src/app/core/queries/models/user.py
@@ -0,0 +1,13 @@
+from dataclasses import dataclass
+from datetime import datetime
+from uuid import UUID
+
+
+@dataclass(frozen=True, slots=True)
+class UserQm:
+ id: UUID
+ username: str
+ role: str
+ is_active: bool
+ created_at: datetime
+ updated_at: datetime
diff --git a/src/app/domain/exceptions/__init__.py b/src/app/core/queries/ports/__init__.py
similarity index 100%
rename from src/app/domain/exceptions/__init__.py
rename to src/app/core/queries/ports/__init__.py
diff --git a/src/app/core/queries/ports/user_reader.py b/src/app/core/queries/ports/user_reader.py
new file mode 100644
index 00000000..baddf715
--- /dev/null
+++ b/src/app/core/queries/ports/user_reader.py
@@ -0,0 +1,23 @@
+from abc import abstractmethod
+from typing import Protocol, TypedDict
+
+from app.core.queries.models.user import UserQm
+from app.core.queries.query_support.offset_pagination import OffsetPaginationParams
+from app.core.queries.query_support.sorting import SortingParams
+
+
+class ListUsersQm(TypedDict):
+ users: list[UserQm]
+ total: int
+ limit: int
+ offset: int
+
+
+class UserReader(Protocol):
+ @abstractmethod
+ async def list_users(
+ self,
+ *,
+ pagination: OffsetPaginationParams,
+ sorting: SortingParams,
+ ) -> ListUsersQm: ...
diff --git a/src/app/domain/ports/__init__.py b/src/app/core/queries/query_support/__init__.py
similarity index 100%
rename from src/app/domain/ports/__init__.py
rename to src/app/core/queries/query_support/__init__.py
diff --git a/src/app/core/queries/query_support/exceptions.py b/src/app/core/queries/query_support/exceptions.py
new file mode 100644
index 00000000..c24533fb
--- /dev/null
+++ b/src/app/core/queries/query_support/exceptions.py
@@ -0,0 +1,9 @@
+from app.core.common.exceptions import BaseError
+
+
+class PaginationError(BaseError):
+ pass
+
+
+class SortingError(BaseError):
+ pass
diff --git a/src/app/core/queries/query_support/offset_pagination.py b/src/app/core/queries/query_support/offset_pagination.py
new file mode 100644
index 00000000..5e6e8db7
--- /dev/null
+++ b/src/app/core/queries/query_support/offset_pagination.py
@@ -0,0 +1,26 @@
+from dataclasses import dataclass
+from typing import ClassVar
+
+from app.core.queries.query_support.exceptions import PaginationError
+
+
+@dataclass(frozen=True, slots=True, kw_only=True)
+class OffsetPaginationParams:
+ MAX_INT32: ClassVar[int] = 2**31 - 1
+
+ limit: int
+ offset: int
+
+ def __post_init__(self) -> None:
+ self._validate(limit=self.limit, offset=self.offset)
+
+ @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.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}")
+ if offset > cls.MAX_INT32:
+ raise PaginationError(f"Offset cannot be greater than {cls.MAX_INT32}, got {offset}")
diff --git a/src/app/application/common/query_params/sorting.py b/src/app/core/queries/query_support/sorting.py
similarity index 100%
rename from src/app/application/common/query_params/sorting.py
rename to src/app/core/queries/query_support/sorting.py
diff --git a/src/app/domain/entities/user.py b/src/app/domain/entities/user.py
deleted file mode 100644
index d7864130..00000000
--- a/src/app/domain/entities/user.py
+++ /dev/null
@@ -1,22 +0,0 @@
-from app.domain.entities.base import Entity
-from app.domain.enums.user_role import UserRole
-from app.domain.value_objects.user_id import UserId
-from app.domain.value_objects.user_password_hash import UserPasswordHash
-from app.domain.value_objects.username import Username
-
-
-class User(Entity[UserId]):
- def __init__(
- self,
- *,
- id_: UserId,
- username: Username,
- password_hash: UserPasswordHash,
- role: UserRole,
- is_active: bool,
- ) -> None:
- super().__init__(id_=id_)
- self.username = username
- self.password_hash = password_hash
- self.role = role
- self.is_active = is_active
diff --git a/src/app/domain/enums/user_role.py b/src/app/domain/enums/user_role.py
deleted file mode 100644
index dd41083b..00000000
--- a/src/app/domain/enums/user_role.py
+++ /dev/null
@@ -1,15 +0,0 @@
-from enum import StrEnum
-
-
-class UserRole(StrEnum):
- SUPER_ADMIN = "super_admin"
- ADMIN = "admin"
- USER = "user"
-
- @property
- def is_assignable(self) -> bool:
- return self != UserRole.SUPER_ADMIN
-
- @property
- def is_changeable(self) -> bool:
- return self != UserRole.SUPER_ADMIN
diff --git a/src/app/domain/exceptions/base.py b/src/app/domain/exceptions/base.py
deleted file mode 100644
index 656b7d23..00000000
--- a/src/app/domain/exceptions/base.py
+++ /dev/null
@@ -1,6 +0,0 @@
-class DomainTypeError(Exception):
- """Invalid construction of domain types (Value Objects)."""
-
-
-class DomainError(Exception):
- """Domain rule violation not tied to domain type construction."""
diff --git a/src/app/domain/exceptions/user.py b/src/app/domain/exceptions/user.py
deleted file mode 100644
index a6ef7df5..00000000
--- a/src/app/domain/exceptions/user.py
+++ /dev/null
@@ -1,44 +0,0 @@
-from typing import Any
-
-from app.domain.enums.user_role import UserRole
-from app.domain.exceptions.base import DomainError
-from app.domain.value_objects.user_id import UserId
-from app.domain.value_objects.username import Username
-
-
-class UsernameAlreadyExistsError(DomainError):
- def __init__(self, username: Any) -> None:
- message = f"User with {username!r} already exists."
- super().__init__(message)
-
-
-class UserNotFoundByIdError(DomainError):
- def __init__(self, user_id: UserId) -> None:
- message = f"User with {user_id.value!r} is not found."
- super().__init__(message)
-
-
-class UserNotFoundByUsernameError(DomainError):
- def __init__(self, username: Username) -> None:
- message = f"User with {username.value!r} is not found."
- super().__init__(message)
-
-
-class ActivationChangeNotPermittedError(DomainError):
- def __init__(self, username: Username, role: UserRole) -> None:
- message = (
- f"Changing activation of user {username.value!r} ({role}) is not permitted."
- )
- super().__init__(message)
-
-
-class RoleAssignmentNotPermittedError(DomainError):
- def __init__(self, role: UserRole) -> None:
- message = f"Assignment of role {role} is not permitted."
- super().__init__(message)
-
-
-class RoleChangeNotPermittedError(DomainError):
- def __init__(self, username: Username, role: UserRole) -> None:
- message = f"Changing role of user {username.value!r} ({role}) is not permitted."
- super().__init__(message)
diff --git a/src/app/domain/ports/password_hasher.py b/src/app/domain/ports/password_hasher.py
deleted file mode 100644
index 1add22ed..00000000
--- a/src/app/domain/ports/password_hasher.py
+++ /dev/null
@@ -1,19 +0,0 @@
-from abc import abstractmethod
-from typing import Protocol
-
-from app.domain.value_objects.raw_password import RawPassword
-from app.domain.value_objects.user_password_hash import UserPasswordHash
-
-
-class PasswordHasher(Protocol):
- @abstractmethod
- async def hash(self, raw_password: RawPassword) -> UserPasswordHash:
- """:raises PasswordHasherBusyError:"""
-
- @abstractmethod
- async def verify(
- self,
- raw_password: RawPassword,
- hashed_password: UserPasswordHash,
- ) -> bool:
- """:raises PasswordHasherBusyError:"""
diff --git a/src/app/domain/ports/user_id_generator.py b/src/app/domain/ports/user_id_generator.py
deleted file mode 100644
index a1de1ce6..00000000
--- a/src/app/domain/ports/user_id_generator.py
+++ /dev/null
@@ -1,8 +0,0 @@
-from abc import abstractmethod
-
-from app.domain.value_objects.user_id import UserId
-
-
-class UserIdGenerator:
- @abstractmethod
- def generate(self) -> UserId: ...
diff --git a/src/app/domain/services/user.py b/src/app/domain/services/user.py
deleted file mode 100644
index 54c57fd5..00000000
--- a/src/app/domain/services/user.py
+++ /dev/null
@@ -1,75 +0,0 @@
-from app.domain.entities.user import User
-from app.domain.enums.user_role import UserRole
-from app.domain.exceptions.user import (
- ActivationChangeNotPermittedError,
- RoleAssignmentNotPermittedError,
- RoleChangeNotPermittedError,
-)
-from app.domain.ports.password_hasher import PasswordHasher
-from app.domain.ports.user_id_generator import UserIdGenerator
-from app.domain.value_objects.raw_password import RawPassword
-from app.domain.value_objects.username import Username
-
-
-class UserService:
- def __init__(
- self,
- user_id_generator: UserIdGenerator,
- password_hasher: PasswordHasher,
- ) -> None:
- self._user_id_generator = user_id_generator
- self._password_hasher = password_hasher
-
- async def create_user(
- self,
- username: Username,
- raw_password: RawPassword,
- role: UserRole = UserRole.USER,
- is_active: bool = True,
- ) -> User:
- """
- :raises RoleAssignmentNotPermittedError:
- :raises PasswordHasherBusyError:
- """
- if not role.is_assignable:
- raise RoleAssignmentNotPermittedError(role)
-
- user_id = self._user_id_generator.generate()
- password_hash = await self._password_hasher.hash(raw_password)
- return User(
- id_=user_id,
- username=username,
- password_hash=password_hash,
- role=role,
- is_active=is_active,
- )
-
- async def is_password_valid(self, user: User, raw_password: RawPassword) -> bool:
- """:raises PasswordHasherBusyError:"""
- return await self._password_hasher.verify(
- raw_password=raw_password,
- hashed_password=user.password_hash,
- )
-
- async def change_password(self, user: User, raw_password: RawPassword) -> None:
- """:raises PasswordHasherBusyError:"""
- user.password_hash = await self._password_hasher.hash(raw_password)
-
- def toggle_user_activation(self, user: User, *, is_active: bool) -> bool:
- """:raises ActivationChangeNotPermittedError:"""
- if not user.role.is_changeable:
- raise ActivationChangeNotPermittedError(user.username, user.role)
- if user.is_active == is_active:
- return False
- user.is_active = is_active
- return True
-
- def toggle_user_admin_role(self, user: User, *, is_admin: bool) -> bool:
- """:raises RoleChangeNotPermittedError:"""
- if not user.role.is_changeable:
- raise RoleChangeNotPermittedError(user.username, user.role)
- target_role = UserRole.ADMIN if is_admin else UserRole.USER
- if user.role == target_role:
- return False
- user.role = target_role
- return True
diff --git a/src/app/domain/value_objects/raw_password.py b/src/app/domain/value_objects/raw_password.py
deleted file mode 100644
index ec209c6b..00000000
--- a/src/app/domain/value_objects/raw_password.py
+++ /dev/null
@@ -1,26 +0,0 @@
-from dataclasses import dataclass, field
-from typing import ClassVar, Final
-
-from app.domain.exceptions.base import DomainTypeError
-from app.domain.value_objects.base import ValueObject
-
-
-@dataclass(frozen=True, slots=True, repr=False)
-class RawPassword(ValueObject):
- """raises DomainTypeError"""
-
- MIN_LEN: ClassVar[Final[int]] = 6
-
- value: bytes = field(init=False, repr=False)
-
- def __init__(self, value: str) -> None:
- """:raises DomainTypeError:"""
- self._validate_password_length(value)
- object.__setattr__(self, "value", value.encode())
-
- def _validate_password_length(self, password_value: str) -> None:
- """:raises DomainTypeError:"""
- if len(password_value) < self.MIN_LEN:
- raise DomainTypeError(
- f"Password must be at least {self.MIN_LEN} characters long.",
- )
diff --git a/src/app/domain/value_objects/user_id.py b/src/app/domain/value_objects/user_id.py
deleted file mode 100644
index 89032534..00000000
--- a/src/app/domain/value_objects/user_id.py
+++ /dev/null
@@ -1,9 +0,0 @@
-from dataclasses import dataclass
-from uuid import UUID
-
-from app.domain.value_objects.base import ValueObject
-
-
-@dataclass(frozen=True, slots=True, repr=False)
-class UserId(ValueObject):
- value: UUID
diff --git a/src/app/domain/value_objects/user_password_hash.py b/src/app/domain/value_objects/user_password_hash.py
deleted file mode 100644
index ee8b4f3b..00000000
--- a/src/app/domain/value_objects/user_password_hash.py
+++ /dev/null
@@ -1,8 +0,0 @@
-from dataclasses import dataclass
-
-from app.domain.value_objects.base import ValueObject
-
-
-@dataclass(frozen=True, slots=True, repr=False)
-class UserPasswordHash(ValueObject):
- value: bytes
diff --git a/src/app/domain/value_objects/username.py b/src/app/domain/value_objects/username.py
deleted file mode 100644
index 8ae842fb..00000000
--- a/src/app/domain/value_objects/username.py
+++ /dev/null
@@ -1,69 +0,0 @@
-import re
-from dataclasses import dataclass
-from typing import ClassVar, Final
-
-from app.domain.exceptions.base import DomainTypeError
-from app.domain.value_objects.base import ValueObject
-
-
-@dataclass(frozen=True, slots=True, repr=False)
-class Username(ValueObject):
- """raises DomainTypeError"""
-
- MIN_LEN: ClassVar[Final[int]] = 5
- MAX_LEN: ClassVar[Final[int]] = 20
-
- # Pattern for validating a username:
- # - starts with a letter (A-Z, a-z) or a digit (0-9)
- PATTERN_START: ClassVar[Final[re.Pattern[str]]] = re.compile(
- r"^[a-zA-Z0-9]",
- )
- # - can contain multiple special characters . - _ between letters and digits,
- PATTERN_ALLOWED_CHARS: ClassVar[Final[re.Pattern[str]]] = re.compile(
- r"[a-zA-Z0-9._-]*",
- )
- # but only one special character can appear consecutively
- PATTERN_NO_CONSECUTIVE_SPECIALS: ClassVar[Final[re.Pattern[str]]] = re.compile(
- r"^[a-zA-Z0-9]+([._-]?[a-zA-Z0-9]+)*[._-]?$",
- )
- # - ends with a letter (A-Z, a-z) or a digit (0-9)
- PATTERN_END: ClassVar[Final[re.Pattern[str]]] = re.compile(
- r".*[a-zA-Z0-9]$",
- )
-
- value: str
-
- def __post_init__(self) -> None:
- """:raises DomainTypeError:"""
- self._validate_username_length(self.value)
- self._validate_username_pattern(self.value)
-
- def _validate_username_length(self, username_value: str) -> None:
- """:raises DomainTypeError:"""
- if len(username_value) < self.MIN_LEN or len(username_value) > self.MAX_LEN:
- raise DomainTypeError(
- f"Username must be between "
- f"{self.MIN_LEN} and "
- f"{self.MAX_LEN} characters.",
- )
-
- def _validate_username_pattern(self, username_value: str) -> None:
- """:raises DomainTypeError:"""
- if not re.match(self.PATTERN_START, username_value):
- raise DomainTypeError(
- "Username must start with a letter (A-Z, a-z) or a digit (0-9).",
- )
- if not re.fullmatch(self.PATTERN_ALLOWED_CHARS, username_value):
- raise DomainTypeError(
- "Username can only contain letters (A-Z, a-z), digits (0-9), "
- "dots (.), hyphens (-), and underscores (_).",
- )
- if not re.fullmatch(self.PATTERN_NO_CONSECUTIVE_SPECIALS, username_value):
- raise DomainTypeError(
- "Username cannot contain consecutive special characters"
- " like .., --, or __.",
- )
- if not re.match(self.PATTERN_END, username_value):
- raise DomainTypeError(
- "Username must end with a letter (A-Z, a-z) or a digit (0-9).",
- )
diff --git a/src/app/infrastructure/adapters/auth_session_access_revoker.py b/src/app/infrastructure/adapters/auth_session_access_revoker.py
new file mode 100644
index 00000000..2fa0fbc3
--- /dev/null
+++ b/src/app/infrastructure/adapters/auth_session_access_revoker.py
@@ -0,0 +1,11 @@
+from app.core.common.entities.types_ import UserId
+from app.core.common.ports.access_revoker import AccessRevoker
+from app.infrastructure.auth_ctx.service import AuthService
+
+
+class AuthSessionAccessRevoker(AccessRevoker):
+ def __init__(self, auth_service: AuthService) -> None:
+ self._auth_service = auth_service
+
+ async def remove_all_user_access(self, user_id: UserId) -> None:
+ await self._auth_service.revoke_all_sessions(user_id)
diff --git a/src/app/infrastructure/adapters/auth_session_identity_provider.py b/src/app/infrastructure/adapters/auth_session_identity_provider.py
new file mode 100644
index 00000000..239fb3eb
--- /dev/null
+++ b/src/app/infrastructure/adapters/auth_session_identity_provider.py
@@ -0,0 +1,11 @@
+from app.core.common.entities.types_ import UserId
+from app.core.common.ports.identity_provider import IdentityProvider
+from app.infrastructure.auth_ctx.service import AuthService
+
+
+class AuthSessionIdentityProvider(IdentityProvider):
+ def __init__(self, auth_service: AuthService) -> None:
+ self._auth_service = auth_service
+
+ async def get_current_user_id(self) -> UserId:
+ return await self._auth_service.get_current_user_id()
diff --git a/src/app/infrastructure/adapters/password_hasher_bcrypt.py b/src/app/infrastructure/adapters/bcrypt_password_hasher.py
similarity index 76%
rename from src/app/infrastructure/adapters/password_hasher_bcrypt.py
rename to src/app/infrastructure/adapters/bcrypt_password_hasher.py
index 01585b00..8a9ed29f 100644
--- a/src/app/infrastructure/adapters/password_hasher_bcrypt.py
+++ b/src/app/infrastructure/adapters/bcrypt_password_hasher.py
@@ -2,19 +2,20 @@
import base64
import hashlib
import hmac
-import logging
from collections.abc import AsyncIterator
+from concurrent.futures import ThreadPoolExecutor
from contextlib import asynccontextmanager
+from typing import NewType
import bcrypt
-from app.domain.ports.password_hasher import PasswordHasher
-from app.domain.value_objects.raw_password import RawPassword
-from app.domain.value_objects.user_password_hash import UserPasswordHash
-from app.infrastructure.adapters.types import HasherSemaphore, HasherThreadPoolExecutor
-from app.infrastructure.exceptions.password_hasher import PasswordHasherBusyError
+from app.core.common.entities.types_ import UserPasswordHash
+from app.core.common.ports.password_hasher import PasswordHasher
+from app.core.common.value_objects.raw_password import RawPassword
+from app.infrastructure.adapters.exceptions import PasswordHasherBusyError
-log = logging.getLogger(__name__)
+HasherThreadPoolExecutor = NewType("HasherThreadPoolExecutor", ThreadPoolExecutor)
+HasherSemaphore = NewType("HasherSemaphore", asyncio.Semaphore)
class BcryptPasswordHasher(PasswordHasher):
@@ -33,7 +34,6 @@ def __init__(
self._semaphore_wait_timeout_s = semaphore_wait_timeout_s
async def hash(self, raw_password: RawPassword) -> UserPasswordHash:
- """:raises PasswordHasherBusyError:"""
async with self._permit():
loop = asyncio.get_running_loop()
return await loop.run_in_executor(
@@ -47,7 +47,6 @@ async def verify(
raw_password: RawPassword,
hashed_password: UserPasswordHash,
) -> bool:
- """:raises PasswordHasherBusyError:"""
async with self._permit():
loop = asyncio.get_running_loop()
return await loop.run_in_executor(
@@ -59,14 +58,13 @@ async def verify(
@asynccontextmanager
async def _permit(self) -> AsyncIterator[None]:
- """:raises PasswordHasherBusyError:"""
try:
await asyncio.wait_for(
self._semaphore.acquire(),
timeout=self._semaphore_wait_timeout_s,
)
- except TimeoutError as err:
- raise PasswordHasherBusyError from err
+ except TimeoutError as e:
+ raise PasswordHasherBusyError from e
try:
yield
finally:
@@ -79,17 +77,13 @@ def hash_sync(self, raw_password: RawPassword) -> UserPasswordHash:
Work factor:
https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#introduction
"""
- log.debug("hash")
base64_hmac_peppered = self._add_pepper(raw_password, self._pepper)
salt = bcrypt.gensalt(rounds=self._work_factor)
return UserPasswordHash(bcrypt.hashpw(base64_hmac_peppered, salt))
- def verify_sync(
- self, raw_password: RawPassword, hashed_password: UserPasswordHash
- ) -> bool:
- log.debug("verify")
+ def verify_sync(self, raw_password: RawPassword, hashed_password: UserPasswordHash) -> bool:
base64_hmac_peppered = self._add_pepper(raw_password, self._pepper)
- return bcrypt.checkpw(base64_hmac_peppered, hashed_password.value)
+ return bcrypt.checkpw(base64_hmac_peppered, hashed_password)
@staticmethod
def _add_pepper(raw_password: RawPassword, pepper: bytes) -> bytes:
diff --git a/src/app/infrastructure/adapters/constants.py b/src/app/infrastructure/adapters/constants.py
deleted file mode 100644
index 6f355f22..00000000
--- a/src/app/infrastructure/adapters/constants.py
+++ /dev/null
@@ -1,8 +0,0 @@
-from typing import Final
-
-DB_CONSTRAINT_VIOLATION: Final[str] = "Database constraint violation."
-DB_COMMIT_DONE: Final[str] = "Commit was done."
-DB_COMMIT_FAILED: Final[str] = "Commit failed."
-DB_FLUSH_DONE: Final[str] = "Flush was done."
-DB_FLUSH_FAILED: Final[str] = "Flush failed."
-DB_QUERY_FAILED: Final[str] = "Database query failed."
diff --git a/src/app/infrastructure/adapters/exceptions.py b/src/app/infrastructure/adapters/exceptions.py
new file mode 100644
index 00000000..d769a1b6
--- /dev/null
+++ b/src/app/infrastructure/adapters/exceptions.py
@@ -0,0 +1,5 @@
+from app.core.common.exceptions import BaseError
+
+
+class PasswordHasherBusyError(BaseError):
+ pass
diff --git a/src/app/infrastructure/adapters/main_flusher_sqla.py b/src/app/infrastructure/adapters/main_flusher_sqla.py
deleted file mode 100644
index 8b997af2..00000000
--- a/src/app/infrastructure/adapters/main_flusher_sqla.py
+++ /dev/null
@@ -1,43 +0,0 @@
-import logging
-from collections.abc import Mapping
-from typing import Any, cast
-
-from sqlalchemy.exc import IntegrityError, SQLAlchemyError
-
-from app.application.common.ports.flusher import Flusher
-from app.domain.exceptions.user import UsernameAlreadyExistsError
-from app.infrastructure.adapters.constants import (
- DB_CONSTRAINT_VIOLATION,
- DB_FLUSH_DONE,
- DB_FLUSH_FAILED,
- DB_QUERY_FAILED,
-)
-from app.infrastructure.adapters.types import MainAsyncSession
-from app.infrastructure.exceptions.gateway import DataMapperError
-
-log = logging.getLogger(__name__)
-
-
-class SqlaMainFlusher(Flusher):
- def __init__(self, session: MainAsyncSession) -> None:
- self._session = session
-
- async def flush(self) -> None:
- """
- :raises DataMapperError:
- :raises UsernameAlreadyExists:
- """
- try:
- await self._session.flush()
- log.debug("%s Main session.", DB_FLUSH_DONE)
-
- except IntegrityError as err:
- if "uq_users_username" in str(err):
- params: Mapping[str, Any] = cast(Mapping[str, Any], err.params)
- username = str(params.get("username", "unknown"))
- raise UsernameAlreadyExistsError(username) from err
-
- raise DataMapperError(DB_CONSTRAINT_VIOLATION) from err
-
- except SQLAlchemyError as err:
- raise DataMapperError(f"{DB_QUERY_FAILED} {DB_FLUSH_FAILED}") from err
diff --git a/src/app/infrastructure/adapters/main_transaction_manager_sqla.py b/src/app/infrastructure/adapters/main_transaction_manager_sqla.py
deleted file mode 100644
index 4fd1f7da..00000000
--- a/src/app/infrastructure/adapters/main_transaction_manager_sqla.py
+++ /dev/null
@@ -1,30 +0,0 @@
-import logging
-
-from sqlalchemy.exc import SQLAlchemyError
-
-from app.application.common.ports.transaction_manager import (
- TransactionManager,
-)
-from app.infrastructure.adapters.constants import (
- DB_COMMIT_DONE,
- DB_COMMIT_FAILED,
- DB_QUERY_FAILED,
-)
-from app.infrastructure.adapters.types import MainAsyncSession
-from app.infrastructure.exceptions.gateway import DataMapperError
-
-log = logging.getLogger(__name__)
-
-
-class SqlaMainTransactionManager(TransactionManager):
- def __init__(self, session: MainAsyncSession) -> None:
- self._session = session
-
- async def commit(self) -> None:
- """:raises DataMapperError:"""
- try:
- await self._session.commit()
- log.debug("%s Main session.", DB_COMMIT_DONE)
-
- except SQLAlchemyError as err:
- raise DataMapperError(f"{DB_QUERY_FAILED} {DB_COMMIT_FAILED}") from err
diff --git a/src/app/infrastructure/adapters/sqla_flusher.py b/src/app/infrastructure/adapters/sqla_flusher.py
new file mode 100644
index 00000000..743f8fee
--- /dev/null
+++ b/src/app/infrastructure/adapters/sqla_flusher.py
@@ -0,0 +1,43 @@
+import logging
+from collections.abc import Mapping
+from typing import Final
+
+from sqlalchemy.exc import IntegrityError, SQLAlchemyError
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.commands.exceptions import UsernameAlreadyExistsError
+from app.core.commands.ports.flusher import Flusher
+from app.infrastructure.exceptions import StorageError
+from app.infrastructure.persistence_sqla import constraint_names as cn
+
+logger = logging.getLogger(__name__)
+
+DB_CONSTRAINT_VIOLATION: Final[str] = "Database constraint violation."
+DB_FLUSH_DONE: Final[str] = "Flush was done."
+DB_FLUSH_FAILED: Final[str] = "Flush failed."
+
+CONSTRAINT_TO_ERROR: Final[Mapping[str, type[Exception]]] = {
+ cn.UQ_USERS_USERNAME: UsernameAlreadyExistsError,
+}
+
+
+class SqlaFlusher(Flusher):
+ def __init__(self, session: AsyncSession) -> None:
+ self._session = session
+
+ async def flush(self) -> None:
+ try:
+ await self._session.flush()
+ logger.debug("%s.", DB_FLUSH_DONE)
+
+ except IntegrityError as e:
+ msg = str(e)
+ for name, exc_type in CONSTRAINT_TO_ERROR.items():
+ if name in msg:
+ raise exc_type from e
+
+ logger.warning("Unhandled integrity error: %s", msg)
+ raise StorageError(DB_CONSTRAINT_VIOLATION) from e
+
+ except SQLAlchemyError as e:
+ raise StorageError(DB_FLUSH_FAILED) from e
diff --git a/src/app/infrastructure/adapters/sqla_transaction_manager.py b/src/app/infrastructure/adapters/sqla_transaction_manager.py
new file mode 100644
index 00000000..f84273d4
--- /dev/null
+++ b/src/app/infrastructure/adapters/sqla_transaction_manager.py
@@ -0,0 +1,26 @@
+import logging
+from typing import Final
+
+from sqlalchemy.exc import SQLAlchemyError
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.commands.ports.transaction_manager import TransactionManager
+from app.infrastructure.exceptions import StorageError
+
+DB_COMMIT_DONE: Final[str] = "Commit was done."
+DB_COMMIT_FAILED: Final[str] = "Commit failed."
+
+logger = logging.getLogger(__name__)
+
+
+class SqlaTransactionManager(TransactionManager):
+ def __init__(self, session: AsyncSession) -> None:
+ self._session = session
+
+ async def commit(self) -> None:
+ try:
+ await self._session.commit()
+ logger.debug("%s.", DB_COMMIT_DONE)
+
+ except SQLAlchemyError as e:
+ raise StorageError(DB_COMMIT_FAILED) from e
diff --git a/src/app/infrastructure/adapters/sqla_user_reader.py b/src/app/infrastructure/adapters/sqla_user_reader.py
new file mode 100644
index 00000000..e41eab8c
--- /dev/null
+++ b/src/app/infrastructure/adapters/sqla_user_reader.py
@@ -0,0 +1,76 @@
+from sqlalchemy import func, select
+from sqlalchemy.exc import SQLAlchemyError
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.queries.models.user import UserQm
+from app.core.queries.ports.user_reader import ListUsersQm, UserReader
+from app.core.queries.query_support.exceptions import SortingError
+from app.core.queries.query_support.offset_pagination import OffsetPaginationParams
+from app.core.queries.query_support.sorting import SortingOrder, SortingParams
+from app.infrastructure.exceptions import ReaderError
+from app.infrastructure.persistence_sqla.mappings.user import users_table
+
+
+class SqlaUserReader(UserReader):
+ def __init__(self, session: AsyncSession) -> None:
+ self._session = session
+
+ async def list_users(
+ self,
+ *,
+ pagination: OffsetPaginationParams,
+ sorting: SortingParams,
+ ) -> ListUsersQm:
+ sorting_column = users_table.c.get(sorting.field)
+ if sorting_column is None:
+ raise SortingError(f"Invalid sorting field: '{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 = (
+ select(
+ users_table.c.id,
+ users_table.c.username,
+ users_table.c.role,
+ users_table.c.is_active,
+ users_table.c.created_at,
+ users_table.c.updated_at,
+ func.count().over().label("total"),
+ )
+ .order_by(order_by_expression, secondary_order_by)
+ .limit(pagination.limit)
+ .offset(pagination.offset)
+ )
+ try:
+ result = await self._session.execute(stmt)
+ rows = result.all()
+ except SQLAlchemyError as e:
+ raise ReaderError from e
+ if not rows:
+ total_stmt = select(func.count()).select_from(users_table)
+ try:
+ total = int(await self._session.scalar(total_stmt) or 0)
+ except SQLAlchemyError as e:
+ raise ReaderError from e
+ return ListUsersQm(
+ users=[],
+ total=total,
+ limit=pagination.limit,
+ offset=pagination.offset,
+ )
+ users = [
+ UserQm(
+ id=row.id,
+ username=row.username,
+ role=row.role,
+ is_active=row.is_active,
+ created_at=row.created_at,
+ updated_at=row.updated_at,
+ )
+ for row in rows
+ ]
+ return ListUsersQm(
+ users=users,
+ total=rows[0].total,
+ limit=pagination.limit,
+ offset=pagination.offset,
+ )
diff --git a/src/app/infrastructure/adapters/sqla_user_tx_storage.py b/src/app/infrastructure/adapters/sqla_user_tx_storage.py
new file mode 100644
index 00000000..a1fbf865
--- /dev/null
+++ b/src/app/infrastructure/adapters/sqla_user_tx_storage.py
@@ -0,0 +1,34 @@
+from sqlalchemy.exc import SQLAlchemyError
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.commands.ports.user_tx_storage import UserTxStorage
+from app.core.common.authorization.ports import AuthzUserFinder
+from app.core.common.entities.types_ import UserId
+from app.core.common.entities.user import User
+from app.infrastructure.exceptions import StorageError
+
+
+class SqlaUserTxStorage(UserTxStorage, AuthzUserFinder):
+ def __init__(self, session: AsyncSession) -> None:
+ self._session = session
+
+ def add(self, user: User) -> None:
+ try:
+ self._session.add(user)
+ except SQLAlchemyError as e:
+ raise StorageError from e
+
+ async def get_by_id(
+ self,
+ user_id: UserId,
+ *,
+ for_update: bool = False,
+ ) -> User | None:
+ try:
+ return await self._session.get(
+ User,
+ user_id,
+ with_for_update=for_update,
+ )
+ except SQLAlchemyError as e:
+ raise StorageError from e
diff --git a/src/app/infrastructure/adapters/system_utc_timer.py b/src/app/infrastructure/adapters/system_utc_timer.py
new file mode 100644
index 00000000..e296c3af
--- /dev/null
+++ b/src/app/infrastructure/adapters/system_utc_timer.py
@@ -0,0 +1,10 @@
+from datetime import UTC, datetime
+
+from app.core.commands.ports.utc_timer import UtcTimer
+from app.core.common.value_objects.utc_datetime import UtcDatetime
+
+
+class SystemUtcTimer(UtcTimer):
+ @property
+ def now(self) -> UtcDatetime:
+ return UtcDatetime(datetime.now(UTC))
diff --git a/src/app/infrastructure/adapters/types.py b/src/app/infrastructure/adapters/types.py
deleted file mode 100644
index 46489e2a..00000000
--- a/src/app/infrastructure/adapters/types.py
+++ /dev/null
@@ -1,9 +0,0 @@
-import asyncio
-from concurrent.futures import ThreadPoolExecutor
-from typing import NewType
-
-from sqlalchemy.ext.asyncio import AsyncSession
-
-MainAsyncSession = NewType("MainAsyncSession", AsyncSession)
-HasherThreadPoolExecutor = NewType("HasherThreadPoolExecutor", ThreadPoolExecutor)
-HasherSemaphore = NewType("HasherSemaphore", asyncio.Semaphore)
diff --git a/src/app/infrastructure/adapters/user_data_mapper_sqla.py b/src/app/infrastructure/adapters/user_data_mapper_sqla.py
deleted file mode 100644
index 2153f2e9..00000000
--- a/src/app/infrastructure/adapters/user_data_mapper_sqla.py
+++ /dev/null
@@ -1,54 +0,0 @@
-from sqlalchemy import select
-from sqlalchemy.exc import SQLAlchemyError
-
-from app.application.common.ports.user_command_gateway import UserCommandGateway
-from app.domain.entities.user import User
-from app.domain.value_objects.user_id import UserId
-from app.domain.value_objects.username import Username
-from app.infrastructure.adapters.constants import DB_QUERY_FAILED
-from app.infrastructure.adapters.types import MainAsyncSession
-from app.infrastructure.exceptions.gateway import DataMapperError
-
-
-class SqlaUserDataMapper(UserCommandGateway):
- def __init__(self, session: MainAsyncSession) -> None:
- self._session = session
-
- def add(self, user: User) -> None:
- """:raises DataMapperError:"""
- try:
- self._session.add(user)
- except SQLAlchemyError as err:
- raise DataMapperError(DB_QUERY_FAILED) from err
-
- async def read_by_id(
- self,
- user_id: UserId,
- for_update: bool = False,
- ) -> User | None:
- """:raises DataMapperError:"""
- stmt = select(User).where(User.id_ == user_id) # type: ignore
-
- if for_update:
- stmt = stmt.with_for_update()
-
- try:
- return (await self._session.execute(stmt)).scalar_one_or_none()
- except SQLAlchemyError as err:
- raise DataMapperError(DB_QUERY_FAILED) from err
-
- async def read_by_username(
- self,
- username: Username,
- for_update: bool = False,
- ) -> User | None:
- """:raises DataMapperError:"""
- stmt = select(User).where(User.username == username) # type: ignore
-
- if for_update:
- stmt = stmt.with_for_update()
-
- try:
- return (await self._session.execute(stmt)).scalar_one_or_none()
- except SQLAlchemyError as err:
- raise DataMapperError(DB_QUERY_FAILED) from err
diff --git a/src/app/infrastructure/adapters/user_id_generator_uuid.py b/src/app/infrastructure/adapters/user_id_generator_uuid.py
deleted file mode 100644
index 3ce9341c..00000000
--- a/src/app/infrastructure/adapters/user_id_generator_uuid.py
+++ /dev/null
@@ -1,9 +0,0 @@
-import uuid_utils.compat as uuid_utils
-
-from app.domain.ports.user_id_generator import UserIdGenerator
-from app.domain.value_objects.user_id import UserId
-
-
-class UuidUserIdGenerator(UserIdGenerator):
- def generate(self) -> UserId:
- return UserId(uuid_utils.uuid7())
diff --git a/src/app/infrastructure/adapters/user_reader_sqla.py b/src/app/infrastructure/adapters/user_reader_sqla.py
deleted file mode 100644
index 5d046629..00000000
--- a/src/app/infrastructure/adapters/user_reader_sqla.py
+++ /dev/null
@@ -1,77 +0,0 @@
-import logging
-
-from sqlalchemy import func, select
-from sqlalchemy.exc import SQLAlchemyError
-
-from app.application.common.exceptions.query import SortingError
-from app.application.common.ports.user_query_gateway import (
- ListUsersQM,
- UserQueryGateway,
- UserQueryModel,
-)
-from app.application.common.query_params.offset_pagination import OffsetPaginationParams
-from app.application.common.query_params.sorting import SortingOrder, SortingParams
-from app.infrastructure.adapters.constants import DB_QUERY_FAILED
-from app.infrastructure.adapters.types import MainAsyncSession
-from app.infrastructure.exceptions.gateway import ReaderError
-from app.infrastructure.persistence_sqla.mappings.user import users_table
-
-log = logging.getLogger(__name__)
-
-
-class SqlaUserReader(UserQueryGateway):
- def __init__(self, session: MainAsyncSession) -> None:
- self._session = session
-
- async def read_all(
- self,
- pagination: OffsetPaginationParams,
- sorting: SortingParams,
- ) -> ListUsersQM:
- """
- :raises SortingError:
- :raises ReaderError:
- """
- sorting_col = users_table.c.get(sorting.field)
- if sorting_col is None:
- raise SortingError(f"Invalid sorting field: '{sorting.field}'")
-
- order_by = (
- sorting_col.asc()
- if sorting.order == SortingOrder.ASC
- else sorting_col.desc()
- )
-
- stmt = (
- select(
- users_table.c.id,
- users_table.c.username,
- users_table.c.role,
- users_table.c.is_active,
- func.count().over().label("total"),
- )
- .order_by(order_by)
- .limit(pagination.limit)
- .offset(pagination.offset)
- )
-
- try:
- result = await self._session.execute(stmt)
- rows = result.all()
- except SQLAlchemyError as err:
- raise ReaderError(DB_QUERY_FAILED) from err
-
- if not rows:
- return ListUsersQM(users=[], total=0)
-
- users = [
- UserQueryModel(
- id_=row.id,
- username=row.username,
- role=row.role,
- is_active=row.is_active,
- )
- for row in rows
- ]
- total = rows[0].total
- return ListUsersQM(users=users, total=total)
diff --git a/src/app/infrastructure/auth/adapters/access_revoker.py b/src/app/infrastructure/auth/adapters/access_revoker.py
deleted file mode 100644
index 89581ef1..00000000
--- a/src/app/infrastructure/auth/adapters/access_revoker.py
+++ /dev/null
@@ -1,12 +0,0 @@
-from app.application.common.ports.access_revoker import AccessRevoker
-from app.domain.value_objects.user_id import UserId
-from app.infrastructure.auth.session.service import AuthSessionService
-
-
-class AuthSessionAccessRevoker(AccessRevoker):
- def __init__(self, auth_session_service: AuthSessionService) -> None:
- self._auth_session_service = auth_session_service
-
- async def remove_all_user_access(self, user_id: UserId) -> None:
- """:raises DataMapperError:"""
- await self._auth_session_service.terminate_all_sessions_for_user(user_id)
diff --git a/src/app/infrastructure/auth/adapters/data_mapper_sqla.py b/src/app/infrastructure/auth/adapters/data_mapper_sqla.py
deleted file mode 100644
index 8814bb03..00000000
--- a/src/app/infrastructure/auth/adapters/data_mapper_sqla.py
+++ /dev/null
@@ -1,61 +0,0 @@
-from sqlalchemy import delete
-from sqlalchemy.exc import SQLAlchemyError
-
-from app.domain.value_objects.user_id import UserId
-from app.infrastructure.adapters.constants import DB_QUERY_FAILED
-from app.infrastructure.auth.adapters.types import AuthAsyncSession
-from app.infrastructure.auth.session.model import AuthSession
-from app.infrastructure.auth.session.ports.gateway import (
- AuthSessionGateway,
-)
-from app.infrastructure.exceptions.gateway import DataMapperError
-
-
-class SqlaAuthSessionDataMapper(AuthSessionGateway):
- def __init__(self, session: AuthAsyncSession) -> None:
- self._session = session
-
- def add(self, auth_session: AuthSession) -> None:
- """:raises DataMapperError:"""
- try:
- self._session.add(auth_session)
- except SQLAlchemyError as err:
- raise DataMapperError(DB_QUERY_FAILED) from err
-
- async def read_by_id(
- self,
- auth_session_id: str,
- for_update: bool = False,
- ) -> AuthSession | None:
- """:raises DataMapperError:"""
- try:
- return await self._session.get(
- AuthSession,
- auth_session_id,
- with_for_update=for_update,
- )
- except SQLAlchemyError as err:
- raise DataMapperError(DB_QUERY_FAILED) from err
-
- async def update(self, auth_session: AuthSession) -> None:
- """:raises DataMapperError:"""
- try:
- await self._session.merge(auth_session)
- except SQLAlchemyError as err:
- raise DataMapperError(DB_QUERY_FAILED) from err
-
- async def delete(self, auth_session_id: str) -> None:
- """:raises DataMapperError:"""
- stmt = delete(AuthSession).where(AuthSession.id_ == auth_session_id) # type: ignore
- try:
- await self._session.execute(stmt)
- except SQLAlchemyError as err:
- raise DataMapperError(DB_QUERY_FAILED) from err
-
- async def delete_all_for_user(self, user_id: UserId) -> None:
- """:raises DataMapperError:"""
- stmt = delete(AuthSession).where(AuthSession.user_id == user_id) # type: ignore
- try:
- await self._session.execute(stmt)
- except SQLAlchemyError as err:
- raise DataMapperError(DB_QUERY_FAILED) from err
diff --git a/src/app/infrastructure/auth/adapters/identity_provider.py b/src/app/infrastructure/auth/adapters/identity_provider.py
deleted file mode 100644
index a44e60a0..00000000
--- a/src/app/infrastructure/auth/adapters/identity_provider.py
+++ /dev/null
@@ -1,12 +0,0 @@
-from app.application.common.ports.identity_provider import IdentityProvider
-from app.domain.value_objects.user_id import UserId
-from app.infrastructure.auth.session.service import AuthSessionService
-
-
-class AuthSessionIdentityProvider(IdentityProvider):
- def __init__(self, auth_session_service: AuthSessionService) -> None:
- self._auth_session_service = auth_session_service
-
- async def get_current_user_id(self) -> UserId:
- """:raises AuthenticationError:"""
- return await self._auth_session_service.get_authenticated_user_id()
diff --git a/src/app/infrastructure/auth/adapters/transaction_manager_sqla.py b/src/app/infrastructure/auth/adapters/transaction_manager_sqla.py
deleted file mode 100644
index 2e2c7d2e..00000000
--- a/src/app/infrastructure/auth/adapters/transaction_manager_sqla.py
+++ /dev/null
@@ -1,30 +0,0 @@
-import logging
-
-from sqlalchemy.exc import SQLAlchemyError
-
-from app.infrastructure.adapters.constants import (
- DB_COMMIT_DONE,
- DB_COMMIT_FAILED,
- DB_QUERY_FAILED,
-)
-from app.infrastructure.auth.adapters.types import AuthAsyncSession
-from app.infrastructure.auth.session.ports.transaction_manager import (
- AuthSessionTransactionManager,
-)
-from app.infrastructure.exceptions.gateway import DataMapperError
-
-log = logging.getLogger(__name__)
-
-
-class SqlaAuthSessionTransactionManager(AuthSessionTransactionManager):
- def __init__(self, session: AuthAsyncSession) -> None:
- self._session = session
-
- async def commit(self) -> None:
- """:raises DataMapperError:"""
- try:
- await self._session.commit()
- log.debug("%s Auth session.", DB_COMMIT_DONE)
-
- except SQLAlchemyError as err:
- raise DataMapperError(f"{DB_QUERY_FAILED} {DB_COMMIT_FAILED}") from err
diff --git a/src/app/infrastructure/auth/exceptions.py b/src/app/infrastructure/auth/exceptions.py
deleted file mode 100644
index 82176ce5..00000000
--- a/src/app/infrastructure/auth/exceptions.py
+++ /dev/null
@@ -1,17 +0,0 @@
-from app.infrastructure.exceptions.base import InfrastructureError
-
-
-class AuthenticationError(InfrastructureError):
- pass
-
-
-class AlreadyAuthenticatedError(InfrastructureError):
- pass
-
-
-class ReAuthenticationError(InfrastructureError):
- pass
-
-
-class AuthenticationChangeError(InfrastructureError):
- pass
diff --git a/src/app/infrastructure/auth/handlers/change_password.py b/src/app/infrastructure/auth/handlers/change_password.py
deleted file mode 100644
index cdc40d29..00000000
--- a/src/app/infrastructure/auth/handlers/change_password.py
+++ /dev/null
@@ -1,75 +0,0 @@
-import logging
-from dataclasses import dataclass
-
-from app.application.common.ports.transaction_manager import (
- TransactionManager,
-)
-from app.application.common.services.current_user import CurrentUserService
-from app.domain.services.user import UserService
-from app.domain.value_objects.raw_password import RawPassword
-from app.infrastructure.auth.exceptions import (
- AuthenticationChangeError,
- ReAuthenticationError,
-)
-from app.infrastructure.auth.handlers.constants import (
- AUTH_PASSWORD_INVALID,
- AUTH_PASSWORD_NEW_SAME_AS_CURRENT,
-)
-
-log = logging.getLogger(__name__)
-
-
-@dataclass(frozen=True, slots=True, kw_only=True)
-class ChangePasswordRequest:
- current_password: str
- new_password: str
-
-
-class ChangePasswordHandler:
- """
- - Open to authenticated users.
- - The current user can change their password.
- - New password must differ from current password.
- """
-
- def __init__(
- self,
- current_user_service: CurrentUserService,
- user_service: UserService,
- transaction_manager: TransactionManager,
- ) -> None:
- self._current_user_service = current_user_service
- self._user_service = user_service
- self._transaction_manager = transaction_manager
-
- async def execute(self, request_data: ChangePasswordRequest) -> None:
- """
- :raises AuthenticationError:
- :raises DataMapperError:
- :raises AuthorizationError:
- :raises DomainTypeError:
- :raises AuthenticationChangeError:
- :raises ReAuthenticationError:
- :raises PasswordHasherBusyError:
- """
- log.info("Change password: started.")
-
- current_user = await self._current_user_service.get_current_user(
- for_update=True
- )
-
- current_password = RawPassword(request_data.current_password)
- new_password = RawPassword(request_data.new_password)
- if current_password == new_password:
- raise AuthenticationChangeError(AUTH_PASSWORD_NEW_SAME_AS_CURRENT)
-
- if not await self._user_service.is_password_valid(
- current_user,
- current_password,
- ):
- raise ReAuthenticationError(AUTH_PASSWORD_INVALID)
-
- await self._user_service.change_password(current_user, new_password)
- await self._transaction_manager.commit()
-
- log.info("Change password: done. User ID: '%s'.", current_user.id_.value)
diff --git a/src/app/infrastructure/auth/handlers/constants.py b/src/app/infrastructure/auth/handlers/constants.py
deleted file mode 100644
index 6a422343..00000000
--- a/src/app/infrastructure/auth/handlers/constants.py
+++ /dev/null
@@ -1,10 +0,0 @@
-from typing import Final
-
-AUTH_ACCOUNT_INACTIVE: Final[str] = "Your account is inactive. Please contact support."
-AUTH_ALREADY_AUTHENTICATED: Final[str] = (
- "You are already authenticated. Consider logging out."
-)
-AUTH_PASSWORD_INVALID: Final[str] = "Invalid password."
-AUTH_PASSWORD_NEW_SAME_AS_CURRENT: Final[str] = (
- "New password must differ from current password."
-)
diff --git a/src/app/infrastructure/auth/handlers/log_in.py b/src/app/infrastructure/auth/handlers/log_in.py
deleted file mode 100644
index 281fbb0a..00000000
--- a/src/app/infrastructure/auth/handlers/log_in.py
+++ /dev/null
@@ -1,95 +0,0 @@
-import logging
-from dataclasses import dataclass
-
-from app.application.common.ports.user_command_gateway import UserCommandGateway
-from app.application.common.services.current_user import CurrentUserService
-from app.domain.entities.user import User
-from app.domain.exceptions.user import UserNotFoundByUsernameError
-from app.domain.services.user import UserService
-from app.domain.value_objects.raw_password import RawPassword
-from app.domain.value_objects.username import Username
-from app.infrastructure.auth.exceptions import (
- AlreadyAuthenticatedError,
- AuthenticationError,
-)
-from app.infrastructure.auth.handlers.constants import (
- AUTH_ACCOUNT_INACTIVE,
- AUTH_ALREADY_AUTHENTICATED,
- AUTH_PASSWORD_INVALID,
-)
-from app.infrastructure.auth.session.service import AuthSessionService
-
-log = logging.getLogger(__name__)
-
-
-@dataclass(frozen=True, slots=True, kw_only=True)
-class LogInRequest:
- username: str
- password: str
-
-
-class LogInHandler:
- """
- - Open to everyone.
- - Authenticates registered user,
- sets a JWT access token with a session ID in cookies,
- and creates a session.
- - A logged-in user cannot log in again
- until the session expires or is terminated.
- - Authentication renews automatically
- when accessing protected routes before expiration.
- - If the JWT is invalid, expired, or the session is terminated,
- the user loses authentication.
- """
-
- def __init__(
- self,
- current_user_service: CurrentUserService,
- user_command_gateway: UserCommandGateway,
- user_service: UserService,
- auth_session_service: AuthSessionService,
- ) -> None:
- self._current_user_service = current_user_service
- self._user_command_gateway = user_command_gateway
- self._user_service = user_service
- self._auth_session_service = auth_session_service
-
- async def execute(self, request_data: LogInRequest) -> None:
- """
- :raises AlreadyAuthenticatedError:
- :raises AuthorizationError:
- :raises DataMapperError:
- :raises DomainTypeError:
- :raises UserNotFoundByUsernameError:
- :raises PasswordHasherBusyError:
- :raises AuthenticationError:
- """
- log.info("Log in: started. Username: '%s'.", request_data.username)
-
- try:
- await self._current_user_service.get_current_user()
- raise AlreadyAuthenticatedError(AUTH_ALREADY_AUTHENTICATED)
- except AuthenticationError:
- pass
-
- username = Username(request_data.username)
- password = RawPassword(request_data.password)
-
- user: User | None = await self._user_command_gateway.read_by_username(username)
- if user is None:
- raise UserNotFoundByUsernameError(username)
-
- if not await self._user_service.is_password_valid(user, password):
- raise AuthenticationError(AUTH_PASSWORD_INVALID)
-
- if not user.is_active:
- raise AuthenticationError(AUTH_ACCOUNT_INACTIVE)
-
- await self._auth_session_service.issue_session(user.id_)
-
- log.info(
- "Log in: done. User, ID: '%s', username '%s', role '%s'.",
- user.id_.value,
- user.username.value,
- user.role.value,
- )
diff --git a/src/app/infrastructure/auth/handlers/log_out.py b/src/app/infrastructure/auth/handlers/log_out.py
deleted file mode 100644
index b70d6e49..00000000
--- a/src/app/infrastructure/auth/handlers/log_out.py
+++ /dev/null
@@ -1,38 +0,0 @@
-import logging
-
-from app.application.common.services.current_user import CurrentUserService
-from app.infrastructure.auth.session.service import AuthSessionService
-
-log = logging.getLogger(__name__)
-
-
-class LogOutHandler:
- """
- - Open to authenticated users.
- - Logs the user out by deleting the JWT access token from cookies
- and removing the session from the database.
- """
-
- def __init__(
- self,
- current_user_service: CurrentUserService,
- auth_session_service: AuthSessionService,
- ) -> None:
- self._current_user_service = current_user_service
- self._auth_session_service = auth_session_service
-
- async def execute(self) -> None:
- """
- :raises AuthenticationError:
- :raises DataMapperError:
- :raises AuthorizationError:
- """
- log.info("Log out: started for unknown user.")
-
- current_user = await self._current_user_service.get_current_user()
-
- log.info("Log out: user identified. User ID: '%s'.", current_user.id_)
-
- await self._auth_session_service.terminate_current_session()
-
- log.info("Log out: done. User ID: '%s'.", current_user.id_)
diff --git a/src/app/infrastructure/auth/handlers/sign_up.py b/src/app/infrastructure/auth/handlers/sign_up.py
deleted file mode 100644
index f58da98f..00000000
--- a/src/app/infrastructure/auth/handlers/sign_up.py
+++ /dev/null
@@ -1,90 +0,0 @@
-import logging
-from dataclasses import dataclass
-from typing import TypedDict
-from uuid import UUID
-
-from app.application.common.ports.flusher import Flusher
-from app.application.common.ports.transaction_manager import TransactionManager
-from app.application.common.ports.user_command_gateway import UserCommandGateway
-from app.application.common.services.current_user import CurrentUserService
-from app.domain.exceptions.user import UsernameAlreadyExistsError
-from app.domain.services.user import UserService
-from app.domain.value_objects.raw_password import RawPassword
-from app.domain.value_objects.username import Username
-from app.infrastructure.auth.exceptions import (
- AlreadyAuthenticatedError,
- AuthenticationError,
-)
-from app.infrastructure.auth.handlers.constants import (
- AUTH_ALREADY_AUTHENTICATED,
-)
-
-log = logging.getLogger(__name__)
-
-
-@dataclass(frozen=True, slots=True, kw_only=True)
-class SignUpRequest:
- username: str
- password: str
-
-
-class SignUpResponse(TypedDict):
- id: UUID
-
-
-class SignUpHandler:
- """
- - Open to everyone.
- - Registers a new user with validation and uniqueness checks.
- - Passwords are peppered, salted, and stored as hashes.
- - A logged-in user cannot sign up until the session expires or is terminated.
- """
-
- def __init__(
- self,
- current_user_service: CurrentUserService,
- user_service: UserService,
- user_command_gateway: UserCommandGateway,
- flusher: Flusher,
- transaction_manager: TransactionManager,
- ) -> None:
- self._current_user_service = current_user_service
- self._user_service = user_service
- self._user_command_gateway = user_command_gateway
- self._flusher = flusher
- self._transaction_manager = transaction_manager
-
- async def execute(self, request_data: SignUpRequest) -> SignUpResponse:
- """
- :raises AlreadyAuthenticatedError:
- :raises AuthorizationError:
- :raises DataMapperError:
- :raises DomainTypeError:
- :raises PasswordHasherBusyError:
- :raises RoleAssignmentNotPermittedError:
- :raises UsernameAlreadyExistsError:
- """
- log.info("Sign up: started. Username: '%s'.", request_data.username)
-
- try:
- await self._current_user_service.get_current_user()
- raise AlreadyAuthenticatedError(AUTH_ALREADY_AUTHENTICATED)
- except AuthenticationError:
- pass
-
- username = Username(request_data.username)
- password = RawPassword(request_data.password)
-
- user = await self._user_service.create_user(username, password)
-
- self._user_command_gateway.add(user)
-
- try:
- await self._flusher.flush()
- except UsernameAlreadyExistsError:
- raise
-
- await self._transaction_manager.commit()
-
- log.info("Sign up: done. Username: '%s'.", user.username.value)
- return SignUpResponse(id=user.id_.value)
diff --git a/src/app/infrastructure/auth/session/id_generator_str.py b/src/app/infrastructure/auth/session/id_generator_str.py
deleted file mode 100644
index 2f16011f..00000000
--- a/src/app/infrastructure/auth/session/id_generator_str.py
+++ /dev/null
@@ -1,6 +0,0 @@
-import secrets
-
-
-class StrAuthSessionIdGenerator:
- def generate(self) -> str:
- return secrets.token_urlsafe(32)
diff --git a/src/app/infrastructure/auth/session/ports/gateway.py b/src/app/infrastructure/auth/session/ports/gateway.py
deleted file mode 100644
index fe78577a..00000000
--- a/src/app/infrastructure/auth/session/ports/gateway.py
+++ /dev/null
@@ -1,32 +0,0 @@
-from abc import abstractmethod
-from typing import Protocol
-
-from app.domain.value_objects.user_id import UserId
-from app.infrastructure.auth.session.model import AuthSession
-
-
-class AuthSessionGateway(Protocol):
- """
- Defined to allow easier mocking and swapping
- of implementations in the same layer.
- """
-
- @abstractmethod
- def add(self, auth_session: AuthSession) -> None:
- """:raises DataMapperError:"""
-
- @abstractmethod
- async def read_by_id(self, auth_session_id: str) -> AuthSession | None:
- """:raises DataMapperError:"""
-
- @abstractmethod
- async def update(self, auth_session: AuthSession) -> None:
- """:raises DataMapperError:"""
-
- @abstractmethod
- async def delete(self, auth_session_id: str) -> None:
- """:raises DataMapperError:"""
-
- @abstractmethod
- async def delete_all_for_user(self, user_id: UserId) -> None:
- """:raises DataMapperError:"""
diff --git a/src/app/infrastructure/auth/session/ports/transaction_manager.py b/src/app/infrastructure/auth/session/ports/transaction_manager.py
deleted file mode 100644
index 3ed24564..00000000
--- a/src/app/infrastructure/auth/session/ports/transaction_manager.py
+++ /dev/null
@@ -1,21 +0,0 @@
-from abc import abstractmethod
-from typing import Protocol
-
-
-class AuthSessionTransactionManager(Protocol):
- """
- Defined to allow easier mocking and swapping
- of implementations in the same layer.
-
- UoW-compatible interface for committing a business transaction.
- May be extended with rollback support.
- The implementation may be an ORM session, such as SQLAlchemy's.
- """
-
- @abstractmethod
- async def commit(self) -> None:
- """
- :raises DataMapperError:
-
- Commit the successful outcome of a business transaction.
- """
diff --git a/src/app/infrastructure/auth/session/ports/transport.py b/src/app/infrastructure/auth/session/ports/transport.py
deleted file mode 100644
index 8b9350cb..00000000
--- a/src/app/infrastructure/auth/session/ports/transport.py
+++ /dev/null
@@ -1,15 +0,0 @@
-from abc import abstractmethod
-from typing import Protocol
-
-from app.infrastructure.auth.session.model import AuthSession
-
-
-class AuthSessionTransport(Protocol):
- @abstractmethod
- def deliver(self, auth_session: AuthSession) -> None: ...
-
- @abstractmethod
- def extract_id(self) -> str | None: ...
-
- @abstractmethod
- def remove_current(self) -> None: ...
diff --git a/src/app/infrastructure/auth/session/service.py b/src/app/infrastructure/auth/session/service.py
deleted file mode 100644
index 8cf7e28b..00000000
--- a/src/app/infrastructure/auth/session/service.py
+++ /dev/null
@@ -1,242 +0,0 @@
-import logging
-from datetime import datetime
-from typing import Final
-
-from app.domain.value_objects.user_id import UserId
-from app.infrastructure.auth.exceptions import AuthenticationError
-from app.infrastructure.auth.session.id_generator_str import (
- StrAuthSessionIdGenerator,
-)
-from app.infrastructure.auth.session.model import AuthSession
-from app.infrastructure.auth.session.ports.gateway import (
- AuthSessionGateway,
-)
-from app.infrastructure.auth.session.ports.transaction_manager import (
- AuthSessionTransactionManager,
-)
-from app.infrastructure.auth.session.ports.transport import AuthSessionTransport
-from app.infrastructure.auth.session.timer_utc import UtcAuthSessionTimer
-from app.infrastructure.exceptions.gateway import DataMapperError
-
-log = logging.getLogger(__name__)
-
-AUTH_UNAVAILABLE: Final[str] = (
- "Authentication is currently unavailable. Please try again later."
-)
-AUTH_NOT_AUTHENTICATED: Final[str] = "Not authenticated."
-AUTH_SESSION_EXPIRED: Final[str] = "Auth session expired."
-AUTH_SESSION_EXTENSION_FAILED: Final[str] = "Auth session extension failed."
-AUTH_SESSION_EXTRACTION_FAILED: Final[str] = "Auth session extraction failed."
-AUTH_SESSION_NOT_FOUND: Final[str] = "Auth session not found."
-
-
-class AuthSessionService:
- def __init__(
- self,
- auth_session_gateway: AuthSessionGateway,
- auth_session_transport: AuthSessionTransport,
- auth_transaction_manager: AuthSessionTransactionManager,
- auth_session_id_generator: StrAuthSessionIdGenerator,
- auth_session_timer: UtcAuthSessionTimer,
- ) -> None:
- self._auth_session_gateway = auth_session_gateway
- self._auth_session_transport = auth_session_transport
- self._auth_transaction_manager = auth_transaction_manager
- self._auth_session_id_generator = auth_session_id_generator
- self._auth_session_timer = auth_session_timer
- self._cached_auth_session: AuthSession | None = None
-
- async def issue_session(self, user_id: UserId) -> None:
- """:raises AuthenticationError:"""
- log.debug("Issue auth session: started. User ID: '%s'.", user_id.value)
-
- auth_session_id: str = self._auth_session_id_generator.generate()
- expiration: datetime = self._auth_session_timer.auth_session_expiration
- auth_session = AuthSession(
- id_=auth_session_id,
- user_id=user_id,
- expiration=expiration,
- )
-
- try:
- self._auth_session_gateway.add(auth_session)
- await self._auth_transaction_manager.commit()
-
- except DataMapperError as err:
- raise AuthenticationError(AUTH_UNAVAILABLE) from err
-
- self._auth_session_transport.deliver(auth_session)
-
- log.debug(
- "Issue auth session: done. User ID: '%s', Auth session ID: '%s'.",
- user_id.value,
- auth_session.id_,
- )
-
- async def get_authenticated_user_id(self) -> UserId:
- """:raises AuthenticationError:"""
- log.debug("Get authenticated user ID: started.")
-
- raw_auth_session = await self._get_current_auth_session()
- valid_auth_session = await self._validate_and_extend_session(raw_auth_session)
- self._cached_auth_session = valid_auth_session
-
- log.debug(
- "Get authenticated user ID: done. Auth session ID: '%s'. User ID: '%s'.",
- valid_auth_session.id_,
- valid_auth_session.user_id.value,
- )
- return valid_auth_session.user_id
-
- async def terminate_current_session(self) -> None:
- log.debug("Terminate current session: started. Auth session ID: unknown.")
-
- auth_session_id: str | None
- if self._cached_auth_session is not None:
- auth_session_id = self._cached_auth_session.id_
- log.debug(
- "Terminate current session: using ID from cache. "
- "Auth session ID: '%s'.",
- auth_session_id,
- )
- else:
- auth_session_id = self._auth_session_transport.extract_id()
- if auth_session_id is None:
- log.warning(
- "Terminate current session failed: partially failed. "
- "Session ID can't be extracted from transport. "
- "Auth session can't be identified.",
- )
- return
- log.debug(
- "Terminate current session: using ID from transport. "
- "Auth session ID: '%s'.",
- auth_session_id,
- )
-
- self._auth_session_transport.remove_current()
-
- try:
- await self._auth_session_gateway.delete(auth_session_id)
- await self._auth_transaction_manager.commit()
- log.debug(
- "Terminate current session: done (transport cleared, storage deleted). "
- "Auth session ID: '%s'.",
- auth_session_id,
- )
-
- except DataMapperError:
- log.warning(
- "Terminate current session: partially failed "
- "(transport cleared, storage delete failed). "
- "Auth session ID: '%s'.",
- auth_session_id,
- )
-
- self._cached_auth_session = None
-
- async def terminate_all_sessions_for_user(self, user_id: UserId) -> None:
- """:raises DataMapperError:"""
- log.debug(
- "Terminate all sessions for user: started. User ID: '%s'.",
- user_id.value,
- )
-
- await self._auth_session_gateway.delete_all_for_user(user_id)
- await self._auth_transaction_manager.commit()
-
- if self._cached_auth_session and self._cached_auth_session.user_id == user_id:
- self._auth_session_transport.remove_current()
- self._cached_auth_session = None
-
- log.debug(
- "Terminate all sessions for user: done. User ID: '%s'.",
- user_id.value,
- )
-
- async def _get_current_auth_session(self) -> AuthSession:
- """:raises AuthenticationError:"""
- log.debug("Get current auth session: started. Auth session ID: unknown.")
-
- if self._cached_auth_session is not None:
- log.debug(
- "Get current auth session: done (from cache). Auth session ID: '%s'.",
- self._cached_auth_session.id_,
- )
- return self._cached_auth_session
-
- auth_session_id: str | None = self._auth_session_transport.extract_id()
- if auth_session_id is None:
- log.debug(AUTH_SESSION_NOT_FOUND)
- raise AuthenticationError(AUTH_NOT_AUTHENTICATED)
-
- log.debug(
- "Get current auth session: reading from storage. Auth session ID: '%s'.",
- auth_session_id,
- )
-
- try:
- auth_session: (
- AuthSession | None
- ) = await self._auth_session_gateway.read_by_id(auth_session_id)
-
- except DataMapperError as err:
- log.error("%s: '%s'", AUTH_SESSION_EXTRACTION_FAILED, err)
- raise AuthenticationError(AUTH_NOT_AUTHENTICATED) from err
-
- if auth_session is None:
- log.debug(AUTH_SESSION_NOT_FOUND)
- raise AuthenticationError(AUTH_NOT_AUTHENTICATED)
-
- log.debug(
- "Get current auth session: done. Auth session ID: '%s'.", auth_session.id_
- )
- return auth_session
-
- async def _validate_and_extend_session(
- self,
- auth_session: AuthSession,
- ) -> AuthSession:
- """:raises AuthenticationError:"""
- log.debug(
- "Validate and extend auth session: started. Auth session ID: '%s'.",
- auth_session.id_,
- )
-
- now = self._auth_session_timer.current_time
- if auth_session.expiration <= now:
- log.debug(AUTH_SESSION_EXPIRED)
- raise AuthenticationError(AUTH_NOT_AUTHENTICATED)
-
- if (
- auth_session.expiration - now
- > self._auth_session_timer.refresh_trigger_interval
- ):
- log.debug(
- "Validate and extend auth session: validated without extension. "
- "Auth session ID: '%s'.",
- auth_session.id_,
- )
- return auth_session
-
- original_expiration = auth_session.expiration
- auth_session.expiration = self._auth_session_timer.auth_session_expiration
-
- try:
- await self._auth_session_gateway.update(auth_session)
- await self._auth_transaction_manager.commit()
-
- except DataMapperError as err:
- log.error("%s: '%s'", AUTH_SESSION_EXTENSION_FAILED, err)
- auth_session.expiration = original_expiration
- return auth_session
-
- self._auth_session_transport.deliver(auth_session)
-
- log.debug(
- "Validate and extend auth session: done. "
- "Auth session ID: '%s'. New expiration: '%s'.",
- auth_session.id_,
- auth_session.expiration.isoformat(),
- )
- return auth_session
diff --git a/src/app/infrastructure/auth/session/timer_utc.py b/src/app/infrastructure/auth/session/timer_utc.py
deleted file mode 100644
index 439fd89d..00000000
--- a/src/app/infrastructure/auth/session/timer_utc.py
+++ /dev/null
@@ -1,19 +0,0 @@
-from datetime import UTC, datetime, timedelta
-
-
-class UtcAuthSessionTimer:
- def __init__(self, ttl_min: timedelta, refresh_threshold: float) -> None:
- self._ttl_min = ttl_min
- self._refresh_threshold = refresh_threshold
-
- @property
- def current_time(self) -> datetime:
- return datetime.now(tz=UTC)
-
- @property
- def auth_session_expiration(self) -> datetime:
- return self.current_time + self._ttl_min
-
- @property
- def refresh_trigger_interval(self) -> timedelta:
- return self._ttl_min * self._refresh_threshold
diff --git a/src/app/domain/services/__init__.py b/src/app/infrastructure/auth_ctx/__init__.py
similarity index 100%
rename from src/app/domain/services/__init__.py
rename to src/app/infrastructure/auth_ctx/__init__.py
diff --git a/src/app/infrastructure/auth_ctx/cookie_manager.py b/src/app/infrastructure/auth_ctx/cookie_manager.py
new file mode 100644
index 00000000..d165aa58
--- /dev/null
+++ b/src/app/infrastructure/auth_ctx/cookie_manager.py
@@ -0,0 +1,22 @@
+from typing import Final, NewType
+
+from starlette.requests import Request
+
+CookieName = NewType("CookieName", str)
+
+STAGED_COOKIE: Final[str] = "staged_cookie"
+
+
+class CookieManager:
+ def __init__(self, request: Request, cookie_name: CookieName) -> None:
+ self._request = request
+ self._cookie_name = cookie_name
+
+ def read(self) -> str | None:
+ return self._request.cookies.get(self._cookie_name)
+
+ def stage_set(self, value: str) -> None:
+ setattr(self._request.state, STAGED_COOKIE, value)
+
+ def stage_delete(self) -> None:
+ setattr(self._request.state, STAGED_COOKIE, None)
diff --git a/src/app/infrastructure/auth_ctx/exceptions.py b/src/app/infrastructure/auth_ctx/exceptions.py
new file mode 100644
index 00000000..94178594
--- /dev/null
+++ b/src/app/infrastructure/auth_ctx/exceptions.py
@@ -0,0 +1,19 @@
+from typing import ClassVar
+
+from app.core.common.exceptions import BaseError
+
+
+class AuthenticationError(BaseError):
+ default_message: ClassVar[str] = "Not authenticated."
+
+
+class AlreadyAuthenticatedError(BaseError):
+ default_message: ClassVar[str] = "You are already authenticated. Consider logging out."
+
+
+class ReAuthenticationError(BaseError):
+ default_message: ClassVar[str] = "Invalid password."
+
+
+class AuthenticationChangeError(BaseError):
+ default_message: ClassVar[str] = "New password must differ from current password."
diff --git a/src/app/domain/value_objects/__init__.py b/src/app/infrastructure/auth_ctx/handlers/__init__.py
similarity index 100%
rename from src/app/domain/value_objects/__init__.py
rename to src/app/infrastructure/auth_ctx/handlers/__init__.py
diff --git a/src/app/infrastructure/auth_ctx/handlers/change_password.py b/src/app/infrastructure/auth_ctx/handlers/change_password.py
new file mode 100644
index 00000000..43c25165
--- /dev/null
+++ b/src/app/infrastructure/auth_ctx/handlers/change_password.py
@@ -0,0 +1,61 @@
+import logging
+from dataclasses import dataclass
+
+from app.core.commands.ports.transaction_manager import TransactionManager
+from app.core.commands.ports.utc_timer import UtcTimer
+from app.core.common.authorization.current_user_service import CurrentUserService
+from app.core.common.services.user import UserService
+from app.core.common.value_objects.raw_password import RawPassword
+from app.infrastructure.auth_ctx.exceptions import (
+ AuthenticationChangeError,
+ ReAuthenticationError,
+)
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass(frozen=True, slots=True, kw_only=True)
+class ChangePasswordRequest:
+ current_password: str
+ new_password: str
+
+
+class ChangePassword:
+ """
+ - Open to authenticated users.
+ - Current user can change their password.
+ - New password must differ from current password.
+ """
+
+ def __init__(
+ self,
+ current_user_service: CurrentUserService,
+ user_service: UserService,
+ utc_timer: UtcTimer,
+ transaction_manager: TransactionManager,
+ ) -> None:
+ self._current_user_service = current_user_service
+ self._user_service = user_service
+ self._utc_timer = utc_timer
+ self._transaction_manager = transaction_manager
+
+ async def execute(self, request: ChangePasswordRequest) -> None:
+ logger.info("Change password: started.")
+
+ current_user = await self._current_user_service.get_current_user(for_update=True)
+ current_password = RawPassword(request.current_password)
+ new_password = RawPassword(request.new_password)
+ if current_password == new_password:
+ raise AuthenticationChangeError
+
+ if not await self._user_service.is_password_valid(current_user, current_password):
+ raise ReAuthenticationError
+
+ await self._user_service.change_password(
+ current_user,
+ new_password,
+ now=self._utc_timer.now,
+ )
+ await self._transaction_manager.commit()
+
+ logger.info("Change password: done.")
diff --git a/src/app/infrastructure/auth_ctx/handlers/log_in.py b/src/app/infrastructure/auth_ctx/handlers/log_in.py
new file mode 100644
index 00000000..5f9eb101
--- /dev/null
+++ b/src/app/infrastructure/auth_ctx/handlers/log_in.py
@@ -0,0 +1,73 @@
+import logging
+from dataclasses import dataclass
+from typing import Final
+
+from app.core.commands.exceptions import UserNotFoundError
+from app.core.common.authorization.current_user_service import CurrentUserService
+from app.core.common.services.user import UserService
+from app.core.common.value_objects.raw_password import RawPassword
+from app.core.common.value_objects.username import Username
+from app.infrastructure.auth_ctx.exceptions import (
+ AlreadyAuthenticatedError,
+ AuthenticationError,
+)
+from app.infrastructure.auth_ctx.service import AuthService
+from app.infrastructure.auth_ctx.sqla_user_tx_storage import AuthSqlaUserTxStorage
+
+AUTH_ACCOUNT_INACTIVE: Final[str] = "Your account is inactive. Please contact support."
+AUTH_PASSWORD_INVALID: Final[str] = "Invalid password." # noqa: S105
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass(frozen=True, slots=True, kw_only=True)
+class LogInRequest:
+ username: str
+ password: str
+
+
+class LogIn:
+ """
+ - Open to everyone.
+ - Authenticates registered user, sets JWT with session ID in cookies, and creates session.
+ - Logged-in user cannot log in again until session expires or is terminated.
+ - Authentication renews automatically when accessing protected routes before expiration.
+ - If JWT is invalid, expired, or session is terminated, user loses authentication.
+ """
+
+ def __init__(
+ self,
+ current_user_service: CurrentUserService,
+ user_tx_storage: AuthSqlaUserTxStorage,
+ user_service: UserService,
+ auth_service: AuthService,
+ ) -> None:
+ self._current_user_service = current_user_service
+ self._user_tx_storage = user_tx_storage
+ self._user_service = user_service
+ self._auth_service = auth_service
+
+ async def execute(self, request: LogInRequest) -> None:
+ logger.info("Log in: started.")
+
+ try:
+ await self._current_user_service.get_current_user()
+ raise AlreadyAuthenticatedError
+ except AuthenticationError:
+ pass
+
+ username = Username(request.username)
+ password = RawPassword(request.password)
+ user = await self._user_tx_storage.get_by_username(username)
+ if user is None:
+ raise UserNotFoundError
+
+ if not await self._user_service.is_password_valid(user, password):
+ raise AuthenticationError(AUTH_PASSWORD_INVALID)
+
+ if not user.is_active:
+ raise AuthenticationError(AUTH_ACCOUNT_INACTIVE)
+
+ await self._auth_service.issue_session(user.id_)
+
+ logger.info("Log in: done.")
diff --git a/src/app/infrastructure/auth_ctx/handlers/log_out.py b/src/app/infrastructure/auth_ctx/handlers/log_out.py
new file mode 100644
index 00000000..3b141afd
--- /dev/null
+++ b/src/app/infrastructure/auth_ctx/handlers/log_out.py
@@ -0,0 +1,29 @@
+import logging
+
+from app.core.common.authorization.current_user_service import CurrentUserService
+from app.infrastructure.auth_ctx.service import AuthService
+
+logger = logging.getLogger(__name__)
+
+
+class LogOut:
+ """
+ - Open to authenticated users.
+ - Logs user out by deleting JWT from cookies and removing session from database.
+ """
+
+ def __init__(
+ self,
+ current_user_service: CurrentUserService,
+ auth_service: AuthService,
+ ) -> None:
+ self._current_user_service = current_user_service
+ self._auth_service = auth_service
+
+ async def execute(self) -> None:
+ logger.info("Log out: started.")
+
+ await self._current_user_service.get_current_user()
+ await self._auth_service.logout_current_session()
+
+ logger.info("Log out: done.")
diff --git a/src/app/infrastructure/auth_ctx/handlers/sign_up.py b/src/app/infrastructure/auth_ctx/handlers/sign_up.py
new file mode 100644
index 00000000..8a21a1c1
--- /dev/null
+++ b/src/app/infrastructure/auth_ctx/handlers/sign_up.py
@@ -0,0 +1,78 @@
+import logging
+from dataclasses import dataclass
+
+from app.core.commands.exceptions import UsernameAlreadyExistsError
+from app.core.commands.ports.flusher import Flusher
+from app.core.commands.ports.transaction_manager import TransactionManager
+from app.core.commands.ports.utc_timer import UtcTimer
+from app.core.common.authorization.current_user_service import CurrentUserService
+from app.core.common.factories.id_factory import create_user_id
+from app.core.common.services.user import UserService
+from app.core.common.value_objects.raw_password import RawPassword
+from app.core.common.value_objects.username import Username
+from app.infrastructure.auth_ctx.exceptions import (
+ AlreadyAuthenticatedError,
+ AuthenticationError,
+)
+from app.infrastructure.auth_ctx.sqla_user_tx_storage import AuthSqlaUserTxStorage
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass(frozen=True, slots=True, kw_only=True)
+class SignUpRequest:
+ username: str
+ password: str
+
+
+class SignUp:
+ """
+ - Open to everyone.
+ - Registers new user with validation and uniqueness checks.
+ - Passwords are peppered, salted, and stored as hashes.
+ - Logged-in user cannot sign up until session expires or is terminated.
+ """
+
+ def __init__(
+ self,
+ current_user_service: CurrentUserService,
+ utc_timer: UtcTimer,
+ user_service: UserService,
+ user_tx_storage: AuthSqlaUserTxStorage,
+ flusher: Flusher,
+ transaction_manager: TransactionManager,
+ ) -> None:
+ self._current_user_service = current_user_service
+ self._utc_timer = utc_timer
+ self._user_service = user_service
+ self._user_tx_storage = user_tx_storage
+ self._flusher = flusher
+ self._transaction_manager = transaction_manager
+
+ async def execute(self, request: SignUpRequest) -> None:
+ logger.info("Sign up: started.")
+
+ try:
+ await self._current_user_service.get_current_user()
+ raise AlreadyAuthenticatedError
+ except AuthenticationError:
+ pass
+
+ username = Username(request.username)
+ password = RawPassword(request.password)
+ now = self._utc_timer.now
+ user = await self._user_service.create_user_with_raw_password(
+ user_id=create_user_id(),
+ username=username,
+ raw_password=password,
+ now=now,
+ )
+ self._user_tx_storage.add(user)
+ try:
+ await self._flusher.flush()
+ except UsernameAlreadyExistsError:
+ raise
+
+ await self._transaction_manager.commit()
+
+ logger.info("Sign up: done.")
diff --git a/src/app/infrastructure/auth_ctx/id_factory.py b/src/app/infrastructure/auth_ctx/id_factory.py
new file mode 100644
index 00000000..b936c7f9
--- /dev/null
+++ b/src/app/infrastructure/auth_ctx/id_factory.py
@@ -0,0 +1,7 @@
+import secrets
+
+from app.infrastructure.auth_ctx.model import SessionId
+
+
+def create_session_id(value: str | None = None) -> SessionId:
+ return SessionId(value if value is not None else secrets.token_urlsafe(32))
diff --git a/src/app/infrastructure/auth_ctx/jwt_processor.py b/src/app/infrastructure/auth_ctx/jwt_processor.py
new file mode 100644
index 00000000..340da028
--- /dev/null
+++ b/src/app/infrastructure/auth_ctx/jwt_processor.py
@@ -0,0 +1,33 @@
+from typing import Any, ClassVar
+
+import jwt
+
+from app.infrastructure.auth_ctx.jwt_types import JwtAlgorithm
+from app.infrastructure.auth_ctx.model import AuthSession, SessionId
+
+
+class JwtProcessor:
+ SESSION_ID_CLAIM: ClassVar[str] = "sid"
+ EXPIRATION_CLAIM: ClassVar[str] = "exp"
+
+ def __init__(self, secret: str, algorithm: JwtAlgorithm) -> None:
+ self._secret = secret
+ self._algorithm = algorithm
+
+ def encode(self, auth_session: AuthSession) -> str:
+ payload: dict[str, Any] = {
+ self.SESSION_ID_CLAIM: auth_session.id_,
+ self.EXPIRATION_CLAIM: auth_session.expiration.value.timestamp(),
+ }
+ return jwt.encode(payload, key=self._secret, algorithm=self._algorithm)
+
+ def decode_session_id(self, token: str) -> SessionId | None:
+ try:
+ payload = jwt.decode(token, key=self._secret, algorithms=[self._algorithm])
+ except jwt.PyJWTError:
+ return None
+
+ value = payload.get(self.SESSION_ID_CLAIM)
+ if not isinstance(value, str):
+ return None
+ return SessionId(value)
diff --git a/src/app/infrastructure/auth_ctx/jwt_types.py b/src/app/infrastructure/auth_ctx/jwt_types.py
new file mode 100644
index 00000000..50822ee7
--- /dev/null
+++ b/src/app/infrastructure/auth_ctx/jwt_types.py
@@ -0,0 +1,10 @@
+from typing import Literal
+
+type JwtAlgorithm = Literal[
+ "HS256",
+ "HS384",
+ "HS512",
+ "RS256",
+ "RS384",
+ "RS512",
+]
diff --git a/src/app/infrastructure/auth/session/model.py b/src/app/infrastructure/auth_ctx/model.py
similarity index 56%
rename from src/app/infrastructure/auth/session/model.py
rename to src/app/infrastructure/auth_ctx/model.py
index 0ae45246..26212ff9 100644
--- a/src/app/infrastructure/auth/session/model.py
+++ b/src/app/infrastructure/auth_ctx/model.py
@@ -1,7 +1,10 @@
from dataclasses import dataclass
-from datetime import datetime
+from typing import NewType
-from app.domain.value_objects.user_id import UserId
+from app.core.common.entities.types_ import UserId
+from app.core.common.value_objects.utc_datetime import UtcDatetime
+
+SessionId = NewType("SessionId", str)
@dataclass(eq=False, kw_only=True)
@@ -11,9 +14,9 @@ class AuthSession:
a monolithic architecture to become modular, while the other classes working
with it are likely to become application and infrastructure layer components.
- For example, `LogInHandler` can become an interactor.
+ For example, `LogIn` can become an interactor.
"""
- id_: str
+ id_: SessionId
user_id: UserId
- expiration: datetime
+ expiration: UtcDatetime
diff --git a/src/app/infrastructure/auth_ctx/service.py b/src/app/infrastructure/auth_ctx/service.py
new file mode 100644
index 00000000..59d8f04f
--- /dev/null
+++ b/src/app/infrastructure/auth_ctx/service.py
@@ -0,0 +1,74 @@
+from app.core.common.entities.types_ import UserId
+from app.infrastructure.auth_ctx.cookie_manager import CookieManager
+from app.infrastructure.auth_ctx.exceptions import AuthenticationError
+from app.infrastructure.auth_ctx.id_factory import create_session_id
+from app.infrastructure.auth_ctx.jwt_processor import JwtProcessor
+from app.infrastructure.auth_ctx.model import AuthSession, SessionId
+from app.infrastructure.auth_ctx.sqla_transaction_manager import AuthSqlaTransactionManager
+from app.infrastructure.auth_ctx.sqla_tx_storage import AuthSessionSqlaTxStorage
+from app.infrastructure.auth_ctx.utc_timer import AuthSessionUtcTimer
+
+
+class AuthService:
+ def __init__(
+ self,
+ session_timer: AuthSessionUtcTimer,
+ session_tx_storage: AuthSessionSqlaTxStorage,
+ transaction_manager: AuthSqlaTransactionManager,
+ jwt_processor: JwtProcessor,
+ cookie_manager: CookieManager,
+ ) -> None:
+ self._session_timer = session_timer
+ self._session_tx_storage = session_tx_storage
+ self._transaction_manager = transaction_manager
+ self._jwt_processor = jwt_processor
+ self._cookie_manager = cookie_manager
+
+ async def issue_session(self, user_id: UserId) -> None:
+ session = AuthSession(
+ id_=create_session_id(),
+ user_id=user_id,
+ expiration=self._session_timer.expiration_from_now,
+ )
+ self._session_tx_storage.add(session)
+ await self._transaction_manager.commit()
+ token = self._jwt_processor.encode(session)
+ self._cookie_manager.stage_set(token)
+
+ async def get_current_user_id(self) -> UserId:
+ session_id = self._get_session_id()
+ if session_id is None:
+ raise AuthenticationError
+
+ session = await self._session_tx_storage.get_by_id(session_id)
+ if session is None:
+ raise AuthenticationError
+
+ if self._session_timer.is_expired(session):
+ raise AuthenticationError
+
+ if self._session_timer.needs_refresh(session):
+ session.expiration = self._session_timer.expiration_from_now
+ await self._session_tx_storage.update(session)
+ await self._transaction_manager.commit()
+ token = self._jwt_processor.encode(session)
+ self._cookie_manager.stage_set(token)
+
+ return session.user_id
+
+ async def logout_current_session(self) -> None:
+ self._cookie_manager.stage_delete()
+ session_id = self._get_session_id()
+ if session_id is not None:
+ await self._session_tx_storage.delete(session_id)
+ await self._transaction_manager.commit()
+
+ async def revoke_all_sessions(self, user_id: UserId) -> None:
+ await self._session_tx_storage.delete_all_for_user(user_id)
+ await self._transaction_manager.commit()
+
+ def _get_session_id(self) -> SessionId | None:
+ token = self._cookie_manager.read()
+ if token is None:
+ return None
+ return self._jwt_processor.decode_session_id(token)
diff --git a/src/app/infrastructure/auth_ctx/sqla_transaction_manager.py b/src/app/infrastructure/auth_ctx/sqla_transaction_manager.py
new file mode 100644
index 00000000..8cec23df
--- /dev/null
+++ b/src/app/infrastructure/auth_ctx/sqla_transaction_manager.py
@@ -0,0 +1,25 @@
+import logging
+from typing import Final
+
+from sqlalchemy.exc import SQLAlchemyError
+
+from app.infrastructure.auth_ctx.types_ import AuthAsyncSession
+from app.infrastructure.exceptions import StorageError
+
+DB_COMMIT_DONE: Final[str] = "Commit was done."
+DB_COMMIT_FAILED: Final[str] = "Commit failed."
+
+logger = logging.getLogger(__name__)
+
+
+class AuthSqlaTransactionManager:
+ def __init__(self, session: AuthAsyncSession) -> None:
+ self._session = session
+
+ async def commit(self) -> None:
+ try:
+ await self._session.commit()
+ logger.debug("%s.", DB_COMMIT_DONE)
+
+ except SQLAlchemyError as e:
+ raise StorageError(DB_COMMIT_FAILED) from e
diff --git a/src/app/infrastructure/auth_ctx/sqla_tx_storage.py b/src/app/infrastructure/auth_ctx/sqla_tx_storage.py
new file mode 100644
index 00000000..77dab19b
--- /dev/null
+++ b/src/app/infrastructure/auth_ctx/sqla_tx_storage.py
@@ -0,0 +1,54 @@
+from sqlalchemy import delete
+from sqlalchemy.exc import SQLAlchemyError
+
+from app.core.common.entities.types_ import UserId
+from app.infrastructure.auth_ctx.model import AuthSession, SessionId
+from app.infrastructure.auth_ctx.types_ import AuthAsyncSession
+from app.infrastructure.exceptions import StorageError
+from app.infrastructure.persistence_sqla.mappings.auth_session import auth_sessions_table
+
+
+class AuthSessionSqlaTxStorage:
+ def __init__(self, session: AuthAsyncSession) -> None:
+ self._session = session
+
+ def add(self, auth_session: AuthSession) -> None:
+ try:
+ self._session.add(auth_session)
+ except SQLAlchemyError as e:
+ raise StorageError from e
+
+ async def get_by_id(
+ self,
+ session_id: SessionId,
+ *,
+ for_update: bool = False,
+ ) -> AuthSession | None:
+ try:
+ return await self._session.get(
+ AuthSession,
+ session_id,
+ with_for_update=for_update,
+ )
+ except SQLAlchemyError as e:
+ raise StorageError from e
+
+ async def update(self, auth_session: AuthSession) -> None:
+ try:
+ await self._session.merge(auth_session)
+ except SQLAlchemyError as e:
+ raise StorageError from e
+
+ async def delete(self, session_id: SessionId) -> None:
+ stmt = delete(auth_sessions_table).where(auth_sessions_table.c.id == session_id)
+ try:
+ await self._session.execute(stmt)
+ except SQLAlchemyError as e:
+ raise StorageError from e
+
+ async def delete_all_for_user(self, user_id: UserId) -> None:
+ stmt = delete(auth_sessions_table).where(auth_sessions_table.c.user_id == user_id)
+ try:
+ await self._session.execute(stmt)
+ except SQLAlchemyError as e:
+ raise StorageError from e
diff --git a/src/app/infrastructure/auth_ctx/sqla_user_tx_storage.py b/src/app/infrastructure/auth_ctx/sqla_user_tx_storage.py
new file mode 100644
index 00000000..17bf092a
--- /dev/null
+++ b/src/app/infrastructure/auth_ctx/sqla_user_tx_storage.py
@@ -0,0 +1,34 @@
+from sqlalchemy import select
+from sqlalchemy.exc import SQLAlchemyError
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.common.entities.user import User
+from app.core.common.value_objects.username import Username
+from app.infrastructure.exceptions import StorageError
+from app.infrastructure.persistence_sqla.mappings.user import users_table
+
+
+class AuthSqlaUserTxStorage:
+ def __init__(self, session: AsyncSession) -> None:
+ self._session = session
+
+ def add(self, user: User) -> None:
+ try:
+ self._session.add(user)
+ except SQLAlchemyError as e:
+ raise StorageError from e
+
+ async def get_by_username(
+ self,
+ username: Username,
+ *,
+ for_update: bool = False,
+ ) -> User | None:
+ stmt = select(User).where(users_table.c.username == username.value)
+ if for_update:
+ stmt = stmt.with_for_update()
+ try:
+ result = await self._session.execute(stmt)
+ except SQLAlchemyError as e:
+ raise StorageError from e
+ return result.scalar_one_or_none()
diff --git a/src/app/infrastructure/auth/adapters/types.py b/src/app/infrastructure/auth_ctx/types_.py
similarity index 100%
rename from src/app/infrastructure/auth/adapters/types.py
rename to src/app/infrastructure/auth_ctx/types_.py
diff --git a/src/app/infrastructure/auth_ctx/utc_timer.py b/src/app/infrastructure/auth_ctx/utc_timer.py
new file mode 100644
index 00000000..9cbe3a9f
--- /dev/null
+++ b/src/app/infrastructure/auth_ctx/utc_timer.py
@@ -0,0 +1,29 @@
+from datetime import UTC, datetime, timedelta
+
+from app.core.common.value_objects.utc_datetime import UtcDatetime
+from app.infrastructure.auth_ctx.model import AuthSession
+
+
+class AuthSessionUtcTimer:
+ def __init__(
+ self,
+ ttl: timedelta,
+ refresh_threshold_ratio: float,
+ ) -> None:
+ self._ttl = ttl
+ self._refresh_threshold_ratio = refresh_threshold_ratio
+
+ @property
+ def now(self) -> UtcDatetime:
+ return UtcDatetime(datetime.now(UTC))
+
+ @property
+ 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
+
+ def needs_refresh(self, session: AuthSession) -> bool:
+ remaining = session.expiration.value - self.now.value
+ return remaining <= self._ttl * self._refresh_threshold_ratio
diff --git a/src/app/infrastructure/exceptions.py b/src/app/infrastructure/exceptions.py
new file mode 100644
index 00000000..1da0f314
--- /dev/null
+++ b/src/app/infrastructure/exceptions.py
@@ -0,0 +1,9 @@
+from app.core.common.exceptions import BaseError
+
+
+class StorageError(BaseError):
+ pass
+
+
+class ReaderError(BaseError):
+ pass
diff --git a/src/app/infrastructure/exceptions/base.py b/src/app/infrastructure/exceptions/base.py
deleted file mode 100644
index 3d16ea1a..00000000
--- a/src/app/infrastructure/exceptions/base.py
+++ /dev/null
@@ -1,2 +0,0 @@
-class InfrastructureError(Exception):
- pass
diff --git a/src/app/infrastructure/exceptions/gateway.py b/src/app/infrastructure/exceptions/gateway.py
deleted file mode 100644
index c66a0f66..00000000
--- a/src/app/infrastructure/exceptions/gateway.py
+++ /dev/null
@@ -1,9 +0,0 @@
-from app.infrastructure.exceptions.base import InfrastructureError
-
-
-class DataMapperError(InfrastructureError):
- pass
-
-
-class ReaderError(InfrastructureError):
- pass
diff --git a/src/app/infrastructure/exceptions/password_hasher.py b/src/app/infrastructure/exceptions/password_hasher.py
deleted file mode 100644
index 2d902b24..00000000
--- a/src/app/infrastructure/exceptions/password_hasher.py
+++ /dev/null
@@ -1,5 +0,0 @@
-from app.infrastructure.exceptions.base import InfrastructureError
-
-
-class PasswordHasherBusyError(InfrastructureError):
- pass
diff --git a/src/app/infrastructure/persistence_sqla/alembic/README b/src/app/infrastructure/persistence_sqla/alembic/README
index e0d0858f..a23d4fb5 100644
--- a/src/app/infrastructure/persistence_sqla/alembic/README
+++ b/src/app/infrastructure/persistence_sqla/alembic/README
@@ -1 +1 @@
-Generic single-database configuration with an async dbapi.
\ No newline at end of file
+Generic single-database configuration with an async dbapi.
diff --git a/src/app/infrastructure/persistence_sqla/alembic/env.py b/src/app/infrastructure/persistence_sqla/alembic/env.py
index 4258d53d..6988e4e2 100644
--- a/src/app/infrastructure/persistence_sqla/alembic/env.py
+++ b/src/app/infrastructure/persistence_sqla/alembic/env.py
@@ -9,9 +9,10 @@
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
+from app.config.loader import load_postgres_settings
+from app.config.settings import PostgresSettings
from app.infrastructure.persistence_sqla.mappings.all import map_tables
from app.infrastructure.persistence_sqla.registry import mapper_registry
-from app.setup.config.settings import AppSettings, load_settings
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
@@ -33,9 +34,9 @@
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
-settings: AppSettings = load_settings()
+settings: PostgresSettings = load_postgres_settings()
-config.set_main_option("sqlalchemy.url", settings.postgres.dsn)
+config.set_main_option("sqlalchemy.url", settings.dsn)
def run_migrations_offline() -> None:
diff --git a/src/app/infrastructure/persistence_sqla/alembic/script.py.mako b/src/app/infrastructure/persistence_sqla/alembic/script.py.mako
index fbc4b07d..11016301 100644
--- a/src/app/infrastructure/persistence_sqla/alembic/script.py.mako
+++ b/src/app/infrastructure/persistence_sqla/alembic/script.py.mako
@@ -13,14 +13,16 @@ ${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
-down_revision: Union[str, None] = ${repr(down_revision)}
+down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
+ """Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
+ """Downgrade schema."""
${downgrades if downgrades else "pass"}
diff --git a/src/app/infrastructure/persistence_sqla/alembic/versions/2025_06_11_2058-e325187c1eeb_users_auth.py b/src/app/infrastructure/persistence_sqla/alembic/versions/2025_06_11_2058-e325187c1eeb_users_auth.py
deleted file mode 100644
index 766646c5..00000000
--- a/src/app/infrastructure/persistence_sqla/alembic/versions/2025_06_11_2058-e325187c1eeb_users_auth.py
+++ /dev/null
@@ -1,52 +0,0 @@
-"""users,auth
-
-Revision ID: e325187c1eeb
-Revises:
-Create Date: 2025-06-11 20:58:58.908948
-
-"""
-
-from typing import Sequence, Union
-
-from alembic import op
-import sqlalchemy as sa
-from sqlalchemy.dialects import postgresql
-
-# revision identifiers, used by Alembic.
-revision: str = "e325187c1eeb"
-down_revision: Union[str, None] = None
-branch_labels: Union[str, Sequence[str], None] = None
-depends_on: Union[str, Sequence[str], None] = None
-
-
-def upgrade() -> None:
- sa.Enum("SUPER_ADMIN", "ADMIN", "USER", name="userrole").create(op.get_bind())
- op.create_table(
- "auth_sessions",
- sa.Column("id", sa.String(), nullable=False),
- sa.Column("user_id", sa.UUID(), nullable=False),
- sa.Column("expiration", sa.DateTime(timezone=True), nullable=False),
- sa.PrimaryKeyConstraint("id", name=op.f("pk_auth_sessions")),
- )
- op.create_table(
- "users",
- sa.Column("id", sa.UUID(), nullable=False),
- sa.Column("username", sa.String(length=20), nullable=False),
- sa.Column("password_hash", sa.LargeBinary(), nullable=False),
- sa.Column(
- "role",
- postgresql.ENUM(
- "SUPER_ADMIN", "ADMIN", "USER", name="userrole", create_type=False
- ),
- nullable=False,
- ),
- sa.Column("is_active", sa.Boolean(), nullable=False),
- sa.PrimaryKeyConstraint("id", name=op.f("pk_users")),
- sa.UniqueConstraint("username", name=op.f("uq_users_username")),
- )
-
-
-def downgrade() -> None:
- op.drop_table("users")
- op.drop_table("auth_sessions")
- sa.Enum("SUPER_ADMIN", "ADMIN", "USER", name="userrole").drop(op.get_bind())
diff --git a/src/app/infrastructure/persistence_sqla/alembic/versions/2026-03-02_221518_users.py b/src/app/infrastructure/persistence_sqla/alembic/versions/2026-03-02_221518_users.py
new file mode 100644
index 00000000..784e49ad
--- /dev/null
+++ b/src/app/infrastructure/persistence_sqla/alembic/versions/2026-03-02_221518_users.py
@@ -0,0 +1,40 @@
+"""users
+
+Revision ID: c64b121a3428
+Revises:
+Create Date: 2026-03-02 22:15:18.425263
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = "c64b121a3428"
+down_revision: Union[str, Sequence[str], None] = None
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ """Upgrade schema."""
+ op.create_table(
+ "users",
+ sa.Column("id", sa.UUID(), nullable=False),
+ sa.Column("username", sa.String(length=20), nullable=False),
+ sa.Column("password_hash", sa.LargeBinary(), nullable=False),
+ sa.Column("role", sa.Enum("SUPER_ADMIN", "ADMIN", "USER", name="user_role", native_enum=False), nullable=False),
+ sa.Column("is_active", sa.Boolean(), nullable=False),
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
+ sa.PrimaryKeyConstraint("id", name=op.f("pk_users")),
+ sa.UniqueConstraint("username", name=op.f("uq_users_username")),
+ )
+
+
+def downgrade() -> None:
+ """Downgrade schema."""
+ op.drop_table("users")
diff --git a/src/app/infrastructure/persistence_sqla/alembic/versions/2026-03-02_230628_auth_sessions.py b/src/app/infrastructure/persistence_sqla/alembic/versions/2026-03-02_230628_auth_sessions.py
new file mode 100644
index 00000000..01284c02
--- /dev/null
+++ b/src/app/infrastructure/persistence_sqla/alembic/versions/2026-03-02_230628_auth_sessions.py
@@ -0,0 +1,38 @@
+"""auth_sessions
+
+Revision ID: 7b50faaefa7c
+Revises: c64b121a3428
+Create Date: 2026-03-02 23:06:28.995808
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = "7b50faaefa7c"
+down_revision: Union[str, Sequence[str], None] = "c64b121a3428"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ """Upgrade schema."""
+ op.create_table(
+ "auth_sessions",
+ sa.Column("id", sa.String(), nullable=False),
+ sa.Column("user_id", sa.UUID(), nullable=False),
+ sa.Column("expiration", sa.DateTime(timezone=True), nullable=False),
+ sa.ForeignKeyConstraint(
+ ["user_id"], ["users.id"], name=op.f("fk_auth_sessions_user_id_users"), ondelete="CASCADE"
+ ),
+ sa.PrimaryKeyConstraint("id", name=op.f("pk_auth_sessions")),
+ )
+
+
+def downgrade() -> None:
+ """Downgrade schema."""
+ op.drop_table("auth_sessions")
diff --git a/src/app/infrastructure/persistence_sqla/constraint_names.py b/src/app/infrastructure/persistence_sqla/constraint_names.py
new file mode 100644
index 00000000..0520a907
--- /dev/null
+++ b/src/app/infrastructure/persistence_sqla/constraint_names.py
@@ -0,0 +1,3 @@
+from typing import Final
+
+UQ_USERS_USERNAME: Final[str] = "uq_users_username"
diff --git a/src/app/infrastructure/persistence_sqla/mappings/all.py b/src/app/infrastructure/persistence_sqla/mappings/all.py
index 61c29d32..78bd6435 100644
--- a/src/app/infrastructure/persistence_sqla/mappings/all.py
+++ b/src/app/infrastructure/persistence_sqla/mappings/all.py
@@ -21,12 +21,13 @@
during database migrations.
"""
-from app.infrastructure.persistence_sqla.mappings.auth_session import (
- map_auth_sessions_table,
-)
+from app.infrastructure.persistence_sqla.mappings.auth_session import map_auth_sessions_table
from app.infrastructure.persistence_sqla.mappings.user import map_users_table
+from app.infrastructure.persistence_sqla.registry import mapper_registry
def map_tables() -> None:
+ if mapper_registry.mappers:
+ return
map_users_table()
map_auth_sessions_table()
diff --git a/src/app/infrastructure/persistence_sqla/mappings/auth_session.py b/src/app/infrastructure/persistence_sqla/mappings/auth_session.py
index 306aa415..f41c4343 100644
--- a/src/app/infrastructure/persistence_sqla/mappings/auth_session.py
+++ b/src/app/infrastructure/persistence_sqla/mappings/auth_session.py
@@ -1,15 +1,20 @@
-from sqlalchemy import UUID, Column, DateTime, String, Table
+from sqlalchemy import UUID, Column, DateTime, ForeignKey, String, Table
from sqlalchemy.orm import composite
-from app.domain.value_objects.user_id import UserId
-from app.infrastructure.auth.session.model import AuthSession
+from app.core.common.value_objects.utc_datetime import UtcDatetime
+from app.infrastructure.auth_ctx.model import AuthSession
from app.infrastructure.persistence_sqla.registry import mapper_registry
auth_sessions_table = Table(
"auth_sessions",
mapper_registry.metadata,
Column("id", String, primary_key=True),
- Column("user_id", UUID(as_uuid=True), nullable=False),
+ Column(
+ "user_id",
+ UUID(as_uuid=True),
+ ForeignKey("users.id", ondelete="CASCADE"),
+ nullable=False,
+ ),
Column("expiration", DateTime(timezone=True), nullable=False),
)
@@ -20,8 +25,8 @@ def map_auth_sessions_table() -> None:
auth_sessions_table,
properties={
"id_": auth_sessions_table.c.id,
- "user_id": composite(UserId, auth_sessions_table.c.user_id),
- "expiration": auth_sessions_table.c.expiration,
+ "user_id": auth_sessions_table.c.user_id,
+ "expiration": composite(UtcDatetime, auth_sessions_table.c.expiration),
},
- column_prefix="_",
+ column_prefix="__",
)
diff --git a/src/app/infrastructure/persistence_sqla/mappings/user.py b/src/app/infrastructure/persistence_sqla/mappings/user.py
index 915c3670..8df05d58 100644
--- a/src/app/infrastructure/persistence_sqla/mappings/user.py
+++ b/src/app/infrastructure/persistence_sqla/mappings/user.py
@@ -1,11 +1,10 @@
-from sqlalchemy import UUID, Boolean, Column, Enum, LargeBinary, String, Table
+from sqlalchemy import UUID, Boolean, Column, DateTime, Enum, LargeBinary, String, Table
from sqlalchemy.orm import composite
-from app.domain.entities.user import User
-from app.domain.enums.user_role import UserRole
-from app.domain.value_objects.user_id import UserId
-from app.domain.value_objects.user_password_hash import UserPasswordHash
-from app.domain.value_objects.username import Username
+from app.core.common.entities.types_ import UserRole
+from app.core.common.entities.user import User
+from app.core.common.value_objects.username import Username
+from app.core.common.value_objects.utc_datetime import UtcDatetime
from app.infrastructure.persistence_sqla.registry import mapper_registry
users_table = Table(
@@ -16,11 +15,17 @@
Column("password_hash", LargeBinary, nullable=False),
Column(
"role",
- Enum(UserRole, name="userrole"),
- default=UserRole.USER,
+ Enum(
+ UserRole,
+ name="user_role",
+ native_enum=False,
+ validate_strings=True,
+ ),
nullable=False,
),
- Column("is_active", Boolean, default=True, nullable=False),
+ Column("is_active", Boolean, nullable=False),
+ Column("created_at", DateTime(timezone=True), nullable=False),
+ Column("updated_at", DateTime(timezone=True), nullable=False),
)
@@ -29,11 +34,13 @@ def map_users_table() -> None:
User,
users_table,
properties={
- "id_": composite(UserId, users_table.c.id),
+ "id_": users_table.c.id,
"username": composite(Username, users_table.c.username),
- "password_hash": composite(UserPasswordHash, users_table.c.password_hash),
+ "password_hash": users_table.c.password_hash,
"role": users_table.c.role,
"is_active": users_table.c.is_active,
+ "_created_at": composite(UtcDatetime, users_table.c.created_at),
+ "updated_at": composite(UtcDatetime, users_table.c.updated_at),
},
- column_prefix="_",
+ column_prefix="__",
)
diff --git a/src/app/infrastructure/auth/__init__.py b/src/app/main/__init__.py
similarity index 100%
rename from src/app/infrastructure/auth/__init__.py
rename to src/app/main/__init__.py
diff --git a/src/app/infrastructure/auth/adapters/__init__.py b/src/app/main/ioc/__init__.py
similarity index 100%
rename from src/app/infrastructure/auth/adapters/__init__.py
rename to src/app/main/ioc/__init__.py
diff --git a/src/app/main/ioc/core.py b/src/app/main/ioc/core.py
new file mode 100644
index 00000000..42aab8d3
--- /dev/null
+++ b/src/app/main/ioc/core.py
@@ -0,0 +1,81 @@
+from dishka import Provider, Scope, provide
+
+from app.config.settings import PasswordHasherSettings
+from app.core.commands.activate_user import ActivateUser
+from app.core.commands.create_user import CreateUser
+from app.core.commands.deactivate_user import DeactivateUser
+from app.core.commands.grant_admin import GrantAdmin
+from app.core.commands.ports.flusher import Flusher
+from app.core.commands.ports.transaction_manager import TransactionManager
+from app.core.commands.ports.user_tx_storage import UserTxStorage
+from app.core.commands.ports.utc_timer import UtcTimer
+from app.core.commands.revoke_admin import RevokeAdmin
+from app.core.commands.set_user_password import SetUserPassword
+from app.core.common.authorization.current_user_service import CurrentUserService
+from app.core.common.authorization.ports import AuthzUserFinder
+from app.core.common.ports.access_revoker import AccessRevoker
+from app.core.common.ports.identity_provider import IdentityProvider
+from app.core.common.ports.password_hasher import PasswordHasher
+from app.core.common.services.user import UserService
+from app.core.queries.list_users import ListUsers
+from app.core.queries.ports.user_reader import UserReader
+from app.infrastructure.adapters.auth_session_access_revoker import AuthSessionAccessRevoker
+from app.infrastructure.adapters.auth_session_identity_provider import AuthSessionIdentityProvider
+from app.infrastructure.adapters.bcrypt_password_hasher import (
+ BcryptPasswordHasher,
+ HasherSemaphore,
+ HasherThreadPoolExecutor,
+)
+from app.infrastructure.adapters.sqla_flusher import SqlaFlusher
+from app.infrastructure.adapters.sqla_transaction_manager import SqlaTransactionManager
+from app.infrastructure.adapters.sqla_user_reader import SqlaUserReader
+from app.infrastructure.adapters.sqla_user_tx_storage import SqlaUserTxStorage
+from app.infrastructure.adapters.system_utc_timer import SystemUtcTimer
+
+
+class CoreProvider(Provider):
+ scope = Scope.REQUEST
+
+ # Services
+ user_service = provide(UserService, scope=Scope.APP)
+ current_user_service = provide(CurrentUserService)
+
+ # Common Ports
+ @provide(scope=Scope.APP)
+ def provide_password_hasher(
+ self,
+ settings: PasswordHasherSettings,
+ executor: HasherThreadPoolExecutor,
+ semaphore: HasherSemaphore,
+ ) -> PasswordHasher:
+ return BcryptPasswordHasher(
+ pepper=settings.PEPPER.encode(),
+ work_factor=settings.WORK_FACTOR,
+ executor=executor,
+ semaphore=semaphore,
+ semaphore_wait_timeout_s=settings.SEMAPHORE_WAIT_TIMEOUT_S,
+ )
+
+ identity_provider = provide(AuthSessionIdentityProvider, provides=IdentityProvider)
+ authz_user_finder = provide(SqlaUserTxStorage, provides=AuthzUserFinder)
+ access_revoker = provide(AuthSessionAccessRevoker, provides=AccessRevoker)
+
+ # Commands Ports
+ utc_timer = provide(SystemUtcTimer, provides=UtcTimer)
+ user_tx_storage = provide(SqlaUserTxStorage, provides=UserTxStorage)
+ flusher = provide(SqlaFlusher, provides=Flusher)
+ tx_manager = provide(SqlaTransactionManager, provides=TransactionManager)
+
+ # Commands
+ create_user = provide(CreateUser)
+ set_user_password = provide(SetUserPassword)
+ grant_admin = provide(GrantAdmin)
+ revoke_admin = provide(RevokeAdmin)
+ activate_user = provide(ActivateUser)
+ deactivate_user = provide(DeactivateUser)
+
+ # Query Ports
+ user_reader = provide(SqlaUserReader, provides=UserReader)
+
+ # Queries
+ list_users = provide(ListUsers)
diff --git a/src/app/main/ioc/infrastructure.py b/src/app/main/ioc/infrastructure.py
new file mode 100644
index 00000000..0dcb0c76
--- /dev/null
+++ b/src/app/main/ioc/infrastructure.py
@@ -0,0 +1,177 @@
+import asyncio
+import logging
+from collections.abc import AsyncIterator, Iterator
+from concurrent.futures import ThreadPoolExecutor
+from typing import cast
+
+from dishka import Provider, Scope, from_context, provide
+from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
+from starlette.requests import Request
+
+from app.config.settings import (
+ CookieSettings,
+ JwtSettings,
+ PasswordHasherSettings,
+ PostgresSettings,
+ SessionSettings,
+ SqlaSettings,
+)
+from app.infrastructure.adapters.bcrypt_password_hasher import HasherSemaphore, HasherThreadPoolExecutor
+from app.infrastructure.auth_ctx.cookie_manager import CookieManager, CookieName
+from app.infrastructure.auth_ctx.handlers.change_password import ChangePassword
+from app.infrastructure.auth_ctx.handlers.log_in import LogIn
+from app.infrastructure.auth_ctx.handlers.log_out import LogOut
+from app.infrastructure.auth_ctx.handlers.sign_up import SignUp
+from app.infrastructure.auth_ctx.jwt_processor import JwtProcessor
+from app.infrastructure.auth_ctx.service import AuthService
+from app.infrastructure.auth_ctx.sqla_transaction_manager import AuthSqlaTransactionManager
+from app.infrastructure.auth_ctx.sqla_tx_storage import AuthSessionSqlaTxStorage
+from app.infrastructure.auth_ctx.sqla_user_tx_storage import AuthSqlaUserTxStorage
+from app.infrastructure.auth_ctx.types_ import AuthAsyncSession
+from app.infrastructure.auth_ctx.utc_timer import AuthSessionUtcTimer
+
+logger = logging.getLogger(__name__)
+
+
+class HasherThreadPoolProvider(Provider):
+ scope = Scope.APP
+
+ @provide
+ def provide_hasher_threadpool_executor(
+ self,
+ settings: PasswordHasherSettings,
+ ) -> Iterator[HasherThreadPoolExecutor]:
+ executor = HasherThreadPoolExecutor(
+ ThreadPoolExecutor(
+ max_workers=settings.MAX_THREADS,
+ thread_name_prefix="bcrypt",
+ )
+ )
+ yield executor
+ logger.debug("Disposing hasher threadpool executor...")
+ executor.shutdown(wait=True, cancel_futures=True)
+ logger.debug("Hasher threadpool executor is disposed.")
+
+ @provide
+ def provide_hasher_semaphore(self, settings: PasswordHasherSettings) -> HasherSemaphore:
+ return HasherSemaphore(asyncio.Semaphore(settings.MAX_THREADS))
+
+
+class PersistenceSqlaProvider(Provider):
+ @provide(scope=Scope.APP)
+ async def provide_async_engine(
+ self,
+ postgres: PostgresSettings,
+ sqla: SqlaSettings,
+ ) -> AsyncIterator[AsyncEngine]:
+ async_engine = create_async_engine(
+ url=postgres.dsn,
+ echo=sqla.ECHO,
+ echo_pool=sqla.ECHO_POOL,
+ pool_size=sqla.POOL_SIZE,
+ max_overflow=sqla.MAX_OVERFLOW,
+ connect_args={"connect_timeout": 5},
+ pool_pre_ping=True,
+ )
+ logger.debug("Async engine created with DSN: %s", postgres.dsn)
+ yield async_engine
+ logger.debug("Disposing async engine...")
+ await async_engine.dispose()
+ logger.debug("Engine is disposed.")
+
+ @provide(scope=Scope.APP)
+ def provide_async_session_factory(
+ self,
+ engine: AsyncEngine,
+ ) -> async_sessionmaker[AsyncSession]:
+ async_session_factory = async_sessionmaker(
+ bind=engine,
+ class_=AsyncSession,
+ autoflush=False,
+ expire_on_commit=False,
+ )
+ logger.debug("Async session maker initialized.")
+ return async_session_factory
+
+ @provide(scope=Scope.REQUEST)
+ async def provide_primary_async_session(
+ self,
+ async_session_factory: async_sessionmaker[AsyncSession],
+ ) -> AsyncIterator[AsyncSession]:
+ """Provides UoW (AsyncSession) for the primary context"""
+ logger.debug("Starting primary async session...")
+ async with async_session_factory() as session:
+ logger.debug("Primary async session started.")
+ yield session
+ logger.debug("Closing primary async session...")
+ logger.debug("Primary async session closed.")
+
+ @provide(scope=Scope.REQUEST)
+ async def provide_auth_async_session(
+ self,
+ async_session_factory: async_sessionmaker[AsyncSession],
+ ) -> AsyncIterator[AuthAsyncSession]:
+ """Provides UoW (AsyncSession) for the auth context."""
+ logger.debug("Starting auth async session...")
+ async with async_session_factory() as session:
+ logger.debug("Auth async session started.")
+ yield cast(AuthAsyncSession, session)
+ logger.debug("Closing auth async session...")
+ logger.debug("Auth async session closed.")
+
+
+class AuthProvider(Provider):
+ scope = Scope.REQUEST
+
+ # Auth context
+ auth_service = provide(AuthService)
+
+ @provide(scope=Scope.APP)
+ def provide_utc_auth_session_timer(
+ self,
+ settings: SessionSettings,
+ ) -> AuthSessionUtcTimer:
+ return AuthSessionUtcTimer(
+ ttl=settings.ttl,
+ refresh_threshold_ratio=settings.REFRESH_THRESHOLD_RATIO,
+ )
+
+ auth_session_tx_storage = provide(AuthSessionSqlaTxStorage)
+ auth_tx_manager = provide(AuthSqlaTransactionManager)
+
+ @provide(scope=Scope.APP)
+ def provide_jwt_processor(
+ self,
+ settings: JwtSettings,
+ ) -> JwtProcessor:
+ return JwtProcessor(
+ secret=settings.SECRET,
+ algorithm=settings.ALGORITHM,
+ )
+
+ @provide(scope=Scope.APP)
+ def provide_cookie_name(self, settings: CookieSettings) -> CookieName:
+ return CookieName(settings.NAME)
+
+ cookie_manager = provide(CookieManager)
+
+ auth_sqla_user_tx_storage = provide(AuthSqlaUserTxStorage)
+
+ # Account handlers
+ sign_up = provide(SignUp)
+ log_in = provide(LogIn)
+ change_password = provide(ChangePassword)
+ log_out = provide(LogOut)
+
+
+class RequestProvider(Provider):
+ request = from_context(provides=Request, scope=Scope.REQUEST)
+
+
+def infrastructure_providers() -> tuple[Provider, ...]:
+ return (
+ HasherThreadPoolProvider(),
+ PersistenceSqlaProvider(),
+ AuthProvider(),
+ RequestProvider(),
+ )
diff --git a/src/app/main/ioc/provider_registry.py b/src/app/main/ioc/provider_registry.py
new file mode 100644
index 00000000..8ca138b9
--- /dev/null
+++ b/src/app/main/ioc/provider_registry.py
@@ -0,0 +1,13 @@
+from collections.abc import Iterable
+
+from dishka import Provider
+
+from app.main.ioc.core import CoreProvider
+from app.main.ioc.infrastructure import infrastructure_providers
+
+
+def get_providers() -> Iterable[Provider]:
+ return (
+ CoreProvider(),
+ *infrastructure_providers(),
+ )
diff --git a/src/app/main/run.py b/src/app/main/run.py
new file mode 100644
index 00000000..b1e9252f
--- /dev/null
+++ b/src/app/main/run.py
@@ -0,0 +1,135 @@
+from collections.abc import AsyncIterator, Callable
+from contextlib import AbstractAsyncContextManager, asynccontextmanager
+
+from dishka import AsyncContainer, Provider, make_async_container
+from dishka.integrations.fastapi import setup_dishka
+from fastapi import FastAPI
+
+from app.config.loader import (
+ load_app_settings,
+ load_cookie_settings,
+ load_jwt_settings,
+ load_password_hasher_settings,
+ load_postgres_settings,
+ load_session_settings,
+ load_sqla_settings,
+)
+from app.config.settings import (
+ AppSettings,
+ CookieSettings,
+ JwtSettings,
+ PasswordHasherSettings,
+ PostgresSettings,
+ SessionSettings,
+ SqlaSettings,
+)
+from app.infrastructure.persistence_sqla.mappings.all import map_tables
+from app.main.ioc.provider_registry import get_providers
+from app.main.setup import setup_global_exception_handlers, setup_logging, setup_middlewares
+from app.presentation.http.root_router import make_fastapi_root_router
+
+
+def make_lifespan() -> Callable[[FastAPI], AbstractAsyncContextManager[None]]:
+ @asynccontextmanager
+ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
+ """Here one can bind APP-scoped dependencies to `app.state` and close them if needed"""
+ # https://dishka.readthedocs.io/en/stable/integrations/fastapi.html
+ container = app.state.dishka_container
+ try:
+ map_tables()
+ yield
+ finally:
+ await container.close()
+
+ return lifespan
+
+
+def make_ioc_container(
+ *di_providers: Provider,
+ app_settings: AppSettings,
+ postgres_settings: PostgresSettings,
+ sqla_settings: SqlaSettings,
+ password_hasher_settings: PasswordHasherSettings,
+ jwt_settings: JwtSettings,
+ session_settings: SessionSettings,
+ cookie_settings: CookieSettings,
+) -> AsyncContainer:
+ return make_async_container(
+ *get_providers(),
+ *di_providers,
+ context={
+ AppSettings: app_settings,
+ PostgresSettings: postgres_settings,
+ SqlaSettings: sqla_settings,
+ PasswordHasherSettings: password_hasher_settings,
+ JwtSettings: jwt_settings,
+ SessionSettings: session_settings,
+ CookieSettings: cookie_settings,
+ },
+ )
+
+
+def make_app(
+ *di_providers: Provider,
+ app_settings: AppSettings | None = None,
+ postgres_settings: PostgresSettings | None = None,
+ sqla_settings: SqlaSettings | None = None,
+ password_hasher_settings: PasswordHasherSettings | None = None,
+ jwt_settings: JwtSettings | None = None,
+ session_settings: SessionSettings | None = None,
+ cookie_settings: CookieSettings | None = None,
+) -> FastAPI:
+ """Pass providers to override existing ones for testing."""
+ if app_settings is None:
+ app_settings = load_app_settings()
+
+ setup_logging(level=app_settings.LOGGING_LEVEL)
+
+ if postgres_settings is None:
+ postgres_settings = load_postgres_settings()
+ if sqla_settings is None:
+ sqla_settings = load_sqla_settings()
+ if password_hasher_settings is None:
+ password_hasher_settings = load_password_hasher_settings()
+ if jwt_settings is None:
+ jwt_settings = load_jwt_settings()
+ if session_settings is None:
+ session_settings = load_session_settings()
+ if cookie_settings is None:
+ cookie_settings = load_cookie_settings()
+
+ app = FastAPI(
+ debug=app_settings.DEBUG_MODE,
+ title=app_settings.SERVICE_NAME,
+ version=app_settings.VERSION,
+ summary=f"OpenAPI schema for {app_settings.SERVICE_NAME}",
+ lifespan=make_lifespan(),
+ root_path=app_settings.ROOT_PATH.rstrip("/"),
+ )
+ container = make_ioc_container(
+ *di_providers,
+ app_settings=app_settings,
+ postgres_settings=postgres_settings,
+ sqla_settings=sqla_settings,
+ password_hasher_settings=password_hasher_settings,
+ jwt_settings=jwt_settings,
+ session_settings=session_settings,
+ cookie_settings=cookie_settings,
+ )
+ setup_dishka(container, app)
+ setup_middlewares(app, cookie_settings)
+ setup_global_exception_handlers(app)
+ app.include_router(
+ make_fastapi_root_router(
+ debug_mode=app_settings.DEBUG_MODE,
+ cookie_name=cookie_settings.NAME,
+ )
+ )
+ return app
+
+
+if __name__ == "__main__":
+ """See clck.ru/3RUG2j if debug in PyCharm is broken"""
+ import uvicorn
+
+ uvicorn.run(app=make_app())
diff --git a/src/app/main/setup.py b/src/app/main/setup.py
new file mode 100644
index 00000000..66fb1bc5
--- /dev/null
+++ b/src/app/main/setup.py
@@ -0,0 +1,36 @@
+import logging
+
+from fastapi import FastAPI
+
+from app.config.logging_ import DATEFMT, FMT, LoggingLevel
+from app.config.settings import CookieSettings
+from app.presentation.http.auth_cookie_middleware import AuthCookieMiddleware
+
+logger = logging.getLogger(__name__)
+
+
+def setup_logging(*, level: LoggingLevel = LoggingLevel.INFO) -> None:
+ logging.basicConfig(
+ level=level,
+ datefmt=DATEFMT,
+ format=FMT,
+ force=True,
+ )
+ logger.info("Logging is set up")
+
+
+def setup_middlewares(app: FastAPI, cookie_settings: CookieSettings) -> None:
+ app.add_middleware(
+ AuthCookieMiddleware,
+ cookie_name=cookie_settings.NAME,
+ cookie_path=cookie_settings.PATH,
+ cookie_httponly=cookie_settings.HTTPONLY,
+ cookie_secure=cookie_settings.SECURE,
+ cookie_samesite=cookie_settings.SAMESITE,
+ )
+ logger.info("Middlewares are set up")
+
+
+def setup_global_exception_handlers(_app: FastAPI) -> None:
+ # A place to register global exception handlers
+ logger.info("Global exception handlers are set up")
diff --git a/src/app/infrastructure/auth/handlers/__init__.py b/src/app/presentation/http/account/__init__.py
similarity index 100%
rename from src/app/infrastructure/auth/handlers/__init__.py
rename to src/app/presentation/http/account/__init__.py
diff --git a/src/app/presentation/http/account/change_password.py b/src/app/presentation/http/account/change_password.py
new file mode 100644
index 00000000..15c3de5e
--- /dev/null
+++ b/src/app/presentation/http/account/change_password.py
@@ -0,0 +1,62 @@
+from inspect import getdoc
+
+from dishka import FromDishka
+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.infrastructure.adapters.exceptions import PasswordHasherBusyError
+from app.infrastructure.auth_ctx.exceptions import AuthenticationChangeError, AuthenticationError, ReAuthenticationError
+from app.infrastructure.auth_ctx.handlers.change_password import ChangePassword, ChangePasswordRequest
+from app.infrastructure.exceptions import StorageError
+from app.presentation.http.errors.callbacks import log_info
+from app.presentation.http.errors.rules import HTTP_503_SERVICE_UNAVAILABLE_RULE
+
+
+class ChangePasswordRequestSchema(BaseModel):
+ """
+ Using Pydantic model here is generally unnecessary.
+ It's only implemented to render specific Swagger UI.
+ """
+
+ model_config = ConfigDict(frozen=True)
+
+ current_password: str
+ new_password: str
+
+
+def make_change_password_router(*, cookie_name: str) -> APIRouter:
+ router = ErrorAwareRouter()
+
+ @router.put(
+ "/password/",
+ error_map={
+ AuthenticationError: status.HTTP_401_UNAUTHORIZED,
+ StorageError: HTTP_503_SERVICE_UNAVAILABLE_RULE,
+ AuthorizationError: status.HTTP_403_FORBIDDEN,
+ BusinessTypeError: status.HTTP_400_BAD_REQUEST,
+ AuthenticationChangeError: status.HTTP_400_BAD_REQUEST,
+ 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),
+ )
+ @inject
+ async def change_password(
+ request_schema: ChangePasswordRequestSchema,
+ handler: FromDishka[ChangePassword],
+ ) -> None:
+ request = ChangePasswordRequest(
+ current_password=request_schema.current_password,
+ new_password=request_schema.new_password,
+ )
+ await handler.execute(request)
+
+ return router
diff --git a/src/app/presentation/http/account/log_in.py b/src/app/presentation/http/account/log_in.py
new file mode 100644
index 00000000..e5be4f1c
--- /dev/null
+++ b/src/app/presentation/http/account/log_in.py
@@ -0,0 +1,44 @@
+from inspect import getdoc
+
+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 UserNotFoundError
+from app.core.common.authorization.exceptions import AuthorizationError
+from app.core.common.exceptions import BusinessTypeError
+from app.infrastructure.adapters.exceptions import PasswordHasherBusyError
+from app.infrastructure.auth_ctx.exceptions import AlreadyAuthenticatedError, AuthenticationError
+from app.infrastructure.auth_ctx.handlers.log_in import LogIn, LogInRequest
+from app.infrastructure.exceptions import StorageError
+from app.presentation.http.errors.callbacks import log_info
+from app.presentation.http.errors.rules import HTTP_503_SERVICE_UNAVAILABLE_RULE
+
+
+def make_log_in_router() -> APIRouter:
+ router = ErrorAwareRouter()
+
+ @router.post(
+ "/login/",
+ error_map={
+ StorageError: HTTP_503_SERVICE_UNAVAILABLE_RULE,
+ AuthorizationError: status.HTTP_403_FORBIDDEN,
+ AlreadyAuthenticatedError: status.HTTP_403_FORBIDDEN,
+ BusinessTypeError: status.HTTP_400_BAD_REQUEST,
+ UserNotFoundError: status.HTTP_404_NOT_FOUND,
+ 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),
+ )
+ @inject
+ async def log_in(
+ request: LogInRequest,
+ handler: FromDishka[LogIn],
+ ) -> None:
+ await handler.execute(request)
+
+ return router
diff --git a/src/app/presentation/http/account/log_out.py b/src/app/presentation/http/account/log_out.py
new file mode 100644
index 00000000..ddfd8924
--- /dev/null
+++ b/src/app/presentation/http/account/log_out.py
@@ -0,0 +1,36 @@
+from inspect import getdoc
+
+from dishka import FromDishka
+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.infrastructure.auth_ctx.exceptions import AuthenticationError
+from app.infrastructure.auth_ctx.handlers.log_out import LogOut
+from app.infrastructure.exceptions import StorageError
+from app.presentation.http.errors.callbacks import log_info
+from app.presentation.http.errors.rules import HTTP_503_SERVICE_UNAVAILABLE_RULE
+
+
+def make_log_out_router(*, cookie_name: str) -> APIRouter:
+ router = ErrorAwareRouter()
+
+ @router.delete(
+ "/logout/",
+ error_map={
+ AuthenticationError: status.HTTP_401_UNAUTHORIZED,
+ 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),
+ )
+ @inject
+ async def log_out(handler: FromDishka[LogOut]) -> None:
+ await handler.execute()
+
+ return router
diff --git a/src/app/presentation/http/account/router.py b/src/app/presentation/http/account/router.py
new file mode 100644
index 00000000..b39cf37e
--- /dev/null
+++ b/src/app/presentation/http/account/router.py
@@ -0,0 +1,15 @@
+from fastapi import APIRouter
+
+from app.presentation.http.account.change_password import make_change_password_router
+from app.presentation.http.account.log_in import make_log_in_router
+from app.presentation.http.account.log_out import make_log_out_router
+from app.presentation.http.account.sign_up import make_sign_up_router
+
+
+def make_account_router(*, cookie_name: str) -> APIRouter:
+ router = APIRouter(prefix="/account", tags=["Account"])
+ router.include_router(make_sign_up_router())
+ router.include_router(make_log_in_router())
+ router.include_router(make_change_password_router(cookie_name=cookie_name))
+ router.include_router(make_log_out_router(cookie_name=cookie_name))
+ return router
diff --git a/src/app/presentation/http/account/sign_up.py b/src/app/presentation/http/account/sign_up.py
new file mode 100644
index 00000000..42c02a16
--- /dev/null
+++ b/src/app/presentation/http/account/sign_up.py
@@ -0,0 +1,43 @@
+from inspect import getdoc
+
+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.infrastructure.adapters.exceptions import PasswordHasherBusyError
+from app.infrastructure.auth_ctx.exceptions import AlreadyAuthenticatedError
+from app.infrastructure.auth_ctx.handlers.sign_up import SignUp, SignUpRequest
+from app.infrastructure.exceptions import StorageError
+from app.presentation.http.errors.callbacks import log_info
+from app.presentation.http.errors.rules import HTTP_503_SERVICE_UNAVAILABLE_RULE
+
+
+def make_sign_up_router() -> APIRouter:
+ router = ErrorAwareRouter()
+
+ @router.post(
+ "/signup/",
+ error_map={
+ StorageError: HTTP_503_SERVICE_UNAVAILABLE_RULE,
+ AuthorizationError: status.HTTP_403_FORBIDDEN,
+ AlreadyAuthenticatedError: status.HTTP_403_FORBIDDEN,
+ BusinessTypeError: status.HTTP_400_BAD_REQUEST,
+ 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),
+ )
+ @inject
+ async def sign_up(
+ request: SignUpRequest,
+ handler: FromDishka[SignUp],
+ ) -> None:
+ await handler.execute(request)
+
+ return router
diff --git a/src/app/presentation/http/api_v1_router.py b/src/app/presentation/http/api_v1_router.py
new file mode 100644
index 00000000..07ee3f96
--- /dev/null
+++ b/src/app/presentation/http/api_v1_router.py
@@ -0,0 +1,11 @@
+from fastapi import APIRouter
+
+from app.presentation.http.account.router import make_account_router
+from app.presentation.http.users.router import make_users_router
+
+
+def make_v1_router(*, cookie_name: str) -> APIRouter:
+ router = APIRouter(prefix="/api/v1")
+ 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/presentation/http/auth/access_token_processor_jwt.py b/src/app/presentation/http/auth/access_token_processor_jwt.py
deleted file mode 100644
index ee0b1cf3..00000000
--- a/src/app/presentation/http/auth/access_token_processor_jwt.py
+++ /dev/null
@@ -1,69 +0,0 @@
-import logging
-from typing import Any, Literal, TypedDict, cast
-
-import jwt
-
-from app.infrastructure.auth.session.model import AuthSession
-from app.presentation.http.auth.constants import (
- ACCESS_TOKEN_INVALID_OR_EXPIRED,
- ACCESS_TOKEN_PAYLOAD_MISSING,
- ACCESS_TOKEN_PAYLOAD_OF_INTEREST,
-)
-
-log = logging.getLogger(__name__)
-
-
-class JwtPayload(TypedDict):
- auth_session_id: str
- exp: int
-
-
-class JwtAccessTokenProcessor:
- def __init__(
- self,
- secret: str,
- algorithm: Literal[
- "HS256",
- "HS384",
- "HS512",
- "RS256",
- "RS384",
- "RS512",
- ],
- ) -> None:
- self._secret = secret
- self._algorithm = algorithm
-
- def encode(self, auth_session: AuthSession) -> str:
- payload = JwtPayload(
- auth_session_id=auth_session.id_,
- exp=int(auth_session.expiration.timestamp()),
- )
- return jwt.encode(
- cast(dict[str, Any], payload),
- key=self._secret,
- algorithm=self._algorithm,
- )
-
- def decode_auth_session_id(self, token: str) -> str | None:
- try:
- payload = jwt.decode(
- token,
- key=self._secret,
- algorithms=[self._algorithm],
- )
-
- except jwt.PyJWTError as err:
- log.debug("%s %s", ACCESS_TOKEN_INVALID_OR_EXPIRED, err)
- return None
-
- auth_session_id: str | None = payload.get(ACCESS_TOKEN_PAYLOAD_OF_INTEREST)
- if auth_session_id is None:
- log.debug(
- "%s '%s'",
- ACCESS_TOKEN_PAYLOAD_MISSING,
- ACCESS_TOKEN_PAYLOAD_OF_INTEREST,
- )
- return None
-
- return auth_session_id
diff --git a/src/app/presentation/http/auth/adapters/session_transport_jwt_cookie.py b/src/app/presentation/http/auth/adapters/session_transport_jwt_cookie.py
deleted file mode 100644
index cfdd4a06..00000000
--- a/src/app/presentation/http/auth/adapters/session_transport_jwt_cookie.py
+++ /dev/null
@@ -1,61 +0,0 @@
-import logging
-
-from starlette.requests import Request
-
-from app.infrastructure.auth.session.model import AuthSession
-from app.infrastructure.auth.session.ports.transport import AuthSessionTransport
-from app.presentation.http.auth.access_token_processor_jwt import (
- JwtAccessTokenProcessor,
-)
-from app.presentation.http.auth.constants import (
- ACCESS_TOKEN_DELIVERED_VIA_COOKIE,
- ACCESS_TOKEN_MARKED_FOR_REMOVAL,
- ACCESS_TOKEN_NOT_FOUND_IN_COOKIE,
- COOKIE_ACCESS_TOKEN_NAME,
- REQUEST_STATE_COOKIE_PARAMS_KEY,
- REQUEST_STATE_DELETE_ACCESS_TOKEN_KEY,
- REQUEST_STATE_NEW_ACCESS_TOKEN_KEY,
-)
-from app.presentation.http.auth.cookie_params import CookieParams
-
-log = logging.getLogger(__name__)
-
-
-class JwtCookieAuthSessionTransport(AuthSessionTransport):
- def __init__(
- self,
- request: Request,
- access_token_processor: JwtAccessTokenProcessor,
- cookie_params: CookieParams,
- ) -> None:
- self._request = request
- self._access_token_processor = access_token_processor
- self._cookie_params = cookie_params
-
- def deliver(self, auth_session: AuthSession) -> None:
- access_token = self._access_token_processor.encode(auth_session)
- setattr(self._request.state, REQUEST_STATE_NEW_ACCESS_TOKEN_KEY, access_token)
- setattr(
- self._request.state,
- REQUEST_STATE_COOKIE_PARAMS_KEY,
- self._cookie_params,
- )
-
- log.debug(
- "%s Session ID: %s",
- ACCESS_TOKEN_DELIVERED_VIA_COOKIE,
- auth_session.id_,
- )
-
- def extract_id(self) -> str | None:
- access_token = self._request.cookies.get(COOKIE_ACCESS_TOKEN_NAME)
- if access_token is None:
- log.debug("%s", ACCESS_TOKEN_NOT_FOUND_IN_COOKIE)
- return None
-
- return self._access_token_processor.decode_auth_session_id(access_token)
-
- def remove_current(self) -> None:
- setattr(self._request.state, REQUEST_STATE_DELETE_ACCESS_TOKEN_KEY, True)
-
- log.debug("%s", ACCESS_TOKEN_MARKED_FOR_REMOVAL)
diff --git a/src/app/presentation/http/auth/asgi_middleware.py b/src/app/presentation/http/auth/asgi_middleware.py
deleted file mode 100644
index 9cffcc01..00000000
--- a/src/app/presentation/http/auth/asgi_middleware.py
+++ /dev/null
@@ -1,101 +0,0 @@
-import logging
-from http.cookies import SimpleCookie
-from typing import Literal
-
-from starlette.datastructures import MutableHeaders
-from starlette.requests import Request
-from starlette.types import ASGIApp, Message, Receive, Scope, Send
-
-from app.presentation.http.auth.constants import (
- COOKIE_ACCESS_TOKEN_NAME,
- REQUEST_STATE_COOKIE_PARAMS_KEY,
- REQUEST_STATE_DELETE_ACCESS_TOKEN_KEY,
- REQUEST_STATE_NEW_ACCESS_TOKEN_KEY,
-)
-from app.presentation.http.auth.cookie_params import (
- CookieParams,
-)
-
-log = logging.getLogger(__name__)
-
-
-class ASGIAuthMiddleware:
- def __init__(self, app: ASGIApp) -> None:
- self.app = app
-
- async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
- if scope["type"] != "http":
- return await self.app(scope, receive, send)
-
- request = Request(scope)
-
- async def send_wrapper(message: Message) -> None:
- if message["type"] == "http.response.start":
- headers = MutableHeaders(scope=message)
- self._maybe_set_cookie(request, headers)
- self._maybe_delete_cookie(request, headers)
- await send(message)
-
- return await self.app(scope, receive, send_wrapper)
-
- def _maybe_set_cookie(self, request: Request, headers: MutableHeaders) -> None:
- new_access_token: str | None = getattr(
- request.state,
- REQUEST_STATE_NEW_ACCESS_TOKEN_KEY,
- None,
- )
- if new_access_token is None:
- return
-
- cookie_params: CookieParams = getattr(
- request.state,
- REQUEST_STATE_COOKIE_PARAMS_KEY,
- CookieParams(secure=False),
- )
- cookie_header = self._make_cookie_header(
- value=new_access_token,
- is_secure=cookie_params.secure,
- samesite=cookie_params.samesite,
- )
- headers.append("Set-Cookie", cookie_header)
- log.debug("Cookie with access token '%s' was set.", new_access_token)
-
- def _maybe_delete_cookie(
- self,
- request: Request,
- headers: MutableHeaders,
- ) -> None:
- if not getattr(request.state, REQUEST_STATE_DELETE_ACCESS_TOKEN_KEY, False):
- return
-
- current_access_token = request.cookies.get(COOKIE_ACCESS_TOKEN_NAME)
- log.debug(
- "Deleting cookie with access token: '%s'.",
- current_access_token if current_access_token else "already deleted",
- )
-
- cookie_header = self._make_cookie_header(value="", max_age=0)
- headers.append("Set-Cookie", cookie_header)
- log.debug("Cookie was deleted.")
-
- def _make_cookie_header(
- self,
- *,
- value: str,
- is_secure: bool = False,
- samesite: Literal["strict"] | None = None,
- max_age: int | None = None,
- ) -> str:
- cookie = SimpleCookie()
- cookie["access_token"] = value
- cookie["access_token"]["path"] = "/"
- cookie["access_token"]["httponly"] = True
-
- if is_secure:
- cookie["access_token"]["secure"] = True
- if samesite:
- cookie["access_token"]["samesite"] = samesite
- if max_age is not None:
- cookie["access_token"]["max-age"] = max_age
-
- return cookie.output(header="").strip()
diff --git a/src/app/presentation/http/auth/constants.py b/src/app/presentation/http/auth/constants.py
deleted file mode 100644
index 6764cd37..00000000
--- a/src/app/presentation/http/auth/constants.py
+++ /dev/null
@@ -1,18 +0,0 @@
-from typing import Final
-
-ACCESS_TOKEN_DELIVERED_VIA_COOKIE: Final[str] = (
- "Delivered auth session token via cookie."
-)
-ACCESS_TOKEN_INVALID_OR_EXPIRED: Final[str] = "Invalid or expired JWT."
-ACCESS_TOKEN_MARKED_FOR_REMOVAL: Final[str] = (
- "Marked access token for removal in response."
-)
-ACCESS_TOKEN_NOT_FOUND_IN_COOKIE: Final[str] = "No access token found in cookie."
-ACCESS_TOKEN_PAYLOAD_OF_INTEREST: Final[str] = "auth_session_id"
-ACCESS_TOKEN_PAYLOAD_MISSING: Final[str] = "JWT payload missing."
-
-COOKIE_ACCESS_TOKEN_NAME: Final[str] = "access_token"
-
-REQUEST_STATE_COOKIE_PARAMS_KEY: Final[str] = "cookie_params"
-REQUEST_STATE_DELETE_ACCESS_TOKEN_KEY: Final[str] = "delete_access_token"
-REQUEST_STATE_NEW_ACCESS_TOKEN_KEY: Final[str] = "new_access_token"
diff --git a/src/app/presentation/http/auth/cookie_params.py b/src/app/presentation/http/auth/cookie_params.py
deleted file mode 100644
index 6db57de3..00000000
--- a/src/app/presentation/http/auth/cookie_params.py
+++ /dev/null
@@ -1,12 +0,0 @@
-from dataclasses import dataclass
-from typing import Literal
-
-
-@dataclass(eq=False, slots=True, kw_only=True)
-class CookieParams:
- secure: bool
- samesite: Literal["strict"] | None = None
-
- def __post_init__(self) -> None:
- if self.secure and self.samesite is None:
- self.samesite = "strict"
diff --git a/src/app/presentation/http/auth/openapi_marker.py b/src/app/presentation/http/auth/openapi_marker.py
deleted file mode 100644
index 36878313..00000000
--- a/src/app/presentation/http/auth/openapi_marker.py
+++ /dev/null
@@ -1,9 +0,0 @@
-from fastapi.security import APIKeyCookie
-
-from app.presentation.http.auth.constants import (
- COOKIE_ACCESS_TOKEN_NAME,
-)
-
-# Cookie extraction marker for Swagger UI (OpenAPI).
-# The actual cookie processing is handled behind the Identity Provider.
-cookie_scheme = APIKeyCookie(name=COOKIE_ACCESS_TOKEN_NAME)
diff --git a/src/app/presentation/http/auth_cookie_middleware.py b/src/app/presentation/http/auth_cookie_middleware.py
new file mode 100644
index 00000000..33d3cee0
--- /dev/null
+++ b/src/app/presentation/http/auth_cookie_middleware.py
@@ -0,0 +1,58 @@
+from typing import ClassVar, Literal, cast
+
+from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
+from starlette.requests import Request
+from starlette.responses import Response
+from starlette.types import ASGIApp
+
+from app.infrastructure.auth_ctx.cookie_manager import STAGED_COOKIE
+
+
+class AuthCookieMiddleware(BaseHTTPMiddleware):
+ MISSING: ClassVar[object] = object()
+
+ def __init__(
+ self,
+ app: ASGIApp,
+ *,
+ cookie_name: str,
+ cookie_path: str,
+ cookie_httponly: bool,
+ cookie_secure: bool,
+ cookie_samesite: Literal["lax", "strict", "none"],
+ ) -> None:
+ super().__init__(app)
+ self._cookie_name = cookie_name
+ self._cookie_path = cookie_path
+ self._cookie_httponly = cookie_httponly
+ self._cookie_secure = cookie_secure
+ self._cookie_samesite = cookie_samesite
+
+ async def dispatch(
+ self,
+ request: Request,
+ call_next: RequestResponseEndpoint,
+ ) -> Response:
+ response = await call_next(request)
+
+ staged = getattr(request.state, STAGED_COOKIE, self.MISSING)
+ if staged is self.MISSING:
+ return response
+
+ value = cast(str | None, staged)
+ if value is None:
+ response.delete_cookie(
+ key=self._cookie_name,
+ path=self._cookie_path,
+ )
+ return response
+
+ response.set_cookie(
+ key=self._cookie_name,
+ value=value,
+ path=self._cookie_path,
+ httponly=self._cookie_httponly,
+ secure=self._cookie_secure,
+ samesite=self._cookie_samesite,
+ )
+ return response
diff --git a/src/app/presentation/http/controllers/account/change_password.py b/src/app/presentation/http/controllers/account/change_password.py
deleted file mode 100644
index c19c8cc5..00000000
--- a/src/app/presentation/http/controllers/account/change_password.py
+++ /dev/null
@@ -1,68 +0,0 @@
-from inspect import getdoc
-from typing import Annotated
-
-from dishka import FromDishka
-from dishka.integrations.fastapi import inject
-from fastapi import APIRouter, Body, Security, status
-from fastapi_error_map import ErrorAwareRouter, rule
-
-from app.application.common.exceptions.authorization import AuthorizationError
-from app.domain.exceptions.base import DomainTypeError
-from app.infrastructure.auth.exceptions import (
- AuthenticationChangeError,
- AuthenticationError,
- ReAuthenticationError,
-)
-from app.infrastructure.auth.handlers.change_password import (
- ChangePasswordHandler,
- ChangePasswordRequest,
-)
-from app.infrastructure.exceptions.gateway import DataMapperError
-from app.infrastructure.exceptions.password_hasher import PasswordHasherBusyError
-from app.presentation.http.auth.openapi_marker import cookie_scheme
-from app.presentation.http.errors.callbacks import log_error, log_info
-from app.presentation.http.errors.translators import (
- ServiceUnavailableTranslator,
-)
-
-
-def create_change_password_router() -> APIRouter:
- router = ErrorAwareRouter()
-
- @router.put(
- "/password",
- description=getdoc(ChangePasswordHandler),
- error_map={
- AuthenticationError: status.HTTP_401_UNAUTHORIZED,
- DataMapperError: rule(
- status=status.HTTP_503_SERVICE_UNAVAILABLE,
- translator=ServiceUnavailableTranslator(),
- on_error=log_error,
- ),
- AuthorizationError: status.HTTP_403_FORBIDDEN,
- DomainTypeError: status.HTTP_400_BAD_REQUEST,
- AuthenticationChangeError: status.HTTP_400_BAD_REQUEST,
- ReAuthenticationError: status.HTTP_403_FORBIDDEN,
- PasswordHasherBusyError: rule(
- status=status.HTTP_503_SERVICE_UNAVAILABLE,
- translator=ServiceUnavailableTranslator(),
- on_error=log_error,
- ),
- },
- default_on_error=log_info,
- status_code=status.HTTP_204_NO_CONTENT,
- dependencies=[Security(cookie_scheme)],
- )
- @inject
- async def change_password(
- current_password: Annotated[str, Body()],
- new_password: Annotated[str, Body()],
- handler: FromDishka[ChangePasswordHandler],
- ) -> None:
- request_data = ChangePasswordRequest(
- current_password=current_password,
- new_password=new_password,
- )
- await handler.execute(request_data)
-
- return router
diff --git a/src/app/presentation/http/controllers/account/log_in.py b/src/app/presentation/http/controllers/account/log_in.py
deleted file mode 100644
index 1febf5be..00000000
--- a/src/app/presentation/http/controllers/account/log_in.py
+++ /dev/null
@@ -1,57 +0,0 @@
-from inspect import getdoc
-
-from dishka import FromDishka
-from dishka.integrations.fastapi import inject
-from fastapi import APIRouter, status
-from fastapi_error_map import ErrorAwareRouter, rule
-
-from app.application.common.exceptions.authorization import AuthorizationError
-from app.domain.exceptions.base import DomainTypeError
-from app.domain.exceptions.user import UserNotFoundByUsernameError
-from app.infrastructure.auth.exceptions import (
- AlreadyAuthenticatedError,
- AuthenticationError,
-)
-from app.infrastructure.auth.handlers.log_in import LogInHandler, LogInRequest
-from app.infrastructure.exceptions.gateway import DataMapperError
-from app.infrastructure.exceptions.password_hasher import PasswordHasherBusyError
-from app.presentation.http.errors.callbacks import log_error, log_info
-from app.presentation.http.errors.translators import (
- ServiceUnavailableTranslator,
-)
-
-
-def create_log_in_router() -> APIRouter:
- router = ErrorAwareRouter()
-
- @router.post(
- "/login",
- description=getdoc(LogInHandler),
- error_map={
- AlreadyAuthenticatedError: status.HTTP_403_FORBIDDEN,
- AuthorizationError: status.HTTP_403_FORBIDDEN,
- DataMapperError: rule(
- status=status.HTTP_503_SERVICE_UNAVAILABLE,
- translator=ServiceUnavailableTranslator(),
- on_error=log_error,
- ),
- DomainTypeError: status.HTTP_400_BAD_REQUEST,
- UserNotFoundByUsernameError: status.HTTP_404_NOT_FOUND,
- PasswordHasherBusyError: rule(
- status=status.HTTP_503_SERVICE_UNAVAILABLE,
- translator=ServiceUnavailableTranslator(),
- on_error=log_error,
- ),
- AuthenticationError: status.HTTP_401_UNAUTHORIZED,
- },
- default_on_error=log_info,
- status_code=status.HTTP_204_NO_CONTENT,
- )
- @inject
- async def login(
- request_data: LogInRequest,
- handler: FromDishka[LogInHandler],
- ) -> None:
- await handler.execute(request_data)
-
- return router
diff --git a/src/app/presentation/http/controllers/account/log_out.py b/src/app/presentation/http/controllers/account/log_out.py
deleted file mode 100644
index be701712..00000000
--- a/src/app/presentation/http/controllers/account/log_out.py
+++ /dev/null
@@ -1,47 +0,0 @@
-from inspect import getdoc
-
-from dishka import FromDishka
-from dishka.integrations.fastapi import inject
-from fastapi import APIRouter, Security, status
-from fastapi_error_map import ErrorAwareRouter, rule
-
-from app.application.common.exceptions.authorization import AuthorizationError
-from app.infrastructure.auth.exceptions import AuthenticationError
-from app.infrastructure.auth.handlers.log_out import LogOutHandler
-from app.infrastructure.exceptions.gateway import DataMapperError
-from app.presentation.http.auth.openapi_marker import cookie_scheme
-from app.presentation.http.errors.callbacks import (
- log_error,
- log_info,
-)
-from app.presentation.http.errors.translators import (
- ServiceUnavailableTranslator,
-)
-
-
-def create_log_out_router() -> APIRouter:
- router = ErrorAwareRouter()
-
- @router.delete(
- "/logout",
- description=getdoc(LogOutHandler),
- error_map={
- AuthenticationError: status.HTTP_401_UNAUTHORIZED,
- AuthorizationError: status.HTTP_403_FORBIDDEN,
- DataMapperError: rule(
- status=status.HTTP_503_SERVICE_UNAVAILABLE,
- translator=ServiceUnavailableTranslator(),
- on_error=log_error,
- ),
- },
- default_on_error=log_info,
- status_code=status.HTTP_204_NO_CONTENT,
- dependencies=[Security(cookie_scheme)],
- )
- @inject
- async def logout(
- handler: FromDishka[LogOutHandler],
- ) -> None:
- await handler.execute()
-
- return router
diff --git a/src/app/presentation/http/controllers/account/router.py b/src/app/presentation/http/controllers/account/router.py
deleted file mode 100644
index 45d9829e..00000000
--- a/src/app/presentation/http/controllers/account/router.py
+++ /dev/null
@@ -1,24 +0,0 @@
-from fastapi import APIRouter
-
-from app.presentation.http.controllers.account.change_password import (
- create_change_password_router,
-)
-from app.presentation.http.controllers.account.log_in import create_log_in_router
-from app.presentation.http.controllers.account.log_out import (
- create_log_out_router,
-)
-from app.presentation.http.controllers.account.sign_up import (
- create_sign_up_router,
-)
-
-
-def create_account_router() -> APIRouter:
- router = APIRouter(
- prefix="/account",
- tags=["Account"],
- )
- router.include_router(create_sign_up_router())
- router.include_router(create_log_in_router())
- router.include_router(create_change_password_router())
- router.include_router(create_log_out_router())
- return router
diff --git a/src/app/presentation/http/controllers/account/sign_up.py b/src/app/presentation/http/controllers/account/sign_up.py
deleted file mode 100644
index d793a9bf..00000000
--- a/src/app/presentation/http/controllers/account/sign_up.py
+++ /dev/null
@@ -1,64 +0,0 @@
-from inspect import getdoc
-
-from dishka import FromDishka
-from dishka.integrations.fastapi import inject
-from fastapi import APIRouter, status
-from fastapi_error_map import ErrorAwareRouter, rule
-
-from app.application.common.exceptions.authorization import AuthorizationError
-from app.domain.exceptions.base import DomainTypeError
-from app.domain.exceptions.user import (
- RoleAssignmentNotPermittedError,
- UsernameAlreadyExistsError,
-)
-from app.infrastructure.auth.exceptions import AlreadyAuthenticatedError
-from app.infrastructure.auth.handlers.sign_up import (
- SignUpHandler,
- SignUpRequest,
- SignUpResponse,
-)
-from app.infrastructure.exceptions.gateway import DataMapperError
-from app.infrastructure.exceptions.password_hasher import PasswordHasherBusyError
-from app.presentation.http.errors.callbacks import (
- log_error,
- log_info,
-)
-from app.presentation.http.errors.translators import (
- ServiceUnavailableTranslator,
-)
-
-
-def create_sign_up_router() -> APIRouter:
- router = ErrorAwareRouter()
-
- @router.post(
- "/signup",
- description=getdoc(SignUpHandler),
- error_map={
- AlreadyAuthenticatedError: status.HTTP_403_FORBIDDEN,
- AuthorizationError: status.HTTP_403_FORBIDDEN,
- DataMapperError: rule(
- status=status.HTTP_503_SERVICE_UNAVAILABLE,
- translator=ServiceUnavailableTranslator(),
- on_error=log_error,
- ),
- DomainTypeError: status.HTTP_400_BAD_REQUEST,
- PasswordHasherBusyError: rule(
- status=status.HTTP_503_SERVICE_UNAVAILABLE,
- translator=ServiceUnavailableTranslator(),
- on_error=log_error,
- ),
- RoleAssignmentNotPermittedError: status.HTTP_422_UNPROCESSABLE_ENTITY,
- UsernameAlreadyExistsError: status.HTTP_409_CONFLICT,
- },
- default_on_error=log_info,
- status_code=status.HTTP_201_CREATED,
- )
- @inject
- async def sign_up(
- request_data: SignUpRequest,
- handler: FromDishka[SignUpHandler],
- ) -> SignUpResponse:
- return await handler.execute(request_data)
-
- return router
diff --git a/src/app/presentation/http/controllers/api_v1_router.py b/src/app/presentation/http/controllers/api_v1_router.py
deleted file mode 100644
index 98d87761..00000000
--- a/src/app/presentation/http/controllers/api_v1_router.py
+++ /dev/null
@@ -1,13 +0,0 @@
-from fastapi import APIRouter
-
-from app.presentation.http.controllers.account.router import create_account_router
-from app.presentation.http.controllers.general.router import create_general_router
-from app.presentation.http.controllers.users.router import create_users_router
-
-
-def create_api_v1_router() -> APIRouter:
- router = APIRouter(prefix="/api/v1")
- router.include_router(create_account_router())
- router.include_router(create_general_router())
- router.include_router(create_users_router())
- return router
diff --git a/src/app/presentation/http/controllers/general/health.py b/src/app/presentation/http/controllers/general/health.py
deleted file mode 100644
index eb6ba991..00000000
--- a/src/app/presentation/http/controllers/general/health.py
+++ /dev/null
@@ -1,16 +0,0 @@
-from fastapi import APIRouter
-from starlette.requests import Request
-
-
-def create_health_router() -> APIRouter:
- router = APIRouter()
-
- @router.get("/health")
- async def health(_: Request) -> dict[str, str]:
- """
- - Open to everyone.
- - Returns `200 OK` if the API is alive.
- """
- return {"status": "ok"}
-
- return router
diff --git a/src/app/presentation/http/controllers/general/router.py b/src/app/presentation/http/controllers/general/router.py
deleted file mode 100644
index d18534f4..00000000
--- a/src/app/presentation/http/controllers/general/router.py
+++ /dev/null
@@ -1,11 +0,0 @@
-from fastapi import APIRouter
-
-from app.presentation.http.controllers.general.health import (
- create_health_router,
-)
-
-
-def create_general_router() -> APIRouter:
- router = APIRouter(tags=["General"])
- router.include_router(create_health_router())
- return router
diff --git a/src/app/presentation/http/controllers/root_router.py b/src/app/presentation/http/controllers/root_router.py
deleted file mode 100644
index 41f0ac49..00000000
--- a/src/app/presentation/http/controllers/root_router.py
+++ /dev/null
@@ -1,19 +0,0 @@
-from fastapi import APIRouter
-from fastapi.responses import RedirectResponse
-
-from app.presentation.http.controllers.api_v1_router import create_api_v1_router
-
-
-def create_root_router() -> APIRouter:
- router = APIRouter()
-
- @router.get("/", tags=["General"])
- async def redirect_to_docs() -> RedirectResponse:
- """
- - Open to everyone.
- - Redirects to Swagger documentation.
- """
- return RedirectResponse(url="docs/")
-
- router.include_router(create_api_v1_router())
- return router
diff --git a/src/app/presentation/http/controllers/users/activate_user.py b/src/app/presentation/http/controllers/users/activate_user.py
deleted file mode 100644
index 320e6f28..00000000
--- a/src/app/presentation/http/controllers/users/activate_user.py
+++ /dev/null
@@ -1,57 +0,0 @@
-from inspect import getdoc
-from typing import Annotated
-from uuid import UUID
-
-from dishka import FromDishka
-from dishka.integrations.fastapi import inject
-from fastapi import APIRouter, Path, Security, status
-from fastapi_error_map import ErrorAwareRouter, rule
-
-from app.application.commands.activate_user import (
- ActivateUserInteractor,
- ActivateUserRequest,
-)
-from app.application.common.exceptions.authorization import AuthorizationError
-from app.domain.exceptions.user import (
- ActivationChangeNotPermittedError,
- UserNotFoundByIdError,
-)
-from app.infrastructure.auth.exceptions import AuthenticationError
-from app.infrastructure.exceptions.gateway import DataMapperError
-from app.presentation.http.auth.openapi_marker import cookie_scheme
-from app.presentation.http.errors.callbacks import log_error, log_info
-from app.presentation.http.errors.translators import (
- ServiceUnavailableTranslator,
-)
-
-
-def create_activate_user_router() -> APIRouter:
- router = ErrorAwareRouter()
-
- @router.put(
- "/{user_id}/activation",
- description=getdoc(ActivateUserInteractor),
- error_map={
- AuthenticationError: status.HTTP_401_UNAUTHORIZED,
- DataMapperError: rule(
- status=status.HTTP_503_SERVICE_UNAVAILABLE,
- translator=ServiceUnavailableTranslator(),
- on_error=log_error,
- ),
- AuthorizationError: status.HTTP_403_FORBIDDEN,
- UserNotFoundByIdError: status.HTTP_404_NOT_FOUND,
- ActivationChangeNotPermittedError: status.HTTP_403_FORBIDDEN,
- },
- default_on_error=log_info,
- status_code=status.HTTP_204_NO_CONTENT,
- dependencies=[Security(cookie_scheme)],
- )
- @inject
- async def activate_user(
- user_id: Annotated[UUID, Path()],
- interactor: FromDishka[ActivateUserInteractor],
- ) -> None:
- request_data = ActivateUserRequest(user_id)
- await interactor.execute(request_data)
-
- return router
diff --git a/src/app/presentation/http/controllers/users/create_user.py b/src/app/presentation/http/controllers/users/create_user.py
deleted file mode 100644
index c8fe6043..00000000
--- a/src/app/presentation/http/controllers/users/create_user.py
+++ /dev/null
@@ -1,83 +0,0 @@
-from inspect import getdoc
-
-from dishka import FromDishka
-from dishka.integrations.fastapi import inject
-from fastapi import APIRouter, Security, status
-from fastapi_error_map import ErrorAwareRouter, rule
-from pydantic import BaseModel, ConfigDict, Field
-
-from app.application.commands.create_user import (
- CreateUserInteractor,
- CreateUserRequest,
- CreateUserResponse,
-)
-from app.application.common.exceptions.authorization import AuthorizationError
-from app.domain.enums.user_role import UserRole
-from app.domain.exceptions.base import DomainTypeError
-from app.domain.exceptions.user import (
- RoleAssignmentNotPermittedError,
- UsernameAlreadyExistsError,
-)
-from app.infrastructure.auth.exceptions import AuthenticationError
-from app.infrastructure.exceptions.gateway import DataMapperError
-from app.infrastructure.exceptions.password_hasher import PasswordHasherBusyError
-from app.presentation.http.auth.openapi_marker import cookie_scheme
-from app.presentation.http.errors.callbacks import log_error, log_info
-from app.presentation.http.errors.translators import (
- ServiceUnavailableTranslator,
-)
-
-
-class CreateUserRequestPydantic(BaseModel):
- """
- Using a Pydantic model here is generally unnecessary.
- It's only implemented to render a specific Swagger UI (OpenAPI) schema.
- """
-
- model_config = ConfigDict(frozen=True)
-
- username: str
- password: str
- role: UserRole = Field(default=UserRole.USER)
-
-
-def create_create_user_router() -> APIRouter:
- router = ErrorAwareRouter()
-
- @router.post(
- "/",
- description=getdoc(CreateUserInteractor),
- error_map={
- AuthenticationError: status.HTTP_401_UNAUTHORIZED,
- DataMapperError: rule(
- status=status.HTTP_503_SERVICE_UNAVAILABLE,
- translator=ServiceUnavailableTranslator(),
- on_error=log_error,
- ),
- AuthorizationError: status.HTTP_403_FORBIDDEN,
- DomainTypeError: status.HTTP_400_BAD_REQUEST,
- PasswordHasherBusyError: rule(
- status=status.HTTP_503_SERVICE_UNAVAILABLE,
- translator=ServiceUnavailableTranslator(),
- on_error=log_error,
- ),
- RoleAssignmentNotPermittedError: status.HTTP_422_UNPROCESSABLE_ENTITY,
- UsernameAlreadyExistsError: status.HTTP_409_CONFLICT,
- },
- default_on_error=log_info,
- status_code=status.HTTP_201_CREATED,
- dependencies=[Security(cookie_scheme)],
- )
- @inject
- async def create_user(
- request_data_pydantic: CreateUserRequestPydantic,
- interactor: FromDishka[CreateUserInteractor],
- ) -> CreateUserResponse:
- request_data = CreateUserRequest(
- username=request_data_pydantic.username,
- password=request_data_pydantic.password,
- role=request_data_pydantic.role,
- )
- return await interactor.execute(request_data)
-
- return router
diff --git a/src/app/presentation/http/controllers/users/deactivate_user.py b/src/app/presentation/http/controllers/users/deactivate_user.py
deleted file mode 100644
index 5214d18d..00000000
--- a/src/app/presentation/http/controllers/users/deactivate_user.py
+++ /dev/null
@@ -1,57 +0,0 @@
-from inspect import getdoc
-from typing import Annotated
-from uuid import UUID
-
-from dishka import FromDishka
-from dishka.integrations.fastapi import inject
-from fastapi import APIRouter, Path, Security, status
-from fastapi_error_map import ErrorAwareRouter, rule
-
-from app.application.commands.deactivate_user import (
- DeactivateUserInteractor,
- DeactivateUserRequest,
-)
-from app.application.common.exceptions.authorization import AuthorizationError
-from app.domain.exceptions.user import (
- ActivationChangeNotPermittedError,
- UserNotFoundByIdError,
-)
-from app.infrastructure.auth.exceptions import AuthenticationError
-from app.infrastructure.exceptions.gateway import DataMapperError
-from app.presentation.http.auth.openapi_marker import cookie_scheme
-from app.presentation.http.errors.callbacks import log_error, log_info
-from app.presentation.http.errors.translators import (
- ServiceUnavailableTranslator,
-)
-
-
-def create_deactivate_user_router() -> APIRouter:
- router = ErrorAwareRouter()
-
- @router.delete(
- "/{user_id}/activation",
- description=getdoc(DeactivateUserInteractor),
- error_map={
- AuthenticationError: status.HTTP_401_UNAUTHORIZED,
- DataMapperError: rule(
- status=status.HTTP_503_SERVICE_UNAVAILABLE,
- translator=ServiceUnavailableTranslator(),
- on_error=log_error,
- ),
- AuthorizationError: status.HTTP_403_FORBIDDEN,
- UserNotFoundByIdError: status.HTTP_404_NOT_FOUND,
- ActivationChangeNotPermittedError: status.HTTP_403_FORBIDDEN,
- },
- default_on_error=log_info,
- status_code=status.HTTP_204_NO_CONTENT,
- dependencies=[Security(cookie_scheme)],
- )
- @inject
- async def deactivate_user(
- user_id: Annotated[UUID, Path()],
- interactor: FromDishka[DeactivateUserInteractor],
- ) -> None:
- request_data = DeactivateUserRequest(user_id)
- await interactor.execute(request_data)
-
- return router
diff --git a/src/app/presentation/http/controllers/users/grant_admin.py b/src/app/presentation/http/controllers/users/grant_admin.py
deleted file mode 100644
index 66839575..00000000
--- a/src/app/presentation/http/controllers/users/grant_admin.py
+++ /dev/null
@@ -1,57 +0,0 @@
-from inspect import getdoc
-from typing import Annotated
-from uuid import UUID
-
-from dishka import FromDishka
-from dishka.integrations.fastapi import inject
-from fastapi import APIRouter, Path, Security, status
-from fastapi_error_map import ErrorAwareRouter, rule
-
-from app.application.commands.grant_admin import (
- GrantAdminInteractor,
- GrantAdminRequest,
-)
-from app.application.common.exceptions.authorization import AuthorizationError
-from app.domain.exceptions.user import (
- RoleChangeNotPermittedError,
- UserNotFoundByIdError,
-)
-from app.infrastructure.auth.exceptions import AuthenticationError
-from app.infrastructure.exceptions.gateway import DataMapperError
-from app.presentation.http.auth.openapi_marker import cookie_scheme
-from app.presentation.http.errors.callbacks import log_error, log_info
-from app.presentation.http.errors.translators import (
- ServiceUnavailableTranslator,
-)
-
-
-def create_grant_admin_router() -> APIRouter:
- router = ErrorAwareRouter()
-
- @router.put(
- "/{user_id}/roles/admin",
- description=getdoc(GrantAdminInteractor),
- error_map={
- AuthenticationError: status.HTTP_401_UNAUTHORIZED,
- DataMapperError: rule(
- status=status.HTTP_503_SERVICE_UNAVAILABLE,
- translator=ServiceUnavailableTranslator(),
- on_error=log_error,
- ),
- AuthorizationError: status.HTTP_403_FORBIDDEN,
- UserNotFoundByIdError: status.HTTP_404_NOT_FOUND,
- RoleChangeNotPermittedError: status.HTTP_403_FORBIDDEN,
- },
- default_on_error=log_info,
- status_code=status.HTTP_204_NO_CONTENT,
- dependencies=[Security(cookie_scheme)],
- )
- @inject
- async def grant_admin(
- user_id: Annotated[UUID, Path()],
- interactor: FromDishka[GrantAdminInteractor],
- ) -> None:
- request_data = GrantAdminRequest(user_id)
- await interactor.execute(request_data)
-
- return router
diff --git a/src/app/presentation/http/controllers/users/list_users.py b/src/app/presentation/http/controllers/users/list_users.py
deleted file mode 100644
index 65e5403f..00000000
--- a/src/app/presentation/http/controllers/users/list_users.py
+++ /dev/null
@@ -1,80 +0,0 @@
-from inspect import getdoc
-from typing import Annotated
-
-from dishka import FromDishka
-from dishka.integrations.fastapi import inject
-from fastapi import APIRouter, Depends, Security, status
-from fastapi_error_map import ErrorAwareRouter, rule
-from pydantic import BaseModel, ConfigDict, Field
-
-from app.application.common.exceptions.authorization import AuthorizationError
-from app.application.common.exceptions.query import PaginationError, SortingError
-from app.application.common.ports.user_query_gateway import ListUsersQM
-from app.application.common.query_params.sorting import SortingOrder
-from app.application.queries.list_users import (
- ListUsersQueryService,
- ListUsersRequest,
-)
-from app.infrastructure.auth.exceptions import AuthenticationError
-from app.infrastructure.exceptions.gateway import DataMapperError, ReaderError
-from app.presentation.http.auth.openapi_marker import cookie_scheme
-from app.presentation.http.errors.callbacks import log_error, log_info
-from app.presentation.http.errors.translators import (
- ServiceUnavailableTranslator,
-)
-
-
-class ListUsersRequestPydantic(BaseModel):
- """
- Using a Pydantic model here is generally unnecessary.
- It's only implemented to render a specific Swagger UI (OpenAPI) schema.
- """
-
- model_config = ConfigDict(frozen=True)
-
- limit: Annotated[int, Field(ge=1)] = 20
- offset: Annotated[int, Field(ge=0)] = 0
- sorting_field: Annotated[str, Field()] = "username"
- sorting_order: Annotated[SortingOrder, Field()] = SortingOrder.ASC
-
-
-def create_list_users_router() -> APIRouter:
- router = ErrorAwareRouter()
-
- @router.get(
- "/",
- description=getdoc(ListUsersQueryService),
- error_map={
- AuthenticationError: status.HTTP_401_UNAUTHORIZED,
- DataMapperError: rule(
- status=status.HTTP_503_SERVICE_UNAVAILABLE,
- translator=ServiceUnavailableTranslator(),
- on_error=log_error,
- ),
- AuthorizationError: status.HTTP_403_FORBIDDEN,
- PaginationError: status.HTTP_400_BAD_REQUEST,
- SortingError: status.HTTP_400_BAD_REQUEST,
- ReaderError: rule(
- status=status.HTTP_503_SERVICE_UNAVAILABLE,
- translator=ServiceUnavailableTranslator(),
- on_error=log_error,
- ),
- },
- default_on_error=log_info,
- status_code=status.HTTP_200_OK,
- dependencies=[Security(cookie_scheme)],
- )
- @inject
- async def list_users(
- request_data_pydantic: Annotated[ListUsersRequestPydantic, Depends()],
- interactor: FromDishka[ListUsersQueryService],
- ) -> ListUsersQM:
- request_data = ListUsersRequest(
- limit=request_data_pydantic.limit,
- offset=request_data_pydantic.offset,
- sorting_field=request_data_pydantic.sorting_field,
- sorting_order=request_data_pydantic.sorting_order,
- )
- return await interactor.execute(request_data)
-
- return router
diff --git a/src/app/presentation/http/controllers/users/revoke_admin.py b/src/app/presentation/http/controllers/users/revoke_admin.py
deleted file mode 100644
index a394d01e..00000000
--- a/src/app/presentation/http/controllers/users/revoke_admin.py
+++ /dev/null
@@ -1,57 +0,0 @@
-from inspect import getdoc
-from typing import Annotated
-from uuid import UUID
-
-from dishka import FromDishka
-from dishka.integrations.fastapi import inject
-from fastapi import APIRouter, Path, Security, status
-from fastapi_error_map import ErrorAwareRouter, rule
-
-from app.application.commands.revoke_admin import (
- RevokeAdminInteractor,
- RevokeAdminRequest,
-)
-from app.application.common.exceptions.authorization import AuthorizationError
-from app.domain.exceptions.user import (
- RoleChangeNotPermittedError,
- UserNotFoundByIdError,
-)
-from app.infrastructure.auth.exceptions import AuthenticationError
-from app.infrastructure.exceptions.gateway import DataMapperError
-from app.presentation.http.auth.openapi_marker import cookie_scheme
-from app.presentation.http.errors.callbacks import log_error, log_info
-from app.presentation.http.errors.translators import (
- ServiceUnavailableTranslator,
-)
-
-
-def create_revoke_admin_router() -> APIRouter:
- router = ErrorAwareRouter()
-
- @router.delete(
- "/{user_id}/roles/admin",
- description=getdoc(RevokeAdminInteractor),
- error_map={
- AuthenticationError: status.HTTP_401_UNAUTHORIZED,
- DataMapperError: rule(
- status=status.HTTP_503_SERVICE_UNAVAILABLE,
- translator=ServiceUnavailableTranslator(),
- on_error=log_error,
- ),
- AuthorizationError: status.HTTP_403_FORBIDDEN,
- UserNotFoundByIdError: status.HTTP_404_NOT_FOUND,
- RoleChangeNotPermittedError: status.HTTP_403_FORBIDDEN,
- },
- default_on_error=log_info,
- status_code=status.HTTP_204_NO_CONTENT,
- dependencies=[Security(cookie_scheme)],
- )
- @inject
- async def revoke_admin(
- user_id: Annotated[UUID, Path()],
- interactor: FromDishka[RevokeAdminInteractor],
- ) -> None:
- request_data = RevokeAdminRequest(user_id)
- await interactor.execute(request_data)
-
- return router
diff --git a/src/app/presentation/http/controllers/users/router.py b/src/app/presentation/http/controllers/users/router.py
deleted file mode 100644
index 6cbf3b27..00000000
--- a/src/app/presentation/http/controllers/users/router.py
+++ /dev/null
@@ -1,36 +0,0 @@
-from fastapi import APIRouter
-
-from app.presentation.http.controllers.users.activate_user import (
- create_activate_user_router,
-)
-from app.presentation.http.controllers.users.create_user import (
- create_create_user_router,
-)
-from app.presentation.http.controllers.users.deactivate_user import (
- create_deactivate_user_router,
-)
-from app.presentation.http.controllers.users.grant_admin import (
- create_grant_admin_router,
-)
-from app.presentation.http.controllers.users.list_users import create_list_users_router
-from app.presentation.http.controllers.users.revoke_admin import (
- create_revoke_admin_router,
-)
-from app.presentation.http.controllers.users.set_user_password import (
- create_set_user_password_router,
-)
-
-
-def create_users_router() -> APIRouter:
- router = APIRouter(
- prefix="/users",
- tags=["Users"],
- )
- router.include_router(create_create_user_router())
- router.include_router(create_list_users_router())
- router.include_router(create_set_user_password_router())
- router.include_router(create_grant_admin_router())
- router.include_router(create_revoke_admin_router())
- router.include_router(create_activate_user_router())
- router.include_router(create_deactivate_user_router())
- return router
diff --git a/src/app/presentation/http/controllers/users/set_user_password.py b/src/app/presentation/http/controllers/users/set_user_password.py
deleted file mode 100644
index 5030af78..00000000
--- a/src/app/presentation/http/controllers/users/set_user_password.py
+++ /dev/null
@@ -1,67 +0,0 @@
-from inspect import getdoc
-from typing import Annotated
-from uuid import UUID
-
-from dishka import FromDishka
-from dishka.integrations.fastapi import inject
-from fastapi import APIRouter, Body, Path, Security, status
-from fastapi_error_map import ErrorAwareRouter, rule
-
-from app.application.commands.set_user_password import (
- SetUserPasswordInteractor,
- SetUserPasswordRequest,
-)
-from app.application.common.exceptions.authorization import AuthorizationError
-from app.domain.exceptions.base import DomainTypeError
-from app.domain.exceptions.user import (
- UserNotFoundByIdError,
-)
-from app.infrastructure.auth.exceptions import AuthenticationError
-from app.infrastructure.exceptions.gateway import DataMapperError
-from app.infrastructure.exceptions.password_hasher import PasswordHasherBusyError
-from app.presentation.http.auth.openapi_marker import cookie_scheme
-from app.presentation.http.errors.callbacks import log_error, log_info
-from app.presentation.http.errors.translators import (
- ServiceUnavailableTranslator,
-)
-
-
-def create_set_user_password_router() -> APIRouter:
- router = ErrorAwareRouter()
-
- @router.put(
- "/{user_id}/password",
- description=getdoc(SetUserPasswordInteractor),
- error_map={
- AuthenticationError: status.HTTP_401_UNAUTHORIZED,
- DataMapperError: rule(
- status=status.HTTP_503_SERVICE_UNAVAILABLE,
- translator=ServiceUnavailableTranslator(),
- on_error=log_error,
- ),
- AuthorizationError: status.HTTP_403_FORBIDDEN,
- DomainTypeError: status.HTTP_400_BAD_REQUEST,
- UserNotFoundByIdError: status.HTTP_404_NOT_FOUND,
- PasswordHasherBusyError: rule(
- status=status.HTTP_503_SERVICE_UNAVAILABLE,
- translator=ServiceUnavailableTranslator(),
- on_error=log_error,
- ),
- },
- default_on_error=log_info,
- status_code=status.HTTP_204_NO_CONTENT,
- dependencies=[Security(cookie_scheme)],
- )
- @inject
- async def set_user_password(
- user_id: Annotated[UUID, Path()],
- password: Annotated[str, Body()],
- interactor: FromDishka[SetUserPasswordInteractor],
- ) -> None:
- request_data = SetUserPasswordRequest(
- user_id=user_id,
- password=password,
- )
- await interactor.execute(request_data)
-
- return router
diff --git a/src/app/presentation/http/errors/callbacks.py b/src/app/presentation/http/errors/callbacks.py
index 7e3e40f6..a9c3e9eb 100644
--- a/src/app/presentation/http/errors/callbacks.py
+++ b/src/app/presentation/http/errors/callbacks.py
@@ -1,11 +1,7 @@
import logging
-log = logging.getLogger(__name__)
+logger = logging.getLogger(__name__)
def log_info(err: Exception) -> None:
- log.info(f"Handled exception: {type(err).__name__} β {err}")
-
-
-def log_error(err: Exception) -> None:
- log.error(f"Handled exception: {type(err).__name__} β {err}")
+ logger.info("Handled exception: %s β %s", type(err).__name__, err)
diff --git a/src/app/presentation/http/errors/rules.py b/src/app/presentation/http/errors/rules.py
new file mode 100644
index 00000000..7667f565
--- /dev/null
+++ b/src/app/presentation/http/errors/rules.py
@@ -0,0 +1,11 @@
+from typing import Final
+
+from fastapi_error_map.rules import Rule, rule
+from starlette import status
+
+from app.presentation.http.errors.translators import ServiceUnavailableTranslator
+
+HTTP_503_SERVICE_UNAVAILABLE_RULE: Final[Rule] = rule(
+ status=status.HTTP_503_SERVICE_UNAVAILABLE,
+ translator=ServiceUnavailableTranslator(),
+)
diff --git a/src/app/presentation/http/errors/translators.py b/src/app/presentation/http/errors/translators.py
index e26deb87..221b0b53 100644
--- a/src/app/presentation/http/errors/translators.py
+++ b/src/app/presentation/http/errors/translators.py
@@ -7,6 +7,4 @@ 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."
- )
+ return SimpleErrorResponseModel(error="Service temporarily unavailable. Please try again later.")
diff --git a/src/app/infrastructure/auth/session/__init__.py b/src/app/presentation/http/health/__init__.py
similarity index 100%
rename from src/app/infrastructure/auth/session/__init__.py
rename to src/app/presentation/http/health/__init__.py
diff --git a/src/app/presentation/http/health/checks.py b/src/app/presentation/http/health/checks.py
new file mode 100644
index 00000000..05609069
--- /dev/null
+++ b/src/app/presentation/http/health/checks.py
@@ -0,0 +1,13 @@
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import AsyncSession
+
+
+class ProbeError(Exception):
+ pass
+
+
+async def db_check(session: AsyncSession) -> None:
+ try:
+ await session.scalar(text("SELECT 1"))
+ except Exception as e:
+ raise ProbeError from e
diff --git a/src/app/presentation/http/health/router.py b/src/app/presentation/http/health/router.py
new file mode 100644
index 00000000..7c9b1e24
--- /dev/null
+++ b/src/app/presentation/http/health/router.py
@@ -0,0 +1,43 @@
+from dishka import FromDishka
+from dishka.integrations.fastapi import inject
+from fastapi import APIRouter
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.presentation.http.health.checks import db_check
+
+
+class InternalServerError(Exception):
+ pass
+
+
+def make_health_router(*, debug_mode: bool) -> APIRouter:
+ router = APIRouter()
+
+ @router.get(
+ "/livez/",
+ include_in_schema=False,
+ )
+ async def liveness_probe() -> str:
+ return "OK"
+
+ @router.get(
+ "/healthz/",
+ include_in_schema=False,
+ )
+ @inject
+ async def readiness_probe(
+ session: FromDishka[AsyncSession],
+ ) -> str:
+ await db_check(session)
+ return "OK"
+
+ if debug_mode:
+
+ @router.get(
+ "/http_error/",
+ include_in_schema=False,
+ )
+ async def generate_http_error() -> None:
+ raise InternalServerError("Internal Server Error")
+
+ return router
diff --git a/src/app/presentation/http/root_router.py b/src/app/presentation/http/root_router.py
new file mode 100644
index 00000000..345b9db7
--- /dev/null
+++ b/src/app/presentation/http/root_router.py
@@ -0,0 +1,20 @@
+from fastapi import APIRouter
+from starlette.responses import RedirectResponse
+
+from app.presentation.http.api_v1_router import make_v1_router
+from app.presentation.http.health.router import make_health_router
+
+
+def make_fastapi_root_router(*, debug_mode: bool, cookie_name: str) -> APIRouter:
+ router = APIRouter()
+
+ @router.get(
+ "/",
+ include_in_schema=False,
+ )
+ async def redirect_to_docs() -> RedirectResponse:
+ return RedirectResponse(url="/docs")
+
+ router.include_router(make_health_router(debug_mode=debug_mode))
+ router.include_router(make_v1_router(cookie_name=cookie_name))
+ return router
diff --git a/src/app/infrastructure/auth/session/ports/__init__.py b/src/app/presentation/http/users/__init__.py
similarity index 100%
rename from src/app/infrastructure/auth/session/ports/__init__.py
rename to src/app/presentation/http/users/__init__.py
diff --git a/src/app/presentation/http/users/activate_user.py b/src/app/presentation/http/users/activate_user.py
new file mode 100644
index 00000000..7a7e2747
--- /dev/null
+++ b/src/app/presentation/http/users/activate_user.py
@@ -0,0 +1,42 @@
+from inspect import getdoc
+from typing import Annotated
+from uuid import UUID
+
+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.infrastructure.auth_ctx.exceptions import AuthenticationError
+from app.infrastructure.exceptions import StorageError
+from app.presentation.http.errors.callbacks import log_info
+from app.presentation.http.errors.rules import HTTP_503_SERVICE_UNAVAILABLE_RULE
+
+
+def make_activate_user_router() -> APIRouter:
+ router = ErrorAwareRouter()
+
+ @router.put(
+ "/{user_id}/activation/",
+ error_map={
+ AuthenticationError: status.HTTP_401_UNAUTHORIZED,
+ StorageError: HTTP_503_SERVICE_UNAVAILABLE_RULE,
+ 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),
+ )
+ @inject
+ async def activate_user(
+ user_id: Annotated[UUID, Path()],
+ interactor: FromDishka[ActivateUser],
+ ) -> None:
+ request = ActivateUserRequest(user_id)
+ await interactor.execute(request)
+
+ return router
diff --git a/src/app/presentation/http/users/create_user.py b/src/app/presentation/http/users/create_user.py
new file mode 100644
index 00000000..c20752cf
--- /dev/null
+++ b/src/app/presentation/http/users/create_user.py
@@ -0,0 +1,46 @@
+from inspect import getdoc
+
+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
+from app.core.commands.exceptions import (
+ UsernameAlreadyExistsError,
+)
+from app.core.common.authorization.exceptions import AuthorizationError
+from app.core.common.exceptions import BusinessTypeError
+from app.infrastructure.adapters.exceptions import PasswordHasherBusyError
+from app.infrastructure.auth_ctx.exceptions import AuthenticationError
+from app.infrastructure.exceptions import StorageError
+from app.presentation.http.errors.callbacks import log_info
+from app.presentation.http.errors.rules import HTTP_503_SERVICE_UNAVAILABLE_RULE
+
+
+def make_create_user_router() -> APIRouter:
+ router = ErrorAwareRouter()
+
+ @router.post(
+ "/",
+ error_map={
+ AuthenticationError: status.HTTP_401_UNAUTHORIZED,
+ StorageError: HTTP_503_SERVICE_UNAVAILABLE_RULE,
+ AuthorizationError: status.HTTP_403_FORBIDDEN,
+ BusinessTypeError: status.HTTP_400_BAD_REQUEST,
+ 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),
+ )
+ @inject
+ async def create_user(
+ request: CreateUserRequest,
+ interactor: FromDishka[CreateUser],
+ ) -> CreateUserResponse:
+ return await interactor.execute(request)
+
+ return router
diff --git a/src/app/presentation/http/users/deactivate_user.py b/src/app/presentation/http/users/deactivate_user.py
new file mode 100644
index 00000000..4c9a95c2
--- /dev/null
+++ b/src/app/presentation/http/users/deactivate_user.py
@@ -0,0 +1,42 @@
+from inspect import getdoc
+from typing import Annotated
+from uuid import UUID
+
+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.infrastructure.auth_ctx.exceptions import AuthenticationError
+from app.infrastructure.exceptions import StorageError
+from app.presentation.http.errors.callbacks import log_info
+from app.presentation.http.errors.rules import HTTP_503_SERVICE_UNAVAILABLE_RULE
+
+
+def make_deactivate_user_router() -> APIRouter:
+ router = ErrorAwareRouter()
+
+ @router.delete(
+ "/{user_id}/activation/",
+ error_map={
+ AuthenticationError: status.HTTP_401_UNAUTHORIZED,
+ StorageError: HTTP_503_SERVICE_UNAVAILABLE_RULE,
+ 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),
+ )
+ @inject
+ async def deactivate_user(
+ user_id: Annotated[UUID, Path()],
+ interactor: FromDishka[DeactivateUser],
+ ) -> None:
+ request = DeactivateUserRequest(user_id)
+ await interactor.execute(request)
+
+ return router
diff --git a/src/app/presentation/http/users/grant_admin.py b/src/app/presentation/http/users/grant_admin.py
new file mode 100644
index 00000000..7428f03c
--- /dev/null
+++ b/src/app/presentation/http/users/grant_admin.py
@@ -0,0 +1,42 @@
+from inspect import getdoc
+from typing import Annotated
+from uuid import UUID
+
+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.infrastructure.auth_ctx.exceptions import AuthenticationError
+from app.infrastructure.exceptions import StorageError
+from app.presentation.http.errors.callbacks import log_info
+from app.presentation.http.errors.rules import HTTP_503_SERVICE_UNAVAILABLE_RULE
+
+
+def make_grant_admin_router() -> APIRouter:
+ router = ErrorAwareRouter()
+
+ @router.put(
+ "/{user_id}/roles/admin/",
+ error_map={
+ AuthenticationError: status.HTTP_401_UNAUTHORIZED,
+ StorageError: HTTP_503_SERVICE_UNAVAILABLE_RULE,
+ 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),
+ )
+ @inject
+ async def grant_admin(
+ user_id: Annotated[UUID, Path()],
+ interactor: FromDishka[GrantAdmin],
+ ) -> None:
+ request = GrantAdminRequest(user_id)
+ await interactor.execute(request)
+
+ return router
diff --git a/src/app/presentation/http/users/list_users.py b/src/app/presentation/http/users/list_users.py
new file mode 100644
index 00000000..6e461b47
--- /dev/null
+++ b/src/app/presentation/http/users/list_users.py
@@ -0,0 +1,66 @@
+from inspect import getdoc
+from typing import Annotated
+
+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
+
+from app.core.common.authorization.exceptions import AuthorizationError
+from app.core.queries.list_users import ListUsers, ListUsersRequest, UserSortingField
+from app.core.queries.ports.user_reader import ListUsersQm
+from app.core.queries.query_support.exceptions import PaginationError
+from app.core.queries.query_support.offset_pagination import OffsetPaginationParams
+from app.core.queries.query_support.sorting import SortingOrder
+from app.infrastructure.auth_ctx.exceptions import AuthenticationError
+from app.infrastructure.exceptions import ReaderError, StorageError
+from app.presentation.http.errors.callbacks import log_info
+from app.presentation.http.errors.rules import HTTP_503_SERVICE_UNAVAILABLE_RULE
+
+
+class ListUsersRequestSchema(BaseModel):
+ """
+ Using Pydantic model here is generally unnecessary.
+ It's only implemented to render specific Swagger UI.
+ """
+
+ model_config = ConfigDict(frozen=True)
+
+ limit: Annotated[int, Field(ge=1, le=OffsetPaginationParams.MAX_INT32)] = 20
+ offset: Annotated[int, Field(ge=0, le=OffsetPaginationParams.MAX_INT32)] = 0
+ sorting_field: Annotated[UserSortingField, Field()] = UserSortingField.UPDATED_AT
+ sorting_order: Annotated[SortingOrder, Field()] = SortingOrder.DESC
+
+
+def make_list_users_router() -> APIRouter:
+ router = ErrorAwareRouter()
+
+ @router.get(
+ "/",
+ error_map={
+ AuthenticationError: status.HTTP_401_UNAUTHORIZED,
+ StorageError: HTTP_503_SERVICE_UNAVAILABLE_RULE,
+ AuthorizationError: status.HTTP_403_FORBIDDEN,
+ 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),
+ )
+ @inject
+ async def list_users(
+ request_schema: Annotated[ListUsersRequestSchema, Depends()],
+ interactor: FromDishka[ListUsers],
+ ) -> ListUsersQm:
+ request = ListUsersRequest(
+ limit=request_schema.limit,
+ offset=request_schema.offset,
+ sorting_field=request_schema.sorting_field,
+ sorting_order=request_schema.sorting_order,
+ )
+ return await interactor.execute(request)
+
+ return router
diff --git a/src/app/presentation/http/users/revoke_admin.py b/src/app/presentation/http/users/revoke_admin.py
new file mode 100644
index 00000000..46f0df6b
--- /dev/null
+++ b/src/app/presentation/http/users/revoke_admin.py
@@ -0,0 +1,42 @@
+from inspect import getdoc
+from typing import Annotated
+from uuid import UUID
+
+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.infrastructure.auth_ctx.exceptions import AuthenticationError
+from app.infrastructure.exceptions import StorageError
+from app.presentation.http.errors.callbacks import log_info
+from app.presentation.http.errors.rules import HTTP_503_SERVICE_UNAVAILABLE_RULE
+
+
+def make_revoke_admin_router() -> APIRouter:
+ router = ErrorAwareRouter()
+
+ @router.delete(
+ "/{user_id}/roles/admin/",
+ error_map={
+ AuthenticationError: status.HTTP_401_UNAUTHORIZED,
+ StorageError: HTTP_503_SERVICE_UNAVAILABLE_RULE,
+ 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),
+ )
+ @inject
+ async def revoke_admin(
+ user_id: Annotated[UUID, Path()],
+ interactor: FromDishka[RevokeAdmin],
+ ) -> None:
+ request = RevokeAdminRequest(user_id)
+ await interactor.execute(request)
+
+ return router
diff --git a/src/app/presentation/http/users/router.py b/src/app/presentation/http/users/router.py
new file mode 100644
index 00000000..ae2f39a2
--- /dev/null
+++ b/src/app/presentation/http/users/router.py
@@ -0,0 +1,26 @@
+from fastapi import APIRouter, Depends
+from fastapi.security import APIKeyCookie
+
+from app.presentation.http.users.activate_user import make_activate_user_router
+from app.presentation.http.users.create_user import make_create_user_router
+from app.presentation.http.users.deactivate_user import make_deactivate_user_router
+from app.presentation.http.users.grant_admin import make_grant_admin_router
+from app.presentation.http.users.list_users import make_list_users_router
+from app.presentation.http.users.revoke_admin import make_revoke_admin_router
+from app.presentation.http.users.set_user_password import make_set_user_password_router
+
+
+def make_users_router(*, cookie_name: str) -> APIRouter:
+ router = APIRouter(
+ prefix="/users",
+ tags=["Users"],
+ dependencies=[Depends(APIKeyCookie(name=cookie_name))],
+ )
+ router.include_router(make_create_user_router())
+ router.include_router(make_list_users_router())
+ router.include_router(make_set_user_password_router())
+ router.include_router(make_grant_admin_router())
+ router.include_router(make_revoke_admin_router())
+ router.include_router(make_activate_user_router())
+ router.include_router(make_deactivate_user_router())
+ return router
diff --git a/src/app/presentation/http/users/set_user_password.py b/src/app/presentation/http/users/set_user_password.py
new file mode 100644
index 00000000..1a5e6dd5
--- /dev/null
+++ b/src/app/presentation/http/users/set_user_password.py
@@ -0,0 +1,63 @@
+from inspect import getdoc
+from typing import Annotated
+from uuid import UUID
+
+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
+
+from app.core.commands.exceptions import UserNotFoundError
+from app.core.commands.set_user_password import SetUserPassword, SetUserPasswordRequest
+from app.core.common.authorization.exceptions import AuthorizationError
+from app.core.common.exceptions import BusinessTypeError
+from app.infrastructure.adapters.exceptions import PasswordHasherBusyError
+from app.infrastructure.auth_ctx.exceptions import AuthenticationError
+from app.infrastructure.exceptions import StorageError
+from app.presentation.http.errors.callbacks import log_info
+from app.presentation.http.errors.rules import HTTP_503_SERVICE_UNAVAILABLE_RULE
+
+
+class SetUserPasswordRequestSchema(BaseModel):
+ """
+ Using Pydantic model here is generally unnecessary.
+ It's only implemented to render specific Swagger UI.
+ """
+
+ model_config = ConfigDict(frozen=True)
+
+ password: str
+
+
+def make_set_user_password_router() -> APIRouter:
+ router = ErrorAwareRouter()
+
+ @router.put(
+ "/{user_id}/password/",
+ error_map={
+ AuthenticationError: status.HTTP_401_UNAUTHORIZED,
+ StorageError: HTTP_503_SERVICE_UNAVAILABLE_RULE,
+ AuthorizationError: status.HTTP_403_FORBIDDEN,
+ BusinessTypeError: status.HTTP_400_BAD_REQUEST,
+ 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),
+ )
+ @inject
+ async def set_user_password(
+ user_id: Annotated[UUID, Path()],
+ request_schema: SetUserPasswordRequestSchema,
+ interactor: FromDishka[SetUserPassword],
+ ) -> None:
+ request = SetUserPasswordRequest(
+ user_id=user_id,
+ password=request_schema.password,
+ )
+ await interactor.execute(request)
+
+ return router
diff --git a/src/app/run.py b/src/app/run.py
deleted file mode 100644
index 3eb7c01b..00000000
--- a/src/app/run.py
+++ /dev/null
@@ -1,36 +0,0 @@
-from dishka import Provider
-from dishka.integrations.fastapi import setup_dishka
-from fastapi import FastAPI
-
-from app.setup.app_factory import create_ioc_container, create_web_app
-from app.setup.config.logs import configure_logging
-from app.setup.config.settings import AppSettings, load_settings
-
-
-def make_app(
- *di_providers: Provider,
- settings: AppSettings | None = None,
-) -> FastAPI:
- """Pass providers to override existing ones for testing."""
- if settings is None:
- configure_logging()
- settings = load_settings()
-
- configure_logging(level=settings.logs.level)
-
- app: FastAPI = create_web_app()
- container = create_ioc_container(settings, *di_providers)
- setup_dishka(container, app)
-
- return app
-
-
-if __name__ == "__main__":
- import uvicorn
-
- uvicorn.run(
- app=make_app(),
- port=8000,
- reload=False,
- loop="uvloop",
- )
diff --git a/src/app/setup/app_factory.py b/src/app/setup/app_factory.py
deleted file mode 100644
index 976c74e2..00000000
--- a/src/app/setup/app_factory.py
+++ /dev/null
@@ -1,48 +0,0 @@
-from collections.abc import AsyncIterator
-from contextlib import asynccontextmanager
-
-from dishka import AsyncContainer, Provider, make_async_container
-from fastapi import FastAPI
-from fastapi.responses import ORJSONResponse
-
-from app.infrastructure.persistence_sqla.mappings.all import map_tables
-from app.presentation.http.auth.asgi_middleware import (
- ASGIAuthMiddleware,
-)
-from app.presentation.http.controllers.root_router import create_root_router
-from app.setup.config.settings import AppSettings
-from app.setup.ioc.provider_registry import get_providers
-
-
-def create_ioc_container(
- settings: AppSettings,
- *di_providers: Provider,
-) -> AsyncContainer:
- return make_async_container(
- *get_providers(),
- *di_providers,
- context={AppSettings: settings},
- )
-
-
-def create_web_app() -> FastAPI:
- app = FastAPI(
- lifespan=lifespan,
- default_response_class=ORJSONResponse,
- )
- # https://github.com/encode/starlette/discussions/2451
- app.add_middleware(ASGIAuthMiddleware)
- # Good place to register global exception handlers
- app.include_router(create_root_router())
- return app
-
-
-@asynccontextmanager
-async def lifespan(app: FastAPI) -> AsyncIterator[None]:
- # https://dishka.readthedocs.io/en/stable/integrations/fastapi.html
- container = app.state.dishka_container
- try:
- map_tables()
- yield
- finally:
- await container.close()
diff --git a/src/app/setup/config/database.py b/src/app/setup/config/database.py
deleted file mode 100644
index 7014d2c8..00000000
--- a/src/app/setup/config/database.py
+++ /dev/null
@@ -1,51 +0,0 @@
-import os
-from typing import Final
-
-from pydantic import BaseModel, Field, PostgresDsn, field_validator
-
-PORT_MIN: Final[int] = 1
-PORT_MAX: Final[int] = 65535
-
-
-class PostgresSettings(BaseModel):
- user: str = Field(alias="USER")
- password: str = Field(alias="PASSWORD")
- db: str = Field(alias="DB")
- host: str = Field(alias="HOST")
- port: int = Field(alias="PORT")
- driver: str = Field(alias="DRIVER")
-
- @field_validator("host")
- @classmethod
- def override_host_from_env(cls, v: str) -> str:
- postgres_host_env = os.environ.get("POSTGRES_HOST")
- if postgres_host_env:
- return postgres_host_env
- return v
-
- @field_validator("port")
- @classmethod
- def validate_port_range(cls, v: int) -> int:
- if not PORT_MIN <= v <= PORT_MAX:
- raise ValueError(f"Port must be between {PORT_MIN} and {PORT_MAX}")
- return v
-
- @property
- def dsn(self) -> str:
- return str(
- PostgresDsn.build(
- scheme=f"postgresql+{self.driver}",
- username=self.user,
- password=self.password,
- host=self.host,
- port=self.port,
- path=self.db,
- ),
- )
-
-
-class SqlaEngineSettings(BaseModel):
- echo: bool = Field(alias="ECHO")
- echo_pool: bool = Field(alias="ECHO_POOL")
- pool_size: int = Field(alias="POOL_SIZE")
- max_overflow: int = Field(alias="MAX_OVERFLOW")
diff --git a/src/app/setup/config/loader.py b/src/app/setup/config/loader.py
deleted file mode 100644
index d0349b1d..00000000
--- a/src/app/setup/config/loader.py
+++ /dev/null
@@ -1,104 +0,0 @@
-import logging
-import os
-import tomllib
-from collections.abc import Mapping
-from enum import StrEnum
-from pathlib import Path
-from types import MappingProxyType
-from typing import Any, Final
-
-ConfigDict = dict[str, Any]
-
-log = logging.getLogger(__name__)
-
-ENV_VAR_NAME: Final[str] = "APP_ENV"
-
-
-class ValidEnvs(StrEnum):
- """
- Values should reflect actual directory names.
- """
-
- LOCAL = "local"
- DEV = "dev"
- PROD = "prod"
-
-
-class DirContents(StrEnum):
- """
- Values should reflect actual file names.
- """
-
- CONFIG_NAME = "config.toml"
- SECRETS_NAME = ".secrets.toml"
- EXPORT_NAME = "export.toml"
- DOTENV_NAME = ".env"
-
-
-BASE_DIR_PATH: Final[Path] = Path(__file__).resolve().parents[4]
-CONFIG_PATH: Final[Path] = BASE_DIR_PATH / "config"
-
-ENV_TO_DIR_PATHS: Final[Mapping[ValidEnvs, Path]] = MappingProxyType({
- ValidEnvs.LOCAL: CONFIG_PATH / ValidEnvs.LOCAL,
- ValidEnvs.DEV: CONFIG_PATH / ValidEnvs.DEV,
- ValidEnvs.PROD: CONFIG_PATH / ValidEnvs.PROD,
-})
-
-
-def validate_env(env: str | None) -> ValidEnvs:
- if env is None:
- raise ValueError(f"{ENV_VAR_NAME} is not set.")
- try:
- return ValidEnvs(env)
- except ValueError as err:
- valid_values = ", ".join(f"'{e}'" for e in ValidEnvs)
- raise ValueError(
- f"Invalid {ENV_VAR_NAME}: '{env}'. Must be one of: {valid_values}.",
- ) from err
-
-
-def get_current_env() -> ValidEnvs:
- return validate_env(os.getenv(ENV_VAR_NAME))
-
-
-def load_full_config(
- env: ValidEnvs,
- dir_paths: Mapping[ValidEnvs, Path] = ENV_TO_DIR_PATHS,
- main_config: DirContents = DirContents.CONFIG_NAME,
- secrets_config: DirContents = DirContents.SECRETS_NAME,
-) -> ConfigDict:
- log.info("Reading config for environment: '%s'", env)
- config = read_config(env=env, config=main_config, dir_paths=dir_paths)
- try:
- secrets = read_config(env=env, config=secrets_config, dir_paths=dir_paths)
- except FileNotFoundError:
- log.warning("Secrets file not found. Full config will not contain secrets.")
- return config
- return merge_dicts(dict1=config, dict2=secrets)
-
-
-def read_config(
- env: ValidEnvs,
- dir_paths: Mapping[ValidEnvs, Path],
- config: DirContents,
-) -> ConfigDict:
- dir_path = dir_paths.get(env)
- if dir_path is None:
- raise FileNotFoundError(f"No directory path configured for environment: {env}")
- file_path = dir_path / config
- if not file_path.is_file():
- raise FileNotFoundError(
- f"The file does not exist at the specified path: {file_path}",
- )
- with file_path.open(mode="rb") as f:
- return tomllib.load(f)
-
-
-def merge_dicts(*, dict1: ConfigDict, dict2: ConfigDict) -> ConfigDict:
- result = dict1.copy()
- for key, value in dict2.items():
- if key in result and isinstance(result[key], dict) and isinstance(value, dict):
- result[key] = merge_dicts(dict1=result[key], dict2=value)
- else:
- result[key] = value
- return result
diff --git a/src/app/setup/config/logs.py b/src/app/setup/config/logs.py
deleted file mode 100644
index 90cfcc3e..00000000
--- a/src/app/setup/config/logs.py
+++ /dev/null
@@ -1,42 +0,0 @@
-import logging
-from enum import StrEnum
-from typing import Final
-
-from pydantic import BaseModel, Field
-
-
-class LoggingLevel(StrEnum):
- DEBUG = "DEBUG"
- INFO = "INFO"
- WARNING = "WARNING"
- ERROR = "ERROR"
- CRITICAL = "CRITICAL"
-
-
-DEFAULT_LOG_LEVEL: Final[LoggingLevel] = LoggingLevel.INFO
-
-FMT: Final[str] = (
- "[%(asctime)s.%(msecs)03d] "
- "[%(threadName)s] "
- "%(funcName)20s "
- "%(module)s:%(lineno)d "
- "%(levelname)-8s - "
- "%(message)s"
-)
-DATEFMT: Final[str] = "%Y-%m-%d %H:%M:%S"
-
-
-def configure_logging(
- *,
- level: LoggingLevel = DEFAULT_LOG_LEVEL,
-) -> None:
- logging.basicConfig(
- level=level,
- datefmt=DATEFMT,
- format=FMT,
- force=True,
- )
-
-
-class LoggingSettings(BaseModel):
- level: LoggingLevel = Field(alias="LEVEL")
diff --git a/src/app/setup/config/security.py b/src/app/setup/config/security.py
deleted file mode 100644
index 21ae40e0..00000000
--- a/src/app/setup/config/security.py
+++ /dev/null
@@ -1,50 +0,0 @@
-from datetime import timedelta
-from typing import Any, Literal
-
-from pydantic import BaseModel, Field, field_validator
-
-
-class AuthSettings(BaseModel):
- jwt_secret: str = Field(alias="JWT_SECRET", min_length=32)
- jwt_algorithm: Literal[
- "HS256",
- "HS384",
- "HS512",
- "RS256",
- "RS384",
- "RS512",
- ] = Field(alias="JWT_ALGORITHM")
- session_ttl_min: timedelta = Field(alias="SESSION_TTL_MIN")
- session_refresh_threshold: float = Field(
- gt=0,
- lt=1,
- alias="SESSION_REFRESH_THRESHOLD",
- )
-
- @field_validator("session_ttl_min", mode="before")
- @classmethod
- def convert_session_ttl_min(cls, v: Any) -> timedelta:
- if not isinstance(v, (int, float)):
- raise ValueError("SESSION_TTL_MIN must be a number (n of minutes, n >= 1).")
- if v < 1:
- raise ValueError("SESSION_TTL_MIN must be at least 1 (n of minutes).")
- return timedelta(minutes=v)
-
-
-class CookiesSettings(BaseModel):
- secure: bool = Field(alias="SECURE")
-
-
-class PasswordSettings(BaseModel):
- pepper: str = Field(alias="PEPPER", min_length=32)
- hasher_work_factor: int = Field(alias="HASHER_WORK_FACTOR", ge=10)
- hasher_max_threads: int = Field(alias="HASHER_MAX_THREADS", ge=1)
- hasher_semaphore_wait_timeout_s: float = Field(
- alias="HASHER_SEMAPHORE_WAIT_TIMEOUT_S", gt=0
- )
-
-
-class SecuritySettings(BaseModel):
- auth: AuthSettings
- cookies: CookiesSettings
- password: PasswordSettings
diff --git a/src/app/setup/config/settings.py b/src/app/setup/config/settings.py
deleted file mode 100644
index e3bcd2fa..00000000
--- a/src/app/setup/config/settings.py
+++ /dev/null
@@ -1,22 +0,0 @@
-from pydantic import (
- BaseModel,
-)
-
-from app.setup.config.database import PostgresSettings, SqlaEngineSettings
-from app.setup.config.loader import ValidEnvs, get_current_env, load_full_config
-from app.setup.config.logs import LoggingSettings
-from app.setup.config.security import SecuritySettings
-
-
-class AppSettings(BaseModel):
- postgres: PostgresSettings
- sqla: SqlaEngineSettings
- security: SecuritySettings
- logs: LoggingSettings
-
-
-def load_settings(env: ValidEnvs | None = None) -> AppSettings:
- if env is None:
- env = get_current_env()
- raw_config = load_full_config(env=env)
- return AppSettings.model_validate(raw_config)
diff --git a/src/app/setup/ioc/application.py b/src/app/setup/ioc/application.py
deleted file mode 100644
index 24f0f084..00000000
--- a/src/app/setup/ioc/application.py
+++ /dev/null
@@ -1,66 +0,0 @@
-from dishka import Provider, Scope, provide, provide_all
-
-from app.application.commands.activate_user import ActivateUserInteractor
-from app.application.commands.create_user import CreateUserInteractor
-from app.application.commands.deactivate_user import DeactivateUserInteractor
-from app.application.commands.grant_admin import GrantAdminInteractor
-from app.application.commands.revoke_admin import RevokeAdminInteractor
-from app.application.commands.set_user_password import SetUserPasswordInteractor
-from app.application.common.ports.access_revoker import AccessRevoker
-from app.application.common.ports.flusher import Flusher
-from app.application.common.ports.identity_provider import IdentityProvider
-from app.application.common.ports.transaction_manager import (
- TransactionManager,
-)
-from app.application.common.ports.user_command_gateway import UserCommandGateway
-from app.application.common.ports.user_query_gateway import UserQueryGateway
-from app.application.common.services.current_user import CurrentUserService
-from app.application.queries.list_users import ListUsersQueryService
-from app.infrastructure.adapters.main_flusher_sqla import SqlaMainFlusher
-from app.infrastructure.adapters.main_transaction_manager_sqla import (
- SqlaMainTransactionManager,
-)
-from app.infrastructure.adapters.user_data_mapper_sqla import (
- SqlaUserDataMapper,
-)
-from app.infrastructure.adapters.user_reader_sqla import SqlaUserReader
-from app.infrastructure.auth.adapters.access_revoker import (
- AuthSessionAccessRevoker,
-)
-from app.infrastructure.auth.adapters.identity_provider import (
- AuthSessionIdentityProvider,
-)
-
-
-class ApplicationProvider(Provider):
- scope = Scope.REQUEST
-
- # Services
- services = provide_all(
- CurrentUserService,
- )
-
- # Ports Persistence
- tx_manager = provide(SqlaMainTransactionManager, provides=TransactionManager)
- flusher = provide(SqlaMainFlusher, provides=Flusher)
- user_command_gateway = provide(SqlaUserDataMapper, provides=UserCommandGateway)
- user_query_gateway = provide(SqlaUserReader, provides=UserQueryGateway)
-
- # Ports Auth
- access_revoker = provide(AuthSessionAccessRevoker, provides=AccessRevoker)
- identity_provider = provide(AuthSessionIdentityProvider, provides=IdentityProvider)
-
- # Commands
- commands = provide_all(
- ActivateUserInteractor,
- SetUserPasswordInteractor,
- CreateUserInteractor,
- DeactivateUserInteractor,
- GrantAdminInteractor,
- RevokeAdminInteractor,
- )
-
- # Queries
- query_services = provide_all(
- ListUsersQueryService,
- )
diff --git a/src/app/setup/ioc/domain.py b/src/app/setup/ioc/domain.py
deleted file mode 100644
index 8042fe72..00000000
--- a/src/app/setup/ioc/domain.py
+++ /dev/null
@@ -1,40 +0,0 @@
-from dishka import Provider, Scope, provide, provide_all
-
-from app.domain.ports.password_hasher import PasswordHasher
-from app.domain.ports.user_id_generator import UserIdGenerator
-from app.domain.services.user import UserService
-from app.infrastructure.adapters.password_hasher_bcrypt import (
- BcryptPasswordHasher,
-)
-from app.infrastructure.adapters.types import HasherSemaphore, HasherThreadPoolExecutor
-from app.infrastructure.adapters.user_id_generator_uuid import (
- UuidUserIdGenerator,
-)
-from app.setup.config.security import SecuritySettings
-
-
-class DomainProvider(Provider):
- scope = Scope.APP
-
- # Services
- user_service = provide_all(
- UserService,
- )
-
- # Ports
- user_id_generator = provide(UuidUserIdGenerator, provides=UserIdGenerator)
-
- @provide
- def provide_password_hasher(
- self,
- security: SecuritySettings,
- executor: HasherThreadPoolExecutor,
- semaphore: HasherSemaphore,
- ) -> PasswordHasher:
- return BcryptPasswordHasher(
- pepper=security.password.pepper.encode(),
- work_factor=security.password.hasher_work_factor,
- executor=executor,
- semaphore=semaphore,
- semaphore_wait_timeout_s=security.password.hasher_semaphore_wait_timeout_s,
- )
diff --git a/src/app/setup/ioc/infrastructure.py b/src/app/setup/ioc/infrastructure.py
deleted file mode 100644
index 96ace964..00000000
--- a/src/app/setup/ioc/infrastructure.py
+++ /dev/null
@@ -1,182 +0,0 @@
-import asyncio
-import logging
-from collections.abc import AsyncIterator, Iterator
-from concurrent.futures import ThreadPoolExecutor
-from typing import cast
-
-from dishka import Provider, Scope, provide, provide_all
-from sqlalchemy.ext.asyncio import (
- AsyncEngine,
- AsyncSession,
- async_sessionmaker,
- create_async_engine,
-)
-
-from app.infrastructure.adapters.types import (
- HasherSemaphore,
- HasherThreadPoolExecutor,
- MainAsyncSession,
-)
-from app.infrastructure.auth.adapters.data_mapper_sqla import (
- SqlaAuthSessionDataMapper,
-)
-from app.infrastructure.auth.adapters.transaction_manager_sqla import (
- SqlaAuthSessionTransactionManager,
-)
-from app.infrastructure.auth.adapters.types import AuthAsyncSession
-from app.infrastructure.auth.handlers.change_password import (
- ChangePasswordHandler,
-)
-from app.infrastructure.auth.handlers.log_in import LogInHandler
-from app.infrastructure.auth.handlers.log_out import LogOutHandler
-from app.infrastructure.auth.handlers.sign_up import SignUpHandler
-from app.infrastructure.auth.session.id_generator_str import (
- StrAuthSessionIdGenerator,
-)
-from app.infrastructure.auth.session.ports.gateway import AuthSessionGateway
-from app.infrastructure.auth.session.ports.transaction_manager import (
- AuthSessionTransactionManager,
-)
-from app.infrastructure.auth.session.ports.transport import AuthSessionTransport
-from app.infrastructure.auth.session.service import AuthSessionService
-from app.infrastructure.auth.session.timer_utc import UtcAuthSessionTimer
-from app.presentation.http.auth.adapters.session_transport_jwt_cookie import (
- JwtCookieAuthSessionTransport,
-)
-from app.setup.config.database import PostgresSettings, SqlaEngineSettings
-from app.setup.config.security import SecuritySettings
-
-log = logging.getLogger(__name__)
-
-
-class MainAdaptersProvider(Provider):
- scope = Scope.APP
-
- @provide
- def provide_hasher_threadpool_executor(
- self,
- security: SecuritySettings,
- ) -> Iterator[HasherThreadPoolExecutor]:
- executor = HasherThreadPoolExecutor(
- ThreadPoolExecutor(
- max_workers=security.password.hasher_max_threads,
- thread_name_prefix="bcrypt",
- )
- )
- yield executor
- log.debug("Disposing hasher threadpool executor...")
- executor.shutdown(wait=True, cancel_futures=True)
- log.debug("Hasher threadpool executor is disposed.")
-
- @provide
- def provide_hasher_semaphore(self, security: SecuritySettings) -> HasherSemaphore:
- return HasherSemaphore(asyncio.Semaphore(security.password.hasher_max_threads))
-
-
-class PersistenceSqlaProvider(Provider):
- @provide(scope=Scope.APP)
- async def provide_async_engine(
- self,
- postgres: PostgresSettings,
- sqla_engine: SqlaEngineSettings,
- ) -> AsyncIterator[AsyncEngine]:
- async_engine = create_async_engine(
- url=postgres.dsn,
- echo=sqla_engine.echo,
- echo_pool=sqla_engine.echo_pool,
- pool_size=sqla_engine.pool_size,
- max_overflow=sqla_engine.max_overflow,
- connect_args={"connect_timeout": 5},
- pool_pre_ping=True,
- )
- log.debug("Async engine created with DSN: %s", postgres.dsn)
- yield async_engine
- log.debug("Disposing async engine...")
- await async_engine.dispose()
- log.debug("Engine is disposed.")
-
- @provide(scope=Scope.APP)
- def provide_async_session_factory(
- self,
- engine: AsyncEngine,
- ) -> async_sessionmaker[AsyncSession]:
- async_session_factory = async_sessionmaker(
- bind=engine,
- class_=AsyncSession,
- autoflush=False,
- expire_on_commit=False,
- )
- log.debug("Async session maker initialized.")
- return async_session_factory
-
- @provide(scope=Scope.REQUEST)
- async def provide_main_async_session(
- self,
- async_session_factory: async_sessionmaker[AsyncSession],
- ) -> AsyncIterator[MainAsyncSession]:
- """Provides UoW (AsyncSession) for the main context."""
- log.debug("Starting Main async session...")
- async with async_session_factory() as session:
- log.debug("Main async session started.")
- yield cast(MainAsyncSession, session)
- log.debug("Closing Main async session.")
- log.debug("Main async session closed.")
-
- @provide(scope=Scope.REQUEST)
- async def provide_auth_async_session(
- self,
- async_session_factory: async_sessionmaker[AsyncSession],
- ) -> AsyncIterator[AuthAsyncSession]:
- """Provides UoW (AsyncSession) for the auth context."""
- log.debug("Starting Auth async session...")
- async with async_session_factory() as session:
- log.debug("Auth async session started.")
- yield cast(AuthAsyncSession, session)
- log.debug("Closing Auth async session.")
- log.debug("Auth async session closed.")
-
-
-class AuthSessionProvider(Provider):
- scope = Scope.REQUEST
-
- service = provide(AuthSessionService)
-
- # Ports
- id_generator = provide(StrAuthSessionIdGenerator, scope=Scope.APP)
-
- @provide(scope=Scope.APP)
- def provide_utc_auth_session_timer(
- self,
- security: SecuritySettings,
- ) -> UtcAuthSessionTimer:
- return UtcAuthSessionTimer(
- ttl_min=security.auth.session_ttl_min,
- refresh_threshold=security.auth.session_refresh_threshold,
- )
-
- gateway = provide(SqlaAuthSessionDataMapper, provides=AuthSessionGateway)
- transport = provide(JwtCookieAuthSessionTransport, provides=AuthSessionTransport)
- tx_manager = provide(
- SqlaAuthSessionTransactionManager,
- provides=AuthSessionTransactionManager,
- )
-
-
-class AuthHandlersProvider(Provider):
- scope = Scope.REQUEST
-
- handlers = provide_all(
- SignUpHandler,
- LogInHandler,
- ChangePasswordHandler,
- LogOutHandler,
- )
-
-
-def infrastructure_providers() -> tuple[Provider, ...]:
- return (
- MainAdaptersProvider(),
- PersistenceSqlaProvider(),
- AuthSessionProvider(),
- AuthHandlersProvider(),
- )
diff --git a/src/app/setup/ioc/presentation.py b/src/app/setup/ioc/presentation.py
deleted file mode 100644
index fff2d4b9..00000000
--- a/src/app/setup/ioc/presentation.py
+++ /dev/null
@@ -1,28 +0,0 @@
-from dishka import Provider, Scope, from_context, provide
-from starlette.requests import Request
-
-from app.presentation.http.auth.access_token_processor_jwt import (
- JwtAccessTokenProcessor,
-)
-from app.presentation.http.auth.cookie_params import CookieParams
-from app.setup.config.security import SecuritySettings
-
-
-class PresentationProvider(Provider):
- scope = Scope.REQUEST
-
- request = from_context(provides=Request)
-
- @provide
- def provide_access_token_processor(
- self,
- security: SecuritySettings,
- ) -> JwtAccessTokenProcessor:
- return JwtAccessTokenProcessor(
- secret=security.auth.jwt_secret,
- algorithm=security.auth.jwt_algorithm,
- )
-
- @provide
- def provide_cookie_params(self, security: SecuritySettings) -> CookieParams:
- return CookieParams(secure=security.cookies.secure)
diff --git a/src/app/setup/ioc/provider_registry.py b/src/app/setup/ioc/provider_registry.py
deleted file mode 100644
index 9d88a47e..00000000
--- a/src/app/setup/ioc/provider_registry.py
+++ /dev/null
@@ -1,19 +0,0 @@
-from collections.abc import Iterable
-
-from dishka import Provider
-
-from app.setup.ioc.application import ApplicationProvider
-from app.setup.ioc.domain import DomainProvider
-from app.setup.ioc.infrastructure import infrastructure_providers
-from app.setup.ioc.presentation import PresentationProvider
-from app.setup.ioc.settings import SettingsProvider
-
-
-def get_providers() -> Iterable[Provider]:
- return (
- DomainProvider(),
- ApplicationProvider(),
- *infrastructure_providers(),
- PresentationProvider(),
- SettingsProvider(),
- )
diff --git a/src/app/setup/ioc/settings.py b/src/app/setup/ioc/settings.py
deleted file mode 100644
index 617e7394..00000000
--- a/src/app/setup/ioc/settings.py
+++ /dev/null
@@ -1,28 +0,0 @@
-from dishka import Provider, Scope, from_context, provide
-
-from app.setup.config.database import PostgresSettings, SqlaEngineSettings
-from app.setup.config.logs import LoggingSettings
-from app.setup.config.security import SecuritySettings
-from app.setup.config.settings import AppSettings
-
-
-class SettingsProvider(Provider):
- scope = Scope.APP
-
- settings = from_context(AppSettings)
-
- @provide
- def postgres(self, settings: AppSettings) -> PostgresSettings:
- return settings.postgres
-
- @provide
- def sqla_engine(self, settings: AppSettings) -> SqlaEngineSettings:
- return settings.sqla
-
- @provide
- def security(self, settings: AppSettings) -> SecuritySettings:
- return settings.security
-
- @provide
- def logs(self, settings: AppSettings) -> LoggingSettings:
- return settings.logs
diff --git a/tests/app/integration/setup/test_cfg_loader.py b/tests/app/integration/setup/test_cfg_loader.py
deleted file mode 100644
index d282a425..00000000
--- a/tests/app/integration/setup/test_cfg_loader.py
+++ /dev/null
@@ -1,5 +0,0 @@
-from app.setup.config.loader import BASE_DIR_PATH
-
-
-def test_base_dir_points_to_root() -> None:
- assert (BASE_DIR_PATH / "pyproject.toml").exists()
diff --git a/tests/app/unit/application/authz_service/test_permissions.py b/tests/app/unit/application/authz_service/test_permissions.py
deleted file mode 100644
index 3ada833b..00000000
--- a/tests/app/unit/application/authz_service/test_permissions.py
+++ /dev/null
@@ -1,113 +0,0 @@
-import pytest
-
-from app.application.common.services.authorization.permissions import (
- CanManageRole,
- CanManageSelf,
- CanManageSubordinate,
- RoleManagementContext,
- UserManagementContext,
-)
-from app.domain.enums.user_role import UserRole
-from tests.app.unit.factories.user_entity import create_user
-from tests.app.unit.factories.value_objects import create_user_id
-
-
-def test_can_manage_self() -> None:
- user_id = create_user_id()
- subject = create_user(user_id=user_id)
- target = create_user(user_id=user_id)
- context = UserManagementContext(subject=subject, target=target)
- sut = CanManageSelf()
-
- assert sut.is_satisfied_by(context)
-
-
-def test_cannot_manage_another_user() -> None:
- subject_id = create_user_id()
- subject = create_user(user_id=subject_id)
- target_id = create_user_id()
- target = create_user(user_id=target_id)
- context = UserManagementContext(subject=subject, target=target)
- sut = CanManageSelf()
-
- assert not sut.is_satisfied_by(context)
-
-
-@pytest.mark.parametrize(
- ("subject_role", "target_role"),
- [
- (UserRole.SUPER_ADMIN, UserRole.ADMIN),
- (UserRole.SUPER_ADMIN, UserRole.USER),
- (UserRole.ADMIN, UserRole.USER),
- ],
-)
-def test_can_manage_subordinate(
- subject_role: UserRole,
- target_role: UserRole,
-) -> None:
- subject = create_user(role=subject_role)
- target = create_user(role=target_role)
- context = UserManagementContext(subject=subject, target=target)
- sut = CanManageSubordinate()
-
- assert sut.is_satisfied_by(context)
-
-
-@pytest.mark.parametrize(
- ("subject_role", "target_role"),
- [
- (UserRole.SUPER_ADMIN, UserRole.SUPER_ADMIN),
- (UserRole.ADMIN, UserRole.SUPER_ADMIN),
- (UserRole.ADMIN, UserRole.ADMIN),
- (UserRole.USER, UserRole.ADMIN),
- ],
-)
-def test_cannot_manage_non_subordinate(
- subject_role: UserRole,
- target_role: UserRole,
-) -> None:
- subject = create_user(role=subject_role)
- target = create_user(role=target_role)
- context = UserManagementContext(subject=subject, target=target)
- sut = CanManageSubordinate()
-
- assert not sut.is_satisfied_by(context)
-
-
-@pytest.mark.parametrize(
- ("subject_role", "target_role"),
- [
- (UserRole.SUPER_ADMIN, UserRole.ADMIN),
- (UserRole.SUPER_ADMIN, UserRole.USER),
- (UserRole.ADMIN, UserRole.USER),
- ],
-)
-def test_can_manage_role(
- subject_role: UserRole,
- target_role: UserRole,
-) -> None:
- subject = create_user(role=subject_role)
- context = RoleManagementContext(subject=subject, target_role=target_role)
- sut = CanManageRole()
-
- assert sut.is_satisfied_by(context)
-
-
-@pytest.mark.parametrize(
- ("subject_role", "target_role"),
- [
- (UserRole.SUPER_ADMIN, UserRole.SUPER_ADMIN),
- (UserRole.ADMIN, UserRole.SUPER_ADMIN),
- (UserRole.ADMIN, UserRole.ADMIN),
- (UserRole.USER, UserRole.ADMIN),
- ],
-)
-def test_cannot_manage_role(
- subject_role: UserRole,
- target_role: UserRole,
-) -> None:
- subject = create_user(role=subject_role)
- context = RoleManagementContext(subject=subject, target_role=target_role)
- sut = CanManageRole()
-
- assert not sut.is_satisfied_by(context)
diff --git a/tests/app/unit/domain/entities/__init__.py b/tests/app/unit/domain/entities/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/app/unit/domain/enums/__init__.py b/tests/app/unit/domain/enums/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/app/unit/domain/enums/test_user_role.py b/tests/app/unit/domain/enums/test_user_role.py
deleted file mode 100644
index 1300db3a..00000000
--- a/tests/app/unit/domain/enums/test_user_role.py
+++ /dev/null
@@ -1,27 +0,0 @@
-import pytest
-
-from app.domain.enums.user_role import UserRole
-
-
-@pytest.mark.parametrize(
- ("role", "expected"),
- [
- (UserRole.USER, True),
- (UserRole.ADMIN, True),
- (UserRole.SUPER_ADMIN, False),
- ],
-)
-def test_assignability(role: UserRole, expected: bool) -> None:
- assert role.is_assignable is expected
-
-
-@pytest.mark.parametrize(
- ("role", "expected"),
- [
- (UserRole.USER, True),
- (UserRole.ADMIN, True),
- (UserRole.SUPER_ADMIN, False),
- ],
-)
-def test_changeability(role: UserRole, expected: bool) -> None:
- assert role.is_changeable is expected
diff --git a/tests/app/unit/domain/services/__init__.py b/tests/app/unit/domain/services/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/app/unit/domain/services/conftest.py b/tests/app/unit/domain/services/conftest.py
deleted file mode 100644
index 0e6e7e12..00000000
--- a/tests/app/unit/domain/services/conftest.py
+++ /dev/null
@@ -1,21 +0,0 @@
-from typing import cast
-from unittest.mock import create_autospec
-
-import pytest
-
-from app.domain.ports.password_hasher import PasswordHasher
-from app.domain.ports.user_id_generator import UserIdGenerator
-from tests.app.unit.domain.services.mock_types import (
- PasswordHasherMock,
- UserIdGeneratorMock,
-)
-
-
-@pytest.fixture
-def user_id_generator() -> UserIdGeneratorMock:
- return cast(UserIdGeneratorMock, create_autospec(UserIdGenerator, instance=True))
-
-
-@pytest.fixture
-def password_hasher() -> PasswordHasherMock:
- return cast(PasswordHasherMock, create_autospec(PasswordHasher, instance=True))
diff --git a/tests/app/unit/domain/services/test_user.py b/tests/app/unit/domain/services/test_user.py
deleted file mode 100644
index 7caa3787..00000000
--- a/tests/app/unit/domain/services/test_user.py
+++ /dev/null
@@ -1,217 +0,0 @@
-import pytest
-
-from app.domain.entities.user import User
-from app.domain.enums.user_role import UserRole
-from app.domain.exceptions.user import (
- ActivationChangeNotPermittedError,
- RoleAssignmentNotPermittedError,
- RoleChangeNotPermittedError,
-)
-from app.domain.services.user import UserService
-from tests.app.unit.domain.services.mock_types import (
- PasswordHasherMock,
- UserIdGeneratorMock,
-)
-from tests.app.unit.factories.user_entity import create_user
-from tests.app.unit.factories.value_objects import (
- create_password_hash,
- create_raw_password,
- create_user_id,
- create_username,
-)
-
-
-@pytest.mark.asyncio
-@pytest.mark.parametrize(
- "role",
- [UserRole.USER, UserRole.ADMIN],
-)
-async def test_creates_active_user_with_hashed_password(
- role: UserRole,
- user_id_generator: UserIdGeneratorMock,
- password_hasher: PasswordHasherMock,
-) -> None:
- # Arrange
- username = create_username()
- raw_password = create_raw_password()
-
- expected_id = create_user_id()
- expected_hash = create_password_hash()
-
- user_id_generator.generate.return_value = expected_id
- password_hasher.hash.return_value = expected_hash
- sut = UserService(user_id_generator, password_hasher) # type: ignore[arg-type]
-
- # Act
- result = await sut.create_user(username, raw_password, role)
-
- # Assert
- assert isinstance(result, User)
- assert result.id_ == expected_id
- assert result.username == username
- assert result.password_hash == expected_hash
- assert result.role == role
- assert result.is_active is True
-
-
-@pytest.mark.asyncio
-async def test_creates_inactive_user_if_specified(
- user_id_generator: UserIdGeneratorMock,
- password_hasher: PasswordHasherMock,
-) -> None:
- username = create_username()
- raw_password = create_raw_password()
- sut = UserService(user_id_generator, password_hasher) # type: ignore[arg-type]
-
- result = await sut.create_user(username, raw_password, is_active=False)
-
- assert not result.is_active
-
-
-@pytest.mark.asyncio
-async def test_fails_to_create_user_with_unassignable_role(
- user_id_generator: UserIdGeneratorMock,
- password_hasher: PasswordHasherMock,
-) -> None:
- username = create_username()
- raw_password = create_raw_password()
- sut = UserService(user_id_generator, password_hasher) # type: ignore[arg-type]
-
- with pytest.raises(RoleAssignmentNotPermittedError):
- await sut.create_user(
- username=username,
- raw_password=raw_password,
- role=UserRole.SUPER_ADMIN,
- )
-
-
-@pytest.mark.asyncio
-@pytest.mark.parametrize(
- "is_valid",
- [True, False],
-)
-async def test_checks_password_authenticity(
- is_valid: bool,
- user_id_generator: UserIdGeneratorMock,
- password_hasher: PasswordHasherMock,
-) -> None:
- # Arrange
- user = create_user()
- raw_password = create_raw_password()
-
- password_hasher.verify.return_value = is_valid
- sut = UserService(user_id_generator, password_hasher) # type: ignore[arg-type]
-
- # Act
- result = await sut.is_password_valid(user, raw_password)
-
- # Assert
- assert result is is_valid
-
-
-@pytest.mark.asyncio
-async def test_changes_password(
- user_id_generator: UserIdGeneratorMock,
- password_hasher: PasswordHasherMock,
-) -> None:
- # Arrange
- initial_hash = create_password_hash(b"old")
- user = create_user(password_hash=initial_hash)
- raw_password = create_raw_password()
-
- expected_hash = create_password_hash(b"new")
- password_hasher.hash.return_value = expected_hash
- sut = UserService(user_id_generator, password_hasher) # type: ignore[arg-type]
-
- # Act
- await sut.change_password(user, raw_password)
-
- # Assert
- assert user.password_hash == expected_hash
-
-
-@pytest.mark.parametrize(
- ("initial_state", "target_state", "expected_result"),
- [
- pytest.param(True, False, True, id="active_to_inactive"),
- pytest.param(False, True, True, id="inactive_to_active"),
- pytest.param(True, True, False, id="already_active"),
- pytest.param(False, False, False, id="already_inactive"),
- ],
-)
-def test_toggles_activation_state(
- initial_state: bool,
- target_state: bool,
- expected_result: bool,
- user_id_generator: UserIdGeneratorMock,
- password_hasher: PasswordHasherMock,
-) -> None:
- user = create_user(is_active=initial_state)
- sut = UserService(user_id_generator, password_hasher) # type: ignore[arg-type]
-
- result = sut.toggle_user_activation(user, is_active=target_state)
-
- assert result is expected_result
- assert user.is_active is target_state
-
-
-@pytest.mark.parametrize(
- "is_active",
- [True, False],
-)
-def test_preserves_super_admin_activation_state(
- is_active: bool,
- user_id_generator: UserIdGeneratorMock,
- password_hasher: PasswordHasherMock,
-) -> None:
- user = create_user(role=UserRole.SUPER_ADMIN, is_active=not is_active)
- sut = UserService(user_id_generator, password_hasher) # type: ignore[arg-type]
-
- with pytest.raises(ActivationChangeNotPermittedError):
- sut.toggle_user_activation(user, is_active=is_active)
-
- assert user.is_active is not is_active
-
-
-@pytest.mark.parametrize(
- ("initial_role", "target_is_admin", "expected_role", "expected_result"),
- [
- pytest.param(UserRole.USER, True, UserRole.ADMIN, True, id="user_to_admin"),
- pytest.param(UserRole.ADMIN, False, UserRole.USER, True, id="admin_to_user"),
- pytest.param(UserRole.USER, False, UserRole.USER, False, id="already_user"),
- pytest.param(UserRole.ADMIN, True, UserRole.ADMIN, False, id="already_admin"),
- ],
-)
-def test_toggles_role(
- initial_role: UserRole,
- target_is_admin: bool,
- expected_role: UserRole,
- expected_result: bool,
- user_id_generator: UserIdGeneratorMock,
- password_hasher: PasswordHasherMock,
-) -> None:
- user = create_user(role=initial_role)
- sut = UserService(user_id_generator, password_hasher) # type: ignore[arg-type]
-
- result = sut.toggle_user_admin_role(user, is_admin=target_is_admin)
-
- assert result is expected_result
- assert user.role == expected_role
-
-
-@pytest.mark.parametrize(
- "is_admin",
- [True, False],
-)
-def test_preserves_super_admin_role(
- is_admin: bool,
- user_id_generator: UserIdGeneratorMock,
- password_hasher: PasswordHasherMock,
-) -> None:
- user = create_user(role=UserRole.SUPER_ADMIN)
- sut = UserService(user_id_generator, password_hasher) # type: ignore[arg-type]
-
- with pytest.raises(RoleChangeNotPermittedError):
- sut.toggle_user_admin_role(user, is_admin=is_admin)
-
- assert user.role == UserRole.SUPER_ADMIN
diff --git a/tests/app/unit/domain/value_objects/__init__.py b/tests/app/unit/domain/value_objects/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/app/unit/factories/__init__.py b/tests/app/unit/factories/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/app/unit/factories/named_entity.py b/tests/app/unit/factories/named_entity.py
deleted file mode 100644
index b89544b0..00000000
--- a/tests/app/unit/factories/named_entity.py
+++ /dev/null
@@ -1,42 +0,0 @@
-from dataclasses import dataclass
-
-from app.domain.entities.base import Entity
-from app.domain.value_objects.base import ValueObject
-
-
-@dataclass(frozen=True, slots=True, repr=False)
-class NamedEntityId(ValueObject):
- value: int
-
-
-class NamedEntity(Entity[NamedEntityId]):
- def __init__(self, *, id_: NamedEntityId, name: str) -> None:
- super().__init__(id_=id_)
- self.name = name
-
-
-class NamedEntitySubclass(NamedEntity):
- def __init__(self, *, id_: NamedEntityId, name: str, value: int) -> None:
- super().__init__(id_=id_, name=name)
- self.value = value
-
-
-def create_named_entity_id(
- id_: int = 42,
-) -> NamedEntityId:
- return NamedEntityId(id_)
-
-
-def create_named_entity(
- id_: int = 42,
- name: str = "name",
-) -> NamedEntity:
- return NamedEntity(id_=NamedEntityId(id_), name=name)
-
-
-def create_named_entity_subclass(
- id_: int = 42,
- name: str = "name",
- value: int = 314,
-) -> NamedEntitySubclass:
- return NamedEntitySubclass(id_=NamedEntityId(id_), name=name, value=value)
diff --git a/tests/app/unit/factories/settings_data.py b/tests/app/unit/factories/settings_data.py
deleted file mode 100644
index ae09ec4c..00000000
--- a/tests/app/unit/factories/settings_data.py
+++ /dev/null
@@ -1,63 +0,0 @@
-from typing import Literal, TypedDict
-
-
-class AuthSettingsData(TypedDict):
- JWT_SECRET: str
- JWT_ALGORITHM: Literal[
- "HS256",
- "HS384",
- "HS512",
- "RS256",
- "RS384",
- "RS512",
- ]
- SESSION_TTL_MIN: int | float
- SESSION_REFRESH_THRESHOLD: int | float
-
-
-class PostgresSettingsData(TypedDict):
- USER: str
- PASSWORD: str
- DB: str
- HOST: str
- PORT: int
- DRIVER: str
-
-
-def create_auth_settings_data(
- jwt_secret: str = "jwt_secret" + "0" * 32,
- jwt_algorithm: Literal[
- "HS256",
- "HS384",
- "HS512",
- "RS256",
- "RS384",
- "RS512",
- ] = "RS256",
- session_ttl_min: int | float = 2,
- session_refresh_threshold: int | float = 0.5,
-) -> AuthSettingsData:
- return AuthSettingsData(
- JWT_SECRET=jwt_secret,
- JWT_ALGORITHM=jwt_algorithm,
- SESSION_TTL_MIN=session_ttl_min,
- SESSION_REFRESH_THRESHOLD=session_refresh_threshold,
- )
-
-
-def create_postgres_settings_data(
- user: str = "user",
- password: str = "password",
- db: str = "db",
- host: str = "localhost",
- port: int = 5432,
- driver: str = "asyncpg",
-) -> PostgresSettingsData:
- return PostgresSettingsData(
- USER=user,
- PASSWORD=password,
- DB=db,
- HOST=host,
- PORT=port,
- DRIVER=driver,
- )
diff --git a/tests/app/unit/factories/tagged_entity.py b/tests/app/unit/factories/tagged_entity.py
deleted file mode 100644
index 7580e6d2..00000000
--- a/tests/app/unit/factories/tagged_entity.py
+++ /dev/null
@@ -1,23 +0,0 @@
-from dataclasses import dataclass
-
-from app.domain.entities.base import Entity
-from app.domain.value_objects.base import ValueObject
-
-
-@dataclass(frozen=True, slots=True, repr=False)
-class TaggedEntityId(ValueObject):
- value: int
-
-
-class TaggedEntity(Entity[TaggedEntityId]):
- def __init__(self, *, id_: TaggedEntityId, tag: str) -> None:
- super().__init__(id_=id_)
- self.tag = tag
-
-
-def create_tagged_entity_id(id_: int = 54) -> TaggedEntityId:
- return TaggedEntityId(id_)
-
-
-def create_tagged_entity(id_: int = 54, tag: str = "tag") -> TaggedEntity:
- return TaggedEntity(id_=TaggedEntityId(id_), tag=tag)
diff --git a/tests/app/unit/factories/user_entity.py b/tests/app/unit/factories/user_entity.py
deleted file mode 100644
index 75d6a667..00000000
--- a/tests/app/unit/factories/user_entity.py
+++ /dev/null
@@ -1,26 +0,0 @@
-from app.domain.entities.user import User
-from app.domain.enums.user_role import UserRole
-from app.domain.value_objects.user_id import UserId
-from app.domain.value_objects.user_password_hash import UserPasswordHash
-from app.domain.value_objects.username import Username
-from tests.app.unit.factories.value_objects import (
- create_password_hash,
- create_user_id,
- create_username,
-)
-
-
-def create_user(
- user_id: UserId | None = None,
- username: Username | None = None,
- password_hash: UserPasswordHash | None = None,
- role: UserRole = UserRole.USER,
- is_active: bool = True,
-) -> User:
- return User(
- id_=user_id or create_user_id(),
- username=username or create_username(),
- password_hash=password_hash or create_password_hash(),
- role=role,
- is_active=is_active,
- )
diff --git a/tests/app/unit/factories/value_objects.py b/tests/app/unit/factories/value_objects.py
deleted file mode 100644
index 5683e335..00000000
--- a/tests/app/unit/factories/value_objects.py
+++ /dev/null
@@ -1,44 +0,0 @@
-import uuid
-from dataclasses import dataclass
-from uuid import UUID
-
-from app.domain.value_objects.base import ValueObject
-from app.domain.value_objects.raw_password import RawPassword
-from app.domain.value_objects.user_id import UserId
-from app.domain.value_objects.user_password_hash import UserPasswordHash
-from app.domain.value_objects.username import Username
-
-
-@dataclass(frozen=True, slots=True, repr=False)
-class SingleFieldVO(ValueObject):
- value: int
-
-
-@dataclass(frozen=True, slots=True, repr=False)
-class MultiFieldVO(ValueObject):
- value1: int
- value2: str
-
-
-def create_single_field_vo(value: int = 1) -> SingleFieldVO:
- return SingleFieldVO(value)
-
-
-def create_multi_field_vo(value1: int = 1, value2: str = "Alice") -> MultiFieldVO:
- return MultiFieldVO(value1, value2)
-
-
-def create_user_id(value: UUID | None = None) -> UserId:
- return UserId(value if value else uuid.uuid4())
-
-
-def create_username(value: str = "Alice") -> Username:
- return Username(value)
-
-
-def create_raw_password(value: str = "Good Password") -> RawPassword:
- return RawPassword(value)
-
-
-def create_password_hash(value: bytes = b"password_hash") -> UserPasswordHash:
- return UserPasswordHash(value)
diff --git a/tests/app/unit/infrastructure/__init__.py b/tests/app/unit/infrastructure/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/app/unit/setup/__init__.py b/tests/app/unit/setup/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/app/unit/setup/test_cfg_database.py b/tests/app/unit/setup/test_cfg_database.py
deleted file mode 100644
index ac9eadfb..00000000
--- a/tests/app/unit/setup/test_cfg_database.py
+++ /dev/null
@@ -1,60 +0,0 @@
-import pytest
-from pydantic import PostgresDsn, ValidationError
-
-from app.setup.config.database import PORT_MAX, PORT_MIN, PostgresSettings
-from tests.app.unit.factories.settings_data import create_postgres_settings_data
-
-
-def test_postgres_host_overridden_by_env_variable(
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- env_host = "changed"
- monkeypatch.setenv("POSTGRES_HOST", env_host)
- data = create_postgres_settings_data(host="initial")
-
- sut = PostgresSettings.model_validate(data)
-
- assert sut.host == env_host
-
-
-@pytest.mark.parametrize(
- "port",
- [
- pytest.param(PORT_MIN, id="lower_bound"),
- pytest.param(PORT_MAX, id="upper_bound"),
- ],
-)
-def test_postgres_port_accepts_correct_value(port: int) -> None:
- data = create_postgres_settings_data(port=port)
-
- PostgresSettings.model_validate(data)
-
-
-@pytest.mark.parametrize(
- "port",
- [
- pytest.param(PORT_MIN - 1, id="too_small"),
- pytest.param(PORT_MAX + 1, id="too_big"),
- ],
-)
-def test_postgres_port_rejects_incorrect_value(port: int) -> None:
- data = create_postgres_settings_data(port=port)
-
- with pytest.raises(ValidationError):
- PostgresSettings.model_validate(data)
-
-
-def test_postgres_dsn_builds_valid_uri_from_fields() -> None:
- data = create_postgres_settings_data(
- user="alice",
- password="secret",
- db="my_db",
- host="localhost",
- port=5678,
- driver="psycopg2",
- )
-
- sut = PostgresSettings.model_validate(data)
-
- assert sut.dsn == "postgresql+psycopg2://alice:secret@localhost:5678/my_db"
- assert PostgresDsn(sut.dsn)
diff --git a/tests/app/unit/setup/test_cfg_loader.py b/tests/app/unit/setup/test_cfg_loader.py
deleted file mode 100644
index 41817378..00000000
--- a/tests/app/unit/setup/test_cfg_loader.py
+++ /dev/null
@@ -1,163 +0,0 @@
-import textwrap
-from copy import deepcopy
-from pathlib import Path
-
-import pytest
-
-from app.setup.config.loader import (
- ENV_VAR_NAME,
- DirContents,
- ValidEnvs,
- get_current_env,
- load_full_config,
- merge_dicts,
- read_config,
- validate_env,
-)
-
-
-@pytest.mark.parametrize("env", list(ValidEnvs))
-def test_returns_enum_for_correct_env_string(env: ValidEnvs) -> None:
- assert validate_env(env) == env
-
-
-@pytest.mark.parametrize(
- "env",
- ["Incorrect", None],
-)
-def test_raises_for_incorrect_env_string_or_none(env: str | None) -> None:
- with pytest.raises(ValueError):
- validate_env(env)
-
-
-@pytest.mark.parametrize("env_str", list(ValidEnvs))
-def test_reads_and_validates_env_var(
- env_str: ValidEnvs,
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- monkeypatch.setenv(ENV_VAR_NAME, str(env_str))
-
- assert get_current_env() == env_str
-
-
-def test_reader_returns_dict_for_valid_toml(tmp_path: Path) -> None:
- cfg_file = tmp_path / "config.toml"
- fake_cfg_text = textwrap.dedent("""\
- [database]
- USER = "test_postgres"
- PORT = 1234
- """)
- cfg_file.write_text(fake_cfg_text, encoding="utf-8")
-
- result = read_config(
- env=ValidEnvs.DEV,
- config=DirContents.CONFIG_NAME,
- dir_paths={ValidEnvs.DEV: tmp_path},
- )
-
- assert result == {"database": {"USER": "test_postgres", "PORT": 1234}}
-
-
-def test_reader_raises_for_missing_dir() -> None:
- with pytest.raises(FileNotFoundError):
- read_config(
- env=ValidEnvs.DEV,
- config=DirContents.CONFIG_NAME,
- dir_paths={ValidEnvs.DEV: Path("wrong_path")},
- )
-
-
-def test_reader_raises_for_missing_env_path() -> None:
- with pytest.raises(FileNotFoundError):
- read_config(
- env=ValidEnvs.PROD,
- config=DirContents.CONFIG_NAME,
- dir_paths={},
- )
-
-
-def test_merges_flat_dicts() -> None:
- assert merge_dicts(dict1={"a": 1}, dict2={"b": 2}) == {"a": 1, "b": 2}
-
-
-def test_merges_nested_dicts() -> None:
- d1 = {"db": {"host": "localhost"}}
- d2 = {"db": {"port": 5432}}
-
- assert merge_dicts(dict1=d1, dict2=d2) == {
- "db": {"host": "localhost", "port": 5432}
- }
-
-
-def test_merger_overwrites_values_to_latest() -> None:
- d1 = {"a": 1}
- d2 = {"a": {"nested": True}}
-
- assert merge_dicts(dict1=d1, dict2=d2) == {"a": {"nested": True}}
-
-
-def test_merger_does_not_mutate_inputs() -> None:
- dict1 = {"a": {"x": 1}, "b": {"z": 3}}
- dict2 = {"a": {"y": 2}, "c": {"w": 4}}
- dict1_copy = deepcopy(dict1)
- dict2_copy = deepcopy(dict2)
-
- merge_dicts(dict1=dict1, dict2=dict2)
-
- assert dict1 == dict1_copy
- assert dict2 == dict2_copy
-
-
-def test_full_loader_merges_config_and_secrets(tmp_path: Path) -> None:
- # Arrange
- config_file = tmp_path / "config.toml"
- config_text = textwrap.dedent("""\
- [db]
- USER = "admin"
- PORT = 5432
- """)
- config_file.write_text(config_text, encoding="utf-8")
-
- secrets_file = tmp_path / ".secrets.toml"
- secrets_text = textwrap.dedent("""\
- [db]
- PASSWORD = "secret"
- """)
- secrets_file.write_text(secrets_text, encoding="utf-8")
-
- # Act
- result = load_full_config(
- env=ValidEnvs.DEV,
- dir_paths={ValidEnvs.DEV: tmp_path},
- )
-
- # Assert
- assert result == {
- "db": {
- "USER": "admin",
- "PORT": 5432,
- "PASSWORD": "secret",
- }
- }
-
-
-def test_full_loader_skips_missing_secrets(tmp_path: Path) -> None:
- config_file = tmp_path / "config.toml"
- config_text = textwrap.dedent("""\
- [db]
- USER = "admin"
- PORT = 5432
- """)
- config_file.write_text(config_text, encoding="utf-8")
-
- result = load_full_config(
- env=ValidEnvs.DEV,
- dir_paths={ValidEnvs.DEV: tmp_path},
- )
-
- assert result == {
- "db": {
- "USER": "admin",
- "PORT": 5432,
- }
- }
diff --git a/tests/app/unit/setup/test_cfg_logs.py b/tests/app/unit/setup/test_cfg_logs.py
deleted file mode 100644
index 6212a8d7..00000000
--- a/tests/app/unit/setup/test_cfg_logs.py
+++ /dev/null
@@ -1,36 +0,0 @@
-import logging
-from collections.abc import Iterator
-
-import pytest
-
-from app.setup.config.logs import LoggingLevel, configure_logging
-
-
-@pytest.fixture
-def clean_logging() -> Iterator[None]:
- try:
- yield
- finally:
- logging.getLogger().handlers.clear()
-
-
-@pytest.mark.parametrize(
- ("lvl_given", "lvl_expected"),
- [
- (LoggingLevel.DEBUG, logging.DEBUG),
- (LoggingLevel.INFO, logging.INFO),
- (LoggingLevel.WARNING, logging.WARNING),
- (LoggingLevel.ERROR, logging.ERROR),
- (LoggingLevel.CRITICAL, logging.CRITICAL),
- ],
-)
-@pytest.mark.usefixtures("clean_logging")
-def test_logger_uses_given_level(
- lvl_given: LoggingLevel,
- lvl_expected: int,
-) -> None:
- logger = logging.getLogger()
-
- configure_logging(level=lvl_given)
-
- assert logger.level == lvl_expected
diff --git a/tests/app/unit/setup/test_cfg_security.py b/tests/app/unit/setup/test_cfg_security.py
deleted file mode 100644
index c00080d0..00000000
--- a/tests/app/unit/setup/test_cfg_security.py
+++ /dev/null
@@ -1,36 +0,0 @@
-from datetime import timedelta
-
-import pytest
-from pydantic import ValidationError
-
-from app.setup.config.security import AuthSettings
-from tests.app.unit.factories.settings_data import create_auth_settings_data
-
-
-@pytest.mark.parametrize(
- ("ttl", "expected"),
- [
- pytest.param(1, timedelta(minutes=1), id="boundary"),
- pytest.param(2.5, timedelta(minutes=2.5), id="ordinary"),
- ],
-)
-def test_auth_converts_ttl_to_timedelta(ttl: int, expected: timedelta) -> None:
- data = create_auth_settings_data(session_ttl_min=ttl)
-
- sut = AuthSettings.model_validate(data)
-
- assert sut.session_ttl_min == expected
-
-
-@pytest.mark.parametrize(
- "ttl",
- [
- pytest.param("1", id="wrong_type"),
- pytest.param(0.99, id="too_small"),
- ],
-)
-def test_auth_rejects_invalid_ttl(ttl: int | str) -> None:
- data = create_auth_settings_data(session_ttl_min=ttl) # type: ignore[arg-type]
-
- with pytest.raises(ValidationError):
- AuthSettings.model_validate(data)
diff --git a/src/app/infrastructure/exceptions/__init__.py b/tests/integration/__init__.py
similarity index 100%
rename from src/app/infrastructure/exceptions/__init__.py
rename to tests/integration/__init__.py
diff --git a/src/app/presentation/http/auth/__init__.py b/tests/integration/no_infra/__init__.py
similarity index 100%
rename from src/app/presentation/http/auth/__init__.py
rename to tests/integration/no_infra/__init__.py
diff --git a/src/app/presentation/http/auth/adapters/__init__.py b/tests/integration/with_infra/__init__.py
similarity index 100%
rename from src/app/presentation/http/auth/adapters/__init__.py
rename to tests/integration/with_infra/__init__.py
diff --git a/tests/integration/with_infra/conftest.py b/tests/integration/with_infra/conftest.py
new file mode 100644
index 00000000..bf0f27d4
--- /dev/null
+++ b/tests/integration/with_infra/conftest.py
@@ -0,0 +1,95 @@
+import os
+from collections.abc import AsyncIterator, Sequence
+from typing import Final, cast
+
+import asgi_lifespan
+import httpx
+import pytest
+from dishka import Provider
+from fastapi import FastAPI
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+from app.config.settings import AppSettings
+from app.infrastructure.persistence_sqla.registry import mapper_registry
+from app.main.run import make_app
+
+LIFESPAN_MANAGER_STARTUP_TIMEOUT_S: Final[int] = 30
+ALLOW_DESTRUCTIVE_TEST_CLEANUP: Final[str] = "ALLOW_DESTRUCTIVE_TEST_CLEANUP"
+ALLOW_DESTRUCTIVE_TEST_CLEANUP_EXPECTED_VALUE: Final[str] = "1"
+
+
+@pytest.fixture(scope="session")
+def allow_destructive() -> None:
+ """Use on fixtures that require potentially dangerous cleanup."""
+ if os.getenv(ALLOW_DESTRUCTIVE_TEST_CLEANUP) != ALLOW_DESTRUCTIVE_TEST_CLEANUP_EXPECTED_VALUE:
+ raise pytest.UsageError(
+ "Destructive cleanup is disabled: "
+ f"{ALLOW_DESTRUCTIVE_TEST_CLEANUP} must be set to {ALLOW_DESTRUCTIVE_TEST_CLEANUP_EXPECTED_VALUE}. "
+ "This guard prevents accidental cleanup of non-test data."
+ )
+
+
+@pytest.fixture
+def it_di_overrides() -> Sequence[Provider]:
+ """
+ Override in a test module to provide custom dependency overrides.
+ Keep the same fixture signature.
+ """
+ return ()
+
+
+@pytest.fixture
+def it_fastapi_app(it_di_overrides: Sequence[Provider]) -> FastAPI:
+ return make_app(
+ *it_di_overrides,
+ app_settings=AppSettings(DEBUG_MODE=False),
+ )
+
+
+@pytest.fixture
+async def it_client(it_fastapi_app: FastAPI) -> AsyncIterator[httpx.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),
+ base_url="http://test",
+ ) as client,
+ ):
+ yield client
+
+
+@pytest.fixture
+async def it_sessionmaker(
+ it_client: httpx.AsyncClient,
+ it_fastapi_app: FastAPI,
+) -> async_sessionmaker[AsyncSession]:
+ container = it_fastapi_app.state.dishka_container
+ session_maker = await container.get(async_sessionmaker[AsyncSession])
+ return cast(async_sessionmaker[AsyncSession], session_maker)
+
+
+@pytest.fixture
+async def it_db_clean(
+ allow_destructive: None,
+ it_sessionmaker: async_sessionmaker[AsyncSession],
+) -> None:
+ table_names = [table.name for table in mapper_registry.metadata.sorted_tables if table.name != "alembic_version"]
+ assert table_names, "it_db_clean: no tables found in mapper_registry.metadata (fixture is a no-op)"
+ sql = "TRUNCATE " + ", ".join(f'"{name}"' for name in table_names) + " RESTART IDENTITY CASCADE;"
+
+ async with it_sessionmaker() as session:
+ await session.execute(text(sql))
+ await session.commit()
+
+
+@pytest.fixture
+async def it_session(
+ it_db_clean: None,
+ it_sessionmaker: async_sessionmaker[AsyncSession],
+) -> AsyncIterator[AsyncSession]:
+ async with it_sessionmaker() as session:
+ yield session
diff --git a/tests/integration/with_infra/test_stairway.py b/tests/integration/with_infra/test_stairway.py
new file mode 100644
index 00000000..6b94d600
--- /dev/null
+++ b/tests/integration/with_infra/test_stairway.py
@@ -0,0 +1,68 @@
+"""
+Test can find forgotten downgrade methods, undeleted data types in downgrade
+methods, typos and many other errors.
+
+Does not require any maintenance - you just add it once to check 80% of typos
+and mistakes in migrations forever.
+
+https://github.com/alvassin/alembic-quickstart
+"""
+
+from argparse import Namespace
+from typing import Final
+
+import pytest
+from alembic.command import downgrade, upgrade
+from alembic.config import Config
+from alembic.script import Script, ScriptDirectory
+
+from app.config.loader import BASE_DIR, load_postgres_settings
+from app.config.settings import PostgresSettings
+
+ALEMBIC_INI_PATH: Final[str] = str(BASE_DIR / "alembic.ini")
+
+
+@pytest.fixture(scope="module")
+def postgres_settings() -> PostgresSettings:
+ return load_postgres_settings()
+
+
+@pytest.fixture(scope="module")
+def alembic_config(postgres_settings: PostgresSettings) -> Config:
+ cmd_opts = Namespace(
+ config=ALEMBIC_INI_PATH,
+ name="alembic",
+ db_url=postgres_settings.dsn,
+ raiseerr=False,
+ x=None,
+ )
+ config = Config(file_=cmd_opts.config, ini_section=cmd_opts.name, cmd_opts=cmd_opts)
+ config.set_main_option("sqlalchemy.url", f"{postgres_settings.dsn}?async_fallback=true")
+ return config
+
+
+def get_revisions() -> list[Script]:
+ # Create Alembic configuration object
+ # (we don't need database for getting revisions list)
+ config = Config(ALEMBIC_INI_PATH)
+
+ # Get directory object with Alembic migrations
+ revisions_dir = ScriptDirectory.from_config(config)
+
+ # Get & sort migrations, from first to last
+ revisions = list(revisions_dir.walk_revisions("base", "heads"))
+ revisions.reverse()
+ return revisions
+
+
+@pytest.mark.parametrize("revision", get_revisions())
+def test_migrations_stairway(
+ allow_destructive: None,
+ alembic_config: Config,
+ revision: Script,
+) -> None:
+ upgrade(alembic_config, revision.revision)
+
+ # We need -1 for downgrading first migration (its down_revision is None)
+ downgrade(alembic_config, str(revision.down_revision or "-1"))
+ upgrade(alembic_config, revision.revision)
diff --git a/src/app/presentation/http/controllers/__init__.py b/tests/performance/__init__.py
similarity index 100%
rename from src/app/presentation/http/controllers/__init__.py
rename to tests/performance/__init__.py
diff --git a/tests/app/performance/profile_password_hasher_bcrypt.py b/tests/performance/profile_bcrypt_password_hasher.py
similarity index 72%
rename from tests/app/performance/profile_password_hasher_bcrypt.py
rename to tests/performance/profile_bcrypt_password_hasher.py
index bbaf22bb..234c45d3 100644
--- a/tests/app/performance/profile_password_hasher_bcrypt.py
+++ b/tests/performance/profile_bcrypt_password_hasher.py
@@ -2,10 +2,8 @@
from line_profiler import LineProfiler
-from app.domain.value_objects.raw_password import RawPassword
-from app.infrastructure.adapters.password_hasher_bcrypt import (
- BcryptPasswordHasher,
-)
+from app.core.common.value_objects.raw_password import RawPassword
+from app.infrastructure.adapters.bcrypt_password_hasher import BcryptPasswordHasher
def profile_password_hashing(hasher: BcryptPasswordHasher) -> None:
@@ -22,11 +20,11 @@ def main() -> None:
semaphore=Mock(),
semaphore_wait_timeout_s=1,
)
-
profiler = LineProfiler()
profiler.add_function(profile_password_hashing)
- profiler.runcall(profile_password_hashing, hasher)
+ profiler.runcall(profile_password_hashing, hasher) # type: ignore[no-untyped-call]
+
profiler.print_stats()
diff --git a/src/app/presentation/http/controllers/account/__init__.py b/tests/sanity/__init__.py
similarity index 100%
rename from src/app/presentation/http/controllers/account/__init__.py
rename to tests/sanity/__init__.py
diff --git a/src/app/presentation/http/controllers/general/__init__.py b/tests/sanity/config/__init__.py
similarity index 100%
rename from src/app/presentation/http/controllers/general/__init__.py
rename to tests/sanity/config/__init__.py
diff --git a/tests/sanity/config/test_loader.py b/tests/sanity/config/test_loader.py
new file mode 100644
index 00000000..93bcfd6c
--- /dev/null
+++ b/tests/sanity/config/test_loader.py
@@ -0,0 +1,5 @@
+from app.config.loader import BASE_DIR
+
+
+def test_base_dir_points_to_root() -> None:
+ assert (BASE_DIR / "pyproject.toml").exists()
diff --git a/src/app/presentation/http/controllers/users/__init__.py b/tests/smoke/__init__.py
similarity index 100%
rename from src/app/presentation/http/controllers/users/__init__.py
rename to tests/smoke/__init__.py
diff --git a/src/app/setup/__init__.py b/tests/unit/__init__.py
similarity index 100%
rename from src/app/setup/__init__.py
rename to tests/unit/__init__.py
diff --git a/src/app/setup/config/__init__.py b/tests/unit/config/__init__.py
similarity index 100%
rename from src/app/setup/config/__init__.py
rename to tests/unit/config/__init__.py
diff --git a/tests/unit/config/test_loader.py b/tests/unit/config/test_loader.py
new file mode 100644
index 00000000..5c36e345
--- /dev/null
+++ b/tests/unit/config/test_loader.py
@@ -0,0 +1,118 @@
+import pytest
+
+from app.config.loader import (
+ load_app_settings,
+ load_cookie_settings,
+ load_jwt_settings,
+ load_password_hasher_settings,
+ load_postgres_settings,
+ load_session_settings,
+ load_sqla_settings,
+)
+from app.config.logging_ import LoggingLevel
+
+
+@pytest.mark.parametrize(
+ "logging_level",
+ [
+ LoggingLevel.DEBUG,
+ LoggingLevel.INFO,
+ LoggingLevel.WARNING,
+ LoggingLevel.ERROR,
+ LoggingLevel.CRITICAL,
+ ],
+)
+def test_load_app_settings_reads_env_vars(monkeypatch: pytest.MonkeyPatch, logging_level: LoggingLevel) -> None:
+ monkeypatch.setenv("APP_SERVICE_NAME", "test-service")
+ monkeypatch.setenv("APP_VERSION", "test-version")
+ monkeypatch.setenv("APP_ROOT_PATH", "test-path")
+ monkeypatch.setenv("APP_DEBUG_MODE", "1")
+ monkeypatch.setenv("APP_LOGGING_LEVEL", logging_level)
+
+ sut = load_app_settings()
+
+ assert sut.SERVICE_NAME == "test-service"
+ assert sut.VERSION == "test-version"
+ assert sut.ROOT_PATH == "test-path"
+ assert sut.DEBUG_MODE is True
+ assert sut.LOGGING_LEVEL == logging_level
+
+
+def test_load_postgres_settings_reads_env_vars(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setenv("POSTGRES_DB", "test-db")
+ monkeypatch.setenv("POSTGRES_HOST", "test-host")
+ monkeypatch.setenv("POSTGRES_PORT", "123456789")
+ monkeypatch.setenv("POSTGRES_USER", "test-user")
+ monkeypatch.setenv("POSTGRES_PASSWORD", "test-password")
+
+ sut = load_postgres_settings()
+
+ assert sut.DB == "test-db"
+ assert sut.HOST == "test-host"
+ assert sut.PORT == 123456789
+ assert sut.USER == "test-user"
+ assert sut.PASSWORD == "test-password"
+
+
+def test_load_sqla_settings_reads_env_vars(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setenv("SQLA_ECHO", "true")
+ monkeypatch.setenv("SQLA_ECHO_POOL", "true")
+ monkeypatch.setenv("SQLA_POOL_SIZE", "123456789")
+ monkeypatch.setenv("SQLA_MAX_OVERFLOW", "987654321")
+
+ sut = load_sqla_settings()
+
+ assert sut.ECHO is True
+ assert sut.ECHO_POOL is True
+ assert sut.POOL_SIZE == 123456789
+ assert sut.MAX_OVERFLOW == 987654321
+
+
+def test_load_password_hasher_settings_reads_env_vars(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setenv("PASSWORD_PEPPER", "test-pepper-test-pepper-test-pepper")
+ monkeypatch.setenv("PASSWORD_WORK_FACTOR", "123456789")
+ monkeypatch.setenv("PASSWORD_MAX_THREADS", "987654321")
+ monkeypatch.setenv("PASSWORD_SEMAPHORE_WAIT_TIMEOUT_S", "1.23456789")
+
+ sut = load_password_hasher_settings()
+
+ assert sut.PEPPER == "test-pepper-test-pepper-test-pepper"
+ assert sut.WORK_FACTOR == 123456789
+ assert sut.MAX_THREADS == 987654321
+ assert sut.SEMAPHORE_WAIT_TIMEOUT_S == 1.23456789
+
+
+def test_load_jwt_settings_reads_env_vars(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setenv("JWT_SECRET", "test-secret-test-secret-test-secret")
+ monkeypatch.setenv("JWT_ALGORITHM", "HS384")
+
+ sut = load_jwt_settings()
+
+ assert sut.SECRET == "test-secret-test-secret-test-secret"
+ assert sut.ALGORITHM == "HS384"
+
+
+def test_load_session_settings_reads_env_vars(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setenv("SESSION_TTL_MIN", "123465789")
+ monkeypatch.setenv("SESSION_REFRESH_THRESHOLD_RATIO", "0.123456789")
+
+ sut = load_session_settings()
+
+ assert sut.TTL_MIN == 123465789
+ assert sut.REFRESH_THRESHOLD_RATIO == 0.123456789
+
+
+def test_load_cookie_settings_reads_env_vars(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setenv("COOKIE_NAME", "test-name")
+ monkeypatch.setenv("COOKIE_PATH", "test-path")
+ monkeypatch.setenv("COOKIE_HTTPONLY", "1")
+ monkeypatch.setenv("COOKIE_SECURE", "false")
+ monkeypatch.setenv("COOKIE_SAMESITE", "strict")
+
+ sut = load_cookie_settings()
+
+ assert sut.NAME == "test-name"
+ assert sut.PATH == "test-path"
+ assert sut.HTTPONLY is True
+ assert sut.SECURE is False
+ assert sut.SAMESITE == "strict"
diff --git a/tests/unit/config/test_settings.py b/tests/unit/config/test_settings.py
new file mode 100644
index 00000000..9b08a3f2
--- /dev/null
+++ b/tests/unit/config/test_settings.py
@@ -0,0 +1,18 @@
+from datetime import timedelta
+
+import pytest
+
+from app.config.settings import SessionSettings
+
+
+@pytest.mark.parametrize(
+ ("ttl_min", "expected"),
+ [
+ pytest.param(1, timedelta(minutes=1), id="boundary"),
+ pytest.param(5, timedelta(minutes=5), id="ordinary"),
+ ],
+)
+def test_ttl_property_builds_timedelta(ttl_min: int, expected: timedelta) -> None:
+ sut = SessionSettings(TTL_MIN=ttl_min)
+
+ assert sut.ttl == expected
diff --git a/src/app/setup/ioc/__init__.py b/tests/unit/core/__init__.py
similarity index 100%
rename from src/app/setup/ioc/__init__.py
rename to tests/unit/core/__init__.py
diff --git a/tests/app/__init__.py b/tests/unit/core/common/__init__.py
similarity index 100%
rename from tests/app/__init__.py
rename to tests/unit/core/common/__init__.py
diff --git a/tests/app/integration/__init__.py b/tests/unit/core/common/authorization/__init__.py
similarity index 100%
rename from tests/app/integration/__init__.py
rename to tests/unit/core/common/authorization/__init__.py
diff --git a/tests/unit/core/common/authorization/factories.py b/tests/unit/core/common/authorization/factories.py
new file mode 100644
index 00000000..94676a7a
--- /dev/null
+++ b/tests/unit/core/common/authorization/factories.py
@@ -0,0 +1,15 @@
+from app.core.common.entities.types_ import UserRole
+from app.core.common.entities.user import User
+from tests.unit.core.common.services.factories import create_super_user, create_user
+
+
+def make_super_admin() -> User:
+ return create_super_user()
+
+
+def make_admin() -> User:
+ return create_user(role=UserRole.ADMIN)
+
+
+def make_user() -> User:
+ return create_user(role=UserRole.USER)
diff --git a/tests/app/unit/application/authz_service/permission_stubs.py b/tests/unit/core/common/authorization/permission_stubs.py
similarity index 67%
rename from tests/app/unit/application/authz_service/permission_stubs.py
rename to tests/unit/core/common/authorization/permission_stubs.py
index 81e06fd9..2cad2987 100644
--- a/tests/app/unit/application/authz_service/permission_stubs.py
+++ b/tests/unit/core/common/authorization/permission_stubs.py
@@ -1,9 +1,9 @@
-from app.application.common.services.authorization.base import (
- Permission,
- PermissionContext,
-)
+from dataclasses import dataclass
+from app.core.common.authorization.base import Permission, PermissionContext
+
+@dataclass(frozen=True, slots=True)
class DummyContext(PermissionContext):
pass
diff --git a/tests/app/unit/application/authz_service/test_authorize.py b/tests/unit/core/common/authorization/test_authorize.py
similarity index 58%
rename from tests/app/unit/application/authz_service/test_authorize.py
rename to tests/unit/core/common/authorization/test_authorize.py
index 084a7258..1a7eba21 100644
--- a/tests/app/unit/application/authz_service/test_authorize.py
+++ b/tests/unit/core/common/authorization/test_authorize.py
@@ -1,14 +1,8 @@
import pytest
-from app.application.common.exceptions.authorization import AuthorizationError
-from app.application.common.services.authorization.authorize import (
- authorize,
-)
-from tests.app.unit.application.authz_service.permission_stubs import (
- AlwaysAllow,
- AlwaysDeny,
- DummyContext,
-)
+from app.core.common.authorization.authorize import authorize
+from app.core.common.authorization.exceptions import AuthorizationError
+from tests.unit.core.common.authorization.permission_stubs import AlwaysAllow, AlwaysDeny, DummyContext
def test_authorize_allows_when_permission_is_satisfied() -> None:
diff --git a/tests/app/unit/application/authz_service/test_composite.py b/tests/unit/core/common/authorization/test_composite.py
similarity index 68%
rename from tests/app/unit/application/authz_service/test_composite.py
rename to tests/unit/core/common/authorization/test_composite.py
index 4e2617cf..8a26ed7f 100644
--- a/tests/app/unit/application/authz_service/test_composite.py
+++ b/tests/unit/core/common/authorization/test_composite.py
@@ -1,21 +1,20 @@
-from app.application.common.services.authorization.composite import AnyOf
-from tests.app.unit.application.authz_service.permission_stubs import (
- AlwaysAllow,
- AlwaysDeny,
- DummyContext,
-)
+from app.core.common.authorization.composite import AnyOf
+from tests.unit.core.common.authorization.permission_stubs import AlwaysAllow, AlwaysDeny, DummyContext
def test_any_of_allows_if_at_least_one_allows() -> None:
sut = AnyOf(AlwaysDeny(), AlwaysAllow())
+
assert sut.is_satisfied_by(DummyContext())
def test_any_of_denies_if_all_deny() -> None:
sut = AnyOf(AlwaysDeny(), AlwaysDeny())
+
assert not sut.is_satisfied_by(DummyContext())
def test_any_of_empty_returns_false() -> None:
sut: AnyOf[DummyContext] = AnyOf()
+
assert not sut.is_satisfied_by(DummyContext())
diff --git a/tests/unit/core/common/authorization/test_permissions.py b/tests/unit/core/common/authorization/test_permissions.py
new file mode 100644
index 00000000..5bd0f5f1
--- /dev/null
+++ b/tests/unit/core/common/authorization/test_permissions.py
@@ -0,0 +1,107 @@
+from collections.abc import Callable
+
+import pytest
+
+from app.core.common.authorization.permissions import (
+ CanManageRole,
+ CanManageSelf,
+ CanManageSubordinate,
+ RoleManagementContext,
+ UserManagementContext,
+)
+from app.core.common.entities.types_ import UserRole
+from app.core.common.entities.user import User
+from app.core.common.factories.id_factory import create_user_id
+from tests.unit.core.common.authorization.factories import make_admin, make_super_admin, make_user
+from tests.unit.core.common.services.factories import create_user
+
+
+def test_can_manage_self() -> None:
+ subject = create_user(user_id=create_user_id())
+ context = UserManagementContext(subject=subject, target=subject)
+ sut = CanManageSelf()
+
+ assert sut.is_satisfied_by(context)
+
+
+def test_cannot_manage_another_user() -> None:
+ subject = create_user(user_id=create_user_id())
+ target = create_user(user_id=create_user_id())
+ context = UserManagementContext(subject=subject, target=target)
+ sut = CanManageSelf()
+
+ assert not sut.is_satisfied_by(context)
+
+
+@pytest.mark.parametrize(
+ ("subject_factory", "target_factory"),
+ [
+ pytest.param(make_super_admin, make_admin, id="super_admin_over_admin"),
+ pytest.param(make_super_admin, make_user, id="super_admin_over_user"),
+ pytest.param(make_admin, make_user, id="admin_over_user"),
+ ],
+)
+def test_can_manage_subordinate(
+ subject_factory: Callable[[], User],
+ target_factory: Callable[[], User],
+) -> None:
+ context = UserManagementContext(subject=subject_factory(), target=target_factory())
+ sut = CanManageSubordinate()
+
+ assert sut.is_satisfied_by(context)
+
+
+@pytest.mark.parametrize(
+ ("subject_factory", "target_factory"),
+ [
+ pytest.param(make_super_admin, make_super_admin, id="super_admin_over_super_admin"),
+ pytest.param(make_admin, make_super_admin, id="admin_over_super_admin"),
+ pytest.param(make_admin, make_admin, id="admin_over_admin"),
+ pytest.param(make_user, make_admin, id="user_over_admin"),
+ ],
+)
+def test_cannot_manage_non_subordinate(
+ subject_factory: Callable[[], User],
+ target_factory: Callable[[], User],
+) -> None:
+ context = UserManagementContext(subject=subject_factory(), target=target_factory())
+ sut = CanManageSubordinate()
+
+ assert not sut.is_satisfied_by(context)
+
+
+@pytest.mark.parametrize(
+ ("subject_factory", "target_role"),
+ [
+ pytest.param(make_super_admin, UserRole.ADMIN, id="super_admin_can_manage_admin_role"),
+ pytest.param(make_super_admin, UserRole.USER, id="super_admin_can_manage_user_role"),
+ pytest.param(make_admin, UserRole.USER, id="admin_can_manage_user_role"),
+ ],
+)
+def test_can_manage_role(
+ subject_factory: Callable[[], User],
+ target_role: UserRole,
+) -> None:
+ context = RoleManagementContext(subject=subject_factory(), target_role=target_role)
+ sut = CanManageRole()
+
+ assert sut.is_satisfied_by(context)
+
+
+@pytest.mark.parametrize(
+ ("subject_factory", "target_role"),
+ [
+ pytest.param(make_super_admin, UserRole.SUPER_ADMIN, id="super_admin_cannot_manage_super_admin_role"),
+ pytest.param(make_admin, UserRole.SUPER_ADMIN, id="admin_cannot_manage_super_admin_role"),
+ pytest.param(make_admin, UserRole.ADMIN, id="admin_cannot_manage_admin_role"),
+ pytest.param(make_user, UserRole.ADMIN, id="user_cannot_manage_admin_role"),
+ ],
+)
+def test_cannot_manage_role(
+ subject_factory: Callable[[], User],
+ target_role: UserRole,
+) -> None:
+ context = RoleManagementContext(subject=subject_factory(), target_role=target_role)
+ sut = CanManageRole()
+
+ assert not sut.is_satisfied_by(context)
diff --git a/tests/app/integration/setup/__init__.py b/tests/unit/core/common/entities/__init__.py
similarity index 100%
rename from tests/app/integration/setup/__init__.py
rename to tests/unit/core/common/entities/__init__.py
diff --git a/tests/unit/core/common/entities/factories.py b/tests/unit/core/common/entities/factories.py
new file mode 100644
index 00000000..714c96cb
--- /dev/null
+++ b/tests/unit/core/common/entities/factories.py
@@ -0,0 +1,42 @@
+from tests.unit.core.common.entities.types_ import (
+ NamedEntity,
+ NamedEntityId,
+ NamedEntitySubclass,
+ TaggedEntity,
+ TaggedEntityId,
+)
+from tests.unit.core.common.value_objects.types_ import SingleFieldVO
+
+
+def create_single_field_vo(value: int = 1) -> SingleFieldVO:
+ return SingleFieldVO(value)
+
+
+def create_named_entity_id(id_: int = 42) -> NamedEntityId:
+ return NamedEntityId(id_)
+
+
+def create_named_entity(
+ id_: int = 42,
+ name: str = "name",
+) -> NamedEntity:
+ return NamedEntity(id_=NamedEntityId(id_), name=name)
+
+
+def create_named_entity_subclass(
+ id_: int = 42,
+ name: str = "name",
+ value: int = 314,
+) -> NamedEntitySubclass:
+ return NamedEntitySubclass(id_=NamedEntityId(id_), name=name, value=value)
+
+
+def create_tagged_entity_id(id_: int = 54) -> TaggedEntityId:
+ return TaggedEntityId(id_)
+
+
+def create_tagged_entity(
+ id_: int = 54,
+ tag: str = "tag",
+) -> TaggedEntity:
+ return TaggedEntity(id_=TaggedEntityId(id_), tag=tag)
diff --git a/tests/app/unit/domain/entities/test_base.py b/tests/unit/core/common/entities/test_base.py
similarity index 91%
rename from tests/app/unit/domain/entities/test_base.py
rename to tests/unit/core/common/entities/test_base.py
index 933bd983..c20d47b6 100644
--- a/tests/app/unit/domain/entities/test_base.py
+++ b/tests/unit/core/common/entities/test_base.py
@@ -1,13 +1,13 @@
import pytest
-from app.domain.entities.base import Entity
-from tests.app.unit.factories.named_entity import (
+from app.core.common.entities.base import Entity
+from tests.unit.core.common.entities.factories import (
create_named_entity,
create_named_entity_id,
create_named_entity_subclass,
+ create_single_field_vo,
+ create_tagged_entity,
)
-from tests.app.unit.factories.tagged_entity import create_tagged_entity
-from tests.app.unit.factories.value_objects import create_single_field_vo
def test_cannot_init() -> None:
diff --git a/tests/unit/core/common/entities/types_.py b/tests/unit/core/common/entities/types_.py
new file mode 100644
index 00000000..afda9fe3
--- /dev/null
+++ b/tests/unit/core/common/entities/types_.py
@@ -0,0 +1,32 @@
+from dataclasses import dataclass
+
+from app.core.common.entities.base import Entity
+from app.core.common.value_objects.base import ValueObject
+
+
+@dataclass(frozen=True, slots=True, repr=False)
+class NamedEntityId(ValueObject):
+ value: int
+
+
+class NamedEntity(Entity[NamedEntityId]):
+ def __init__(self, *, id_: NamedEntityId, name: str) -> None:
+ super().__init__(id_=id_)
+ self.name = name
+
+
+class NamedEntitySubclass(NamedEntity):
+ def __init__(self, *, id_: NamedEntityId, name: str, value: int) -> None:
+ super().__init__(id_=id_, name=name)
+ self.value = value
+
+
+@dataclass(frozen=True, slots=True, repr=False)
+class TaggedEntityId(ValueObject):
+ value: int
+
+
+class TaggedEntity(Entity[TaggedEntityId]):
+ def __init__(self, *, id_: TaggedEntityId, tag: str) -> None:
+ super().__init__(id_=id_)
+ self.tag = tag
diff --git a/tests/app/performance/__init__.py b/tests/unit/core/common/services/__init__.py
similarity index 100%
rename from tests/app/performance/__init__.py
rename to tests/unit/core/common/services/__init__.py
diff --git a/tests/unit/core/common/services/conftest.py b/tests/unit/core/common/services/conftest.py
new file mode 100644
index 00000000..034b0192
--- /dev/null
+++ b/tests/unit/core/common/services/conftest.py
@@ -0,0 +1,12 @@
+from typing import cast
+from unittest.mock import create_autospec
+
+import pytest
+
+from app.core.common.ports.password_hasher import PasswordHasher
+from tests.unit.core.common.services.mock_types import PasswordHasherMock
+
+
+@pytest.fixture
+def password_hasher() -> PasswordHasherMock:
+ return cast(PasswordHasherMock, create_autospec(PasswordHasher, instance=True))
diff --git a/tests/unit/core/common/services/factories.py b/tests/unit/core/common/services/factories.py
new file mode 100644
index 00000000..2ab1707d
--- /dev/null
+++ b/tests/unit/core/common/services/factories.py
@@ -0,0 +1,88 @@
+import uuid
+from datetime import UTC, datetime
+from uuid import UUID
+
+from app.core.common.entities.types_ import UserId, UserPasswordHash, UserRole
+from app.core.common.entities.user import User
+from app.core.common.ports.password_hasher import PasswordHasher
+from app.core.common.services.user import UserService
+from app.core.common.value_objects.raw_password import RawPassword
+from app.core.common.value_objects.username import Username
+from app.core.common.value_objects.utc_datetime import UtcDatetime
+from tests.unit.core.common.services.stubs import StubPasswordHasher
+
+
+def create_user_id(value: UUID | None = None) -> UserId:
+ return UserId(value if value is not None else uuid.uuid4())
+
+
+def create_username(value: str | None = None) -> Username:
+ default = f"user_{uuid.uuid4().hex[:8]}"
+ return Username(value if value is not None else default)
+
+
+def create_raw_password(value: str | None = None) -> RawPassword:
+ default = uuid.uuid4().hex
+ return RawPassword(value if value is not None else default)
+
+
+def create_password_hash(value: bytes | None = None) -> UserPasswordHash:
+ default = uuid.uuid4().bytes
+ return UserPasswordHash(value if value is not None else default)
+
+
+def create_now(value: datetime | None = None) -> UtcDatetime:
+ default = datetime.now(UTC)
+ return UtcDatetime(value if value is not None else default)
+
+
+def create_role(value: str | None = None) -> UserRole:
+ return UserRole(value) if value is not None else UserRole.USER
+
+
+def create_is_active(value: bool | None = None) -> bool:
+ return value if value is not None else True
+
+
+def create_user_service(password_hasher: PasswordHasher | None = None) -> UserService:
+ return UserService(password_hasher=password_hasher if password_hasher is not None else StubPasswordHasher())
+
+
+def create_user(
+ *,
+ user_id: UserId | None = None,
+ username: Username | None = None,
+ password_hash: UserPasswordHash | None = None,
+ now: UtcDatetime | None = None,
+ role: UserRole | None = None,
+ is_active: bool | None = None,
+) -> User:
+ user_service = create_user_service()
+ return user_service.create_user(
+ user_id=user_id if user_id is not None else create_user_id(),
+ username=username if username is not None else create_username(),
+ password_hash=password_hash if password_hash is not None else create_password_hash(),
+ now=now if now is not None else create_now(),
+ role=role if role is not None else create_role(),
+ is_active=is_active if is_active is not None else create_is_active(),
+ )
+
+
+def create_super_user(
+ *,
+ user_id: UserId | None = None,
+ username: Username | None = None,
+ password_hash: UserPasswordHash | None = None,
+ now: UtcDatetime | None = None,
+ is_active: bool | None = None,
+) -> User:
+ now_ = now if now is not None else create_now()
+ return User(
+ id_=user_id if user_id is not None else create_user_id(),
+ username=username if username is not None else create_username(),
+ password_hash=password_hash if password_hash is not None else create_password_hash(),
+ role=UserRole.SUPER_ADMIN,
+ is_active=is_active if is_active is not None else create_is_active(),
+ created_at=now_,
+ updated_at=now_,
+ )
diff --git a/tests/app/unit/domain/services/mock_types.py b/tests/unit/core/common/services/mock_types.py
similarity index 51%
rename from tests/app/unit/domain/services/mock_types.py
rename to tests/unit/core/common/services/mock_types.py
index c4e7614c..049d43dd 100644
--- a/tests/app/unit/domain/services/mock_types.py
+++ b/tests/unit/core/common/services/mock_types.py
@@ -1,9 +1,5 @@
from typing import Protocol
-from unittest.mock import AsyncMock, Mock
-
-
-class UserIdGeneratorMock(Protocol):
- generate: Mock
+from unittest.mock import AsyncMock
class PasswordHasherMock(Protocol):
diff --git a/tests/unit/core/common/services/stubs.py b/tests/unit/core/common/services/stubs.py
new file mode 100644
index 00000000..d210712c
--- /dev/null
+++ b/tests/unit/core/common/services/stubs.py
@@ -0,0 +1,13 @@
+import hashlib
+
+from app.core.common.entities.types_ import UserPasswordHash
+from app.core.common.ports.password_hasher import PasswordHasher
+from app.core.common.value_objects.raw_password import RawPassword
+
+
+class StubPasswordHasher(PasswordHasher):
+ async def hash(self, raw_password: RawPassword) -> UserPasswordHash:
+ return UserPasswordHash(hashlib.sha256(raw_password.value).digest())
+
+ async def verify(self, raw_password: RawPassword, hashed_password: UserPasswordHash) -> bool:
+ return await self.hash(raw_password) == hashed_password
diff --git a/tests/unit/core/common/services/test_stubs.py b/tests/unit/core/common/services/test_stubs.py
new file mode 100644
index 00000000..34938b07
--- /dev/null
+++ b/tests/unit/core/common/services/test_stubs.py
@@ -0,0 +1,30 @@
+import pytest
+
+from tests.unit.core.common.services.factories import create_raw_password
+from tests.unit.core.common.services.stubs import StubPasswordHasher
+
+
+@pytest.mark.asyncio
+async def test_stub_password_hasher_verify_true_and_false() -> None:
+ raw_ok = create_raw_password("test-password")
+ raw_bad = create_raw_password("wrong-password")
+ sut = StubPasswordHasher()
+
+ hashed_ok = await sut.hash(raw_ok)
+
+ assert await sut.verify(raw_ok, hashed_ok) is True
+ assert await sut.verify(raw_bad, hashed_ok) is False
+
+
+@pytest.mark.asyncio
+async def test_stub_password_hasher_hash_is_deterministic() -> None:
+ raw1 = create_raw_password()
+ raw2 = create_raw_password()
+ sut = StubPasswordHasher()
+
+ h1a = await sut.hash(raw1)
+ h1b = await sut.hash(raw1)
+ h2 = await sut.hash(raw2)
+
+ assert h1a == h1b
+ assert h1a != h2
diff --git a/tests/unit/core/common/services/test_user.py b/tests/unit/core/common/services/test_user.py
new file mode 100644
index 00000000..76d6297d
--- /dev/null
+++ b/tests/unit/core/common/services/test_user.py
@@ -0,0 +1,264 @@
+import pytest
+
+from app.core.common.entities.types_ import UserRole
+from app.core.common.entities.user import User
+from app.core.common.exceptions import (
+ ActivationChangeNotPermittedError,
+ RoleAssignmentNotPermittedError,
+ RoleChangeNotPermittedError,
+)
+from tests.unit.core.common.services.factories import (
+ create_now,
+ create_password_hash,
+ create_raw_password,
+ create_super_user,
+ create_user,
+ create_user_id,
+ create_user_service,
+ create_username,
+)
+from tests.unit.core.common.services.mock_types import PasswordHasherMock
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "role",
+ [UserRole.USER, UserRole.ADMIN],
+)
+async def test_creates_active_user_with_hashed_password(
+ role: UserRole,
+ password_hasher: PasswordHasherMock,
+) -> None:
+ sut = create_user_service(password_hasher=password_hasher)
+ user_id = create_user_id()
+ username = create_username()
+ raw_password = create_raw_password()
+ expected_hash = create_password_hash()
+ created_at = create_now()
+ password_hasher.hash.return_value = expected_hash
+
+ user = await sut.create_user_with_raw_password(
+ user_id=user_id,
+ username=username,
+ raw_password=raw_password,
+ now=created_at,
+ role=role,
+ )
+
+ assert isinstance(user, User)
+ assert user.id_ == user_id
+ assert user.username == username
+ assert user.password_hash == expected_hash
+ assert user.role == role
+ assert user.is_active is True
+ assert user.created_at == created_at
+ assert user.updated_at == created_at
+
+
+@pytest.mark.asyncio
+async def test_creates_inactive_user_if_specified(password_hasher: PasswordHasherMock) -> None:
+ sut = create_user_service(password_hasher=password_hasher)
+ user_id = create_user_id()
+ username = create_username()
+ raw_password = create_raw_password()
+ created_at = create_now()
+ password_hasher.hash.return_value = create_password_hash()
+
+ user = await sut.create_user_with_raw_password(
+ user_id=user_id,
+ username=username,
+ raw_password=raw_password,
+ now=created_at,
+ is_active=False,
+ )
+
+ assert not user.is_active
+ assert user.created_at == created_at
+ assert user.updated_at == created_at
+
+
+@pytest.mark.asyncio
+async def test_fails_to_create_user_with_unassignable_role() -> None:
+ sut = create_user_service()
+ user_id = create_user_id()
+ username = create_username()
+ raw_password = create_raw_password()
+
+ with pytest.raises(RoleAssignmentNotPermittedError):
+ await sut.create_user_with_raw_password(
+ user_id=user_id,
+ username=username,
+ raw_password=raw_password,
+ now=create_now(),
+ role=UserRole.SUPER_ADMIN,
+ )
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("password", "expected"),
+ [
+ pytest.param("test-password", True, id="valid"),
+ pytest.param("wrong-password", False, id="invalid"),
+ ],
+)
+async def test_checks_password_authenticity(password: str, expected: bool) -> None:
+ sut = create_user_service()
+ user = await sut.create_user_with_raw_password(
+ user_id=create_user_id(),
+ username=create_username(),
+ raw_password=create_raw_password("test-password"),
+ now=create_now(),
+ )
+
+ assert await sut.is_password_valid(user, create_raw_password(password)) is expected
+
+
+@pytest.mark.asyncio
+async def test_changes_password() -> None:
+ sut = create_user_service()
+ old_raw = create_raw_password()
+ created_at = create_now()
+ user = await sut.create_user_with_raw_password(
+ user_id=create_user_id(),
+ username=create_username(),
+ raw_password=old_raw,
+ now=created_at,
+ )
+ initial_hash = user.password_hash
+ new_raw = create_raw_password()
+ updated_at = create_now()
+
+ await sut.change_password(user, new_raw, now=updated_at)
+
+ assert user.password_hash != initial_hash
+ assert await sut.is_password_valid(user, new_raw) is True
+ assert await sut.is_password_valid(user, old_raw) is False
+ assert user.created_at == created_at
+ assert user.updated_at == updated_at
+
+
+@pytest.mark.parametrize(
+ ("initial_role", "target_is_admin", "expected_role"),
+ [
+ pytest.param(UserRole.USER, True, UserRole.ADMIN, id="user_to_admin"),
+ pytest.param(UserRole.ADMIN, False, UserRole.USER, id="admin_to_user"),
+ ],
+)
+def test_set_role_changes_role_when_needed(
+ initial_role: UserRole,
+ target_is_admin: bool,
+ expected_role: UserRole,
+) -> None:
+ sut = create_user_service()
+ created_at = create_now()
+ user = create_user(role=initial_role, now=created_at)
+ updated_at = create_now()
+
+ result = sut.set_role(user, now=updated_at, is_admin=target_is_admin)
+
+ assert result is True
+ assert user.role == expected_role
+ assert user.created_at == created_at
+ assert user.updated_at == updated_at
+
+
+@pytest.mark.parametrize(
+ ("role", "is_admin"),
+ [
+ pytest.param(UserRole.USER, False, id="already_user"),
+ pytest.param(UserRole.ADMIN, True, id="already_admin"),
+ ],
+)
+def test_set_role_does_nothing_when_already_in_target_role(
+ role: UserRole,
+ is_admin: bool,
+) -> None:
+ sut = create_user_service()
+ created_at = create_now()
+ user = create_user(role=role, now=created_at)
+ attempt_at = create_now()
+
+ result = sut.set_role(user, now=attempt_at, is_admin=is_admin)
+
+ assert result is False
+ assert user.role == role
+ assert user.created_at == created_at
+ assert user.updated_at == created_at
+
+
+@pytest.mark.parametrize(
+ "is_admin",
+ [True, False],
+)
+def test_preserves_super_admin_role(is_admin: bool) -> None:
+ sut = create_user_service()
+ created_at = create_now()
+ user = create_super_user(now=created_at)
+
+ with pytest.raises(RoleChangeNotPermittedError):
+ sut.set_role(user, now=create_now(), is_admin=is_admin)
+
+ assert user.role == UserRole.SUPER_ADMIN
+ assert user.updated_at == created_at
+
+
+@pytest.mark.parametrize(
+ ("initial_state", "target_state"),
+ [
+ pytest.param(True, False, id="active_to_inactive"),
+ pytest.param(False, True, id="inactive_to_active"),
+ ],
+)
+def test_set_activation_changes_state_when_needed(
+ initial_state: bool,
+ target_state: bool,
+) -> None:
+ sut = create_user_service()
+ created_at = create_now()
+ user = create_user(is_active=initial_state, now=created_at)
+ updated_at = create_now()
+
+ result = sut.set_activation(user, now=updated_at, is_active=target_state)
+
+ assert result is True
+ assert user.is_active is target_state
+ assert user.created_at == created_at
+ assert user.updated_at == updated_at
+
+
+@pytest.mark.parametrize(
+ "state",
+ [
+ pytest.param(True, id="already_active"),
+ pytest.param(False, id="already_inactive"),
+ ],
+)
+def test_set_activation_does_nothing_when_already_in_target_state(state: bool) -> None:
+ sut = create_user_service()
+ created_at = create_now()
+ user = create_user(is_active=state, now=created_at)
+ attempt_at = create_now()
+
+ result = sut.set_activation(user, now=attempt_at, is_active=state)
+
+ assert result is False
+ assert user.is_active is state
+ assert user.created_at == created_at
+ assert user.updated_at == created_at
+
+
+@pytest.mark.parametrize(
+ "is_active",
+ [True, False],
+)
+def test_preserves_system_user_activation_state(is_active: bool) -> None:
+ sut = create_user_service()
+ created_at = create_now()
+ user = create_super_user(now=created_at, is_active=is_active)
+
+ with pytest.raises(ActivationChangeNotPermittedError):
+ sut.set_activation(user, now=create_now(), is_active=not is_active)
+
+ assert user.is_active is is_active
+ assert user.updated_at == created_at
diff --git a/tests/app/unit/__init__.py b/tests/unit/core/common/value_objects/__init__.py
similarity index 100%
rename from tests/app/unit/__init__.py
rename to tests/unit/core/common/value_objects/__init__.py
diff --git a/tests/app/unit/domain/value_objects/test_base.py b/tests/unit/core/common/value_objects/test_base.py
similarity index 60%
rename from tests/app/unit/domain/value_objects/test_base.py
rename to tests/unit/core/common/value_objects/test_base.py
index 190b3ea5..b86cc2f4 100644
--- a/tests/app/unit/domain/value_objects/test_base.py
+++ b/tests/unit/core/common/value_objects/test_base.py
@@ -1,13 +1,16 @@
from dataclasses import FrozenInstanceError, dataclass, field, fields
-from typing import ClassVar, Final
+from typing import ClassVar
import pytest
-from app.domain.value_objects.base import ValueObject
-from tests.app.unit.factories.value_objects import (
- create_multi_field_vo,
- create_single_field_vo,
-)
+from app.core.common.value_objects.base import ValueObject
+from tests.unit.core.common.value_objects.types_ import SingleFieldVO
+
+
+@dataclass(frozen=True, slots=True, repr=False)
+class MultiFieldVO(ValueObject):
+ value1: int
+ value2: str
def test_cannot_init() -> None:
@@ -28,7 +31,7 @@ def test_child_cannot_init_with_only_class_fields() -> None:
@dataclass(frozen=True)
class ClassFieldsVO(ValueObject):
foo: ClassVar[int] = 0
- bar: ClassVar[Final[str]] = "baz"
+ bar: ClassVar[str] = "baz"
with pytest.raises(TypeError):
ClassFieldsVO()
@@ -41,8 +44,8 @@ class MixedFieldsVO(ValueObject):
bar: str
sut = MixedFieldsVO(bar="baz")
- sut_fields = fields(sut)
+ sut_fields = fields(sut)
assert len(sut_fields) == 1
assert sut_fields[0].name == "bar"
assert sut_fields[0].type is str
@@ -62,7 +65,7 @@ def test_class_field_final_equivalence() -> None:
@dataclass(frozen=True)
class MixedFieldsVO:
a: ClassVar[int] = 1
- b: ClassVar[Final[str]] = "bar"
+ b: ClassVar[str] = "bar"
c: str = "baz"
sut_field_names = [f.name for f in fields(MixedFieldsVO)]
@@ -71,38 +74,40 @@ class MixedFieldsVO:
def test_is_immutable() -> None:
- vo_value = 123
- sut = create_single_field_vo(vo_value)
+ sut = SingleFieldVO(1)
with pytest.raises(FrozenInstanceError):
# noinspection PyDataclass
- sut.value = vo_value + 1 # type: ignore[misc]
+ sut.value = sut.value + 1 # type: ignore[misc]
def test_equality() -> None:
- vo1 = create_multi_field_vo()
- vo2 = create_multi_field_vo()
+ vo_value_1 = 1
+ vo_value_2 = "Alice"
+ sut1 = MultiFieldVO(value1=vo_value_1, value2=vo_value_2)
+ sut2 = MultiFieldVO(value1=vo_value_1, value2=vo_value_2)
- assert vo1 == vo2
+ assert sut1 == sut2
def test_inequality() -> None:
- vo1 = create_multi_field_vo(value2="one")
- vo2 = create_multi_field_vo(value2="two")
+ vo_value_1 = 1
+ sut1 = MultiFieldVO(value1=vo_value_1, value2="Alice")
+ sut2 = MultiFieldVO(value1=vo_value_1, value2="Bob")
- assert vo1 != vo2
+ assert sut1 != sut2
def test_single_field_vo_repr() -> None:
- sut = create_single_field_vo(123)
+ sut = SingleFieldVO(1)
- assert repr(sut) == "SingleFieldVO(123)"
+ assert repr(sut) == "SingleFieldVO(1)"
def test_multi_field_vo_repr() -> None:
- sut = create_multi_field_vo(value1=123, value2="abc")
+ sut = MultiFieldVO(value1=1, value2="Alice")
- assert repr(sut) == "MultiFieldVO(value1=123, value2='abc')"
+ assert repr(sut) == "MultiFieldVO(value1=1, value2='Alice')"
def test_class_field_not_in_repr() -> None:
@@ -110,7 +115,7 @@ def test_class_field_not_in_repr() -> None:
class MixedFieldsVO(ValueObject):
baz: int
foo: ClassVar[int] = 0
- bar: ClassVar[Final[str]] = "baz"
+ bar: ClassVar[str] = "baz"
sut = MixedFieldsVO(baz=1)
@@ -123,9 +128,9 @@ class HiddenFieldVO(ValueObject):
visible: int
hidden: int = field(repr=False)
- sut = HiddenFieldVO(123, 456)
+ sut = HiddenFieldVO(1, 2)
- assert repr(sut) == "HiddenFieldVO(123)"
+ assert repr(sut) == "HiddenFieldVO(1)"
def test_all_fields_hidden_repr() -> None:
@@ -134,6 +139,23 @@ class HiddenFieldVO(ValueObject):
hidden_1: int = field(repr=False)
hidden_2: int = field(repr=False)
- sut = HiddenFieldVO(123, 456)
+ sut = HiddenFieldVO(1, 2)
assert repr(sut) == "HiddenFieldVO()"
+
+
+def test_is_hashable() -> None:
+ sut1 = MultiFieldVO(value1=123, value2="abc")
+ sut2 = MultiFieldVO(value1=123, value2="abc")
+ sut3 = MultiFieldVO(value1=456, value2="def")
+
+ dict_with_sut_as_keys = {
+ sut1: "value1",
+ sut3: "value3",
+ }
+
+ assert dict_with_sut_as_keys[sut1] == "value1"
+ assert dict_with_sut_as_keys[sut2] == "value1"
+ assert dict_with_sut_as_keys[sut3] == "value3"
+ assert len(dict_with_sut_as_keys) == 2
+ assert hash(sut1) == hash(sut2) != hash(sut3)
diff --git a/tests/app/unit/domain/value_objects/test_raw_password.py b/tests/unit/core/common/value_objects/test_raw_password.py
similarity index 60%
rename from tests/app/unit/domain/value_objects/test_raw_password.py
rename to tests/unit/core/common/value_objects/test_raw_password.py
index e70ff950..086c740e 100644
--- a/tests/app/unit/domain/value_objects/test_raw_password.py
+++ b/tests/unit/core/common/value_objects/test_raw_password.py
@@ -1,7 +1,7 @@
import pytest
-from app.domain.exceptions.base import DomainTypeError
-from app.domain.value_objects.raw_password import RawPassword
+from app.core.common.exceptions import BusinessTypeError
+from app.core.common.value_objects.raw_password import RawPassword
def test_accepts_boundary_length() -> None:
@@ -13,5 +13,5 @@ def test_accepts_boundary_length() -> None:
def test_rejects_out_of_bounds_length() -> None:
password = "a" * (RawPassword.MIN_LEN - 1)
- with pytest.raises(DomainTypeError):
+ with pytest.raises(BusinessTypeError):
RawPassword(password)
diff --git a/tests/app/unit/domain/value_objects/test_username.py b/tests/unit/core/common/value_objects/test_username.py
similarity index 86%
rename from tests/app/unit/domain/value_objects/test_username.py
rename to tests/unit/core/common/value_objects/test_username.py
index 2f12625a..17d774c2 100644
--- a/tests/app/unit/domain/value_objects/test_username.py
+++ b/tests/unit/core/common/value_objects/test_username.py
@@ -1,7 +1,7 @@
import pytest
-from app.domain.exceptions.base import DomainTypeError
-from app.domain.value_objects.username import Username
+from app.core.common.exceptions import BusinessTypeError
+from app.core.common.value_objects.username import Username
@pytest.mark.parametrize(
@@ -23,7 +23,7 @@ def test_accepts_boundary_length(username: str) -> None:
],
)
def test_rejects_out_of_bounds_length(username: str) -> None:
- with pytest.raises(DomainTypeError):
+ with pytest.raises(BusinessTypeError):
Username(username)
@@ -63,5 +63,5 @@ def test_accepts_correct_names(username: str) -> None:
],
)
def test_rejects_incorrect_names(username: str) -> None:
- with pytest.raises(DomainTypeError):
+ with pytest.raises(BusinessTypeError):
Username(username)
diff --git a/tests/unit/core/common/value_objects/test_utc_datetime.py b/tests/unit/core/common/value_objects/test_utc_datetime.py
new file mode 100644
index 00000000..9a3c5633
--- /dev/null
+++ b/tests/unit/core/common/value_objects/test_utc_datetime.py
@@ -0,0 +1,33 @@
+from datetime import UTC, datetime, timedelta
+from zoneinfo import ZoneInfo
+
+import pytest
+
+from app.core.common.exceptions import BusinessTypeError
+from app.core.common.value_objects.utc_datetime import UtcDatetime
+
+
+def test_normalizes_value_to_utc_when_input_has_offset() -> None:
+ raw = datetime.fromisoformat("2024-01-01T03:00:00+03:00")
+
+ sut = UtcDatetime(raw)
+
+ assert sut.value.utcoffset() == timedelta(0)
+ assert sut.value == datetime(2024, 1, 1, 0, 0, tzinfo=UTC)
+ assert sut.value.timestamp() == raw.timestamp()
+
+
+def test_preserves_same_timestamp_when_input_is_zoneinfo() -> None:
+ raw = datetime(2024, 1, 15, 6, 0, tzinfo=ZoneInfo("Asia/Almaty"))
+
+ sut = UtcDatetime(raw)
+
+ assert sut.value.utcoffset() == timedelta(0)
+ assert sut.value.timestamp() == raw.timestamp()
+
+
+def test_raises_when_input_is_naive_datetime() -> None:
+ raw = datetime.fromisoformat("2024-01-01T03:00:00")
+
+ with pytest.raises(BusinessTypeError):
+ UtcDatetime(raw)
diff --git a/tests/unit/core/common/value_objects/types_.py b/tests/unit/core/common/value_objects/types_.py
new file mode 100644
index 00000000..1f53e459
--- /dev/null
+++ b/tests/unit/core/common/value_objects/types_.py
@@ -0,0 +1,8 @@
+from dataclasses import dataclass
+
+from app.core.common.value_objects.base import ValueObject
+
+
+@dataclass(frozen=True, slots=True, repr=False)
+class SingleFieldVO(ValueObject):
+ value: int
diff --git a/tests/app/unit/application/__init__.py b/tests/unit/core/queries/__init__.py
similarity index 100%
rename from tests/app/unit/application/__init__.py
rename to tests/unit/core/queries/__init__.py
diff --git a/tests/app/unit/application/authz_service/__init__.py b/tests/unit/core/queries/query_support/__init__.py
similarity index 100%
rename from tests/app/unit/application/authz_service/__init__.py
rename to tests/unit/core/queries/query_support/__init__.py
diff --git a/tests/unit/core/queries/query_support/test_offset_pagination.py b/tests/unit/core/queries/query_support/test_offset_pagination.py
new file mode 100644
index 00000000..ccb429e8
--- /dev/null
+++ b/tests/unit/core/queries/query_support/test_offset_pagination.py
@@ -0,0 +1,37 @@
+import pytest
+
+from app.core.queries.query_support.exceptions import PaginationError
+from app.core.queries.query_support.offset_pagination import OffsetPaginationParams
+
+
+def test_accepts_valid_params() -> None:
+ sut = OffsetPaginationParams(limit=10, offset=0)
+
+ assert sut.limit == 10
+ assert sut.offset == 0
+
+
+def test_limit_must_be_greater_than_0() -> None:
+ with pytest.raises(PaginationError):
+ OffsetPaginationParams(limit=0, offset=0)
+
+
+def test_limit_cannot_exceed_max_int32() -> None:
+ with pytest.raises(PaginationError):
+ OffsetPaginationParams(
+ limit=OffsetPaginationParams.MAX_INT32 + 1,
+ offset=0,
+ )
+
+
+def test_offset_must_be_non_negative() -> None:
+ with pytest.raises(PaginationError):
+ OffsetPaginationParams(limit=1, offset=-1)
+
+
+def test_offset_cannot_exceed_max_int32() -> None:
+ with pytest.raises(PaginationError):
+ OffsetPaginationParams(
+ limit=1,
+ offset=OffsetPaginationParams.MAX_INT32 + 1,
+ )
diff --git a/tests/app/unit/domain/__init__.py b/tests/unit/infrastructure/__init__.py
similarity index 100%
rename from tests/app/unit/domain/__init__.py
rename to tests/unit/infrastructure/__init__.py
diff --git a/tests/app/unit/infrastructure/conftest.py b/tests/unit/infrastructure/conftest.py
similarity index 78%
rename from tests/app/unit/infrastructure/conftest.py
rename to tests/unit/infrastructure/conftest.py
index 0d901ba8..71735bac 100644
--- a/tests/app/unit/infrastructure/conftest.py
+++ b/tests/unit/infrastructure/conftest.py
@@ -5,8 +5,11 @@
import pytest
-from app.infrastructure.adapters.password_hasher_bcrypt import BcryptPasswordHasher
-from app.infrastructure.adapters.types import HasherSemaphore, HasherThreadPoolExecutor
+from app.infrastructure.adapters.bcrypt_password_hasher import (
+ BcryptPasswordHasher,
+ HasherSemaphore,
+ HasherThreadPoolExecutor,
+)
@pytest.fixture(scope="session")
@@ -18,9 +21,7 @@ def hasher_max_threads() -> int:
def hasher_threadpool_executor(
hasher_max_threads: int,
) -> Iterator[HasherThreadPoolExecutor]:
- executor = HasherThreadPoolExecutor(
- ThreadPoolExecutor(max_workers=hasher_max_threads)
- )
+ executor = HasherThreadPoolExecutor(ThreadPoolExecutor(max_workers=hasher_max_threads))
yield executor
executor.shutdown(wait=True, cancel_futures=True)
diff --git a/tests/app/unit/infrastructure/test_password_hasher_bcrypt.py b/tests/unit/infrastructure/test_bcrypt_password_hasher.py
similarity index 92%
rename from tests/app/unit/infrastructure/test_password_hasher_bcrypt.py
rename to tests/unit/infrastructure/test_bcrypt_password_hasher.py
index 706c4300..08822758 100644
--- a/tests/app/unit/infrastructure/test_password_hasher_bcrypt.py
+++ b/tests/unit/infrastructure/test_bcrypt_password_hasher.py
@@ -2,10 +2,8 @@
import pytest
-from app.infrastructure.adapters.password_hasher_bcrypt import (
- BcryptPasswordHasher,
-)
-from tests.app.unit.factories.value_objects import create_raw_password
+from app.infrastructure.adapters.bcrypt_password_hasher import BcryptPasswordHasher
+from tests.unit.core.common.services.factories import create_raw_password
@pytest.mark.slow
diff --git a/uv.lock b/uv.lock
index f70d986d..657897ea 100644
--- a/uv.lock
+++ b/uv.lock
@@ -4,38 +4,38 @@ requires-python = "==3.13.*"
[[package]]
name = "alembic"
-version = "1.17.1"
+version = "1.18.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mako" },
{ name = "sqlalchemy" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/6e/b6/2a81d7724c0c124edc5ec7a167e85858b6fd31b9611c6fb8ecf617b7e2d3/alembic-1.17.1.tar.gz", hash = "sha256:8a289f6778262df31571d29cca4c7fbacd2f0f582ea0816f4c399b6da7528486", size = 1981285, upload-time = "2025-10-29T00:23:16.667Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/a5/32/7df1d81ec2e50fb661944a35183d87e62d3f6c6d9f8aff64a4f245226d55/alembic-1.17.1-py3-none-any.whl", hash = "sha256:cbc2386e60f89608bb63f30d2d6cc66c7aaed1fe105bd862828600e5ad167023", size = 247848, upload-time = "2025-10-29T00:23:18.79Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" },
]
[[package]]
name = "alembic-postgresql-enum"
-version = "1.8.0"
+version = "1.10.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "alembic" },
{ name = "sqlalchemy" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/58/04/e465cb5c051fb056b7fadda7667b3e1fb4d32d7f19533e3bbff071c73788/alembic_postgresql_enum-1.8.0.tar.gz", hash = "sha256:132cd5fdc4a2a0b6498f3d89ea1c7b2a5ddc3281ddd84edae7259ec4c0a215a0", size = 15858, upload-time = "2025-07-20T12:25:50.626Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/63/cf/b5147b926441e7cdc12f306465dac488ff22364b1467a09a5d43743b8cfc/alembic_postgresql_enum-1.10.0.tar.gz", hash = "sha256:ea23481de3d6a00d68d369f92a46ff4fa27b2575e39c583145f1e23ea9c7511d", size = 18557, upload-time = "2026-02-23T16:15:04.148Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/77/80/4e6e841f9a0403b520b8f28650c2cdf5905e25bd4ff403b43daec580fed3/alembic_postgresql_enum-1.8.0-py3-none-any.whl", hash = "sha256:0e62833f8d1aca2c58fa09cae1d4a52472fb32d2dde32b68c84515fffcf401d5", size = 23697, upload-time = "2025-07-20T12:25:49.048Z" },
+ { url = "https://files.pythonhosted.org/packages/58/8a/4db3821584824ac68df00a6ca27331d3f41f40105f22a8dc1b30b1ec83af/alembic_postgresql_enum-1.10.0-py3-none-any.whl", hash = "sha256:39297cb5dc0e71a3e08df1feeab9ffcc1b6aac0be02bf578748d74c442cd3fbf", size = 27774, upload-time = "2026-02-23T16:15:02.789Z" },
]
[[package]]
name = "annotated-doc"
-version = "0.0.3"
+version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d7/a6/dc46877b911e40c00d395771ea710d5e77b6de7bacd5fdcd78d70cc5a48f/annotated_doc-0.0.3.tar.gz", hash = "sha256:e18370014c70187422c33e945053ff4c286f453a984eba84d0dbfa0c935adeda", size = 5535, upload-time = "2025-10-24T14:57:10.718Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/02/b7/cf592cb5de5cb3bade3357f8d2cf42bf103bbe39f459824b4939fd212911/annotated_doc-0.0.3-py3-none-any.whl", hash = "sha256:348ec6664a76f1fd3be81f43dffbee4c7e8ce931ba71ec67cc7f4ade7fbbb580", size = 5488, upload-time = "2025-10-24T14:57:09.462Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
]
[[package]]
@@ -49,15 +49,26 @@ wheels = [
[[package]]
name = "anyio"
-version = "4.11.0"
+version = "4.12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
+]
+
+[[package]]
+name = "asgi-lifespan"
+version = "2.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
{ name = "sniffio" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/6a/da/e7908b54e0f8043725a990bf625f2041ecf6bfe8eb7b19407f1c00b630f7/asgi-lifespan-2.1.0.tar.gz", hash = "sha256:5e2effaf0bfe39829cf2d64e7ecc47c7d86d676a6599f7afba378c31f5e3a308", size = 15627, upload-time = "2023-03-28T17:35:49.126Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/f5/c36551e93acba41a59939ae6a0fb77ddb3f2e8e8caa716410c65f7341f72/asgi_lifespan-2.1.0-py3-none-any.whl", hash = "sha256:ed840706680e28428c01e14afb3875d7d76d3206f3d5b2f2294e059b5c23804f", size = 10895, upload-time = "2023-03-28T17:35:47.772Z" },
]
[[package]]
@@ -113,6 +124,42 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" },
]
+[[package]]
+name = "boolean-py"
+version = "5.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c4/cf/85379f13b76f3a69bca86b60237978af17d6aa0bc5998978c3b8cf05abb2/boolean_py-5.0.tar.gz", hash = "sha256:60cbc4bad079753721d32649545505362c754e121570ada4658b852a3a318d95", size = 37047, upload-time = "2025-04-03T10:39:49.734Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e5/ca/78d423b324b8d77900030fa59c4aa9054261ef0925631cd2501dd015b7b7/boolean_py-5.0-py3-none-any.whl", hash = "sha256:ef28a70bd43115208441b53a045d1549e2f0ec6e3d08a9d142cbc41c1938e8d9", size = 26577, upload-time = "2025-04-03T10:39:48.449Z" },
+]
+
+[[package]]
+name = "cachecontrol"
+version = "0.14.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "msgpack" },
+ { name = "requests" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/2d/f6/c972b32d80760fb79d6b9eeb0b3010a46b89c0b23cf6329417ff7886cd22/cachecontrol-0.14.4.tar.gz", hash = "sha256:e6220afafa4c22a47dd0badb319f84475d79108100d04e26e8542ef7d3ab05a1", size = 16150, upload-time = "2025-11-14T04:32:13.138Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ef/79/c45f2d53efe6ada1110cf6f9fca095e4ff47a0454444aefdde6ac4789179/cachecontrol-0.14.4-py3-none-any.whl", hash = "sha256:b7ac014ff72ee199b5f8af1de29d60239954f223e948196fa3d84adaffc71d2b", size = 22247, upload-time = "2025-11-14T04:32:11.733Z" },
+]
+
+[package.optional-dependencies]
+filecache = [
+ { name = "filelock" },
+]
+
+[[package]]
+name = "certifi"
+version = "2026.2.25"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
+]
+
[[package]]
name = "cffi"
version = "2.0.0"
@@ -138,23 +185,48 @@ wheels = [
[[package]]
name = "cfgv"
-version = "3.4.0"
+version = "3.5.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" },
+]
+
+[[package]]
+name = "charset-normalizer"
+version = "3.4.4"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" },
+ { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" },
+ { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" },
+ { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" },
+ { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" },
+ { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
+ { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
]
[[package]]
name = "click"
-version = "8.3.0"
+version = "8.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" },
+ { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
]
[[package]]
@@ -168,74 +240,104 @@ wheels = [
[[package]]
name = "coverage"
-version = "7.10.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/6d/8f/6ac7fbb29e35645065f7be835bfe3e0cce567f80390de2f3db65d83cb5e3/coverage-7.10.0.tar.gz", hash = "sha256:2768885aef484b5dcde56262cbdfba559b770bfc46994fe9485dc3614c7a5867", size = 819816, upload-time = "2025-07-24T16:53:00.896Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/fc/a7/a47f64718c2229b7860a334edd4e6ff41ec8513f3d3f4246284610344392/coverage-7.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d883fee92b9245c0120fa25b5d36de71ccd4cfc29735906a448271e935d8d86d", size = 215143, upload-time = "2025-07-24T16:51:14.105Z" },
- { url = "https://files.pythonhosted.org/packages/ea/86/14d76a409e9ffab10d5aece73ac159dbd102fc56627e203413bfc6d53b24/coverage-7.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c87e59e88268d30e33d3665ede4fbb77b513981a2df0059e7c106ca3de537586", size = 215401, upload-time = "2025-07-24T16:51:15.978Z" },
- { url = "https://files.pythonhosted.org/packages/f4/b3/fb5c28148a19035a3877fac4e40b044a4c97b24658c980bcf7dff18bfab8/coverage-7.10.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f669d969f669a11d6ceee0b733e491d9a50573eb92a71ffab13b15f3aa2665d4", size = 245949, upload-time = "2025-07-24T16:51:17.628Z" },
- { url = "https://files.pythonhosted.org/packages/6d/95/357559ecfe73970d2023845797361e6c2e6c2c05f970073fff186fe19dd7/coverage-7.10.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9582bd6c6771300a847d328c1c4204e751dbc339a9e249eecdc48cada41f72e6", size = 248295, upload-time = "2025-07-24T16:51:19.46Z" },
- { url = "https://files.pythonhosted.org/packages/7e/58/bac5bc43085712af201f76a24733895331c475e5ddda88ac36c1332a65e6/coverage-7.10.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:91f97e9637dc7977842776fdb7ad142075d6fa40bc1b91cb73685265e0d31d32", size = 249733, upload-time = "2025-07-24T16:51:21.518Z" },
- { url = "https://files.pythonhosted.org/packages/b2/db/104b713b3b74752ee365346677fb104765923982ae7bd93b95ca41fe256b/coverage-7.10.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ae4fa92b6601a62367c6c9967ad32ad4e28a89af54b6bb37d740946b0e0534dd", size = 247943, upload-time = "2025-07-24T16:51:23.194Z" },
- { url = "https://files.pythonhosted.org/packages/32/4f/bef25c797c9496cf31ae9cfa93ce96b4414cacf13688e4a6000982772fd5/coverage-7.10.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3a5cc8b97473e7b3623dd17a42d2194a2b49de8afecf8d7d03c8987237a9552c", size = 245914, upload-time = "2025-07-24T16:51:24.766Z" },
- { url = "https://files.pythonhosted.org/packages/36/6b/b3efa0b506dbb9a37830d6dc862438fe3ad2833c5f889152bce24d9577cf/coverage-7.10.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc1cbb7f623250e047c32bd7aa1bb62ebc62608d5004d74df095e1059141ac88", size = 247296, upload-time = "2025-07-24T16:51:26.361Z" },
- { url = "https://files.pythonhosted.org/packages/1f/aa/95a845266aeacab4c57b08e0f4e0e2899b07809a18fd0c1ddef2ac2c9138/coverage-7.10.0-cp313-cp313-win32.whl", hash = "sha256:1380cc5666d778e77f1587cd88cc317158111f44d54c0dd3975f0936993284e0", size = 217566, upload-time = "2025-07-24T16:51:28.961Z" },
- { url = "https://files.pythonhosted.org/packages/a0/d1/27b6e5073a8026b9e0f4224f1ac53217ce589a4cdab1bee878f23bff64f0/coverage-7.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:bf03cf176af098ee578b754a03add4690b82bdfe070adfb5d192d0b1cd15cf82", size = 218337, upload-time = "2025-07-24T16:51:31.45Z" },
- { url = "https://files.pythonhosted.org/packages/c7/06/0e3ba498b11e2245fd96bd7e8dcdf90e1dd36d57f49f308aa650ff0561b8/coverage-7.10.0-cp313-cp313-win_arm64.whl", hash = "sha256:8041c78cd145088116db2329b2fb6e89dc338116c962fbe654b7e9f5d72ab957", size = 216740, upload-time = "2025-07-24T16:51:33.317Z" },
- { url = "https://files.pythonhosted.org/packages/44/8b/11529debbe3e6b39ef6e7c8912554724adc6dc10adbb617a855ecfd387eb/coverage-7.10.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:37cc2c06052771f48651160c080a86431884db9cd62ba622cab71049b90a95b3", size = 215866, upload-time = "2025-07-24T16:51:35.339Z" },
- { url = "https://files.pythonhosted.org/packages/9c/6d/d8981310879e395f39af66536665b75135b1bc88dd21c7764e3340e9ce69/coverage-7.10.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:91f37270b16178b05fa107d85713d29bf21606e37b652d38646eef5f2dfbd458", size = 216083, upload-time = "2025-07-24T16:51:36.932Z" },
- { url = "https://files.pythonhosted.org/packages/c3/84/93295402de002de8b8c953bf6a1f19687174c4db7d44c1e85ffc153a772d/coverage-7.10.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f9b0b0168864d09bcb9a3837548f75121645c4cfd0efce0eb994c221955c5b10", size = 257320, upload-time = "2025-07-24T16:51:38.734Z" },
- { url = "https://files.pythonhosted.org/packages/02/5c/d0540db4869954dac0f69ad709adcd51f3a73ab11fcc9435ee76c518944a/coverage-7.10.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:df0be435d3b616e7d3ee3f9ebbc0d784a213986fe5dff9c6f1042ee7cfd30157", size = 259182, upload-time = "2025-07-24T16:51:40.463Z" },
- { url = "https://files.pythonhosted.org/packages/59/b2/d7d57a41a15ca4b47290862efd6b596d0a185bfd26f15d04db9f238aa56c/coverage-7.10.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35e9aba1c4434b837b1d567a533feba5ce205e8e91179c97974b28a14c23d3a0", size = 261322, upload-time = "2025-07-24T16:51:42.44Z" },
- { url = "https://files.pythonhosted.org/packages/16/92/fd828ae411b3da63673305617b6fbeccc09feb7dfe397d164f55a65cd880/coverage-7.10.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a0b0c481e74dfad631bdc2c883e57d8b058e5c90ba8ef087600995daf7bbec18", size = 258914, upload-time = "2025-07-24T16:51:44.115Z" },
- { url = "https://files.pythonhosted.org/packages/28/49/4aa5f5464b2e1215640c0400c5b007e7f5cdade8bf39c55c33b02f3a8c7f/coverage-7.10.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8aec1b7c8922808a433c13cd44ace6fceac0609f4587773f6c8217a06102674b", size = 257051, upload-time = "2025-07-24T16:51:45.75Z" },
- { url = "https://files.pythonhosted.org/packages/1e/5a/ded2346098c7f48ff6e135b5005b97de4cd9daec5c39adb4ecf3a60967da/coverage-7.10.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:04ec59ceb3a594af0927f2e0d810e1221212abd9a2e6b5b917769ff48760b460", size = 257869, upload-time = "2025-07-24T16:51:47.41Z" },
- { url = "https://files.pythonhosted.org/packages/46/66/e06cedb8fc7d1c96630b2f549b8cdc084e2623dcc70c900cb3b705a36a60/coverage-7.10.0-cp313-cp313t-win32.whl", hash = "sha256:b6871e62d29646eb9b3f5f92def59e7575daea1587db21f99e2b19561187abda", size = 218243, upload-time = "2025-07-24T16:51:49.136Z" },
- { url = "https://files.pythonhosted.org/packages/e7/1e/e84dd5ff35ed066bd6150e5c26fe0061ded2c59c209fd4f18db0650766c0/coverage-7.10.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff99cff2be44f78920b76803f782e91ffb46ccc7fa89eccccc0da3ca94285b64", size = 219334, upload-time = "2025-07-24T16:51:50.789Z" },
- { url = "https://files.pythonhosted.org/packages/b7/e0/b7b60b5dbc4e88eac0a0e9d5b4762409a59b29bf4e772b3509c8543ccaba/coverage-7.10.0-cp313-cp313t-win_arm64.whl", hash = "sha256:3246b63501348fe47299d12c47a27cfc221cfbffa1c2d857bcc8151323a4ae4f", size = 217196, upload-time = "2025-07-24T16:51:52.599Z" },
- { url = "https://files.pythonhosted.org/packages/09/df/7c34bada8ace39f688b3bd5bc411459a20a3204ccb0984c90169a80a9366/coverage-7.10.0-py3-none-any.whl", hash = "sha256:310a786330bb0463775c21d68e26e79973839b66d29e065c5787122b8dd4489f", size = 206777, upload-time = "2025-07-24T16:52:59.009Z" },
+version = "7.13.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" },
+ { url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" },
+ { url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" },
+ { url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" },
+ { url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" },
+ { url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" },
+ { url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" },
+ { url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" },
+ { url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" },
+ { url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" },
+ { url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" },
+ { url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" },
]
[[package]]
name = "cryptography"
-version = "46.0.3"
+version = "46.0.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" },
- { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" },
- { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" },
- { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" },
- { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" },
- { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" },
- { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" },
- { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" },
- { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" },
- { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" },
- { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" },
- { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" },
- { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" },
- { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" },
- { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" },
- { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" },
- { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" },
- { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" },
- { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" },
- { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" },
- { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" },
- { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" },
- { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" },
- { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" },
- { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" },
- { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" },
- { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" },
- { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" },
- { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" },
- { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" },
+sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" },
+ { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" },
+ { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" },
+ { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" },
+ { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" },
+ { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" },
+ { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" },
+ { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" },
+ { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" },
+ { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" },
+ { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" },
+ { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" },
+ { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" },
+ { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" },
+]
+
+[[package]]
+name = "cyclonedx-python-lib"
+version = "11.6.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "license-expression" },
+ { name = "packageurl-python" },
+ { name = "py-serializable" },
+ { name = "sortedcontainers" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/89/ed/54ecfa25fc145c58bf4f98090f7b6ffe5188d0759248c57dde44427ea239/cyclonedx_python_lib-11.6.0.tar.gz", hash = "sha256:7fb85a4371fa3a203e5be577ac22b7e9a7157f8b0058b7448731474d6dea7bf0", size = 1408147, upload-time = "2025-12-02T12:28:46.446Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c7/1b/534ad8a5e0f9470522811a8e5a9bc5d328fb7738ba29faf357467a4ef6d0/cyclonedx_python_lib-11.6.0-py3-none-any.whl", hash = "sha256:94f4aae97db42a452134dafdddcfab9745324198201c4777ed131e64c8380759", size = 511157, upload-time = "2025-12-02T12:28:44.158Z" },
+]
+
+[[package]]
+name = "defusedxml"
+version = "0.7.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" },
]
[[package]]
@@ -262,11 +364,11 @@ wheels = [
[[package]]
name = "dishka"
-version = "1.7.2"
+version = "1.9.1"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/40/d7/1be31f5ef32387059190353f9fa493ff4d07a1c75fa856c7566ca45e0800/dishka-1.7.2.tar.gz", hash = "sha256:47d4cb5162b28c61bf5541860e605ed5eaf5c667122299c7ef657c86fc8d5a49", size = 68132, upload-time = "2025-09-24T21:23:05.135Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/b9/97/18d4a9bd44f6baa975cd8d54ed3a1a86b341a43c9c077e647d351c9d4573/dishka-1.9.1.tar.gz", hash = "sha256:973f19dc65160a97370181106764ae076052af4489e94b0cedb3eb4e47fe13bf", size = 274962, upload-time = "2026-03-08T09:43:47.298Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/b7/b9/89381173b4f336e986d72471198614806cd313e0f85c143ccb677c310223/dishka-1.7.2-py3-none-any.whl", hash = "sha256:f6faa6ab321903926b825b3337d77172ee693450279b314434864978d01fbad3", size = 94774, upload-time = "2025-09-24T21:23:03.246Z" },
+ { url = "https://files.pythonhosted.org/packages/33/98/c8f80be83fbd92f5f9d4bdb5d619a9c9901fb1523c0b02a448b942e532e6/dishka-1.9.1-py3-none-any.whl", hash = "sha256:5080a46bf40bd403aee396aac81f999f679078655f9a6f2062111d62e94e7b18", size = 114327, upload-time = "2026-03-08T09:43:46.097Z" },
]
[[package]]
@@ -280,22 +382,23 @@ wheels = [
[[package]]
name = "fastapi"
-version = "0.121.0"
+version = "0.133.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
+ { name = "typing-inspection" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/8c/e3/77a2df0946703973b9905fd0cde6172c15e0781984320123b4f5079e7113/fastapi-0.121.0.tar.gz", hash = "sha256:06663356a0b1ee93e875bbf05a31fb22314f5bed455afaaad2b2dad7f26e98fa", size = 342412, upload-time = "2025-11-03T10:25:54.818Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/22/6f/0eafed8349eea1fa462238b54a624c8b408cd1ba2795c8e64aa6c34f8ab7/fastapi-0.133.1.tar.gz", hash = "sha256:ed152a45912f102592976fde6cbce7dae1a8a1053da94202e51dd35d184fadd6", size = 378741, upload-time = "2026-02-25T18:18:17.398Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/dd/2c/42277afc1ba1a18f8358561eee40785d27becab8f80a1f945c0a3051c6eb/fastapi-0.121.0-py3-none-any.whl", hash = "sha256:8bdf1b15a55f4e4b0d6201033da9109ea15632cb76cf156e7b8b4019f2172106", size = 109183, upload-time = "2025-11-03T10:25:53.27Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/c9/a175a7779f3599dfa4adfc97a6ce0e157237b3d7941538604aadaf97bfb6/fastapi-0.133.1-py3-none-any.whl", hash = "sha256:658f34ba334605b1617a65adf2ea6461901bdb9af3a3080d63ff791ecf7dc2e2", size = 109029, upload-time = "2026-02-25T18:18:18.578Z" },
]
[[package]]
name = "fastapi-clean-example"
-version = "0.1"
+version = "0.2"
source = { editable = "." }
dependencies = [
{ name = "alembic" },
@@ -304,111 +407,105 @@ dependencies = [
{ name = "dishka" },
{ name = "fastapi" },
{ name = "fastapi-error-map" },
- { name = "orjson" },
{ name = "psycopg", extra = ["binary"] },
+ { name = "pydantic-settings" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "sqlalchemy", extra = ["mypy"] },
{ name = "uuid-utils" },
{ name = "uvicorn" },
- { name = "uvloop" },
]
[package.dev-dependencies]
dev = [
+ { name = "asgi-lifespan" },
{ name = "coverage" },
{ name = "deptry" },
+ { name = "httpx" },
{ name = "import-linter" },
{ name = "line-profiler" },
{ name = "mypy" },
+ { name = "pip-audit" },
{ name = "pre-commit" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
+ { name = "pytest-cov" },
{ name = "ruff" },
{ name = "slotscheck" },
-]
-test = [
- { name = "coverage" },
- { name = "line-profiler" },
- { name = "pytest" },
- { name = "pytest-asyncio" },
+ { name = "tombi" },
]
[package.metadata]
requires-dist = [
- { name = "alembic", specifier = "==1.17.1" },
- { name = "alembic-postgresql-enum", specifier = "==1.8.0" },
+ { name = "alembic", specifier = "==1.18.4" },
+ { name = "alembic-postgresql-enum", specifier = "==1.10.0" },
{ name = "bcrypt", specifier = "==5.0.0" },
- { name = "dishka", specifier = "==1.7.2" },
- { name = "fastapi", specifier = "==0.121.0" },
- { name = "fastapi-error-map", specifier = "==0.9.8" },
- { name = "orjson", specifier = "==3.11.4" },
- { name = "psycopg", extras = ["binary"], specifier = "==3.2.12" },
- { name = "pyjwt", extras = ["crypto"], specifier = "==2.10.1" },
- { name = "sqlalchemy", extras = ["mypy"], specifier = "==2.0.44" },
- { name = "uuid-utils", specifier = "==0.11.1" },
- { name = "uvicorn", specifier = "==0.38.0" },
- { name = "uvloop", specifier = "==0.22.1" },
+ { name = "dishka", specifier = "==1.9.1" },
+ { name = "fastapi", specifier = "==0.133.1" },
+ { name = "fastapi-error-map", specifier = "==0.9.10" },
+ { name = "psycopg", extras = ["binary"], specifier = "==3.3.3" },
+ { name = "pydantic-settings", specifier = "==2.13.1" },
+ { name = "pyjwt", extras = ["crypto"], specifier = "==2.11.0" },
+ { name = "sqlalchemy", extras = ["mypy"], specifier = "==2.0.47" },
+ { name = "uuid-utils", specifier = "==0.14.1" },
+ { name = "uvicorn", specifier = "==0.41.0" },
]
[package.metadata.requires-dev]
dev = [
- { name = "coverage", specifier = "==7.10.0" },
+ { name = "asgi-lifespan", specifier = "==2.1.0" },
+ { name = "coverage", specifier = "==7.13.4" },
{ name = "deptry", specifier = "==0.24.0" },
- { name = "import-linter", specifier = "==2.9" },
- { name = "line-profiler", specifier = "==5.0.0" },
- { name = "mypy", specifier = "==1.17.0" },
- { name = "pre-commit", specifier = "==4.2.0" },
- { name = "pytest", specifier = "==8.4.1" },
- { name = "pytest-asyncio", specifier = "==1.1.0" },
- { name = "ruff", specifier = "==0.12.5" },
+ { name = "httpx", specifier = "==0.28.1" },
+ { name = "import-linter", specifier = "==2.10" },
+ { name = "line-profiler", specifier = "==5.0.2" },
+ { name = "mypy", specifier = "==1.19.1" },
+ { name = "pip-audit", specifier = "==2.10.0" },
+ { name = "pre-commit", specifier = "==4.5.1" },
+ { name = "pytest", specifier = "==9.0.2" },
+ { name = "pytest-asyncio", specifier = "==1.3.0" },
+ { name = "pytest-cov", specifier = "==7.0.0" },
+ { name = "ruff", specifier = "==0.15.4" },
{ name = "slotscheck", specifier = "==0.19.1" },
-]
-test = [
- { name = "coverage", specifier = "==7.10.0" },
- { name = "line-profiler", specifier = "==5.0.0" },
- { name = "pytest", specifier = "==8.4.1" },
- { name = "pytest-asyncio", specifier = "==1.1.0" },
+ { name = "tombi", specifier = "==0.7.33" },
]
[[package]]
name = "fastapi-error-map"
-version = "0.9.8"
+version = "0.9.10"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "fastapi" },
- { name = "orjson" },
+ { name = "packaging" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/84/9a/81aefff01594bfced5afdfb6c93de02a0f28fccc562f28c6bd721d7876a8/fastapi_error_map-0.9.8.tar.gz", hash = "sha256:894f6884598e4dd8b6c76cae59dee1522813ac3799ba0231b05465193c752f93", size = 386418, upload-time = "2025-11-02T01:58:06.546Z" }
+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" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/37/07/850dc161f16d79ec86f61e90e1dda2bc95c70c462b2b3fb0e46455b1dc98/fastapi_error_map-0.9.8-py3-none-any.whl", hash = "sha256:1d54a5a40b4a7c8653266f0c3f1f3d6be4729e19fd6ec34c29addc85d3e27b58", size = 20462, upload-time = "2025-11-02T01:58:04.545Z" },
+ { 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" },
]
[[package]]
name = "filelock"
-version = "3.20.0"
+version = "3.24.3"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/58/46/0028a82567109b5ef6e4d2a1f04a583fb513e6cf9527fcdd09afd817deeb/filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4", size = 18922, upload-time = "2025-10-08T18:03:50.056Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/73/92/a8e2479937ff39185d20dd6a851c1a63e55849e447a55e798cc2e1f49c65/filelock-3.24.3.tar.gz", hash = "sha256:011a5644dc937c22699943ebbfc46e969cdde3e171470a6e40b9533e5a72affa", size = 37935, upload-time = "2026-02-19T00:48:20.543Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054, upload-time = "2025-10-08T18:03:48.35Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/0f/5d0c71a1aefeb08efff26272149e07ab922b64f46c63363756224bd6872e/filelock-3.24.3-py3-none-any.whl", hash = "sha256:426e9a4660391f7f8a810d71b0555bce9008b0a1cc342ab1f6947d37639e002d", size = 24331, upload-time = "2026-02-19T00:48:18.465Z" },
]
[[package]]
name = "greenlet"
-version = "3.2.4"
+version = "3.3.2"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/03/b8/704d753a5a45507a7aab61f18db9509302ed3d0a27ac7e0359ec2905b1a6/greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d", size = 188260, upload-time = "2025-08-07T13:24:33.51Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" },
- { url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" },
- { url = "https://files.pythonhosted.org/packages/f7/0b/bc13f787394920b23073ca3b6c4a7a21396301ed75a655bcb47196b50e6e/greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc", size = 655191, upload-time = "2025-08-07T13:45:29.752Z" },
- { url = "https://files.pythonhosted.org/packages/f2/d6/6adde57d1345a8d0f14d31e4ab9c23cfe8e2cd39c3baf7674b4b0338d266/greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a", size = 649516, upload-time = "2025-08-07T13:53:16.314Z" },
- { url = "https://files.pythonhosted.org/packages/7f/3b/3a3328a788d4a473889a2d403199932be55b1b0060f4ddd96ee7cdfcad10/greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504", size = 652169, upload-time = "2025-08-07T13:18:32.861Z" },
- { url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" },
- { url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" },
- { url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" },
- { url = "https://files.pythonhosted.org/packages/1c/53/f9c440463b3057485b8594d7a638bed53ba531165ef0ca0e6c364b5cc807/greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b", size = 1564759, upload-time = "2025-11-04T12:42:19.395Z" },
- { url = "https://files.pythonhosted.org/packages/47/e4/3bb4240abdd0a8d23f4f88adec746a3099f0d86bfedb623f063b2e3b4df0/greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929", size = 1634288, upload-time = "2025-11-04T12:42:21.174Z" },
- { url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" },
+ { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" },
+ { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" },
+ { url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" },
+ { url = "https://files.pythonhosted.org/packages/91/39/5ef5aa23bc545aa0d31e1b9b55822b32c8da93ba657295840b6b34124009/greenlet-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:a7945dd0eab63ded0a48e4dcade82939783c172290a7903ebde9e184333ca124", size = 230961, upload-time = "2026-02-20T20:16:58.461Z" },
+ { url = "https://files.pythonhosted.org/packages/62/6b/a89f8456dcb06becff288f563618e9f20deed8dd29beea14f9a168aef64b/greenlet-3.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:394ead29063ee3515b4e775216cb756b2e3b4a7e55ae8fd884f17fa579e6b327", size = 230221, upload-time = "2026-02-20T20:17:37.152Z" },
]
[[package]]
@@ -453,13 +550,41 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
+[[package]]
+name = "httpcore"
+version = "1.0.9"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "h11" },
+]
+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" }
+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" },
+]
+
+[[package]]
+name = "httpx"
+version = "0.28.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "certifi" },
+ { name = "httpcore" },
+ { name = "idna" },
+]
+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" }
+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" },
+]
+
[[package]]
name = "identify"
-version = "2.6.15"
+version = "2.6.16"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311, upload-time = "2025-10-02T17:43:40.631Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/5b/8d/e8b97e6bd3fb6fb271346f7981362f1e04d6a7463abd0de79e1fda17c067/identify-2.6.16.tar.gz", hash = "sha256:846857203b5511bbe94d5a352a48ef2359532bc8f6727b5544077a0dcfb24980", size = 99360, upload-time = "2026-01-12T18:58:58.201Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/58/40fbbcefeda82364720eba5cf2270f98496bdfa19ea75b4cccae79c698e6/identify-2.6.16-py2.py3-none-any.whl", hash = "sha256:391ee4d77741d994189522896270b787aed8670389bfd60f326d677d64a6dfb0", size = 99202, upload-time = "2026-01-12T18:58:56.627Z" },
]
[[package]]
@@ -473,17 +598,19 @@ wheels = [
[[package]]
name = "import-linter"
-version = "2.9"
+version = "2.10"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
+ { name = "fastapi" },
{ name = "grimp" },
{ name = "rich" },
{ name = "typing-extensions" },
+ { name = "uvicorn" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/03/ea/9d3ba8e6851d22a073d21ff143a6b23f844dc97f46b41c0dccd26e26d6d3/import_linter-2.9.tar.gz", hash = "sha256:0d7da2a9bb0a534171a592795bd46c8cca86bd6dc6e6e665fa95ba4ed5024215", size = 288196, upload-time = "2025-12-11T11:55:06.087Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/10/c4/a83cc1ea9ed0171725c0e2edc11fd929994d4f026028657e8b30d62bca37/import_linter-2.10.tar.gz", hash = "sha256:c6a5057d2dbd32e1854c4d6b60e90dfad459b7ab5356230486d8521f25872963", size = 1149263, upload-time = "2026-02-06T17:57:24.779Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/62/3d/657a586f9324ad24538cd797d5c471286e217987e1d0f265575cebe594a9/import_linter-2.9-py3-none-any.whl", hash = "sha256:06403ede04c975cda2ea9050498c16b2021c0261b5cedf47c6c5d8725894b1a2", size = 44899, upload-time = "2025-12-11T11:55:04.87Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/e5/4b7b9435eac78ecfd537fa1004a0bcf0f4eac17d3a893f64d38a7bacb51b/import_linter-2.10-py3-none-any.whl", hash = "sha256:cc2ddd7ec0145cbf83f3b25391d2a5dbbf138382aaf80708612497fa6ebc8f60", size = 637081, upload-time = "2026-02-06T17:57:23.386Z" },
]
[[package]]
@@ -495,19 +622,57 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
+[[package]]
+name = "librt"
+version = "0.8.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529, upload-time = "2026-02-17T16:11:57.809Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669, upload-time = "2026-02-17T16:11:58.843Z" },
+ { url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279, upload-time = "2026-02-17T16:11:59.862Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288, upload-time = "2026-02-17T16:12:00.954Z" },
+ { url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809, upload-time = "2026-02-17T16:12:02.108Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075, upload-time = "2026-02-17T16:12:03.631Z" },
+ { url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486, upload-time = "2026-02-17T16:12:04.725Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219, upload-time = "2026-02-17T16:12:05.828Z" },
+ { url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750, upload-time = "2026-02-17T16:12:07.09Z" },
+ { url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624, upload-time = "2026-02-17T16:12:08.43Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969, upload-time = "2026-02-17T16:12:09.633Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000, upload-time = "2026-02-17T16:12:10.632Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495, upload-time = "2026-02-17T16:12:11.633Z" },
+]
+
+[[package]]
+name = "license-expression"
+version = "30.4.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "boolean-py" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/40/71/d89bb0e71b1415453980fd32315f2a037aad9f7f70f695c7cec7035feb13/license_expression-30.4.4.tar.gz", hash = "sha256:73448f0aacd8d0808895bdc4b2c8e01a8d67646e4188f887375398c761f340fd", size = 186402, upload-time = "2025-07-22T11:13:32.17Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/af/40/791891d4c0c4dab4c5e187c17261cedc26285fd41541577f900470a45a4d/license_expression-30.4.4-py3-none-any.whl", hash = "sha256:421788fdcadb41f049d2dc934ce666626265aeccefddd25e162a26f23bcbf8a4", size = 120615, upload-time = "2025-07-22T11:13:31.217Z" },
+]
+
[[package]]
name = "line-profiler"
-version = "5.0.0"
+version = "5.0.2"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/ea/5c/bbe9042ef5cf4c6cad4bf4d6f7975193430eba9191b7278ea114a3993fbb/line_profiler-5.0.0.tar.gz", hash = "sha256:a80f0afb05ba0d275d9dddc5ff97eab637471167ff3e66dcc7d135755059398c", size = 376919, upload-time = "2025-07-23T20:15:41.819Z" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/03/b6/6d18ad201417a9c5168995541d0fd7981b5652b2b34f6e46a3a93c0f1beb/line_profiler-5.0.2.tar.gz", hash = "sha256:8d8a990c84c64bcde45af22af502d17bc0ae107be405ce41bba92af5c39c0000", size = 407075, upload-time = "2026-02-23T23:31:20.698Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/7d/eb/bc4420cf68661406c98d590656d72eed6f7d76e45accf568802dc83615ef/line_profiler-5.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9873fabbae1587778a551176758a70a5f6c89d8d070a1aca7a689677d41a1348", size = 624828, upload-time = "2025-07-23T20:15:05.315Z" },
- { url = "https://files.pythonhosted.org/packages/f2/6e/6e0a4c1009975d27810027427d601acbad75b45947040d0fd80cec5b3e94/line_profiler-5.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2cd6cdb5a4d3b4ced607104dbed73ec820a69018decd1a90904854380536ed32", size = 487651, upload-time = "2025-07-23T20:15:06.961Z" },
- { url = "https://files.pythonhosted.org/packages/3b/2c/e60e61f24faa0e6eca375bdac9c4b4b37c3267488d7cb1a8c5bd74cf5cdc/line_profiler-5.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:34d6172a3bd14167b3ea2e629d71b08683b17b3bc6eb6a4936d74e3669f875b6", size = 474071, upload-time = "2025-07-23T20:15:08.607Z" },
- { url = "https://files.pythonhosted.org/packages/e1/d5/6f178e74746f84cc17381f607d191c54772207770d585fda773b868bfe28/line_profiler-5.0.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e5edd859be322aa8252253e940ac1c60cca4c385760d90a402072f8f35e4b967", size = 1405434, upload-time = "2025-07-23T20:15:09.862Z" },
- { url = "https://files.pythonhosted.org/packages/9b/32/ce67bbf81e5c78cc8d606afe6a192fbef30395021b2aaffe15681e186e3f/line_profiler-5.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d4f97b223105eed6e525994f5653061bd981e04838ee5d14e01d17c26185094", size = 1467553, upload-time = "2025-07-23T20:15:11.195Z" },
- { url = "https://files.pythonhosted.org/packages/c1/c1/431ffb89a351aaa63f8358442e0b9456a3bb745cebdf9c0d7aa4d47affca/line_profiler-5.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4758007e491bee3be40ebcca460596e0e28e7f39b735264694a9cafec729dfa9", size = 2442489, upload-time = "2025-07-23T20:15:12.602Z" },
- { url = "https://files.pythonhosted.org/packages/ce/9d/e34cc99c8abca3a27911d3542a87361e9c292fa1258d182e4a0a5c442850/line_profiler-5.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:213b19c4b65942db5d477e603c18c76126e3811a39d8bab251d930d8ce82ffba", size = 461377, upload-time = "2025-07-23T20:15:13.871Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/64/856b920e026fbd239df875ec05e63583f7bd7f250805215ab6e132da11d1/line_profiler-5.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:016effba91d34d15229d41984e921a27f66a7b634f1d7adf6c57c743f3d6a0eb", size = 642642, upload-time = "2026-02-23T23:30:27.63Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/08/0a56fab0a36818af6ffc8073700db2f402db5a62477b69d938c19871d631/line_profiler-5.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:506e800dd408a8aafadf39ff4e4a1375ae7794910d00098f191520a2f390cb99", size = 503787, upload-time = "2026-02-23T23:30:29.226Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/9a/0ab45cf92b2c13261b475c440e18bb18d9497cc2ad5dfaf38c231c72b02b/line_profiler-5.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1e67f77bcb349a663cb22819f65621bcd2a39889524dd890d1d88f8736841b7b", size = 493631, upload-time = "2026-02-23T23:30:30.502Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/15/a5b603f0c7c795aa656a95e2a70d139dc499b5d153b6a3129bbba6b6f913/line_profiler-5.0.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6b9d08e85fd48d254ae253e76dc72598e94200ef7002eb1ae0bab4cc9c5e41a", size = 1464022, upload-time = "2026-02-23T23:30:31.793Z" },
+ { url = "https://files.pythonhosted.org/packages/27/6f/0f399c72eecaf8f8c00e84238b5786afc34d0a4ef5ad10c63c712715ba86/line_profiler-5.0.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31290e06ac25cd87fee46ebe979541d4ec7c8d6f15c5cbe5874a932b1cee95bb", size = 1483425, upload-time = "2026-02-23T23:30:33.15Z" },
+ { url = "https://files.pythonhosted.org/packages/65/18/f4c642a29719a84d17ea8b58cd6e60943573a28228c30c568565ed5512aa/line_profiler-5.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1d7fbcc2dbd8534fc6f7d2b440076749b2235cdc525eb177fefafeaf7550373f", size = 2410276, upload-time = "2026-02-23T23:30:34.943Z" },
+ { url = "https://files.pythonhosted.org/packages/90/33/701203686e7d27a545e3bbc8e81fffc7d091c42ed33564be4e72376ef45b/line_profiler-5.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:55f04671f48afcd90858c18fbdb2509463c77d717ed5424664f096e902206b6b", size = 2495283, upload-time = "2026-02-23T23:30:36.616Z" },
+ { url = "https://files.pythonhosted.org/packages/34/e1/59fe065f67ed1fb8f974a9e3434685af1fc1f6a154489f7ab0992eab1c73/line_profiler-5.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:d2262d4bbbcf72bd430fc5763073792a0f1cb20e64de0f7ecf6e8ae16627d876", size = 479287, upload-time = "2026-02-23T23:30:38.152Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/83/89f6ae52fa77960404ee88fc078ee680e504bf1ab8724ac01430cee0f5a5/line_profiler-5.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:abf755b020d91b639cbc563015eca381ca64e6bd27ee55ef9004a3a17b6d4dcf", size = 461960, upload-time = "2026-02-23T23:30:39.657Z" },
]
[[package]]
@@ -573,24 +738,42 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
+[[package]]
+name = "msgpack"
+version = "1.1.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" },
+ { url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" },
+ { url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" },
+ { url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" },
+ { url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" },
+ { url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" },
+]
+
[[package]]
name = "mypy"
-version = "1.17.0"
+version = "1.19.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
+ { name = "librt", marker = "platform_python_implementation != 'PyPy'" },
{ name = "mypy-extensions" },
{ name = "pathspec" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/1e/e3/034322d5a779685218ed69286c32faa505247f1f096251ef66c8fd203b08/mypy-1.17.0.tar.gz", hash = "sha256:e5d7ccc08ba089c06e2f5629c660388ef1fee708444f1dee0b9203fa031dee03", size = 3352114, upload-time = "2025-07-14T20:34:30.181Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/be/7b/5f8ab461369b9e62157072156935cec9d272196556bdc7c2ff5f4c7c0f9b/mypy-1.17.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2c41aa59211e49d717d92b3bb1238c06d387c9325d3122085113c79118bebb06", size = 11070019, upload-time = "2025-07-14T20:32:07.99Z" },
- { url = "https://files.pythonhosted.org/packages/9c/f8/c49c9e5a2ac0badcc54beb24e774d2499748302c9568f7f09e8730e953fa/mypy-1.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0e69db1fb65b3114f98c753e3930a00514f5b68794ba80590eb02090d54a5d4a", size = 10114457, upload-time = "2025-07-14T20:33:47.285Z" },
- { url = "https://files.pythonhosted.org/packages/89/0c/fb3f9c939ad9beed3e328008b3fb90b20fda2cddc0f7e4c20dbefefc3b33/mypy-1.17.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:03ba330b76710f83d6ac500053f7727270b6b8553b0423348ffb3af6f2f7b889", size = 11857838, upload-time = "2025-07-14T20:33:14.462Z" },
- { url = "https://files.pythonhosted.org/packages/4c/66/85607ab5137d65e4f54d9797b77d5a038ef34f714929cf8ad30b03f628df/mypy-1.17.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:037bc0f0b124ce46bfde955c647f3e395c6174476a968c0f22c95a8d2f589bba", size = 12731358, upload-time = "2025-07-14T20:32:25.579Z" },
- { url = "https://files.pythonhosted.org/packages/73/d0/341dbbfb35ce53d01f8f2969facbb66486cee9804048bf6c01b048127501/mypy-1.17.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c38876106cb6132259683632b287238858bd58de267d80defb6f418e9ee50658", size = 12917480, upload-time = "2025-07-14T20:34:21.868Z" },
- { url = "https://files.pythonhosted.org/packages/64/63/70c8b7dbfc520089ac48d01367a97e8acd734f65bd07813081f508a8c94c/mypy-1.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:d30ba01c0f151998f367506fab31c2ac4527e6a7b2690107c7a7f9e3cb419a9c", size = 9589666, upload-time = "2025-07-14T20:34:16.841Z" },
- { url = "https://files.pythonhosted.org/packages/e3/fc/ee058cc4316f219078464555873e99d170bde1d9569abd833300dbeb484a/mypy-1.17.0-py3-none-any.whl", hash = "sha256:15d9d0018237ab058e5de3d8fce61b6fa72cc59cc78fd91f1b474bce12abf496", size = 2283195, upload-time = "2025-07-14T20:31:54.753Z" },
+ { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" },
+ { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" },
+ { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" },
+ { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" },
]
[[package]]
@@ -604,61 +787,102 @@ wheels = [
[[package]]
name = "nodeenv"
-version = "1.9.1"
+version = "1.10.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" },
+ { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
]
[[package]]
-name = "orjson"
-version = "3.11.4"
+name = "packageurl-python"
+version = "0.17.6"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/c6/fe/ed708782d6709cc60eb4c2d8a361a440661f74134675c72990f2c48c785f/orjson-3.11.4.tar.gz", hash = "sha256:39485f4ab4c9b30a3943cfe99e1a213c4776fb69e8abd68f66b83d5a0b0fdc6d", size = 5945188, upload-time = "2025-10-24T15:50:38.027Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/f5/d6/3b5a4e3cfaef7a53869a26ceb034d1ff5e5c27c814ce77260a96d50ab7bb/packageurl_python-0.17.6.tar.gz", hash = "sha256:1252ce3a102372ca6f86eb968e16f9014c4ba511c5c37d95a7f023e2ca6e5c25", size = 50618, upload-time = "2025-11-24T15:20:17.998Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/23/15/c52aa7112006b0f3d6180386c3a46ae057f932ab3425bc6f6ac50431cca1/orjson-3.11.4-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:2d6737d0e616a6e053c8b4acc9eccea6b6cce078533666f32d140e4f85002534", size = 243525, upload-time = "2025-10-24T15:49:29.737Z" },
- { url = "https://files.pythonhosted.org/packages/ec/38/05340734c33b933fd114f161f25a04e651b0c7c33ab95e9416ade5cb44b8/orjson-3.11.4-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:afb14052690aa328cc118a8e09f07c651d301a72e44920b887c519b313d892ff", size = 128871, upload-time = "2025-10-24T15:49:31.109Z" },
- { url = "https://files.pythonhosted.org/packages/55/b9/ae8d34899ff0c012039b5a7cb96a389b2476e917733294e498586b45472d/orjson-3.11.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38aa9e65c591febb1b0aed8da4d469eba239d434c218562df179885c94e1a3ad", size = 130055, upload-time = "2025-10-24T15:49:33.382Z" },
- { url = "https://files.pythonhosted.org/packages/33/aa/6346dd5073730451bee3681d901e3c337e7ec17342fb79659ec9794fc023/orjson-3.11.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f2cf4dfaf9163b0728d061bebc1e08631875c51cd30bf47cb9e3293bfbd7dcd5", size = 129061, upload-time = "2025-10-24T15:49:34.935Z" },
- { url = "https://files.pythonhosted.org/packages/39/e4/8eea51598f66a6c853c380979912d17ec510e8e66b280d968602e680b942/orjson-3.11.4-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:89216ff3dfdde0e4070932e126320a1752c9d9a758d6a32ec54b3b9334991a6a", size = 136541, upload-time = "2025-10-24T15:49:36.923Z" },
- { url = "https://files.pythonhosted.org/packages/9a/47/cb8c654fa9adcc60e99580e17c32b9e633290e6239a99efa6b885aba9dbc/orjson-3.11.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9daa26ca8e97fae0ce8aa5d80606ef8f7914e9b129b6b5df9104266f764ce436", size = 137535, upload-time = "2025-10-24T15:49:38.307Z" },
- { url = "https://files.pythonhosted.org/packages/43/92/04b8cc5c2b729f3437ee013ce14a60ab3d3001465d95c184758f19362f23/orjson-3.11.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c8b2769dc31883c44a9cd126560327767f848eb95f99c36c9932f51090bfce9", size = 136703, upload-time = "2025-10-24T15:49:40.795Z" },
- { url = "https://files.pythonhosted.org/packages/aa/fd/d0733fcb9086b8be4ebcfcda2d0312865d17d0d9884378b7cffb29d0763f/orjson-3.11.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1469d254b9884f984026bd9b0fa5bbab477a4bfe558bba6848086f6d43eb5e73", size = 136293, upload-time = "2025-10-24T15:49:42.347Z" },
- { url = "https://files.pythonhosted.org/packages/c2/d7/3c5514e806837c210492d72ae30ccf050ce3f940f45bf085bab272699ef4/orjson-3.11.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:68e44722541983614e37117209a194e8c3ad07838ccb3127d96863c95ec7f1e0", size = 140131, upload-time = "2025-10-24T15:49:43.638Z" },
- { url = "https://files.pythonhosted.org/packages/9c/dd/ba9d32a53207babf65bd510ac4d0faaa818bd0df9a9c6f472fe7c254f2e3/orjson-3.11.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8e7805fda9672c12be2f22ae124dcd7b03928d6c197544fe12174b86553f3196", size = 406164, upload-time = "2025-10-24T15:49:45.498Z" },
- { url = "https://files.pythonhosted.org/packages/8e/f9/f68ad68f4af7c7bde57cd514eaa2c785e500477a8bc8f834838eb696a685/orjson-3.11.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:04b69c14615fb4434ab867bf6f38b2d649f6f300af30a6705397e895f7aec67a", size = 149859, upload-time = "2025-10-24T15:49:46.981Z" },
- { url = "https://files.pythonhosted.org/packages/b6/d2/7f847761d0c26818395b3d6b21fb6bc2305d94612a35b0a30eae65a22728/orjson-3.11.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:639c3735b8ae7f970066930e58cf0ed39a852d417c24acd4a25fc0b3da3c39a6", size = 139926, upload-time = "2025-10-24T15:49:48.321Z" },
- { url = "https://files.pythonhosted.org/packages/9f/37/acd14b12dc62db9a0e1d12386271b8661faae270b22492580d5258808975/orjson-3.11.4-cp313-cp313-win32.whl", hash = "sha256:6c13879c0d2964335491463302a6ca5ad98105fc5db3565499dcb80b1b4bd839", size = 136007, upload-time = "2025-10-24T15:49:49.938Z" },
- { url = "https://files.pythonhosted.org/packages/c0/a9/967be009ddf0a1fffd7a67de9c36656b28c763659ef91352acc02cbe364c/orjson-3.11.4-cp313-cp313-win_amd64.whl", hash = "sha256:09bf242a4af98732db9f9a1ec57ca2604848e16f132e3f72edfd3c5c96de009a", size = 131314, upload-time = "2025-10-24T15:49:51.248Z" },
- { url = "https://files.pythonhosted.org/packages/cb/db/399abd6950fbd94ce125cb8cd1a968def95174792e127b0642781e040ed4/orjson-3.11.4-cp313-cp313-win_arm64.whl", hash = "sha256:a85f0adf63319d6c1ba06fb0dbf997fced64a01179cf17939a6caca662bf92de", size = 126152, upload-time = "2025-10-24T15:49:52.922Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/2f/c7277b7615a93f51b5fbc1eacfc1b75e8103370e786fd8ce2abf6e5c04ab/packageurl_python-0.17.6-py3-none-any.whl", hash = "sha256:31a85c2717bc41dd818f3c62908685ff9eebcb68588213745b14a6ee9e7df7c9", size = 36776, upload-time = "2025-11-24T15:20:16.962Z" },
]
[[package]]
name = "packaging"
-version = "25.0"
+version = "26.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[package]]
name = "pathspec"
-version = "0.12.1"
+version = "1.0.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" },
+]
+
+[[package]]
+name = "pip"
+version = "26.0.1"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/48/83/0d7d4e9efe3344b8e2fe25d93be44f64b65364d3c8d7bc6dc90198d5422e/pip-26.0.1.tar.gz", hash = "sha256:c4037d8a277c89b320abe636d59f91e6d0922d08a05b60e85e53b296613346d8", size = 1812747, upload-time = "2026-02-05T02:20:18.702Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" },
+ { url = "https://files.pythonhosted.org/packages/de/f0/c81e05b613866b76d2d1066490adf1a3dbc4ee9d9c839961c3fc8a6997af/pip-26.0.1-py3-none-any.whl", hash = "sha256:bdb1b08f4274833d62c1aa29e20907365a2ceb950410df15fc9521bad440122b", size = 1787723, upload-time = "2026-02-05T02:20:16.416Z" },
+]
+
+[[package]]
+name = "pip-api"
+version = "0.0.34"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pip" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b9/f1/ee85f8c7e82bccf90a3c7aad22863cc6e20057860a1361083cd2adacb92e/pip_api-0.0.34.tar.gz", hash = "sha256:9b75e958f14c5a2614bae415f2adf7eeb54d50a2cfbe7e24fd4826471bac3625", size = 123017, upload-time = "2024-07-09T20:32:30.641Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/91/f7/ebf5003e1065fd00b4cbef53bf0a65c3d3e1b599b676d5383ccb7a8b88ba/pip_api-0.0.34-py3-none-any.whl", hash = "sha256:8b2d7d7c37f2447373aa2cf8b1f60a2f2b27a84e1e9e0294a3f6ef10eb3ba6bb", size = 120369, upload-time = "2024-07-09T20:32:29.099Z" },
+]
+
+[[package]]
+name = "pip-audit"
+version = "2.10.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cachecontrol", extra = ["filecache"] },
+ { name = "cyclonedx-python-lib" },
+ { name = "packaging" },
+ { name = "pip-api" },
+ { name = "pip-requirements-parser" },
+ { name = "platformdirs" },
+ { name = "requests" },
+ { name = "rich" },
+ { name = "tomli" },
+ { name = "tomli-w" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/bd/89/0e999b413facab81c33d118f3ac3739fd02c0622ccf7c4e82e37cebd8447/pip_audit-2.10.0.tar.gz", hash = "sha256:427ea5bf61d1d06b98b1ae29b7feacc00288a2eced52c9c58ceed5253ef6c2a4", size = 53776, upload-time = "2025-12-01T23:42:40.612Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/be/f3/4888f895c02afa085630a3a3329d1b18b998874642ad4c530e9a4d7851fe/pip_audit-2.10.0-py3-none-any.whl", hash = "sha256:16e02093872fac97580303f0848fa3ad64f7ecf600736ea7835a2b24de49613f", size = 61518, upload-time = "2025-12-01T23:42:39.193Z" },
+]
+
+[[package]]
+name = "pip-requirements-parser"
+version = "32.0.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "packaging" },
+ { name = "pyparsing" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5e/2a/63b574101850e7f7b306ddbdb02cb294380d37948140eecd468fae392b54/pip-requirements-parser-32.0.1.tar.gz", hash = "sha256:b4fa3a7a0be38243123cf9d1f3518da10c51bdb165a2b2985566247f9155a7d3", size = 209359, upload-time = "2022-12-21T15:25:22.732Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/d0/d04f1d1e064ac901439699ee097f58688caadea42498ec9c4b4ad2ef84ab/pip_requirements_parser-32.0.1-py3-none-any.whl", hash = "sha256:4659bc2a667783e7a15d190f6fccf8b2486685b6dba4c19c3876314769c57526", size = 35648, upload-time = "2022-12-21T15:25:21.046Z" },
]
[[package]]
name = "platformdirs"
-version = "4.5.0"
+version = "4.9.2"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/1b/04/fea538adf7dbbd6d186f551d595961e564a3b6715bdf276b477460858672/platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291", size = 28394, upload-time = "2026-02-16T03:56:10.574Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" },
+ { url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168, upload-time = "2026-02-16T03:56:08.891Z" },
]
[[package]]
@@ -672,7 +896,7 @@ wheels = [
[[package]]
name = "pre-commit"
-version = "4.2.0"
+version = "4.5.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cfgv" },
@@ -681,21 +905,21 @@ dependencies = [
{ name = "pyyaml" },
{ name = "virtualenv" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/08/39/679ca9b26c7bb2999ff122d50faa301e49af82ca9c066ec061cfbc0c6784/pre_commit-4.2.0.tar.gz", hash = "sha256:601283b9757afd87d40c4c4a9b2b5de9637a8ea02eaff7adc2d0fb4e04841146", size = 193424, upload-time = "2025-03-18T21:35:20.987Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/88/74/a88bf1b1efeae488a0c0b7bdf71429c313722d1fc0f377537fbe554e6180/pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd", size = 220707, upload-time = "2025-03-18T21:35:19.343Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" },
]
[[package]]
name = "psycopg"
-version = "3.2.12"
+version = "3.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/a8/77/c72d10262b872617e509a0c60445afcc4ce2cd5cd6bc1c97700246d69c85/psycopg-3.2.12.tar.gz", hash = "sha256:85c08d6f6e2a897b16280e0ff6406bef29b1327c045db06d21f364d7cd5da90b", size = 160642, upload-time = "2025-10-26T00:46:03.045Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/d3/b6/379d0a960f8f435ec78720462fd94c4863e7a31237cf81bf76d0af5883bf/psycopg-3.3.3.tar.gz", hash = "sha256:5e9a47458b3c1583326513b2556a2a9473a1001a56c9efe9e587245b43148dd9", size = 165624, upload-time = "2026-02-18T16:52:16.546Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/c8/28/8c4f90e415411dc9c78d6ba10b549baa324659907c13f64bfe3779d4066c/psycopg-3.2.12-py3-none-any.whl", hash = "sha256:8a1611a2d4c16ae37eada46438be9029a35bb959bb50b3d0e1e93c0f3d54c9ee", size = 206765, upload-time = "2025-10-26T00:10:42.173Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/5b/181e2e3becb7672b502f0ed7f16ed7352aca7c109cfb94cf3878a9186db9/psycopg-3.3.3-py3-none-any.whl", hash = "sha256:f96525a72bcfade6584ab17e89de415ff360748c766f0106959144dcbb38c698", size = 212768, upload-time = "2026-02-18T16:46:27.365Z" },
]
[package.optional-dependencies]
@@ -705,32 +929,46 @@ binary = [
[[package]]
name = "psycopg-binary"
-version = "3.2.12"
+version = "3.3.3"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/03/0a/cac9fdf1df16a269ba0e5f0f06cac61f826c94cadb39df028cdfe19d3a33/psycopg_binary-3.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05f32239aec25c5fb15f7948cffdc2dc0dac098e48b80a140e4ba32b572a2e7d", size = 4590414, upload-time = "2026-02-18T16:50:01.441Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/c0/d8f8508fbf440edbc0099b1abff33003cd80c9e66eb3a1e78834e3fb4fb9/psycopg_binary-3.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c84f9d214f2d1de2fafebc17fa68ac3f6561a59e291553dfc45ad299f4898c1", size = 4669021, upload-time = "2026-02-18T16:50:08.803Z" },
+ { url = "https://files.pythonhosted.org/packages/04/05/097016b77e343b4568feddf12c72171fc513acef9a4214d21b9478569068/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e77957d2ba17cada11be09a5066d93026cdb61ada7c8893101d7fe1c6e1f3925", size = 5467453, upload-time = "2026-02-18T16:50:14.985Z" },
+ { url = "https://files.pythonhosted.org/packages/91/23/73244e5feb55b5ca109cede6e97f32ef45189f0fdac4c80d75c99862729d/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:42961609ac07c232a427da7c87a468d3c82fee6762c220f38e37cfdacb2b178d", size = 5151135, upload-time = "2026-02-18T16:50:24.82Z" },
+ { url = "https://files.pythonhosted.org/packages/11/49/5309473b9803b207682095201d8708bbc7842ddf3f192488a69204e36455/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae07a3114313dd91fce686cab2f4c44af094398519af0e0f854bc707e1aeedf1", size = 6737315, upload-time = "2026-02-18T16:50:35.106Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/5d/03abe74ef34d460b33c4d9662bf6ec1dd38888324323c1a1752133c10377/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d257c58d7b36a621dcce1d01476ad8b60f12d80eb1406aee4cf796f88b2ae482", size = 4979783, upload-time = "2026-02-18T16:50:42.067Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/6c/3fbf8e604e15f2f3752900434046c00c90bb8764305a1b81112bff30ba24/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:07c7211f9327d522c9c47560cae00a4ecf6687f4e02d779d035dd3177b41cb12", size = 4509023, upload-time = "2026-02-18T16:50:50.116Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/6b/1a06b43b7c7af756c80b67eac8bfaa51d77e68635a8a8d246e4f0bb7604a/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:8e7e9eca9b363dbedeceeadd8be97149d2499081f3c52d141d7cd1f395a91f83", size = 4185874, upload-time = "2026-02-18T16:50:55.97Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/d3/bf49e3dcaadba510170c8d111e5e69e5ae3f981c1554c5bb71c75ce354bb/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:cb85b1d5702877c16f28d7b92ba030c1f49ebcc9b87d03d8c10bf45a2f1c7508", size = 3925668, upload-time = "2026-02-18T16:51:03.299Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/92/0aac830ed6a944fe334404e1687a074e4215630725753f0e3e9a9a595b62/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d4606c84d04b80f9138d72f1e28c6c02dc5ae0c7b8f3f8aaf89c681ce1cd1b1", size = 4234973, upload-time = "2026-02-18T16:51:09.097Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/96/102244653ee5a143ece5afe33f00f52fe64e389dfce8dbc87580c6d70d3d/psycopg_binary-3.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:74eae563166ebf74e8d950ff359be037b85723d99ca83f57d9b244a871d6c13b", size = 3551342, upload-time = "2026-02-18T16:51:13.892Z" },
+]
+
+[[package]]
+name = "py-serializable"
+version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "defusedxml" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/73/21/d250cfca8ff30c2e5a7447bc13861541126ce9bd4426cd5d0c9f08b5547d/py_serializable-2.1.0.tar.gz", hash = "sha256:9d5db56154a867a9b897c0163b33a793c804c80cee984116d02d49e4578fc103", size = 52368, upload-time = "2025-07-21T09:56:48.07Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/b2/0b/9d480aba4a4864832c29e6fc94ddd34d9927c276448eb3b56ffe24ed064c/psycopg_binary-3.2.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:442f20153415f374ae5753ca618637611a41a3c58c56d16ce55f845d76a3cf7b", size = 4017829, upload-time = "2025-10-26T00:26:27.031Z" },
- { url = "https://files.pythonhosted.org/packages/a4/f3/0d294b30349bde24a46741a1f27a10e8ab81e9f4118d27c2fe592acfb42a/psycopg_binary-3.2.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:79de3cc5adbf51677009a8fda35ac9e9e3686d5595ab4b0c43ec7099ece6aeb5", size = 4089835, upload-time = "2025-10-26T00:27:01.392Z" },
- { url = "https://files.pythonhosted.org/packages/82/d4/ff82e318e5a55d6951b278d3af7b4c7c1b19344e3a3722b6613f156a38ea/psycopg_binary-3.2.12-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:095ccda59042a1239ac2fefe693a336cb5cecf8944a8d9e98b07f07e94e2b78d", size = 4625474, upload-time = "2025-10-26T00:27:40.34Z" },
- { url = "https://files.pythonhosted.org/packages/b1/e8/2c9df6475a5ab6d614d516f4497c568d84f7d6c21d0e11444468c9786c9f/psycopg_binary-3.2.12-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:efab679a2c7d1bf7d0ec0e1ecb47fe764945eff75bb4321f2e699b30a12db9b3", size = 4720350, upload-time = "2025-10-26T00:28:20.104Z" },
- { url = "https://files.pythonhosted.org/packages/74/f5/7aec81b0c41985dc006e2d5822486ad4b7c2a1a97a5a05e37dc2adaf1512/psycopg_binary-3.2.12-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d369e79ad9647fc8217cbb51bbbf11f9a1ffca450be31d005340157ffe8e91b3", size = 4411621, upload-time = "2025-10-26T00:28:59.104Z" },
- { url = "https://files.pythonhosted.org/packages/fc/15/d3cb41b8fa9d5f14320ab250545fbb66f9ddb481e448e618902672a806c0/psycopg_binary-3.2.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eedc410f82007038030650aa58f620f9fe0009b9d6b04c3dc71cbd3bae5b2675", size = 3863081, upload-time = "2025-10-26T00:29:31.235Z" },
- { url = "https://files.pythonhosted.org/packages/69/8a/72837664e63e3cd3aa145cedcf29e5c21257579739aba78ab7eb668f7d9c/psycopg_binary-3.2.12-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f3bae4be7f6781bf6c9576eedcd5e1bb74468126fa6de991e47cdb1a8ea3a42a", size = 3537428, upload-time = "2025-10-26T00:30:01.465Z" },
- { url = "https://files.pythonhosted.org/packages/cc/7e/1b78ae38e7d69e6d7fb1e2dcce101493f5fa429480bac3a68b876c9b1635/psycopg_binary-3.2.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8ffe75fe6be902dadd439adf4228c98138a992088e073ede6dd34e7235f4e03e", size = 3585981, upload-time = "2025-10-26T00:30:31.635Z" },
- { url = "https://files.pythonhosted.org/packages/a3/f8/245b4868b2dac46c3fb6383b425754ae55df1910c826d305ed414da03777/psycopg_binary-3.2.12-cp313-cp313-win_amd64.whl", hash = "sha256:2598d0e4f2f258da13df0560187b3f1dfc9b8688c46b9d90176360ae5212c3fc", size = 2912929, upload-time = "2025-10-26T00:30:56.413Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/bf/7595e817906a29453ba4d99394e781b6fabe55d21f3c15d240f85dd06bb1/py_serializable-2.1.0-py3-none-any.whl", hash = "sha256:b56d5d686b5a03ba4f4db5e769dc32336e142fc3bd4d68a8c25579ebb0a67304", size = 23045, upload-time = "2025-07-21T09:56:46.848Z" },
]
[[package]]
name = "pycparser"
-version = "2.23"
+version = "3.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
]
[[package]]
name = "pydantic"
-version = "2.12.3"
+version = "2.12.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
@@ -738,39 +976,48 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/f3/1e/4f0a3233767010308f2fd6bd0814597e3f63f1dc98304a9112b8759df4ff/pydantic-2.12.3.tar.gz", hash = "sha256:1da1c82b0fc140bb0103bc1441ffe062154c8d38491189751ee00fd8ca65ce74", size = 819383, upload-time = "2025-10-17T15:04:21.222Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/a1/6b/83661fa77dcefa195ad5f8cd9af3d1a7450fd57cc883ad04d65446ac2029/pydantic-2.12.3-py3-none-any.whl", hash = "sha256:6986454a854bc3bc6e5443e1369e06a3a456af9d339eda45510f517d9ea5c6bf", size = 462431, upload-time = "2025-10-17T15:04:19.346Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
]
[[package]]
name = "pydantic-core"
-version = "2.41.4"
+version = "2.41.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/df/18/d0944e8eaaa3efd0a91b0f1fc537d3be55ad35091b6a87638211ba691964/pydantic_core-2.41.4.tar.gz", hash = "sha256:70e47929a9d4a1905a67e4b687d5946026390568a8e952b92824118063cee4d5", size = 457557, upload-time = "2025-10-14T10:23:47.909Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/13/d0/c20adabd181a029a970738dfe23710b52a31f1258f591874fcdec7359845/pydantic_core-2.41.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:85e050ad9e5f6fe1004eec65c914332e52f429bc0ae12d6fa2092407a462c746", size = 2105688, upload-time = "2025-10-14T10:20:54.448Z" },
- { url = "https://files.pythonhosted.org/packages/00/b6/0ce5c03cec5ae94cca220dfecddc453c077d71363b98a4bbdb3c0b22c783/pydantic_core-2.41.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7393f1d64792763a48924ba31d1e44c2cfbc05e3b1c2c9abb4ceeadd912cced", size = 1910807, upload-time = "2025-10-14T10:20:56.115Z" },
- { url = "https://files.pythonhosted.org/packages/68/3e/800d3d02c8beb0b5c069c870cbb83799d085debf43499c897bb4b4aaff0d/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94dab0940b0d1fb28bcab847adf887c66a27a40291eedf0b473be58761c9799a", size = 1956669, upload-time = "2025-10-14T10:20:57.874Z" },
- { url = "https://files.pythonhosted.org/packages/60/a4/24271cc71a17f64589be49ab8bd0751f6a0a03046c690df60989f2f95c2c/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:de7c42f897e689ee6f9e93c4bec72b99ae3b32a2ade1c7e4798e690ff5246e02", size = 2051629, upload-time = "2025-10-14T10:21:00.006Z" },
- { url = "https://files.pythonhosted.org/packages/68/de/45af3ca2f175d91b96bfb62e1f2d2f1f9f3b14a734afe0bfeff079f78181/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:664b3199193262277b8b3cd1e754fb07f2c6023289c815a1e1e8fb415cb247b1", size = 2224049, upload-time = "2025-10-14T10:21:01.801Z" },
- { url = "https://files.pythonhosted.org/packages/af/8f/ae4e1ff84672bf869d0a77af24fd78387850e9497753c432875066b5d622/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d95b253b88f7d308b1c0b417c4624f44553ba4762816f94e6986819b9c273fb2", size = 2342409, upload-time = "2025-10-14T10:21:03.556Z" },
- { url = "https://files.pythonhosted.org/packages/18/62/273dd70b0026a085c7b74b000394e1ef95719ea579c76ea2f0cc8893736d/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1351f5bbdbbabc689727cb91649a00cb9ee7203e0a6e54e9f5ba9e22e384b84", size = 2069635, upload-time = "2025-10-14T10:21:05.385Z" },
- { url = "https://files.pythonhosted.org/packages/30/03/cf485fff699b4cdaea469bc481719d3e49f023241b4abb656f8d422189fc/pydantic_core-2.41.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1affa4798520b148d7182da0615d648e752de4ab1a9566b7471bc803d88a062d", size = 2194284, upload-time = "2025-10-14T10:21:07.122Z" },
- { url = "https://files.pythonhosted.org/packages/f9/7e/c8e713db32405dfd97211f2fc0a15d6bf8adb7640f3d18544c1f39526619/pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7b74e18052fea4aa8dea2fb7dbc23d15439695da6cbe6cfc1b694af1115df09d", size = 2137566, upload-time = "2025-10-14T10:21:08.981Z" },
- { url = "https://files.pythonhosted.org/packages/04/f7/db71fd4cdccc8b75990f79ccafbbd66757e19f6d5ee724a6252414483fb4/pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:285b643d75c0e30abda9dc1077395624f314a37e3c09ca402d4015ef5979f1a2", size = 2316809, upload-time = "2025-10-14T10:21:10.805Z" },
- { url = "https://files.pythonhosted.org/packages/76/63/a54973ddb945f1bca56742b48b144d85c9fc22f819ddeb9f861c249d5464/pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:f52679ff4218d713b3b33f88c89ccbf3a5c2c12ba665fb80ccc4192b4608dbab", size = 2311119, upload-time = "2025-10-14T10:21:12.583Z" },
- { url = "https://files.pythonhosted.org/packages/f8/03/5d12891e93c19218af74843a27e32b94922195ded2386f7b55382f904d2f/pydantic_core-2.41.4-cp313-cp313-win32.whl", hash = "sha256:ecde6dedd6fff127c273c76821bb754d793be1024bc33314a120f83a3c69460c", size = 1981398, upload-time = "2025-10-14T10:21:14.584Z" },
- { url = "https://files.pythonhosted.org/packages/be/d8/fd0de71f39db91135b7a26996160de71c073d8635edfce8b3c3681be0d6d/pydantic_core-2.41.4-cp313-cp313-win_amd64.whl", hash = "sha256:d081a1f3800f05409ed868ebb2d74ac39dd0c1ff6c035b5162356d76030736d4", size = 2030735, upload-time = "2025-10-14T10:21:16.432Z" },
- { url = "https://files.pythonhosted.org/packages/72/86/c99921c1cf6650023c08bfab6fe2d7057a5142628ef7ccfa9921f2dda1d5/pydantic_core-2.41.4-cp313-cp313-win_arm64.whl", hash = "sha256:f8e49c9c364a7edcbe2a310f12733aad95b022495ef2a8d653f645e5d20c1564", size = 1973209, upload-time = "2025-10-14T10:21:18.213Z" },
- { url = "https://files.pythonhosted.org/packages/36/0d/b5706cacb70a8414396efdda3d72ae0542e050b591119e458e2490baf035/pydantic_core-2.41.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ed97fd56a561f5eb5706cebe94f1ad7c13b84d98312a05546f2ad036bafe87f4", size = 1877324, upload-time = "2025-10-14T10:21:20.363Z" },
- { url = "https://files.pythonhosted.org/packages/de/2d/cba1fa02cfdea72dfb3a9babb067c83b9dff0bbcb198368e000a6b756ea7/pydantic_core-2.41.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a870c307bf1ee91fc58a9a61338ff780d01bfae45922624816878dce784095d2", size = 1884515, upload-time = "2025-10-14T10:21:22.339Z" },
- { url = "https://files.pythonhosted.org/packages/07/ea/3df927c4384ed9b503c9cc2d076cf983b4f2adb0c754578dfb1245c51e46/pydantic_core-2.41.4-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d25e97bc1f5f8f7985bdc2335ef9e73843bb561eb1fa6831fdfc295c1c2061cf", size = 2042819, upload-time = "2025-10-14T10:21:26.683Z" },
- { url = "https://files.pythonhosted.org/packages/6a/ee/df8e871f07074250270a3b1b82aad4cd0026b588acd5d7d3eb2fcb1471a3/pydantic_core-2.41.4-cp313-cp313t-win_amd64.whl", hash = "sha256:d405d14bea042f166512add3091c1af40437c2e7f86988f3915fabd27b1e9cd2", size = 1995866, upload-time = "2025-10-14T10:21:28.951Z" },
- { url = "https://files.pythonhosted.org/packages/fc/de/b20f4ab954d6d399499c33ec4fafc46d9551e11dc1858fb7f5dca0748ceb/pydantic_core-2.41.4-cp313-cp313t-win_arm64.whl", hash = "sha256:19f3684868309db5263a11bace3c45d93f6f24afa2ffe75a647583df22a2ff89", size = 1970034, upload-time = "2025-10-14T10:21:30.869Z" },
+sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
+ { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
+ { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
+ { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
+ { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
+]
+
+[[package]]
+name = "pydantic-settings"
+version = "2.13.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pydantic" },
+ { name = "python-dotenv" },
+ { name = "typing-inspection" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" },
]
[[package]]
@@ -784,11 +1031,11 @@ wheels = [
[[package]]
name = "pyjwt"
-version = "2.10.1"
+version = "2.11.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" },
]
[package.optional-dependencies]
@@ -796,9 +1043,18 @@ crypto = [
{ name = "cryptography" },
]
+[[package]]
+name = "pyparsing"
+version = "3.3.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" },
+]
+
[[package]]
name = "pytest"
-version = "8.4.1"
+version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
@@ -807,21 +1063,57 @@ dependencies = [
{ name = "pluggy" },
{ name = "pygments" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload-time = "2025-06-18T05:48:06.109Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
name = "pytest-asyncio"
-version = "1.1.0"
+version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/4e/51/f8794af39eeb870e87a8c8068642fc07bce0c854d6865d7dd0f2a9d338c2/pytest_asyncio-1.1.0.tar.gz", hash = "sha256:796aa822981e01b68c12e4827b8697108f7205020f24b5793b3c41555dab68ea", size = 46652, upload-time = "2025-07-16T04:29:26.393Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
+]
+
+[[package]]
+name = "pytest-cov"
+version = "7.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "coverage" },
+ { name = "pluggy" },
+ { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
+]
+
+[[package]]
+name = "python-discovery"
+version = "1.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "filelock" },
+ { name = "platformdirs" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/82/bb/93a3e83bdf9322c7e21cafd092e56a4a17c4d8ef4277b6eb01af1a540a6f/python_discovery-1.1.0.tar.gz", hash = "sha256:447941ba1aed8cc2ab7ee3cb91be5fc137c5bdbb05b7e6ea62fbdcb66e50b268", size = 55674, upload-time = "2026-02-26T09:42:49.668Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/06/54/82a6e2ef37f0f23dccac604b9585bdcbd0698604feb64807dcb72853693e/python_discovery-1.1.0-py3-none-any.whl", hash = "sha256:a162893b8809727f54594a99ad2179d2ede4bf953e12d4c7abc3cc9cdbd1437b", size = 30687, upload-time = "2026-02-26T09:42:48.548Z" },
+]
+
+[[package]]
+name = "python-dotenv"
+version = "1.2.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/c7/9d/bf86eddabf8c6c9cb1ea9a869d6873b46f105a5d292d3a6f7071f5b07935/pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf", size = 15157, upload-time = "2025-07-16T04:29:24.929Z" },
+ { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
]
[[package]]
@@ -842,6 +1134,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
]
+[[package]]
+name = "requests"
+version = "2.32.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "charset-normalizer" },
+ { name = "idna" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
+]
+
[[package]]
name = "requirements-parser"
version = "0.13.0"
@@ -856,40 +1163,40 @@ wheels = [
[[package]]
name = "rich"
-version = "14.2.0"
+version = "14.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" },
+ { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" },
]
[[package]]
name = "ruff"
-version = "0.12.5"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/30/cd/01015eb5034605fd98d829c5839ec2c6b4582b479707f7c1c2af861e8258/ruff-0.12.5.tar.gz", hash = "sha256:b209db6102b66f13625940b7f8c7d0f18e20039bb7f6101fbdac935c9612057e", size = 5170722, upload-time = "2025-07-24T13:26:37.456Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/d4/de/ad2f68f0798ff15dd8c0bcc2889558970d9a685b3249565a937cd820ad34/ruff-0.12.5-py3-none-linux_armv6l.whl", hash = "sha256:1de2c887e9dec6cb31fcb9948299de5b2db38144e66403b9660c9548a67abd92", size = 11819133, upload-time = "2025-07-24T13:25:56.369Z" },
- { url = "https://files.pythonhosted.org/packages/f8/fc/c6b65cd0e7fbe60f17e7ad619dca796aa49fbca34bb9bea5f8faf1ec2643/ruff-0.12.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d1ab65e7d8152f519e7dea4de892317c9da7a108da1c56b6a3c1d5e7cf4c5e9a", size = 12501114, upload-time = "2025-07-24T13:25:59.471Z" },
- { url = "https://files.pythonhosted.org/packages/c5/de/c6bec1dce5ead9f9e6a946ea15e8d698c35f19edc508289d70a577921b30/ruff-0.12.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:962775ed5b27c7aa3fdc0d8f4d4433deae7659ef99ea20f783d666e77338b8cf", size = 11716873, upload-time = "2025-07-24T13:26:01.496Z" },
- { url = "https://files.pythonhosted.org/packages/a1/16/cf372d2ebe91e4eb5b82a2275c3acfa879e0566a7ac94d331ea37b765ac8/ruff-0.12.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:73b4cae449597e7195a49eb1cdca89fd9fbb16140c7579899e87f4c85bf82f73", size = 11958829, upload-time = "2025-07-24T13:26:03.721Z" },
- { url = "https://files.pythonhosted.org/packages/25/bf/cd07e8f6a3a6ec746c62556b4c4b79eeb9b0328b362bb8431b7b8afd3856/ruff-0.12.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b13489c3dc50de5e2d40110c0cce371e00186b880842e245186ca862bf9a1ac", size = 11626619, upload-time = "2025-07-24T13:26:06.118Z" },
- { url = "https://files.pythonhosted.org/packages/d8/c9/c2ccb3b8cbb5661ffda6925f81a13edbb786e623876141b04919d1128370/ruff-0.12.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f1504fea81461cf4841778b3ef0a078757602a3b3ea4b008feb1308cb3f23e08", size = 13221894, upload-time = "2025-07-24T13:26:08.292Z" },
- { url = "https://files.pythonhosted.org/packages/6b/58/68a5be2c8e5590ecdad922b2bcd5583af19ba648f7648f95c51c3c1eca81/ruff-0.12.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c7da4129016ae26c32dfcbd5b671fe652b5ab7fc40095d80dcff78175e7eddd4", size = 14163909, upload-time = "2025-07-24T13:26:10.474Z" },
- { url = "https://files.pythonhosted.org/packages/bd/d1/ef6b19622009ba8386fdb792c0743f709cf917b0b2f1400589cbe4739a33/ruff-0.12.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ca972c80f7ebcfd8af75a0f18b17c42d9f1ef203d163669150453f50ca98ab7b", size = 13583652, upload-time = "2025-07-24T13:26:13.381Z" },
- { url = "https://files.pythonhosted.org/packages/62/e3/1c98c566fe6809a0c83751d825a03727f242cdbe0d142c9e292725585521/ruff-0.12.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8dbbf9f25dfb501f4237ae7501d6364b76a01341c6f1b2cd6764fe449124bb2a", size = 12700451, upload-time = "2025-07-24T13:26:15.488Z" },
- { url = "https://files.pythonhosted.org/packages/24/ff/96058f6506aac0fbc0d0fc0d60b0d0bd746240a0594657a2d94ad28033ba/ruff-0.12.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c47dea6ae39421851685141ba9734767f960113d51e83fd7bb9958d5be8763a", size = 12937465, upload-time = "2025-07-24T13:26:17.808Z" },
- { url = "https://files.pythonhosted.org/packages/eb/d3/68bc5e7ab96c94b3589d1789f2dd6dd4b27b263310019529ac9be1e8f31b/ruff-0.12.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c5076aa0e61e30f848846f0265c873c249d4b558105b221be1828f9f79903dc5", size = 11771136, upload-time = "2025-07-24T13:26:20.422Z" },
- { url = "https://files.pythonhosted.org/packages/52/75/7356af30a14584981cabfefcf6106dea98cec9a7af4acb5daaf4b114845f/ruff-0.12.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a5a4c7830dadd3d8c39b1cc85386e2c1e62344f20766be6f173c22fb5f72f293", size = 11601644, upload-time = "2025-07-24T13:26:22.928Z" },
- { url = "https://files.pythonhosted.org/packages/c2/67/91c71d27205871737cae11025ee2b098f512104e26ffd8656fd93d0ada0a/ruff-0.12.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:46699f73c2b5b137b9dc0fc1a190b43e35b008b398c6066ea1350cce6326adcb", size = 12478068, upload-time = "2025-07-24T13:26:26.134Z" },
- { url = "https://files.pythonhosted.org/packages/34/04/b6b00383cf2f48e8e78e14eb258942fdf2a9bf0287fbf5cdd398b749193a/ruff-0.12.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5a655a0a0d396f0f072faafc18ebd59adde8ca85fb848dc1b0d9f024b9c4d3bb", size = 12991537, upload-time = "2025-07-24T13:26:28.533Z" },
- { url = "https://files.pythonhosted.org/packages/3e/b9/053d6445dc7544fb6594785056d8ece61daae7214859ada4a152ad56b6e0/ruff-0.12.5-py3-none-win32.whl", hash = "sha256:dfeb2627c459b0b78ca2bbdc38dd11cc9a0a88bf91db982058b26ce41714ffa9", size = 11751575, upload-time = "2025-07-24T13:26:30.835Z" },
- { url = "https://files.pythonhosted.org/packages/bc/0f/ab16e8259493137598b9149734fec2e06fdeda9837e6f634f5c4e35916da/ruff-0.12.5-py3-none-win_amd64.whl", hash = "sha256:ae0d90cf5f49466c954991b9d8b953bd093c32c27608e409ae3564c63c5306a5", size = 12882273, upload-time = "2025-07-24T13:26:32.929Z" },
- { url = "https://files.pythonhosted.org/packages/00/db/c376b0661c24cf770cb8815268190668ec1330eba8374a126ceef8c72d55/ruff-0.12.5-py3-none-win_arm64.whl", hash = "sha256:48cdbfc633de2c5c37d9f090ba3b352d1576b0015bfc3bc98eaf230275b7e805", size = 11951564, upload-time = "2025-07-24T13:26:34.994Z" },
+version = "0.15.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/da/31/d6e536cdebb6568ae75a7f00e4b4819ae0ad2640c3604c305a0428680b0c/ruff-0.15.4.tar.gz", hash = "sha256:3412195319e42d634470cc97aa9803d07e9d5c9223b99bcb1518f0c725f26ae1", size = 4569550, upload-time = "2026-02-26T20:04:14.959Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f2/82/c11a03cfec3a4d26a0ea1e571f0f44be5993b923f905eeddfc397c13d360/ruff-0.15.4-py3-none-linux_armv6l.whl", hash = "sha256:a1810931c41606c686bae8b5b9a8072adac2f611bb433c0ba476acba17a332e0", size = 10453333, upload-time = "2026-02-26T20:04:20.093Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/5d/6a1f271f6e31dffb31855996493641edc3eef8077b883eaf007a2f1c2976/ruff-0.15.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5a1632c66672b8b4d3e1d1782859e98d6e0b4e70829530666644286600a33992", size = 10853356, upload-time = "2026-02-26T20:04:05.808Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/d8/0fab9f8842b83b1a9c2bf81b85063f65e93fb512e60effa95b0be49bfc54/ruff-0.15.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a4386ba2cd6c0f4ff75252845906acc7c7c8e1ac567b7bc3d373686ac8c222ba", size = 10187434, upload-time = "2026-02-26T20:03:54.656Z" },
+ { url = "https://files.pythonhosted.org/packages/85/cc/cc220fd9394eff5db8d94dec199eec56dd6c9f3651d8869d024867a91030/ruff-0.15.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2496488bdfd3732747558b6f95ae427ff066d1fcd054daf75f5a50674411e75", size = 10535456, upload-time = "2026-02-26T20:03:52.738Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/0f/bced38fa5cf24373ec767713c8e4cadc90247f3863605fb030e597878661/ruff-0.15.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3f1c4893841ff2d54cbda1b2860fa3260173df5ddd7b95d370186f8a5e66a4ac", size = 10287772, upload-time = "2026-02-26T20:04:08.138Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/90/58a1802d84fed15f8f281925b21ab3cecd813bde52a8ca033a4de8ab0e7a/ruff-0.15.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:820b8766bd65503b6c30aaa6331e8ef3a6e564f7999c844e9a547c40179e440a", size = 11049051, upload-time = "2026-02-26T20:04:03.53Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/ac/b7ad36703c35f3866584564dc15f12f91cb1a26a897dc2fd13d7cb3ae1af/ruff-0.15.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9fb74bab47139c1751f900f857fa503987253c3ef89129b24ed375e72873e85", size = 11890494, upload-time = "2026-02-26T20:04:10.497Z" },
+ { url = "https://files.pythonhosted.org/packages/93/3d/3eb2f47a39a8b0da99faf9c54d3eb24720add1e886a5309d4d1be73a6380/ruff-0.15.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f80c98765949c518142b3a50a5db89343aa90f2c2bf7799de9986498ae6176db", size = 11326221, upload-time = "2026-02-26T20:04:12.84Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/90/bf134f4c1e5243e62690e09d63c55df948a74084c8ac3e48a88468314da6/ruff-0.15.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:451a2e224151729b3b6c9ffb36aed9091b2996fe4bdbd11f47e27d8f2e8888ec", size = 11168459, upload-time = "2026-02-26T20:04:00.969Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/e5/a64d27688789b06b5d55162aafc32059bb8c989c61a5139a36e1368285eb/ruff-0.15.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a8f157f2e583c513c4f5f896163a93198297371f34c04220daf40d133fdd4f7f", size = 11104366, upload-time = "2026-02-26T20:03:48.099Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/f6/32d1dcb66a2559763fc3027bdd65836cad9eb09d90f2ed6a63d8e9252b02/ruff-0.15.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:917cc68503357021f541e69b35361c99387cdbbf99bd0ea4aa6f28ca99ff5338", size = 10510887, upload-time = "2026-02-26T20:03:45.771Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/92/22d1ced50971c5b6433aed166fcef8c9343f567a94cf2b9d9089f6aa80fe/ruff-0.15.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e9737c8161da79fd7cfec19f1e35620375bd8b2a50c3e77fa3d2c16f574105cc", size = 10285939, upload-time = "2026-02-26T20:04:22.42Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/f4/7c20aec3143837641a02509a4668fb146a642fd1211846634edc17eb5563/ruff-0.15.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:291258c917539e18f6ba40482fe31d6f5ac023994ee11d7bdafd716f2aab8a68", size = 10765471, upload-time = "2026-02-26T20:03:58.924Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/09/6d2f7586f09a16120aebdff8f64d962d7c4348313c77ebb29c566cefc357/ruff-0.15.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3f83c45911da6f2cd5936c436cf86b9f09f09165f033a99dcf7477e34041cbc3", size = 11263382, upload-time = "2026-02-26T20:04:24.424Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/fa/2ef715a1cd329ef47c1a050e10dee91a9054b7ce2fcfdd6a06d139afb7ec/ruff-0.15.4-py3-none-win32.whl", hash = "sha256:65594a2d557d4ee9f02834fcdf0a28daa8b3b9f6cb2cb93846025a36db47ef22", size = 10506664, upload-time = "2026-02-26T20:03:50.56Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/a8/c688ef7e29983976820d18710f955751d9f4d4eb69df658af3d006e2ba3e/ruff-0.15.4-py3-none-win_amd64.whl", hash = "sha256:04196ad44f0df220c2ece5b0e959c2f37c777375ec744397d21d15b50a75264f", size = 11651048, upload-time = "2026-02-26T20:04:17.191Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/0a/9e1be9035b37448ce2e68c978f0591da94389ade5a5abafa4cf99985d1b2/ruff-0.15.4-py3-none-win_arm64.whl", hash = "sha256:60d5177e8cfc70e51b9c5fad936c634872a74209f934c1e79107d11787ad5453", size = 10966776, upload-time = "2026-02-26T20:03:56.908Z" },
]
[[package]]
@@ -913,25 +1220,39 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
+[[package]]
+name = "sortedcontainers"
+version = "2.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" },
+]
+
[[package]]
name = "sqlalchemy"
-version = "2.0.44"
+version = "2.0.47"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/f0/f2/840d7b9496825333f532d2e3976b8eadbf52034178aac53630d09fe6e1ef/sqlalchemy-2.0.44.tar.gz", hash = "sha256:0ae7454e1ab1d780aee69fd2aae7d6b8670a581d8847f2d1e0f7ddfbf47e5a22", size = 9819830, upload-time = "2025-10-10T14:39:12.935Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/cd/4b/1e00561093fe2cd8eef09d406da003c8a118ff02d6548498c1ae677d68d9/sqlalchemy-2.0.47.tar.gz", hash = "sha256:e3e7feb57b267fe897e492b9721ae46d5c7de6f9e8dee58aacf105dc4e154f3d", size = 9886323, upload-time = "2026-02-24T16:34:27.947Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/45/d3/c67077a2249fdb455246e6853166360054c331db4613cda3e31ab1cadbef/sqlalchemy-2.0.44-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ff486e183d151e51b1d694c7aa1695747599bb00b9f5f604092b54b74c64a8e1", size = 2135479, upload-time = "2025-10-10T16:03:37.671Z" },
- { url = "https://files.pythonhosted.org/packages/2b/91/eabd0688330d6fd114f5f12c4f89b0d02929f525e6bf7ff80aa17ca802af/sqlalchemy-2.0.44-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0b1af8392eb27b372ddb783b317dea0f650241cea5bd29199b22235299ca2e45", size = 2123212, upload-time = "2025-10-10T16:03:41.755Z" },
- { url = "https://files.pythonhosted.org/packages/b0/bb/43e246cfe0e81c018076a16036d9b548c4cc649de241fa27d8d9ca6f85ab/sqlalchemy-2.0.44-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b61188657e3a2b9ac4e8f04d6cf8e51046e28175f79464c67f2fd35bceb0976", size = 3255353, upload-time = "2025-10-10T15:35:31.221Z" },
- { url = "https://files.pythonhosted.org/packages/b9/96/c6105ed9a880abe346b64d3b6ddef269ddfcab04f7f3d90a0bf3c5a88e82/sqlalchemy-2.0.44-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b87e7b91a5d5973dda5f00cd61ef72ad75a1db73a386b62877d4875a8840959c", size = 3260222, upload-time = "2025-10-10T15:43:50.124Z" },
- { url = "https://files.pythonhosted.org/packages/44/16/1857e35a47155b5ad927272fee81ae49d398959cb749edca6eaa399b582f/sqlalchemy-2.0.44-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:15f3326f7f0b2bfe406ee562e17f43f36e16167af99c4c0df61db668de20002d", size = 3189614, upload-time = "2025-10-10T15:35:32.578Z" },
- { url = "https://files.pythonhosted.org/packages/88/ee/4afb39a8ee4fc786e2d716c20ab87b5b1fb33d4ac4129a1aaa574ae8a585/sqlalchemy-2.0.44-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e77faf6ff919aa8cd63f1c4e561cac1d9a454a191bb864d5dd5e545935e5a40", size = 3226248, upload-time = "2025-10-10T15:43:51.862Z" },
- { url = "https://files.pythonhosted.org/packages/32/d5/0e66097fc64fa266f29a7963296b40a80d6a997b7ac13806183700676f86/sqlalchemy-2.0.44-cp313-cp313-win32.whl", hash = "sha256:ee51625c2d51f8baadf2829fae817ad0b66b140573939dd69284d2ba3553ae73", size = 2101275, upload-time = "2025-10-10T15:03:26.096Z" },
- { url = "https://files.pythonhosted.org/packages/03/51/665617fe4f8c6450f42a6d8d69243f9420f5677395572c2fe9d21b493b7b/sqlalchemy-2.0.44-cp313-cp313-win_amd64.whl", hash = "sha256:c1c80faaee1a6c3428cecf40d16a2365bcf56c424c92c2b6f0f9ad204b899e9e", size = 2127901, upload-time = "2025-10-10T15:03:27.548Z" },
- { url = "https://files.pythonhosted.org/packages/9c/5e/6a29fa884d9fb7ddadf6b69490a9d45fded3b38541713010dad16b77d015/sqlalchemy-2.0.44-py3-none-any.whl", hash = "sha256:19de7ca1246fbef9f9d1bff8f1ab25641569df226364a0e40457dc5457c54b05", size = 1928718, upload-time = "2025-10-10T15:29:45.32Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/e5/0af64ce7d8f60ec5328c10084e2f449e7912a9b8bdbefdcfb44454a25f49/sqlalchemy-2.0.47-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:456a135b790da5d3c6b53d0ef71ac7b7d280b7f41eb0c438986352bf03ca7143", size = 2152551, upload-time = "2026-02-24T17:05:47.675Z" },
+ { url = "https://files.pythonhosted.org/packages/63/79/746b8d15f6940e2ac469ce22d7aa5b1124b1ab820bad9b046eb3000c88a6/sqlalchemy-2.0.47-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09a2f7698e44b3135433387da5d8846cf7cc7c10e5425af7c05fee609df978b6", size = 3278782, upload-time = "2026-02-24T17:18:10.012Z" },
+ { url = "https://files.pythonhosted.org/packages/91/b1/bd793ddb34345d1ed43b13ab2d88c95d7d4eb2e28f5b5a99128b9cc2bca2/sqlalchemy-2.0.47-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0bbc72e6a177c78d724f9106aaddc0d26a2ada89c6332b5935414eccf04cbd5", size = 3295155, upload-time = "2026-02-24T17:27:22.827Z" },
+ { url = "https://files.pythonhosted.org/packages/97/84/7213def33f94e5ca6f5718d259bc9f29de0363134648425aa218d4356b23/sqlalchemy-2.0.47-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:75460456b043b78b6006e41bdf5b86747ee42eafaf7fffa3b24a6e9a456a2092", size = 3226834, upload-time = "2026-02-24T17:18:11.465Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/06/456810204f4dc29b5f025b1b0a03b4bd6b600ebf3c1040aebd90a257fa33/sqlalchemy-2.0.47-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5d9adaa616c3bc7d80f9ded57cd84b51d6617cad6a5456621d858c9f23aaee01", size = 3265001, upload-time = "2026-02-24T17:27:24.813Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/20/df3920a4b2217dbd7390a5bd277c1902e0393f42baaf49f49b3c935e7328/sqlalchemy-2.0.47-cp313-cp313-win32.whl", hash = "sha256:76e09f974382a496a5ed985db9343628b1cb1ac911f27342e4cc46a8bac10476", size = 2113647, upload-time = "2026-02-24T17:22:55.747Z" },
+ { url = "https://files.pythonhosted.org/packages/46/06/7873ddf69918efbfabd7211829f4bd8019739d0a719253112d305d3ba51d/sqlalchemy-2.0.47-cp313-cp313-win_amd64.whl", hash = "sha256:0664089b0bf6724a0bfb49a0cf4d4da24868a0a5c8e937cd7db356d5dcdf2c66", size = 2139425, upload-time = "2026-02-24T17:22:57.033Z" },
+ { url = "https://files.pythonhosted.org/packages/54/fa/61ad9731370c90ac7ea5bf8f5eaa12c48bb4beec41c0fa0360becf4ac10d/sqlalchemy-2.0.47-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ed0c967c701ae13da98eb220f9ddab3044ab63504c1ba24ad6a59b26826ad003", size = 3558809, upload-time = "2026-02-24T17:12:15.232Z" },
+ { url = "https://files.pythonhosted.org/packages/33/d5/221fac96f0529391fe374875633804c866f2b21a9c6d3a6ca57d9c12cfd7/sqlalchemy-2.0.47-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3537943a61fd25b241e976426a0c6814434b93cf9b09d39e8e78f3c9eb9a487", size = 3525480, upload-time = "2026-02-24T17:27:59.602Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/55/8247d53998c3673e4a8d1958eba75c6f5cc3b39082029d400bb1f2a911ae/sqlalchemy-2.0.47-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:57f7e336a64a0dba686c66392d46b9bc7af2c57d55ce6dc1697b4ef32b043ceb", size = 3466569, upload-time = "2026-02-24T17:12:16.94Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/b5/c1f0eea1bac6790845f71420a7fe2f2a0566203aa57543117d4af3b77d1c/sqlalchemy-2.0.47-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dff735a621858680217cb5142b779bad40ef7322ddbb7c12062190db6879772e", size = 3475770, upload-time = "2026-02-24T17:28:02.034Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/ed/2f43f92474ea0c43c204657dc47d9d002cd738b96ca2af8e6d29a9b5e42d/sqlalchemy-2.0.47-cp313-cp313t-win32.whl", hash = "sha256:3893dc096bb3cca9608ea3487372ffcea3ae9b162f40e4d3c51dd49db1d1b2dc", size = 2141300, upload-time = "2026-02-24T17:14:37.024Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/a9/8b73f9f1695b6e92f7aaf1711135a1e3bbeb78bca9eded35cb79180d3c6d/sqlalchemy-2.0.47-cp313-cp313t-win_amd64.whl", hash = "sha256:b5103427466f4b3e61f04833ae01f9a914b1280a2a8bcde3a9d7ab11f3755b42", size = 2173053, upload-time = "2026-02-24T17:14:38.688Z" },
+ { url = "https://files.pythonhosted.org/packages/15/9f/7c378406b592fcf1fc157248607b495a40e3202ba4a6f1372a2ba6447717/sqlalchemy-2.0.47-py3-none-any.whl", hash = "sha256:e2647043599297a1ef10e720cf310846b7f31b6c841fee093d2b09d81215eb93", size = 1940159, upload-time = "2026-02-24T17:15:07.158Z" },
]
[package.optional-dependencies]
@@ -941,14 +1262,63 @@ mypy = [
[[package]]
name = "starlette"
-version = "0.49.3"
+version = "0.52.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/de/1a/608df0b10b53b0beb96a37854ee05864d182ddd4b1156a22f1ad3860425a/starlette-0.49.3.tar.gz", hash = "sha256:1c14546f299b5901a1ea0e34410575bc33bbd741377a10484a54445588d00284", size = 2655031, upload-time = "2025-11-01T15:12:26.13Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/a3/e0/021c772d6a662f43b63044ab481dc6ac7592447605b5b35a957785363122/starlette-0.49.3-py3-none-any.whl", hash = "sha256:b579b99715fdc2980cf88c8ec96d3bf1ce16f5a8051a7c2b84ef9b1cdecaea2f", size = 74340, upload-time = "2025-11-01T15:12:24.387Z" },
+ { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" },
+]
+
+[[package]]
+name = "tombi"
+version = "0.7.33"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d3/3e/fedb827e9fefb30b8032b1fa0da7ce22f74d3cded06dfd017388b30a5a51/tombi-0.7.33.tar.gz", hash = "sha256:d9b416b83495d5b367a26c6a3264f581c3b4b3e0355e659d349849f65949d927", size = 496800, upload-time = "2026-02-26T12:33:42.942Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5b/51/41d77af7562d5278927292682bd6b812509033579c23406e28d2343da459/tombi-0.7.33-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fd0c058110ed4588056dbc024f923b979728572c6d652ed2b2ba1be0b6d77621", size = 8859117, upload-time = "2026-02-26T12:33:33.898Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/05/c5f69a3c2cf95dcece1642d7d3f7d052fb40be8d7c7bb7baa031fa2ffe67/tombi-0.7.33-py3-none-macosx_11_0_arm64.whl", hash = "sha256:47334bccab9d94f5bf2de9c5d2d52b93d08945e84207266dde8e85cb4cae9e98", size = 8579752, upload-time = "2026-02-26T12:33:32.238Z" },
+ { url = "https://files.pythonhosted.org/packages/16/d4/bff163b6c70d66ff91a4ebb9f25241f59d54ea7c4f651ca711a0f2a3124c/tombi-0.7.33-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4617da85a37430e170ce6d57670566c802caf41a466269e1ce54a8be889a1653", size = 8776853, upload-time = "2026-02-26T12:33:23.414Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/a3/17b2355e531c654ff97e608261cf6e264465665827b98625f6840f5123bd/tombi-0.7.33-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0c4969427c30f6cb30a64fc8672b3d77d2db9f67aecc7ca437dbc28c7e673e3c", size = 9906639, upload-time = "2026-02-26T12:33:28.739Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/d9/1320257c57f927d6881e35d897df99c4674e8a673848685a34438731e572/tombi-0.7.33-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50995e36d0d02ac12afe6f554789e718a55e307bf07901eabb53fe05090032c7", size = 10089362, upload-time = "2026-02-26T12:33:25.292Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/2b/b9844ccba3b598fb153d1c85db5a18652a2b69ba6fd20f4b214fc733a93f/tombi-0.7.33-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:07a6a914529c4f980631cfa0477c747c59179f8f411831412388316bf151db15", size = 8926789, upload-time = "2026-02-26T12:33:26.968Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/4a/f3986efed8c00c08250cfa3c47fc168c761612913af06ac134f8d029ad93/tombi-0.7.33-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c434113529cc9c6349b03b5e4212b5306e37854a815f29e5e85a99d530f4242", size = 9241245, upload-time = "2026-02-26T12:33:30.379Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/f3/0d7280d2456c63c114e1fc9c22ad052bfbf44a992d138a98e1401102ea53/tombi-0.7.33-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:2d7d8d6652a1f16eedd5fdd9f7add5fb420ba02091ebb74a7697a1ed40e84eab", size = 9051631, upload-time = "2026-02-26T12:33:21.226Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/3c/53c3048e0b5ec8d5f8804f6249901d392a66f79ebb7a680bfc55a45b47dc/tombi-0.7.33-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:61403577982914f2feeaec9f77d7bf33fb046bedc13e48d8940fe034cd5a46ec", size = 9087680, upload-time = "2026-02-26T12:33:35.569Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/11/40f5aee65d4f2e2ecf6dc75fb41664b979f39b171502f7df169e0613f257/tombi-0.7.33-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:34312399beb47e9134a4ad7e3d2283a46d6c8f9f49201f00dbc94352f2e825db", size = 8824062, upload-time = "2026-02-26T12:33:37.468Z" },
+ { url = "https://files.pythonhosted.org/packages/28/8e/075c883390cbf91cbc737cbf69ae0b462837b3ab7d1905a302cd8db68e16/tombi-0.7.33-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4e502a39551f2522204e02994fb54f7a6a7c2f9c83bbad99800177122301b8b4", size = 9407628, upload-time = "2026-02-26T12:33:39.247Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/19/1e08efb31699ca38993aab0c3db70528c13623044c0ffdd0ad6c66c5dc2b/tombi-0.7.33-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f0e796de9b5efc0ded76161646db06398b0013e090a1438dab0c7054fc931d31", size = 9456244, upload-time = "2026-02-26T12:33:41.343Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/79/cdadd57eae5c6b251f8bacc238e5ca7cc145825aa0ce043691b6d8b7fa84/tombi-0.7.33-py3-none-win32.whl", hash = "sha256:a51206edd521f3d055cf7e3cd01aa3fd43fdb4e56e4c8d9495043c8314cad6c1", size = 7004210, upload-time = "2026-02-26T12:33:45.215Z" },
+ { url = "https://files.pythonhosted.org/packages/52/ab/57a86ece7001b5ef18376478cc77b2a3526933f6d6a3588b342ffd13459e/tombi-0.7.33-py3-none-win_amd64.whl", hash = "sha256:53e207e525913d7faf78d5883c05efc0d7d5e3578dcd0d80e8523d10b5cddc7a", size = 8110936, upload-time = "2026-02-26T12:33:43.847Z" },
+]
+
+[[package]]
+name = "tomli"
+version = "2.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" },
+ { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" },
+ { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" },
+ { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" },
+ { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" },
+]
+
+[[package]]
+name = "tomli-w"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" }
+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]]
@@ -974,70 +1344,68 @@ wheels = [
[[package]]
name = "tzdata"
-version = "2025.2"
+version = "2025.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" },
+]
+
+[[package]]
+name = "urllib3"
+version = "2.6.3"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" },
+ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
]
[[package]]
name = "uuid-utils"
-version = "0.11.1"
+version = "0.14.1"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/e2/ef/b6c1fd4fee3b2854bf9d602530ab8b6624882e2691c15a9c4d22ea8c03eb/uuid_utils-0.11.1.tar.gz", hash = "sha256:7ef455547c2ccb712840b106b5ab006383a9bfe4125ba1c5ab92e47bcbf79b46", size = 19933, upload-time = "2025-10-02T13:32:09.526Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/7b/d1/38a573f0c631c062cf42fa1f5d021d4dd3c31fb23e4376e4b56b0c9fbbed/uuid_utils-0.14.1.tar.gz", hash = "sha256:9bfc95f64af80ccf129c604fb6b8ca66c6f256451e32bc4570f760e4309c9b69", size = 22195, upload-time = "2026-02-20T22:50:38.833Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/40/f5/254d7ce4b3aa4a1a3a4f279e0cc74eec8b4d3a61641d8ffc6e983907f2ca/uuid_utils-0.11.1-cp39-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:4bc8cf73c375b9ea11baf70caacc2c4bf7ce9bfd804623aa0541e5656f3dbeaf", size = 581019, upload-time = "2025-10-02T13:31:32.239Z" },
- { url = "https://files.pythonhosted.org/packages/68/e6/f7d14c4e1988d8beb3ac9bd773f370376c704925bdfb07380f5476bb2986/uuid_utils-0.11.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0d2cb3bcc6f5862d08a0ee868b18233bc63ba9ea0e85ea9f3f8e703983558eba", size = 294377, upload-time = "2025-10-02T13:31:34.01Z" },
- { url = "https://files.pythonhosted.org/packages/8e/40/847a9a0258e7a2a14b015afdaa06ee4754a2680db7b74bac159d594eeb18/uuid_utils-0.11.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:463400604f623969f198aba9133ebfd717636f5e34257340302b1c3ff685dc0f", size = 328070, upload-time = "2025-10-02T13:31:35.619Z" },
- { url = "https://files.pythonhosted.org/packages/44/0c/c5d342d31860c9b4f481ef31a4056825961f9b462d216555e76dcee580ea/uuid_utils-0.11.1-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:aef66b935342b268c6ffc1796267a1d9e73135740a10fe7e4098e1891cbcc476", size = 333610, upload-time = "2025-10-02T13:31:37.058Z" },
- { url = "https://files.pythonhosted.org/packages/e1/4b/52edc023ffcb9ab9a4042a58974a79c39ba7a565e683f1fd9814b504cf13/uuid_utils-0.11.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fd65c41b81b762278997de0d027161f27f9cc4058fa57bbc0a1aaa63a63d6d1a", size = 475669, upload-time = "2025-10-02T13:31:38.38Z" },
- { url = "https://files.pythonhosted.org/packages/59/81/ee55ee63264531bb1c97b5b6033ad6ec81b5cd77f89174e9aef3af3d8889/uuid_utils-0.11.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ccfac9d5d7522d61accabb8c68448ead6407933415e67e62123ed6ed11f86510", size = 331946, upload-time = "2025-10-02T13:31:39.66Z" },
- { url = "https://files.pythonhosted.org/packages/cf/07/5d4be27af0e9648afa512f0d11bb6d96cb841dd6d29b57baa3fbf55fd62e/uuid_utils-0.11.1-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:003f48f05c01692d0c1f7e413d194e7299a1a364e0047a4eb904d3478b84eca1", size = 352920, upload-time = "2025-10-02T13:31:40.94Z" },
- { url = "https://files.pythonhosted.org/packages/5b/48/a69dddd9727512b0583b87bfff97d82a8813b28fb534a183c9e37033cfef/uuid_utils-0.11.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a5c936042120bdc30d62f539165beaa4a6ba7e817a89e5409a6f06dc62c677a9", size = 509413, upload-time = "2025-10-02T13:31:42.547Z" },
- { url = "https://files.pythonhosted.org/packages/66/0d/1b529a3870c2354dd838d5f133a1cba75220242b0061f04a904ca245a131/uuid_utils-0.11.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:2e16dcdbdf4cd34ffb31ead6236960adb50e6c962c9f4554a6ecfdfa044c6259", size = 529454, upload-time = "2025-10-02T13:31:44.338Z" },
- { url = "https://files.pythonhosted.org/packages/bd/f2/04a3f77c85585aac09d546edaf871a4012052fb8ace6dbddd153b4d50f02/uuid_utils-0.11.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f8b21fed11b23134502153d652c77c3a37fa841a9aa15a4e6186d440a22f1a0e", size = 498084, upload-time = "2025-10-02T13:31:45.601Z" },
- { url = "https://files.pythonhosted.org/packages/89/08/538b380b4c4b220f3222c970930fe459cc37f1dfc6c8dc912568d027f17d/uuid_utils-0.11.1-cp39-abi3-win32.whl", hash = "sha256:72abab5ab27c1b914e3f3f40f910532ae242df1b5f0ae43f1df2ef2f610b2a8c", size = 174314, upload-time = "2025-10-02T13:31:47.269Z" },
- { url = "https://files.pythonhosted.org/packages/00/66/971ec830094ac1c7d46381678f7138c1805015399805e7dd7769c893c9c8/uuid_utils-0.11.1-cp39-abi3-win_amd64.whl", hash = "sha256:5ed9962f8993ef2fd418205f92830c29344102f86871d99b57cef053abf227d9", size = 179214, upload-time = "2025-10-02T13:31:48.344Z" },
+ { url = "https://files.pythonhosted.org/packages/43/b7/add4363039a34506a58457d96d4aa2126061df3a143eb4d042aedd6a2e76/uuid_utils-0.14.1-cp39-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:93a3b5dc798a54a1feb693f2d1cb4cf08258c32ff05ae4929b5f0a2ca624a4f0", size = 604679, upload-time = "2026-02-20T22:50:27.469Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/84/d1d0bef50d9e66d31b2019997c741b42274d53dde2e001b7a83e9511c339/uuid_utils-0.14.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:ccd65a4b8e83af23eae5e56d88034b2fe7264f465d3e830845f10d1591b81741", size = 309346, upload-time = "2026-02-20T22:50:31.857Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/ed/b6d6fd52a6636d7c3eddf97d68da50910bf17cd5ac221992506fb56cf12e/uuid_utils-0.14.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b56b0cacd81583834820588378e432b0696186683b813058b707aedc1e16c4b1", size = 344714, upload-time = "2026-02-20T22:50:42.642Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/a7/a19a1719fb626fe0b31882db36056d44fe904dc0cf15b06fdf56b2679cf7/uuid_utils-0.14.1-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb3cf14de789097320a3c56bfdfdd51b1225d11d67298afbedee7e84e3837c96", size = 350914, upload-time = "2026-02-20T22:50:36.487Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/fc/f6690e667fdc3bb1a73f57951f97497771c56fe23e3d302d7404be394d4f/uuid_utils-0.14.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60e0854a90d67f4b0cc6e54773deb8be618f4c9bad98d3326f081423b5d14fae", size = 482609, upload-time = "2026-02-20T22:50:37.511Z" },
+ { url = "https://files.pythonhosted.org/packages/54/6e/dcd3fa031320921a12ec7b4672dea3bd1dd90ddffa363a91831ba834d559/uuid_utils-0.14.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce6743ba194de3910b5feb1a62590cd2587e33a73ab6af8a01b642ceb5055862", size = 345699, upload-time = "2026-02-20T22:50:46.87Z" },
+ { url = "https://files.pythonhosted.org/packages/04/28/e5220204b58b44ac0047226a9d016a113fde039280cc8732d9e6da43b39f/uuid_utils-0.14.1-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:043fb58fde6cf1620a6c066382f04f87a8e74feb0f95a585e4ed46f5d44af57b", size = 372205, upload-time = "2026-02-20T22:50:28.438Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/d9/3d2eb98af94b8dfffc82b6a33b4dfc87b0a5de2c68a28f6dde0db1f8681b/uuid_utils-0.14.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c915d53f22945e55fe0d3d3b0b87fd965a57f5fd15666fd92d6593a73b1dd297", size = 521836, upload-time = "2026-02-20T22:50:23.057Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/15/0eb106cc6fe182f7577bc0ab6e2f0a40be247f35c5e297dbf7bbc460bd02/uuid_utils-0.14.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:0972488e3f9b449e83f006ead5a0e0a33ad4a13e4462e865b7c286ab7d7566a3", size = 625260, upload-time = "2026-02-20T22:50:25.949Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/17/f539507091334b109e7496830af2f093d9fc8082411eafd3ece58af1f8ba/uuid_utils-0.14.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:1c238812ae0c8ffe77d8d447a32c6dfd058ea4631246b08b5a71df586ff08531", size = 587824, upload-time = "2026-02-20T22:50:35.225Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/c2/d37a7b2e41f153519367d4db01f0526e0d4b06f1a4a87f1c5dfca5d70a8b/uuid_utils-0.14.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:bec8f8ef627af86abf8298e7ec50926627e29b34fa907fcfbedb45aaa72bca43", size = 551407, upload-time = "2026-02-20T22:50:44.915Z" },
+ { url = "https://files.pythonhosted.org/packages/65/36/2d24b2cbe78547c6532da33fb8613debd3126eccc33a6374ab788f5e46e9/uuid_utils-0.14.1-cp39-abi3-win32.whl", hash = "sha256:b54d6aa6252d96bac1fdbc80d26ba71bad9f220b2724d692ad2f2310c22ef523", size = 183476, upload-time = "2026-02-20T22:50:32.745Z" },
+ { url = "https://files.pythonhosted.org/packages/83/92/2d7e90df8b1a69ec4cff33243ce02b7a62f926ef9e2f0eca5a026889cd73/uuid_utils-0.14.1-cp39-abi3-win_amd64.whl", hash = "sha256:fc27638c2ce267a0ce3e06828aff786f91367f093c80625ee21dad0208e0f5ba", size = 187147, upload-time = "2026-02-20T22:50:45.807Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/26/529f4beee17e5248e37e0bc17a2761d34c0fa3b1e5729c88adb2065bae6e/uuid_utils-0.14.1-cp39-abi3-win_arm64.whl", hash = "sha256:b04cb49b42afbc4ff8dbc60cf054930afc479d6f4dd7f1ec3bbe5dbfdde06b7a", size = 188132, upload-time = "2026-02-20T22:50:41.718Z" },
]
[[package]]
name = "uvicorn"
-version = "0.38.0"
+version = "0.41.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d", size = 80605, upload-time = "2025-10-18T13:46:44.63Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" },
-]
-
-[[package]]
-name = "uvloop"
-version = "0.22.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/32/ce/eeb58ae4ac36fe09e3842eb02e0eb676bf2c53ae062b98f1b2531673efdd/uvicorn-0.41.0.tar.gz", hash = "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a", size = 82633, upload-time = "2026-02-16T23:07:24.1Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" },
- { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" },
- { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" },
- { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" },
- { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" },
- { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" },
+ { url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783, upload-time = "2026-02-16T23:07:22.357Z" },
]
[[package]]
name = "virtualenv"
-version = "20.35.4"
+version = "21.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "distlib" },
{ name = "filelock" },
{ name = "platformdirs" },
+ { name = "python-discovery" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/20/28/e6f1a6f655d620846bd9df527390ecc26b3805a0c5989048c210e22c5ca9/virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c", size = 6028799, upload-time = "2025-10-29T06:57:40.511Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/2f/c9/18d4b36606d6091844daa3bd93cf7dc78e6f5da21d9f21d06c221104b684/virtualenv-21.1.0.tar.gz", hash = "sha256:1990a0188c8f16b6b9cf65c9183049007375b26aad415514d377ccacf1e4fb44", size = 5840471, upload-time = "2026-02-27T08:49:29.702Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" },
+ { url = "https://files.pythonhosted.org/packages/78/55/896b06bf93a49bec0f4ae2a6f1ed12bd05c8860744ac3a70eda041064e4d/virtualenv-21.1.0-py3-none-any.whl", hash = "sha256:164f5e14c5587d170cf98e60378eb91ea35bf037be313811905d3a24ea33cc07", size = 5825072, upload-time = "2026-02-27T08:49:27.516Z" },
]