diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bee8a64 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__ diff --git a/API.md b/API.md new file mode 100644 index 0000000..f4f921c --- /dev/null +++ b/API.md @@ -0,0 +1,104 @@ +# Prerequisites + +To work with this project, you need: +* make +* docker & docker-compose + +These tools are used for building, running, and testing the project. + +# Initial Setup + +After cloning the repository, it is recommended to run the test suite to ensure the system is working correctly: +```console +make test_suite +``` + +The test suite covers: +* API controllers +* Services +* Repositories + +# Running the Stage Environment + +The stage environment is preconfigured for demo purposes. Secrets are included in the repository. + +To start the stage environment: +```console +make run_stage +``` + +This will: +* Start the database +* Apply migrations +* Launch the FastAPI server + +Swagger documentation will be available at: +``` +http://localhost:8000/docs +``` + +You can test endpoints via Swagger or using the CLI. + +# CLI Usage + +You can test endpoints via Swagger or using the CLI. +```console +# Check available CLI commands +./cli servers --help + +# List servers +./cli servers list + +# Create a new server +./cli servers create +# Example: +./cli servers create dkushche 10.22.32.3 offline + +# Get a server by ID +./cli servers get 1 + +# Update a server (all fields are required since PUT is used) +./cli servers update 1 +# Example: +./cli servers update 1 dima 10.22.32.3 active + +# Delete a server +./cli servers delete 1 +``` + + Note: The update operation is a PUT request, so all fields must be provided; missing fields will be removed, which is not desired. + +# Development Environment + +The development environment is designed with containerized toolboxes. Containers include all necessary software but do not run any services by default. The source code is mounted via bind volumes, and FastAPI is configured with autoreload for fast development. + +Useful `make` commands: +```console +# Start all development containers +make run_dev_services + +# Enter the API container +make enter_dev_api +# Inside: +# - Start server: uv run main.py +# - Install packages: uv sync --frozen +# - Run tests: uv run -- pytest + +# Enter the migrations container +make enter_dev_migrations + +# Enter the CLI container +make enter_dev_cli +``` + +Notes: +* The database is reset between test runs using fixtures to ensure consistent test results. +* The project uses FastAPI dependency injection, pytest-asyncio, and asyncpg for asynchronous PostgreSQL interactions. + + +# Further improvements + +* Adding Linter +* Adding more sophisticated tests for API +* Improve test framework(refactore fixture recheck scoping) +* Add E2E tests diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..00e2cac --- /dev/null +++ b/Makefile @@ -0,0 +1,50 @@ +.PHONY: run_dev_services \ + enter_dev_api\ + enter_dev_migrations + enter_dev_cli \ + stop_dev_services \ + run_stage clear_stage \ + test_suite\ + help + +.DEFAULT_GOAL := help + +## Show this help +help: + @awk 'BEGIN {FS = ":.*##"; printf "\nAvailable targets:\n\n"} \ + /^[a-zA-Z_-]+:.*##/ { printf " %-25s %s\n", $$1, $$2 }' $(MAKEFILE_LIST) + +## Run dev services (API, DB, migrator) in background +run_dev_services: clear_stage ## Start DEV environment (detached) + docker compose -f infra/compose/dev.yml up --build --detach + +## Enter dev API container shell +enter_dev_api: ## Enter DEV API container + docker compose -f infra/compose/dev.yml exec api.mathpix.com bash + +## Enter dev migrator container shell +enter_dev_migrations: ## Enter DEV migrator container + docker compose -f infra/compose/dev.yml exec migrator.mathpix.com bash + +## Enter dev cli container shell +enter_dev_cli: ## Enter DEV CLI container + docker compose -f infra/compose/dev.yml exec cli.mathpix.com bash + +## Stop and remove dev services and volumes +stop_dev_services: ## Stop DEV environment and remove volumes + docker compose -f infra/compose/dev.yml down --volumes + +## Run stage services (foreground) +run_stage: stop_dev_services ## Start STAGE environment + docker compose -f infra/compose/stage.yml up --build + +## Stop stage services and remove volumes +clear_stage: ## Stop STAGE environment and remove volumes + docker compose -f infra/compose/stage.yml down --volumes + +test_suite: stop_dev_services run_dev_services ## Run test suite + docker compose -f infra/compose/dev.yml exec migrator.mathpix.com uv sync --frozen + docker compose -f infra/compose/dev.yml exec migrator.mathpix.com uv run main.py + + docker compose -f infra/compose/dev.yml exec api.mathpix.com uv sync --frozen + docker compose -f infra/compose/dev.yml exec api.mathpix.com uv run uv run -- pytest diff --git a/cli b/cli new file mode 120000 index 0000000..4adc21c --- /dev/null +++ b/cli @@ -0,0 +1 @@ +infra/compose/bin/stage_cli \ No newline at end of file diff --git a/infra/compose/.env b/infra/compose/.env new file mode 100644 index 0000000..10ec436 --- /dev/null +++ b/infra/compose/.env @@ -0,0 +1,8 @@ +POSTGRES_USER=postgres +POSTGRES_PASSWORD=QVlTe2IKrRY2unSDu8P5N436wpx59ur7ctSHB5doZwxi +POSTGRES_DB=inventory +POSTGRES_HOST=inventorydb.mathpix.com + +DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:5432/${POSTGRES_DB} +API_PORT=8000 +API_URL=http://api.mathpix.com:${API_PORT} diff --git a/infra/compose/bin/stage_cli b/infra/compose/bin/stage_cli new file mode 100755 index 0000000..9d52bf1 --- /dev/null +++ b/infra/compose/bin/stage_cli @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +set -e -o pipefail + +SCRIPT_PATH="$(readlink -f "$0")" +SCRIPT_DIR="$(dirname "$SCRIPT_PATH")" + +cd "$SCRIPT_DIR/.." + +docker compose -f stage.yml exec cli.mathpix.com python main.py "$@" diff --git a/infra/compose/common/db.yml b/infra/compose/common/db.yml new file mode 100644 index 0000000..81747fa --- /dev/null +++ b/infra/compose/common/db.yml @@ -0,0 +1,21 @@ +services: + inventorydb.mathpix.com: + image: postgres:18.1-alpine + container_name: inventorydb + restart: always + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + volumes: + - db_data:/var/lib/postgresql/data + + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 5s + timeout: 5s + retries: 5 + start_period: 5s + +volumes: + db_data: diff --git a/infra/compose/dev.yml b/infra/compose/dev.yml new file mode 100644 index 0000000..93023b4 --- /dev/null +++ b/infra/compose/dev.yml @@ -0,0 +1,45 @@ +include: + - common/db.yml +services: + migrator.mathpix.com: + build: + context: ../../projects/migrator/postgres + dockerfile: Dockerfile + target: dev + volumes: + - ../../projects/migrator/postgres:/app + container_name: migrator + depends_on: + - inventorydb.mathpix.com + environment: + DATABASE_URL: ${DATABASE_URL} + + api.mathpix.com: + build: + context: ../../projects/api + dockerfile: Dockerfile + target: dev + volumes: + - ../../projects/api:/app + container_name: api + depends_on: + - inventorydb.mathpix.com + - migrator.mathpix.com + ports: + - ${API_PORT}:8000 + environment: + DATABASE_URL: ${DATABASE_URL} + ENV: dev + + cli.mathpix.com: + build: + context: ../../projects/cli + dockerfile: Dockerfile + target: dev + volumes: + - ../../projects/cli:/app + container_name: cli + depends_on: + - api.mathpix.com + environment: + API_URL: ${API_URL} diff --git a/infra/compose/stage.yml b/infra/compose/stage.yml new file mode 100644 index 0000000..ad6a968 --- /dev/null +++ b/infra/compose/stage.yml @@ -0,0 +1,45 @@ +include: + - common/db.yml +services: + migrator.mathpix.com: + build: + context: ../../projects/migrator/postgres + dockerfile: Dockerfile + target: runtime + container_name: migrator + depends_on: + inventorydb.mathpix.com: + condition: service_healthy + environment: + DATABASE_URL: ${DATABASE_URL} + restart: no + + api.mathpix.com: + build: + context: ../../projects/api + dockerfile: Dockerfile + target: runtime + container_name: api + depends_on: + inventorydb.mathpix.com: + condition: service_healthy + migrator.mathpix.com: + condition: service_completed_successfully + ports: + - ${API_PORT}:8000 + environment: + DATABASE_URL: ${DATABASE_URL} + ENV: stage + restart: always + + cli.mathpix.com: + build: + context: ../../projects/cli + dockerfile: Dockerfile + target: runtime + container_name: cli + depends_on: + - api.mathpix.com + environment: + API_URL: ${API_URL} + restart: no diff --git a/projects/api/.gitignore b/projects/api/.gitignore new file mode 100644 index 0000000..89c2554 --- /dev/null +++ b/projects/api/.gitignore @@ -0,0 +1,2 @@ +.venv +.pytest_cache diff --git a/projects/api/.python-version b/projects/api/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/projects/api/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/projects/api/Dockerfile b/projects/api/Dockerfile new file mode 100644 index 0000000..be8322d --- /dev/null +++ b/projects/api/Dockerfile @@ -0,0 +1,71 @@ +################################################## +FROM python:3.12-slim AS base_builder + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN < None: + try: + init_fn = REPOSITORY_REGISTRY[repository] + except KeyError: + raise RuntimeError( + f"Unsupported repository backend: {repository}. " + f"Supported: {list(REPOSITORY_REGISTRY.keys())}" + ) + + await init_fn(database_url) diff --git a/projects/api/src/db/postgres.py b/projects/api/src/db/postgres.py new file mode 100644 index 0000000..6254050 --- /dev/null +++ b/projects/api/src/db/postgres.py @@ -0,0 +1,18 @@ +import asyncpg + +_pool: asyncpg.Pool | None = None + + +async def init_pool(database_url: str) -> None: + global _pool + _pool = await asyncpg.create_pool( + dsn=database_url, + min_size=1, + max_size=10, + ) + + +def get_pool() -> asyncpg.Pool: + if _pool is None: + raise RuntimeError("Postgres pool not initialized") + return _pool diff --git a/projects/api/src/deps.py b/projects/api/src/deps.py new file mode 100644 index 0000000..f58cbc2 --- /dev/null +++ b/projects/api/src/deps.py @@ -0,0 +1,27 @@ +from typing import Type +from fastapi import Depends + +from src.settings import get_settings +from src.services.servers_service import ServersService + +from src.repositories.interfaces.servers import ServersRepository +from src.repositories.postgres.servers.servers import PostgresServersRepository + +REPOSITORY_REGISTRY: dict[str, Type[ServersRepository]] = { + "postgres": PostgresServersRepository, +} + +def get_servers_repository( + settings=Depends(get_settings), +) -> ServersRepository: + repo_cls = REPOSITORY_REGISTRY.get(settings.repository) + if not repo_cls: + raise RuntimeError(f"Unsupported repository type: {settings.repository}") + + return repo_cls() + + +def get_servers_service( + repo: ServersRepository = Depends(get_servers_repository), +): + return ServersService(repo) diff --git a/projects/api/src/domain/exceptions.py b/projects/api/src/domain/exceptions.py new file mode 100644 index 0000000..d7b58d7 --- /dev/null +++ b/projects/api/src/domain/exceptions.py @@ -0,0 +1,6 @@ +class DomainError(Exception): + """Base class for domain-level errors""" + + +class InvalidIPAddress(DomainError): + pass diff --git a/projects/api/src/domain/servers.py b/projects/api/src/domain/servers.py new file mode 100644 index 0000000..398f241 --- /dev/null +++ b/projects/api/src/domain/servers.py @@ -0,0 +1,31 @@ +import ipaddress + +from dataclasses import dataclass +from typing import Literal +from src.domain.exceptions import InvalidIPAddress + + +ServerState = Literal["active", "offline", "retired"] + + +@dataclass(slots=True) +class Server: + hostname: str + ip_address: str + state: ServerState + id: int | None = None + + def activate(self) -> None: + self.state = "active" + + def deactivate(self) -> None: + self.state = "offline" + + def retire(self) -> None: + self.state = "retired" + + def __post_init__(self): + try: + ipaddress.ip_address(self.ip_address) + except ValueError: + raise InvalidIPAddress("Invalid IP address") diff --git a/projects/api/src/exceptions.py b/projects/api/src/exceptions.py new file mode 100644 index 0000000..e48f0e9 --- /dev/null +++ b/projects/api/src/exceptions.py @@ -0,0 +1,37 @@ +from fastapi import FastAPI +from fastapi.requests import Request +from fastapi.responses import JSONResponse + +from src.domain.exceptions import DomainError +from src.services.exceptions import HostnameAlreadyExists, ServerNotFound + +def register_exception_handlers(app: FastAPI) -> None: + @app.exception_handler(ServerNotFound) + async def server_not_found_handler( + request: Request, + exc: ServerNotFound, + ): + return JSONResponse( + status_code=404, + content={"detail": "Server not found"}, + ) + + @app.exception_handler(HostnameAlreadyExists) + async def hostname_exists_handler( + request: Request, + exc: HostnameAlreadyExists, + ): + return JSONResponse( + status_code=409, + content={"detail": "Hostname already exists"}, + ) + + @app.exception_handler(DomainError) + async def domain_error_handler( + request: Request, + exc: DomainError, + ): + return JSONResponse( + status_code=400, + content={"detail": str(exc)}, + ) diff --git a/projects/api/src/repositories/interfaces/servers.py b/projects/api/src/repositories/interfaces/servers.py new file mode 100644 index 0000000..be77ba8 --- /dev/null +++ b/projects/api/src/repositories/interfaces/servers.py @@ -0,0 +1,29 @@ +from abc import ABC, abstractmethod + +from src.domain.servers import Server + +class ServersRepository(ABC): + + @abstractmethod + async def create(self, server: Server) -> Server: + pass + + @abstractmethod + async def list(self) -> list[Server]: + pass + + @abstractmethod + async def get_by_id(self, server_id: int) -> Server | None: + pass + + @abstractmethod + async def update(self, server_id: int, server: Server) -> Server | None: + pass + + @abstractmethod + async def delete(self, server_id: int) -> None: + pass + + @abstractmethod + async def exists_by_hostname(self, hostname: str) -> bool: + pass diff --git a/projects/api/src/repositories/postgres/servers/queries/create.sql b/projects/api/src/repositories/postgres/servers/queries/create.sql new file mode 100644 index 0000000..f0786f1 --- /dev/null +++ b/projects/api/src/repositories/postgres/servers/queries/create.sql @@ -0,0 +1,3 @@ +INSERT INTO servers (hostname, ip_address, state) +VALUES ($1, $2, $3) +RETURNING id; diff --git a/projects/api/src/repositories/postgres/servers/queries/delete.sql b/projects/api/src/repositories/postgres/servers/queries/delete.sql new file mode 100644 index 0000000..b4747ad --- /dev/null +++ b/projects/api/src/repositories/postgres/servers/queries/delete.sql @@ -0,0 +1,3 @@ +DELETE +FROM servers +WHERE id = $1; diff --git a/projects/api/src/repositories/postgres/servers/queries/exists_by_hostname.sql b/projects/api/src/repositories/postgres/servers/queries/exists_by_hostname.sql new file mode 100644 index 0000000..38d750c --- /dev/null +++ b/projects/api/src/repositories/postgres/servers/queries/exists_by_hostname.sql @@ -0,0 +1,3 @@ +SELECT 1 +FROM servers +WHERE hostname = $1; diff --git a/projects/api/src/repositories/postgres/servers/queries/get_by_id.sql b/projects/api/src/repositories/postgres/servers/queries/get_by_id.sql new file mode 100644 index 0000000..31536a3 --- /dev/null +++ b/projects/api/src/repositories/postgres/servers/queries/get_by_id.sql @@ -0,0 +1,3 @@ +SELECT id, hostname, ip_address, state +FROM servers +WHERE id = $1; diff --git a/projects/api/src/repositories/postgres/servers/queries/list.sql b/projects/api/src/repositories/postgres/servers/queries/list.sql new file mode 100644 index 0000000..3ed8f1d --- /dev/null +++ b/projects/api/src/repositories/postgres/servers/queries/list.sql @@ -0,0 +1,2 @@ +SELECT id, hostname, ip_address, state +FROM servers; diff --git a/projects/api/src/repositories/postgres/servers/queries/update.sql b/projects/api/src/repositories/postgres/servers/queries/update.sql new file mode 100644 index 0000000..e1576b0 --- /dev/null +++ b/projects/api/src/repositories/postgres/servers/queries/update.sql @@ -0,0 +1,6 @@ +UPDATE servers +SET hostname = $1, + ip_address = $2, + state = $3 +WHERE id = $4 +RETURNING id, hostname, ip_address, state; diff --git a/projects/api/src/repositories/postgres/servers/servers.py b/projects/api/src/repositories/postgres/servers/servers.py new file mode 100644 index 0000000..d2fdb14 --- /dev/null +++ b/projects/api/src/repositories/postgres/servers/servers.py @@ -0,0 +1,71 @@ +from src.db.postgres import get_pool + +from pathlib import Path +from src.domain.servers import Server +from src.repositories.interfaces.servers import ServersRepository + +QUERIES_DIR = Path(__file__).parent / "queries" +SQL_FILE_NAME = str +SQL_QUERY = str + +def load_queries() -> dict[SQL_FILE_NAME, SQL_QUERY]: + return { + file_path.name: file_path.read_text() + for file_path in QUERIES_DIR.glob("*.sql") + } + + +class PostgresServersRepository(ServersRepository): + def __init__(self): + self._pool = get_pool() + self._queries = load_queries() + + async def create(self, server: Server) -> Server: + async with self._pool.acquire() as conn: + server_id = await conn.fetchval( + self._queries["create.sql"], + server.hostname, + server.ip_address, + server.state, + ) + server.id = server_id + return server + + async def list(self) -> list[Server]: + async with self._pool.acquire() as conn: + rows = await conn.fetch(self._queries["list.sql"]) + return [Server(**dict(row)) for row in rows] + + async def get_by_id(self, server_id: int) -> Server | None: + async with self._pool.acquire() as conn: + row = await conn.fetchrow( + self._queries["get_by_id.sql"], + server_id, + ) + return Server(**dict(row)) if row else None + + async def update(self, server_id: int, server: Server) -> Server | None: + async with self._pool.acquire() as conn: + row = await conn.fetchrow( + self._queries["update.sql"], + server.hostname, + server.ip_address, + server.state, + server_id, + ) + return Server(**dict(row)) if row else None + + async def delete(self, server_id: int) -> None: + async with self._pool.acquire() as conn: + await conn.execute( + self._queries["delete.sql"], + server_id, + ) + + async def exists_by_hostname(self, hostname: str) -> bool: + async with self._pool.acquire() as conn: + row = await conn.fetchrow( + self._queries["exists_by_hostname.sql"], + hostname, + ) + return row is not None diff --git a/projects/api/src/routes/__init__.py b/projects/api/src/routes/__init__.py new file mode 100644 index 0000000..5f252cc --- /dev/null +++ b/projects/api/src/routes/__init__.py @@ -0,0 +1,8 @@ +from fastapi import APIRouter + +from src.routes.servers.router import router as servers_router + +main_router = APIRouter() +main_router.include_router(servers_router) + +__all__ = ["main_router"] diff --git a/projects/api/src/routes/servers/router.py b/projects/api/src/routes/servers/router.py new file mode 100644 index 0000000..5a33fa4 --- /dev/null +++ b/projects/api/src/routes/servers/router.py @@ -0,0 +1,62 @@ +from fastapi import APIRouter, Depends, status + +from src.routes.servers.schemas import ( + ServerCreate, + ServerPut, + ServerResponse, +) +from src.services.servers_service import ServersService + +from src.deps import get_servers_service +from src.domain.servers import Server + +router = APIRouter( + prefix="/servers", + tags=["Servers"], +) + + +@router.post( + "", + response_model=ServerResponse, + status_code=status.HTTP_201_CREATED, +) +async def create_server( + data: ServerCreate, + service: ServersService = Depends(get_servers_service), +): + server = Server(**data.model_dump()) + return await service.create(server) + + +@router.get("", response_model=list[ServerResponse]) +async def list_servers( + service: ServersService = Depends(get_servers_service), +): + return await service.list() + + +@router.get("/{server_id}", response_model=ServerResponse) +async def get_server( + server_id: int, + service: ServersService = Depends(get_servers_service), +): + return await service.get(server_id) + + +@router.put("/{server_id}", response_model=ServerResponse) +async def update_server( + server_id: int, + data: ServerPut, + service: ServersService = Depends(get_servers_service), +): + server = Server(**data.model_dump()) + return await service.update(server_id, server) + + +@router.delete("/{server_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_server( + server_id: int, + service: ServersService = Depends(get_servers_service), +): + await service.delete(server_id) diff --git a/projects/api/src/routes/servers/schemas/__init__.py b/projects/api/src/routes/servers/schemas/__init__.py new file mode 100644 index 0000000..7301043 --- /dev/null +++ b/projects/api/src/routes/servers/schemas/__init__.py @@ -0,0 +1,8 @@ +from .requests import ServerCreate, ServerPut +from .response import ServerResponse + +__all__ = [ + "ServerCreate", + "ServerPut", + "ServerResponse", +] diff --git a/projects/api/src/routes/servers/schemas/requests.py b/projects/api/src/routes/servers/schemas/requests.py new file mode 100644 index 0000000..bf46c56 --- /dev/null +++ b/projects/api/src/routes/servers/schemas/requests.py @@ -0,0 +1,19 @@ +from pydantic import BaseModel, IPvAnyAddress, Field +from typing import Literal + + +ServerState = Literal["active", "offline", "retired"] + + +class ServerBase(BaseModel): + hostname: str = Field(..., min_length=1) + ip_address: IPvAnyAddress + state: ServerState + + +class ServerCreate(ServerBase): + pass + + +class ServerPut(ServerBase): + pass diff --git a/projects/api/src/routes/servers/schemas/response.py b/projects/api/src/routes/servers/schemas/response.py new file mode 100644 index 0000000..4602b03 --- /dev/null +++ b/projects/api/src/routes/servers/schemas/response.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel, ConfigDict, IPvAnyAddress +from typing import Literal + + +class ServerResponse(BaseModel): + id: int + hostname: str + ip_address: IPvAnyAddress + state: Literal["active", "offline", "retired"] + + model_config = ConfigDict(from_attributes=True) diff --git a/projects/api/src/services/exceptions.py b/projects/api/src/services/exceptions.py new file mode 100644 index 0000000..554593c --- /dev/null +++ b/projects/api/src/services/exceptions.py @@ -0,0 +1,10 @@ +class ServiceError(Exception): + """Base service-level error""" + + +class HostnameAlreadyExists(ServiceError): + pass + + +class ServerNotFound(ServiceError): + pass diff --git a/projects/api/src/services/servers_service.py b/projects/api/src/services/servers_service.py new file mode 100644 index 0000000..347b568 --- /dev/null +++ b/projects/api/src/services/servers_service.py @@ -0,0 +1,37 @@ +from src.domain.servers import Server +from src.repositories.interfaces.servers import ServersRepository + +from src.services.exceptions import HostnameAlreadyExists, ServerNotFound + +class ServersService: + def __init__(self, repo: ServersRepository): + self._repo = repo + + async def create(self, server: Server) -> Server: + if await self._repo.exists_by_hostname(server.hostname): + raise HostnameAlreadyExists("Hostname already exists") + + return await self._repo.create(server) + + async def list(self) -> list[Server]: + return await self._repo.list() + + async def get(self, server_id: int) -> Server: + server = await self._repo.get_by_id(server_id) + if not server: + raise ServerNotFound(server_id) + + return server + + async def update(self, server_id: int, server: Server) -> Server: + if not await self._repo.get_by_id(server_id): + raise ServerNotFound(server_id) + + return await self._repo.update(server_id, server) + + async def delete(self, server_id: int) -> None: + server = await self._repo.get_by_id(server_id) + if not server: + raise ServerNotFound(server_id) + + await self._repo.delete(server_id) diff --git a/projects/api/src/settings.py b/projects/api/src/settings.py new file mode 100644 index 0000000..9e8d4ed --- /dev/null +++ b/projects/api/src/settings.py @@ -0,0 +1,17 @@ +from typing import Literal +from functools import lru_cache +from pydantic_settings import BaseSettings + +class Settings(BaseSettings): + env: Literal["dev", "stage", "prod"] + database_url: str + + host: str + port: int + + repository: Literal["postgres"] + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/projects/api/tests/conftest.py b/projects/api/tests/conftest.py new file mode 100644 index 0000000..e4fbae4 --- /dev/null +++ b/projects/api/tests/conftest.py @@ -0,0 +1,11 @@ +import pytest + +from src.db.db import init_db +from src.settings import get_settings + +@pytest.fixture +async def db(): + settings = get_settings() + await init_db(settings.repository, settings.database_url) + + yield diff --git a/projects/api/tests/repositories/postgres/test_repositories_postgres_servers.py b/projects/api/tests/repositories/postgres/test_repositories_postgres_servers.py new file mode 100644 index 0000000..1460522 --- /dev/null +++ b/projects/api/tests/repositories/postgres/test_repositories_postgres_servers.py @@ -0,0 +1,89 @@ +import pytest +from src.repositories.postgres.servers.servers import PostgresServersRepository +from src.domain.servers import Server +from src.db.postgres import get_pool + +from src.db.db import init_db +from src.settings import get_settings + +@pytest.fixture +async def db(): + settings = get_settings() + await init_db(settings.repository, settings.database_url) + + yield + + +@pytest.fixture +async def repo(db): + repository = PostgresServersRepository() + yield repository + + pool = await get_pool() + await pool.close() + + +@pytest.fixture(scope="function", autouse=True) +async def cleanup(repo): + pool = await get_pool() + + async with pool.acquire() as conn: + await conn.execute("TRUNCATE TABLE servers RESTART IDENTITY CASCADE;") + + yield + + async with pool.acquire() as conn: + await conn.execute("TRUNCATE TABLE servers RESTART IDENTITY CASCADE;") + + +@pytest.mark.asyncio +async def test_create_and_get_by_id(repo: PostgresServersRepository): + server = Server(id=None, hostname="s1", ip_address="192.168.1.1", state="active") + created = await repo.create(server) + + assert created.id is not None + + fetched = await repo.get_by_id(created.id) + assert fetched.id == created.id + assert fetched.hostname == "s1" + + +@pytest.mark.asyncio +async def test_list(repo: PostgresServersRepository): + server1 = Server(id=None, hostname="s1", ip_address="192.168.1.1", state="active") + server2 = Server(id=None, hostname="s2", ip_address="192.168.1.2", state="offline") + await repo.create(server1) + await repo.create(server2) + + servers = await repo.list() + assert len(servers) == 2 + assert {s.hostname for s in servers} == {"s1", "s2"} + + +@pytest.mark.asyncio +async def test_update(repo: PostgresServersRepository): + server = Server(id=None, hostname="s1", ip_address="192.168.1.1", state="active") + created = await repo.create(server) + + updated_server = Server(id=None, hostname="s1-upd", ip_address="10.0.0.1", state="retired") + result = await repo.update(created.id, updated_server) + + assert result.id == created.id + assert result.hostname == "s1-upd" + assert result.state == "retired" + + +@pytest.mark.asyncio +async def test_delete_and_exists(repo: PostgresServersRepository): + server = Server(id=None, hostname="s1", ip_address="192.168.1.1", state="active") + created = await repo.create(server) + + exists_before = await repo.exists_by_hostname("s1") + assert exists_before is True + + await repo.delete(created.id) + + exists_after = await repo.exists_by_hostname("s1") + assert exists_after is False + + assert await repo.get_by_id(created.id) is None diff --git a/projects/api/tests/routes/test_routes_servers.py b/projects/api/tests/routes/test_routes_servers.py new file mode 100644 index 0000000..d844fd6 --- /dev/null +++ b/projects/api/tests/routes/test_routes_servers.py @@ -0,0 +1,78 @@ +from fastapi import FastAPI +from fastapi.testclient import TestClient +from unittest.mock import AsyncMock + +from src.routes.servers.router import router +from src.domain.servers import Server + +# create FastAPI test app +app = FastAPI() +app.include_router(router) + +# create a mock service +mock_service = AsyncMock() + +# override dependency +from src.deps import get_servers_service +app.dependency_overrides[get_servers_service] = lambda: mock_service + +client = TestClient(app) + +server_data = { + "hostname": "server1", + "ip_address": "192.168.1.1", + "state": "active", +} + + +def test_create_server(): + mock_service.create.return_value = Server(id=1, **server_data) + + response = client.post("/servers", json=server_data) + assert response.status_code == 201 + data = response.json() + assert data["hostname"] == server_data["hostname"] + assert data["ip_address"] == server_data["ip_address"] + assert data["state"] == server_data["state"] + mock_service.create.assert_awaited_once() + + +def test_list_servers(): + mock_service.list.return_value = [ + Server(id=1, **server_data) + ] + + response = client.get("/servers") + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["hostname"] == server_data["hostname"] + mock_service.list.assert_awaited_once() + + +def test_get_server(): + mock_service.get.return_value = Server(id=1, **server_data) + + response = client.get("/servers/1") + assert response.status_code == 200 + data = response.json() + assert data["hostname"] == server_data["hostname"] + mock_service.get.assert_awaited_once_with(1) + + +def test_update_server(): + updated_data = server_data.copy() + updated_data["state"] = "offline" + mock_service.update.return_value = Server(id=1, **updated_data) + + response = client.put("/servers/1", json=updated_data) + assert response.status_code == 200 + data = response.json() + assert data["state"] == "offline" + mock_service.update.assert_awaited_once() + + +def test_delete_server(): + response = client.delete("/servers/1") + assert response.status_code == 204 + mock_service.delete.assert_awaited_once_with(1) diff --git a/projects/api/tests/services/test_services_servers.py b/projects/api/tests/services/test_services_servers.py new file mode 100644 index 0000000..44fed1d --- /dev/null +++ b/projects/api/tests/services/test_services_servers.py @@ -0,0 +1,126 @@ +import pytest +from dataclasses import asdict +from unittest.mock import AsyncMock + +from src.domain.servers import Server +from src.services.servers_service import ServersService +from src.services.exceptions import HostnameAlreadyExists, ServerNotFound + + +@pytest.fixture +def mock_repo(): + repo = AsyncMock() + return repo + + +@pytest.fixture +def service(mock_repo): + return ServersService(mock_repo) + + +@pytest.mark.asyncio +async def test_create_server_success(service, mock_repo): + server = Server(id=None, hostname="s1", ip_address="192.168.1.1", state="active") + mock_repo.exists_by_hostname.return_value = False + mock_repo.create.return_value = Server( + id=1, state=server.state, hostname=server.hostname, ip_address=server.ip_address + ) + + result = await service.create(server) + + assert result.id == 1 + assert result.hostname == server.hostname + mock_repo.exists_by_hostname.assert_awaited_once_with(server.hostname) + mock_repo.create.assert_awaited_once_with(server) + + +@pytest.mark.asyncio +async def test_create_server_hostname_exists(service, mock_repo): + server = Server(id=None, hostname="s1", ip_address="192.168.1.1", state="active") + mock_repo.exists_by_hostname.return_value = True + + with pytest.raises(HostnameAlreadyExists): + await service.create(server) + + mock_repo.exists_by_hostname.assert_awaited_once_with(server.hostname) + mock_repo.create.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_list_servers(service, mock_repo): + server = Server(id=1, hostname="s1", ip_address="192.168.1.1", state="active") + mock_repo.list.return_value = [server] + + result = await service.list() + + assert len(result) == 1 + assert result[0].id == 1 + mock_repo.list.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_get_server_success(service, mock_repo): + server = Server(id=1, hostname="s1", ip_address="192.168.1.1", state="active") + mock_repo.get_by_id.return_value = server + + result = await service.get(1) + + assert result.id == 1 + mock_repo.get_by_id.assert_awaited_once_with(1) + + +@pytest.mark.asyncio +async def test_get_server_not_found(service, mock_repo): + mock_repo.get_by_id.return_value = None + + with pytest.raises(ServerNotFound): + await service.get(1) + + mock_repo.get_by_id.assert_awaited_once_with(1) + + +@pytest.mark.asyncio +async def test_update_server_success(service, mock_repo): + server = Server(id=1, hostname="s1", ip_address="192.168.1.1", state="active") + mock_repo.get_by_id.return_value = server + mock_repo.update.return_value = server + + result = await service.update(1, server) + + assert result.id == 1 + mock_repo.get_by_id.assert_awaited_once_with(1) + mock_repo.update.assert_awaited_once_with(1, server) + + +@pytest.mark.asyncio +async def test_update_server_not_found(service, mock_repo): + server = Server(id=1, hostname="s1", ip_address="192.168.1.1", state="active") + mock_repo.get_by_id.return_value = None + + with pytest.raises(ServerNotFound): + await service.update(1, server) + + mock_repo.get_by_id.assert_awaited_once_with(1) + mock_repo.update.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_delete_server_success(service, mock_repo): + server = Server(id=1, hostname="s1", ip_address="192.168.1.1", state="active") + mock_repo.get_by_id.return_value = server + + await service.delete(1) + + mock_repo.get_by_id.assert_awaited_once_with(1) + mock_repo.delete.assert_awaited_once_with(1) + + +@pytest.mark.asyncio +async def test_delete_server_not_found(service, mock_repo): + mock_repo.get_by_id.return_value = None + + with pytest.raises(ServerNotFound): + await service.delete(1) + + mock_repo.get_by_id.assert_awaited_once_with(1) + mock_repo.delete.assert_not_awaited() diff --git a/projects/api/uv.lock b/projects/api/uv.lock new file mode 100644 index 0000000..b541402 --- /dev/null +++ b/projects/api/uv.lock @@ -0,0 +1,353 @@ +version = 1 +revision = 3 +requires-python = "==3.12.*" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +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/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]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions" }, +] +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 = "asyncpg" +version = "0.31.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042, upload-time = "2025-11-24T23:25:49.578Z" }, + { url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504, upload-time = "2025-11-24T23:25:51.501Z" }, + { url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241, upload-time = "2025-11-24T23:25:53.278Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321, upload-time = "2025-11-24T23:25:54.982Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685, upload-time = "2025-11-24T23:25:57.43Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858, upload-time = "2025-11-24T23:25:59.636Z" }, + { url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852, upload-time = "2025-11-24T23:26:01.084Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" }, +] + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +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/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]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "fastapi" +version = "0.128.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/08/8c8508db6c7b9aae8f7175046af41baad690771c9bcde676419965e338c7/fastapi-0.128.0.tar.gz", hash = "sha256:1cc179e1cef10a6be60ffe429f79b829dce99d8de32d7acb7e6c8dfdf7f2645a", size = 365682, upload-time = "2025-12-27T15:21:13.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/05/5cbb59154b093548acd0f4c7c474a118eda06da25aa75c616b72d8fcd92a/fastapi-0.128.0-py3-none-any.whl", hash = "sha256:aebd93f9716ee3b4f4fcfe13ffb7cf308d99c9f3ab5622d8877441072561582d", size = 103094, upload-time = "2025-12-27T15:21:12.154Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +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 = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +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 = "inventory-management-api" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "asyncpg" }, + { name = "fastapi" }, + { name = "pydantic-settings" }, + { name = "uvicorn" }, +] + +[package.dev-dependencies] +dev = [ + { name = "httpx" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + +[package.metadata] +requires-dist = [ + { name = "asyncpg", specifier = "==0.31.0" }, + { name = "fastapi", specifier = "==0.128.0" }, + { name = "pydantic-settings", specifier = "==2.12.0" }, + { name = "uvicorn", specifier = "==0.40.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "httpx", specifier = "==0.28.1" }, + { name = "pytest", specifier = "==9.0.2" }, + { name = "pytest-asyncio", specifier = "==1.3.0" }, +] + +[[package]] +name = "packaging" +version = "25.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" } +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" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +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/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.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +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/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +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/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.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions" }, +] +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 = "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/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]] +name = "starlette" +version = "0.50.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" }, +] diff --git a/projects/cli/.python-version b/projects/cli/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/projects/cli/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/projects/cli/Dockerfile b/projects/cli/Dockerfile new file mode 100644 index 0000000..32e8e77 --- /dev/null +++ b/projects/cli/Dockerfile @@ -0,0 +1,57 @@ +################################################## +FROM python:3.12-slim AS base_builder + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN < str: + api_url = os.environ.get("API_URL") + if not api_url: + print("[red]ERROR:[/] API_URL environment variable must be set.", file=sys.stderr) + raise typer.Exit(code=1) + return api_url + +client = ServersClient(get_api_url()) + + +def handle_request(func, *args, **kwargs): + """Helper to wrap HTTP calls and handle errors gracefully""" + try: + return func(*args, **kwargs) + except requests.HTTPError as e: + if e.response.status_code == 404: + print("[red]Resource not found[/]") + else: + print(f"[red]HTTP error {e.response.status_code}: {e.response.text}[/]") + raise typer.Exit(code=1) + except requests.RequestException as e: + print(f"[red]Connection error:[/] {e}") + raise typer.Exit(code=1) + + +@app.command("list") +def list_servers(): + """List all servers""" + servers = handle_request(client.list_servers) + if not servers: + print("[yellow]No servers found[/]") + return + + for s in servers: + print(f"[green]{s['id']}[/] {s['hostname']} - {s['ip_address']} ({s['state']})") + + +@app.command("get") +def get_server(server_id: int = typer.Argument(..., help="ID of the server")): + """Get a server by ID""" + s = handle_request(client.get_server, server_id) + print(f"[green]{s['id']}[/] {s['hostname']} - {s['ip_address']} ({s['state']})") + + +@app.command("create") +def create_server( + hostname: str = typer.Argument(..., help="Hostname of the server"), + ip_address: str = typer.Argument(..., help="IP address of the server"), + state: str = typer.Argument(..., help="Server state: active, offline, retired") +): + """Create a new server""" + s = handle_request(client.create_server, hostname, ip_address, state) + print(f"[green]Created[/] {s['id']} {s['hostname']}") + + +@app.command("update") +def update_server( + server_id: int = typer.Argument(..., help="ID of the server to update"), + hostname: str = typer.Argument(..., help="New hostname"), + ip_address: str = typer.Argument(..., help="New IP address"), + state: str = typer.Argument(..., help="New state: active, offline, retired") +): + """Update a server (PUT - all fields required)""" + s = handle_request(client.update_server, server_id, hostname, ip_address, state) + print(f"[yellow]Updated[/] {s['id']} {s['hostname']}") + + +@app.command("delete") +def delete_server(server_id: int = typer.Argument(..., help="ID of the server to delete")): + """Delete a server""" + handle_request(client.delete_server, server_id) + print(f"[red]Deleted server[/] {server_id}") diff --git a/projects/cli/uv.lock b/projects/cli/uv.lock new file mode 100644 index 0000000..8e19777 --- /dev/null +++ b/projects/cli/uv.lock @@ -0,0 +1,184 @@ +version = 1 +revision = 3 +requires-python = "==3.12.*" + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +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/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { 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.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +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/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]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "inventory-management-cli" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "requests" }, + { name = "rich" }, + { name = "typer" }, +] + +[package.metadata] +requires-dist = [ + { name = "requests", specifier = "==2.32.5" }, + { name = "rich", specifier = "==14.2.0" }, + { name = "typer", specifier = "==0.21.1" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +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 = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[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 = "rich" +version = "14.2.0" +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" } +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" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "typer" +version = "0.21.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/bf/8825b5929afd84d0dabd606c67cd57b8388cb3ec385f7ef19c5cc2202069/typer-0.21.1.tar.gz", hash = "sha256:ea835607cd752343b6b2b7ce676893e5a0324082268b48f27aa058bdb7d2145d", size = 110371, upload-time = "2026-01-06T11:21:10.989Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/1d/d9257dd49ff2ca23ea5f132edf1281a0c4f9de8a762b9ae399b670a59235/typer-0.21.1-py3-none-any.whl", hash = "sha256:7985e89081c636b88d172c2ee0cfe33c253160994d47bdfdc302defd7d1f1d01", size = 47381, upload-time = "2026-01-06T11:21:09.824Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +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/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] diff --git a/projects/migrator/postgres/.gitignore b/projects/migrator/postgres/.gitignore new file mode 100644 index 0000000..1d17dae --- /dev/null +++ b/projects/migrator/postgres/.gitignore @@ -0,0 +1 @@ +.venv diff --git a/projects/migrator/postgres/.python-version b/projects/migrator/postgres/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/projects/migrator/postgres/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/projects/migrator/postgres/Dockerfile b/projects/migrator/postgres/Dockerfile new file mode 100644 index 0000000..8f89ae3 --- /dev/null +++ b/projects/migrator/postgres/Dockerfile @@ -0,0 +1,67 @@ +################################################## +FROM python:3.12-slim AS base_builder + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN <