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 +[![Mentioned in Awesome FastAPI](https://awesome.re/mentioned-badge.svg)](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. - -

- Clean Architecture Diagram -
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 - -![#gold](https://placehold.co/15x15/gold/gold.svg) **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. - -![#red](https://placehold.co/15x15/red/red.svg) **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. - -![#green](https://placehold.co/15x15/green/green.svg) **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**." - -
- Revised Interpretation of CA-D - Revised Interpretation of CA-D, alternative -
-

- Figure 2: Revised Interpretation of Clean Architecture
- (diagrammed β€” original and alternative representation) -
-

- -## Layered Approach Continued - -![#blue](https://placehold.co/15x15/blue/blue.svg) **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. - -![#gray](https://placehold.co/15x15/gray/gray.svg) **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. - -

- Basic Dependency Graph -
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. - -

- Corrupted Dependency -
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. - -

- Correct Dependency -
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. - -

- Correct Dependency with DI -
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 - -

- 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 - -

- Application Interactor -
Figure 8: Application Interactor -

- -
- -
- Application Interactor - Adapter - -

- Application Interactor - Adapter -
Figure 9: Application Interactor - Adapter -

- -
- -
- Domain - Adapter - -

- Domain - Adapter -
Figure 10: Domain - Adapter -

- -
- -
- Infrastructure Controller - Handler -

- 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 -

- 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 -

- 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 - -

- Handlers -
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 - -

- Configuration flow -
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 @@ - - - -
FastAPI
Request Data
(Pydantic)
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)
Abstract IdP
Application
Objects
Application
Other Ports
Domain
with Services
Application
Data Gateways
\ 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
Business
Data Gateways
Application
Other Ports
Domain
with Services
Application
OtherΒ Adapters
Business
Data Mappers
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 @@ - - - -
External
Presentation
Infrastructure
Application
Domain
\ 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 @@ - - - -
depends on
Application
implements
Infrastructure
Adapter
Port
\ 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 @@ - - - -
depends on
Application
implements
Infrastructure
Adapter
Port
injects to
bases on
creates
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 @@ - - - -
Application
Infrastructure
\ 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 @@ - - - -
Domain
with Services
Domain Adapters
Domain Ports
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 @@ - - - -
Concrete IdP
Infrastructure
Data & Services
Abstract IdP
Application
Interactors /
Services
Domain
with 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 @@ - - - -
FastAPI
Request Data
(Pydantic)
Infrastructure Logic Controllers
Infrastructure
Handlers
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 Ports
Infrastructure Adapters
FastAPI
Infrastructure
Data
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 @@ - - - -
INFRASTRUCTURE
APPLICATION
DOMAIN
PRESENTATION
EXTERNAL
\ 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 @@ - - - -
INFRASTRUCTURE
DOMAIN
APPLICATION
PRESENTATION
EXTERNAL
\ 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/export.toml
export APP_ENV=dev
make dotenv
config/dev/.env.dev
config/dev/config.toml
config/dev/.secrets.toml
config/dev/docker-compose.yaml
can be used by
app
can be used by ...
fill in
call
call
get
... 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" }, ]