diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d661ef5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +*.pyc +*.pyd +*.pyo +.env +.env.example +.git/ +.gitignore +.Python +.venv/ +__pycache__/ +env/ +ENV/ +venv/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..583a717 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +POSTGRES_USER= +POSTGRES_PASSWORD= +POSTGRES_DB= +POSTGRES_HOST= +DATABASE_PORT= +SERVER_API_URL= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..17f1503 --- /dev/null +++ b/.gitignore @@ -0,0 +1,161 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +alembic/versions/*.py[cod] + +# C extensions +*.so + +# Distribution / packaging +.Python +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +.idea/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..b7e01fc --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,34 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: check-case-conflict + - id: check-illegal-windows-names + - id: check-json + - id: pretty-format-json + args: + - "--autofix" + - "--indent=2" + - "--no-sort-keys" + - id: check-toml + - id: check-yaml + - id: check-merge-conflict + - id: mixed-line-ending + args: ["--fix=lf"] + - id: name-tests-test + args: ["--pytest-test-first"] + exclude: "^app/tests/fixtures/" + # - id: no-commit-to-branch + # args: ['--branch', 'main', '--branch', 'dev'] + - id: end-of-file-fixer + - id: trailing-whitespace + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.11.13 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + - repo: https://github.com/RobertCraigie/pyright-python + rev: v1.1.401 + hooks: + - id: pyright diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..95ed564 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.14.2 diff --git a/API.md b/API.md new file mode 100644 index 0000000..bf00870 --- /dev/null +++ b/API.md @@ -0,0 +1,106 @@ +# Server Management API & CLI + +Quick reference for the Server Management System. + +--- + +## 🚀 Quick Start + +### Environment Configuration +```bash +# Copy example environment file +cp .env.example .env + +# Edit .env and fill in values: +# POSTGRES_USER=postgres +# POSTGRES_PASSWORD=postgres +# POSTGRES_DB=servers +# POSTGRES_HOST=localhost +# DATABASE_PORT=5432 +# SERVER_API_URL=http://localhost:5001 +``` + +### Docker Compose (Recommended) +```bash +# Start API + Database +docker compose up -d + +# Verify +curl http://localhost:5001/ +``` + +### Run Tests +```bash +uv run pytest tests/ -v +``` + +--- + +## 📡 API Endpoints + +**Base URL:** `http://localhost:5001` + +**Documentation:** +- Swagger UI: http://localhost:5001/docs + +--- + +## 💻 CLI Commands + +### Installation +```bash +uv sync +``` + +### Essential Commands +```bash +# Health check +uv run server-cli health + +# List servers +uv run server-cli servers list +uv run server-cli servers list --format json + +# Get server +uv run server-cli servers get + +# Create server +uv run server-cli servers create \ + --name "Web Server" \ + --ip "192.168.1.10" \ + --hostname "web-01.local" \ + --state active + +# Update server +uv run server-cli servers update --state offline + +# Delete server +uv run server-cli servers delete + +# Search & filter +uv run server-cli servers search "web" +uv run server-cli servers filter --state active +``` + +### Configuration +```bash +uv run server-cli config show +uv run server-cli config set api_url http://localhost:5001 +``` + +**Environment Variable:** +```bash +export SERVER_API_URL=http://localhost:5001 +``` + +--- + +## 🔍 Server States + +| State | Description | +|-------|-------------| +| `active` | Server is running | +| `offline` | Server is down | +| `retired` | Server decommissioned | + +--- diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..af37317 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +FROM python:3.14-slim + +# Prevent Python from writing pyc files and buffering stdout/stderr +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONFAULTHANDLER=1 + +# Set working directory +WORKDIR /app + +# Install uv for fast dependency management +COPY --from=ghcr.io/astral-sh/uv:0.9.18 /uv /uvx /bin/ + +# Create non-root user for security +RUN groupadd -r appuser && \ + useradd -r -g appuser -u 1000 appuser && \ + chown -R appuser:appuser /app + +# Copy dependency files first for better layer caching +COPY --chown=appuser:appuser uv.lock pyproject.toml ./ + +# Install dependencies as non-root user +USER appuser +RUN uv sync --frozen --no-cache + +# Add virtual environment to PATH +ENV PATH="/app/.venv/bin:$PATH" + +# Copy application code +COPY --chown=appuser:appuser . . + +# Expose the application port +EXPOSE 5000 + +# Run the application +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "5000"] diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..7bd8594 --- /dev/null +++ b/app/config.py @@ -0,0 +1,48 @@ +from typing import Any + +from pydantic_settings import BaseSettings + + +class DatabaseSettings(BaseSettings): + DATABASE_PORT: int = 5432 + POSTGRES_PASSWORD: str = "" + POSTGRES_USER: str = "" + POSTGRES_DB: str = "" + POSTGRES_HOST: str = "localhost" + + +class AppSettings(BaseSettings): + database: DatabaseSettings = DatabaseSettings() + + class Config: + env_file = "./.env" + extra = "allow" + + +class LogConfig(BaseSettings): + LOGGER_NAME: str = "app" + LOG_FORMAT: str = "%(levelprefix)s | %(asctime)s | %(message)s" + LOG_LEVEL: str = "DEBUG" + + version: int = 1 + disable_existing_loggers: bool = False + formatters: dict[str, Any] = { + "default": { + "()": "uvicorn.logging.DefaultFormatter", + "fmt": LOG_FORMAT, + "datefmt": "%Y-%m-%d %H:%M:%S", + }, + } + handlers: dict[str, Any] = { + "default": { + "formatter": "default", + "class": "logging.StreamHandler", + "stream": "ext://sys.stderr", + }, + } + loggers: dict[str, Any] = { + LOGGER_NAME: {"handlers": ["default"], "level": LOG_LEVEL}, + } + + +config = AppSettings() diff --git a/app/controller.py b/app/controller.py new file mode 100644 index 0000000..051b808 --- /dev/null +++ b/app/controller.py @@ -0,0 +1,134 @@ +from logging import getLogger +from typing import Optional + +from sqlalchemy import text +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.schemas import ServerListResponse, ServerResponse + +logger = getLogger(__name__) + + +class ServerController: + @staticmethod + def list_servers(db: Session) -> ServerListResponse: + result = db.execute(text("SELECT * FROM servers")) + servers = result.fetchall() + + processed_servers = [ + ServerResponse( + id=server.id, + name=server.name, + ip_address=server.ip_address, + hostname=server.hostname, + state=server.state, + ) + for server in servers + ] + + logger.info("Retrieved list of all servers.") + return ServerListResponse(servers=processed_servers) + + @staticmethod + def get_server(db: Session, server_id: int) -> Optional[ServerResponse]: + result = db.execute(text("SELECT * FROM servers WHERE id = :server_id"), {"server_id": server_id}) + server = result.fetchone() + if server: + logger.info(f"Server with ID '{server_id}' has been retrieved.") + return ServerResponse( + id=server.id, + name=server.name, + ip_address=server.ip_address, + hostname=server.hostname, + state=server.state, + ) + else: + logger.warning(f"Server with ID '{server_id}' not found.") + return None + + @staticmethod + def create_server( + db: Session, + name: str, + ip_address: str, + hostname: str, + state: str = "active", + ) -> None: + try: + db.execute( + text( + "INSERT INTO servers (name, ip_address, hostname, state) VALUES (:name, :ip_address, :hostname, :state)" + ), + {"name": name, "ip_address": ip_address, "hostname": hostname, "state": state} + ) + db.commit() + logger.info(f"Server '{name}' has been created.") + except IntegrityError as e: + db.rollback() + if "unique constraint" in str(e).lower() and "hostname" in str(e).lower(): + logger.error(f"Hostname '{hostname}' already exists.") + raise ValueError(f"Hostname '{hostname}' already exists.") + raise + except Exception: + db.rollback() + raise + + @staticmethod + def update_server( + db: Session, + server_id: int, + name: Optional[str] = None, + ip_address: Optional[str] = None, + hostname: Optional[str] = None, + state: Optional[str] = None, + ) -> None: + updates = [] + params = {"server_id": server_id} + + if name is not None: + updates.append("name = :name") + params["name"] = name + if ip_address is not None: + updates.append("ip_address = :ip_address") + params["ip_address"] = ip_address + if hostname is not None: + updates.append("hostname = :hostname") + params["hostname"] = hostname + if state is not None: + updates.append("state = :state") + params["state"] = state + + if updates: + try: + update_str = ", ".join(updates) + result = db.execute(text(f"UPDATE servers SET {update_str} WHERE id = :server_id"), params) + if result.rowcount == 0: + logger.warning(f"Server with ID '{server_id}' not found.") + raise ValueError(f"Server with ID '{server_id}' not found.") + db.commit() + logger.info(f"Server with ID '{server_id}' has been updated.") + except IntegrityError as e: + db.rollback() + if "unique constraint" in str(e).lower() and "hostname" in str(e).lower(): + logger.error(f"Hostname '{hostname}' already exists.") + raise ValueError(f"Hostname '{hostname}' already exists.") + raise + except Exception: + db.rollback() + raise + else: + logger.info(f"No updates provided for server with ID '{server_id}'.") + + @staticmethod + def delete_server(db: Session, server_id: int) -> None: + try: + result = db.execute(text("DELETE FROM servers WHERE id = :server_id"), {"server_id": server_id}) + if result.rowcount == 0: + logger.warning(f"Server with ID '{server_id}' not found.") + raise ValueError(f"Server with ID '{server_id}' not found.") + db.commit() + logger.info(f"Server with ID '{server_id}' has been deleted.") + except Exception: + db.rollback() + raise diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..42adbb7 --- /dev/null +++ b/app/database.py @@ -0,0 +1,48 @@ +from collections.abc import Generator +from typing import Annotated + +from fastapi import Depends +from sqlalchemy.engine import create_engine +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.sql import text + +from app.config import config + +SQLALCHEMY_DATABASE_URL = f"postgresql://{config.database.POSTGRES_USER}:{config.database.POSTGRES_PASSWORD}@{config.database.POSTGRES_HOST}:{config.database.DATABASE_PORT}/{config.database.POSTGRES_DB}" + +# Create a synchronous engine +engine = create_engine(SQLALCHEMY_DATABASE_URL, echo=True) + +# Create a session factory +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +# Dependency for getting the database session +def get_db() -> Generator: + db = SessionLocal() + try: + yield db + finally: + db.close() + + +# Define a type alias for convenience +db_dependency = Annotated[Session, Depends(get_db)] + + +def init_db(): + with SessionLocal() as db: + db.execute( + text(""" + CREATE TABLE IF NOT EXISTS servers ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + hostname VARCHAR(255) NOT NULL UNIQUE, + ip_address VARCHAR(45) NOT NULL, + state VARCHAR(20) NOT NULL CHECK (state IN ('active', 'offline', 'retired')), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + ) + db.commit() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..252d05b --- /dev/null +++ b/app/main.py @@ -0,0 +1,54 @@ +import logging +from contextlib import asynccontextmanager +from logging.config import dictConfig +from typing import Any + +from fastapi import FastAPI +from fastapi.openapi.utils import get_openapi + +from app.config import LogConfig +from app.database import init_db +from app.routers import servers_router + +dictConfig(LogConfig().model_dump()) +logger = logging.getLogger("app") + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup: Initialize database + logger.info("Application startup: Initializing database") + init_db() + logger.info("Database initialized successfully") + yield + # Shutdown + logger.info("Application shutdown") + + +app = FastAPI(lifespan=lifespan, title="FastAPI Starter", version="1.0.0") + +# Include routers +app.include_router(servers_router, tags=["servers"]) + + +@app.get("/") +def read_root() -> dict[str, str]: + return {"message": "Welcome to FastAPI Starter!"} + + +def custom_openapi() -> dict[str, Any]: + if app.openapi_schema: + return app.openapi_schema + + openapi_schema = get_openapi( + title="Documentation", + version="1.0.0", + description="Docs for Starter Project", + routes=app.routes, + ) + + app.openapi_schema = openapi_schema + return app.openapi_schema + + +app.openapi = custom_openapi diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..d50846d --- /dev/null +++ b/app/routers/__init__.py @@ -0,0 +1,3 @@ +from .servers import router as servers_router + +__all__ = ["servers_router"] diff --git a/app/routers/servers.py b/app/routers/servers.py new file mode 100644 index 0000000..174b38c --- /dev/null +++ b/app/routers/servers.py @@ -0,0 +1,96 @@ +from fastapi import APIRouter, HTTPException + +from app.controller import ServerController +from app.database import db_dependency +from app.schemas import ( + ServerCreateRequest, + ServerListResponse, + ServerResponse, + ServerUpdateRequest, + StatusResponse, +) + +router = APIRouter() + + +@router.get("/servers", status_code=200) +async def list_servers(db: db_dependency) -> ServerListResponse: + try: + servers = ServerController.list_servers(db) + return servers + except Exception as e: + raise HTTPException( + status_code=500, detail=f"Error retrieving servers: {str(e)}" + ) + + +@router.get("/servers/{server_id}", status_code=200) +async def get_server(db: db_dependency, server_id: int) -> ServerResponse: + try: + server = ServerController.get_server(db, server_id) + if server is None: + raise HTTPException( + status_code=404, detail=f"Server with ID {server_id} not found" + ) + return server + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=500, detail=f"Error retrieving server: {str(e)}" + ) + + +@router.post("/servers", status_code=201) +async def create_server( + db: db_dependency, server_info: ServerCreateRequest +) -> StatusResponse: + server_data = server_info.model_dump() + try: + ServerController.create_server(db, **server_data) + return StatusResponse( + status=201, + message="Server created", + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error creating server: {str(e)}") + + +@router.put("/servers/{server_id}", status_code=200) +async def update_server( + db: db_dependency, server_id: int, server_info: ServerUpdateRequest +) -> StatusResponse: + server_data = server_info.model_dump() + try: + ServerController.update_server(db, server_id, **server_data) + return StatusResponse( + status=200, + message=f"Server {server_id} updated", + ) + except ValueError as e: + # Check if it's a "not found" error + if "not found" in str(e).lower(): + raise HTTPException(status_code=404, detail=str(e)) + # Otherwise it's a validation error (e.g., duplicate hostname) + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error updating server: {str(e)}") + + +@router.delete("/servers/{server_id}", status_code=200) +async def delete_server(db: db_dependency, server_id: int) -> StatusResponse: + try: + ServerController.delete_server(db, server_id) + return StatusResponse( + status=200, + message=f"Server {server_id} deleted", + ) + except ValueError as e: + # Check if it's a "not found" error + if "not found" in str(e).lower(): + raise HTTPException(status_code=404, detail=str(e)) + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error deleting server: {str(e)}") diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 0000000..e9aec53 --- /dev/null +++ b/app/schemas.py @@ -0,0 +1,59 @@ +from ipaddress import ip_address +from typing import Any, Literal + +from pydantic import BaseModel, field_validator + +VALID_STATES = Literal["active", "offline", "retired"] + + +class ServerResponse(BaseModel): + id: int + name: str + ip_address: str + hostname: str + state: str + + +class ServerListResponse(BaseModel): + servers: list[ServerResponse] + + +class ServerCreateRequest(BaseModel): + name: str + ip_address: str + hostname: str + state: VALID_STATES = "active" + + @field_validator("ip_address") + @classmethod + def validate_ip_address(cls, v: Any) -> str: + """Validate that ip_address is a valid IPv4 or IPv6 address.""" + try: + ip_address(v) + return v + except ValueError: + raise ValueError(f"'{v}' is not a valid IP address") + + +class ServerUpdateRequest(BaseModel): + name: str | None = None + ip_address: str | None = None + hostname: str | None = None + state: VALID_STATES | None = None + + @field_validator("ip_address") + @classmethod + def validate_ip_address(cls, v: Any) -> str | None: + """Validate that ip_address is a valid IPv4 or IPv6 address.""" + if v is None: + return v + try: + ip_address(v) + return v + except ValueError: + raise ValueError(f"'{v}' is not a valid IP address") + + +class StatusResponse(BaseModel): + status: int + message: str | None = None diff --git a/cli/__init__.py b/cli/__init__.py new file mode 100644 index 0000000..ea40a4b --- /dev/null +++ b/cli/__init__.py @@ -0,0 +1,8 @@ +""" +CLI package for server management. + +This package provides a command-line interface for managing servers +through the FastAPI application. +""" + +__version__ = "1.0.0" diff --git a/cli/commands/__init__.py b/cli/commands/__init__.py new file mode 100644 index 0000000..c169421 --- /dev/null +++ b/cli/commands/__init__.py @@ -0,0 +1,3 @@ +from . import servers + +__all__ = ["servers"] diff --git a/cli/commands/servers.py b/cli/commands/servers.py new file mode 100644 index 0000000..8263d63 --- /dev/null +++ b/cli/commands/servers.py @@ -0,0 +1,374 @@ +from typing import Optional + +import typer +from rich.console import Console + +from cli.utils import ( + APIClient, + confirm_action, + format_output, + get_config, + print_error, + print_success, +) + +app = typer.Typer(help="Manage servers") +console = Console() + + +@app.command("list") +def list_servers( + output_format: str = typer.Option( + None, + "--format", + "-f", + help="Output format (table, json, detail)", + ), + api_url: Optional[str] = typer.Option( + None, + "--api-url", + help="API base URL (overrides config)", + envvar="SERVER_API_URL", + ), +) -> None: + """ + List all servers. + + Examples: + server-cli servers list + server-cli servers list --format json + server-cli servers list --api-url http://localhost:5001 + """ + config = get_config() + base_url = api_url or config.get("api_url") + fmt = output_format or config.get("output_format", "table") + + try: + with APIClient(base_url=base_url) as client: + servers = client.list_servers() + format_output(servers, output_format=fmt) + except Exception as e: + print_error(f"Failed to list servers: {e}") + raise typer.Exit(code=1) + + +@app.command("get") +def get_server( + server_id: int = typer.Argument(..., help="Server ID"), + output_format: str = typer.Option( + None, + "--format", + "-f", + help="Output format (table, json, detail)", + ), + api_url: Optional[str] = typer.Option( + None, + "--api-url", + help="API base URL (overrides config)", + envvar="SERVER_API_URL", + ), +) -> None: + """ + Get details of a specific server. + + Examples: + server-cli servers get 1 + server-cli servers get 1 --format json + """ + config = get_config() + base_url = api_url or config.get("api_url") + fmt = output_format or config.get("output_format", "detail") + + try: + with APIClient(base_url=base_url) as client: + server = client.get_server(server_id) + format_output(server, output_format=fmt) + except Exception as e: + print_error(f"Failed to get server: {e}") + raise typer.Exit(code=1) + + +@app.command("create") +def create_server( + name: str = typer.Option(..., "--name", "-n", help="Server name"), + ip_address: str = typer.Option(..., "--ip", "-i", help="IP address (IPv4 or IPv6)"), + hostname: str = typer.Option( + ..., "--hostname", "-h", help="Hostname (must be unique)" + ), + state: str = typer.Option( + "active", + "--state", + "-s", + help="Server state (active, offline, retired)", + ), + api_url: Optional[str] = typer.Option( + None, + "--api-url", + help="API base URL (overrides config)", + envvar="SERVER_API_URL", + ), +) -> None: + """ + Create a new server. + + Examples: + server-cli servers create -n "Web Server" -i 192.168.1.10 -h web-01.example.com + server-cli servers create -n "DB Server" -i 10.0.0.5 -h db-01 -s offline + """ + config = get_config() + base_url = api_url or config.get("api_url") + + # Validate state + valid_states = ["active", "offline", "retired"] + if state not in valid_states: + print_error( + f"Invalid state '{state}'. Must be one of: {', '.join(valid_states)}" + ) + raise typer.Exit(code=1) + + try: + with APIClient(base_url=base_url) as client: + client.create_server( + name=name, + ip_address=ip_address, + hostname=hostname, + state=state, + ) + print_success(f"Server '{name}' created successfully!") + + # Show the created server + console.print(f"[dim]Hostname: {hostname}[/dim]") + console.print(f"[dim]IP: {ip_address}[/dim]") + console.print(f"[dim]State: {state}[/dim]") + except Exception as e: + print_error(f"Failed to create server: {e}") + raise typer.Exit(code=1) + + +@app.command("update") +def update_server( + server_id: int = typer.Argument(..., help="Server ID"), + name: Optional[str] = typer.Option(None, "--name", "-n", help="New server name"), + ip_address: Optional[str] = typer.Option(None, "--ip", "-i", help="New IP address"), + hostname: Optional[str] = typer.Option( + None, "--hostname", "-h", help="New hostname" + ), + state: Optional[str] = typer.Option( + None, + "--state", + "-s", + help="New server state (active, offline, retired)", + ), + api_url: Optional[str] = typer.Option( + None, + "--api-url", + help="API base URL (overrides config)", + envvar="SERVER_API_URL", + ), +) -> None: + """ + Update an existing server. + + You can update one or more fields. Only provided fields will be updated. + + Examples: + server-cli servers update 1 --name "New Name" + server-cli servers update 1 --state offline + server-cli servers update 1 -n "Updated" -i 192.168.1.20 -s retired + """ + config = get_config() + base_url = api_url or config.get("api_url") + + # Check if at least one field is provided + if not any([name, ip_address, hostname, state]): + print_error("At least one field must be provided for update") + raise typer.Exit(code=1) + + # Validate state if provided + if state: + valid_states = ["active", "offline", "retired"] + if state not in valid_states: + print_error( + f"Invalid state '{state}'. Must be one of: {', '.join(valid_states)}" + ) + raise typer.Exit(code=1) + + try: + with APIClient(base_url=base_url) as client: + client.update_server( + server_id=server_id, + name=name, + ip_address=ip_address, + hostname=hostname, + state=state, + ) + print_success(f"Server {server_id} updated successfully!") + + # Show what was updated + updates = [] + if name: + updates.append(f"name → {name}") + if ip_address: + updates.append(f"IP → {ip_address}") + if hostname: + updates.append(f"hostname → {hostname}") + if state: + updates.append(f"state → {state}") + + for update in updates: + console.print(f"[dim] • {update}[/dim]") + except Exception as e: + print_error(f"Failed to update server: {e}") + raise typer.Exit(code=1) + + +@app.command("delete") +def delete_server( + server_id: int = typer.Argument(..., help="Server ID"), + force: bool = typer.Option( + False, + "--force", + "-f", + help="Skip confirmation prompt", + ), + api_url: Optional[str] = typer.Option( + None, + "--api-url", + help="API base URL (overrides config)", + envvar="SERVER_API_URL", + ), +) -> None: + """ + Delete a server. + + By default, this command will ask for confirmation before deleting. + Use --force to skip the confirmation. + + Examples: + server-cli servers delete 1 + server-cli servers delete 1 --force + """ + config = get_config() + base_url = api_url or config.get("api_url") + confirm_delete = config.get("confirm_delete", True) + + # Ask for confirmation unless --force is used + if not force and confirm_delete: + if not confirm_action(f"Are you sure you want to delete server {server_id}?"): + console.print("[yellow]Deletion cancelled.[/yellow]") + raise typer.Exit(code=0) + + try: + with APIClient(base_url=base_url) as client: + client.delete_server(server_id) + print_success(f"Server {server_id} deleted successfully!") + except Exception as e: + print_error(f"Failed to delete server: {e}") + raise typer.Exit(code=1) + + +@app.command("search") +def search_servers( + query: str = typer.Argument(..., help="Search query (searches name, hostname, IP)"), + output_format: str = typer.Option( + None, + "--format", + "-f", + help="Output format (table, json)", + ), + api_url: Optional[str] = typer.Option( + None, + "--api-url", + help="API base URL (overrides config)", + envvar="SERVER_API_URL", + ), +) -> None: + """ + Search for servers by name, hostname, or IP address. + + Examples: + server-cli servers search web + server-cli servers search 192.168 + server-cli servers search example.com + """ + config = get_config() + base_url = api_url or config.get("api_url") + fmt = output_format or config.get("output_format", "table") + + try: + with APIClient(base_url=base_url) as client: + servers = client.list_servers() + + # Filter servers based on query + query_lower = query.lower() + filtered_servers = [ + server + for server in servers + if query_lower in server.get("name", "").lower() + or query_lower in server.get("hostname", "").lower() + or query_lower in server.get("ip_address", "").lower() + ] + + if not filtered_servers: + console.print(f"[yellow]No servers found matching '{query}'[/yellow]") + else: + format_output(filtered_servers, output_format=fmt) + except Exception as e: + print_error(f"Failed to search servers: {e}") + raise typer.Exit(code=1) + + +@app.command("filter") +def filter_servers( + state: Optional[str] = typer.Option(None, "--state", "-s", help="Filter by state"), + output_format: str = typer.Option( + None, + "--format", + "-f", + help="Output format (table, json)", + ), + api_url: Optional[str] = typer.Option( + None, + "--api-url", + help="API base URL (overrides config)", + envvar="SERVER_API_URL", + ), +) -> None: + """ + Filter servers by criteria. + + Examples: + server-cli servers filter --state active + server-cli servers filter --state offline --format json + """ + config = get_config() + base_url = api_url or config.get("api_url") + fmt = output_format or config.get("output_format", "table") + + # Validate state if provided + if state: + valid_states = ["active", "offline", "retired"] + if state not in valid_states: + print_error( + f"Invalid state '{state}'. Must be one of: {', '.join(valid_states)}" + ) + raise typer.Exit(code=1) + + try: + with APIClient(base_url=base_url) as client: + servers = client.list_servers() + + # Filter servers + filtered_servers = servers + if state: + filtered_servers = [ + s for s in filtered_servers if s.get("state") == state + ] + + if not filtered_servers: + console.print("[yellow]No servers found matching the criteria[/yellow]") + else: + format_output(filtered_servers, output_format=fmt) + except Exception as e: + print_error(f"Failed to filter servers: {e}") + raise typer.Exit(code=1) diff --git a/cli/main.py b/cli/main.py new file mode 100644 index 0000000..a3a69f3 --- /dev/null +++ b/cli/main.py @@ -0,0 +1,245 @@ +import sys +from typing import Optional + +import typer +from rich.console import Console +from rich.panel import Panel +from rich.table import Table + +from cli import __version__ +from cli.commands import servers +from cli.utils import APIClient, get_config, print_error, print_success + +app = typer.Typer( + name="server-cli", + help="CLI tool for managing servers via the FastAPI application.", + no_args_is_help=True, +) +console = Console() + +# Add servers subcommand +app.add_typer(servers.app, name="servers") + + +@app.command() +def version() -> None: + """ + Display the CLI version. + + Examples: + server-cli version + """ + console.print( + f"[bold blue]server-cli[/bold blue] version [green]{__version__}[/green]" + ) + + +@app.command() +def health( + api_url: Optional[str] = typer.Option( + None, + "--api-url", + help="API base URL (overrides config)", + envvar="SERVER_API_URL", + ), +) -> None: + """ + Check if the API server is reachable. + + Examples: + server-cli health + server-cli health --api-url http://localhost:5001 + """ + config = get_config() + base_url = api_url or config.get("api_url") + + console.print(f"[dim]Checking API health at {base_url}...[/dim]") + + try: + with APIClient(base_url=base_url) as client: + is_healthy = client.health_check() + + if is_healthy: + print_success(f"API is reachable at {base_url}") + else: + print_error(f"API is not responding at {base_url}") + raise typer.Exit(code=1) + except Exception as e: + print_error(f"Failed to connect to API: {e}") + raise typer.Exit(code=1) + + +@app.command() +def config( + action: str = typer.Argument( + ..., + help="Action to perform (show, set, get, reset, delete)", + ), + key: Optional[str] = typer.Argument(None, help="Configuration key"), + value: Optional[str] = typer.Argument(None, help="Configuration value"), +) -> None: + """ + Manage CLI configuration. + + Actions: + show - Display all configuration settings + get - Get a specific configuration value + set - Set a configuration value + delete - Delete a configuration key + reset - Reset configuration to defaults + + Examples: + server-cli config show + server-cli config get api_url + server-cli config set api_url http://localhost:5001 + server-cli config set output_format json + server-cli config delete custom_setting + server-cli config reset + """ + cfg = get_config() + + if action == "show": + # Display all configuration + config_data = cfg.get_all() + + table = Table( + title="CLI Configuration", + show_header=True, + header_style="bold magenta", + border_style="blue", + ) + table.add_column("Key", style="cyan") + table.add_column("Value", style="green") + + for k, v in sorted(config_data.items()): + table.add_row(k, str(v)) + + console.print(table) + console.print(f"\n[dim]Config file: {cfg.config_path}[/dim]") + + elif action == "get": + if not key: + print_error("Key is required for 'get' action") + raise typer.Exit(code=1) + + value = cfg.get(key) + if value is not None: + console.print(f"[cyan]{key}[/cyan] = [green]{value}[/green]") + else: + print_error(f"Configuration key '{key}' not found") + raise typer.Exit(code=1) + + elif action == "set": + if not key or value is None: + print_error("Both key and value are required for 'set' action") + raise typer.Exit(code=1) + + # Try to parse value as appropriate type + parsed_value = value + if value.lower() == "true": + parsed_value = True + elif value.lower() == "false": + parsed_value = False + elif value.isdigit(): + parsed_value = int(value) + elif value.replace(".", "", 1).isdigit(): + parsed_value = float(value) + + cfg.set(key, parsed_value) + print_success(f"Set {key} = {parsed_value}") + + elif action == "delete": + if not key: + print_error("Key is required for 'delete' action") + raise typer.Exit(code=1) + + if cfg.delete(key): + print_success(f"Deleted configuration key '{key}'") + else: + print_error(f"Configuration key '{key}' not found") + raise typer.Exit(code=1) + + elif action == "reset": + from rich.prompt import Confirm + + if Confirm.ask("Are you sure you want to reset configuration to defaults?"): + cfg.reset() + print_success("Configuration reset to defaults") + else: + console.print("[yellow]Reset cancelled[/yellow]") + + else: + print_error(f"Unknown action '{action}'") + console.print("[dim]Valid actions: show, get, set, delete, reset[/dim]") + raise typer.Exit(code=1) + + +@app.command() +def info() -> None: + """ + Display information about the CLI and its configuration. + + Examples: + server-cli info + """ + cfg = get_config() + + info_content = f"""[bold]Version:[/bold] {__version__} + [bold]Config File:[/bold] {cfg.config_path} + [bold]API URL:[/bold] {cfg.get("api_url")} + [bold]Output Format:[/bold] {cfg.get("output_format")} + [bold]Timeout:[/bold] {cfg.get("timeout")}s + + [dim]Run 'server-cli --help' for available commands[/dim]""" + + panel = Panel( + info_content, + title="[bold blue]Server CLI Info[/bold blue]", + border_style="blue", + expand=False, + ) + + console.print(panel) + + +@app.callback() +def main( + ctx: typer.Context, + verbose: bool = typer.Option( + False, + "--verbose", + "-v", + help="Enable verbose output", + ), +) -> None: + """ + Server Management CLI + + A command-line interface for managing servers through the FastAPI application. + + Use 'server-cli COMMAND --help' for more information about a specific command. + """ + # Store verbose flag in context + ctx.obj = {"verbose": verbose} + + if verbose: + console.print("[dim]Verbose mode enabled[/dim]") + + +def cli_main() -> None: + """Entry point for the CLI application.""" + try: + app() + except KeyboardInterrupt: + console.print("\n[yellow]Operation cancelled by user[/yellow]") + sys.exit(130) + except Exception as e: + if "--verbose" in sys.argv or "-v" in sys.argv: + console.print_exception() + else: + print_error(f"An error occurred: {e}") + sys.exit(1) + + +if __name__ == "__main__": + cli_main() diff --git a/cli/utils/__init__.py b/cli/utils/__init__.py new file mode 100644 index 0000000..7f1d4f0 --- /dev/null +++ b/cli/utils/__init__.py @@ -0,0 +1,30 @@ +from .api_client import APIClient +from .config import Config, get_config +from .output import ( + confirm_action, + format_output, + print_error, + print_info, + print_json, + print_server_detail, + print_servers_table, + print_success, + print_warning, + prompt_for_input, +) + +__all__ = [ + "APIClient", + "Config", + "get_config", + "confirm_action", + "format_output", + "print_error", + "print_info", + "print_json", + "print_server_detail", + "print_servers_table", + "print_success", + "print_warning", + "prompt_for_input", +] diff --git a/cli/utils/api_client.py b/cli/utils/api_client.py new file mode 100644 index 0000000..51b011f --- /dev/null +++ b/cli/utils/api_client.py @@ -0,0 +1,179 @@ +import os +from typing import Any, Optional + +import httpx +from rich.console import Console + +console = Console() + + +class APIClient: + """Client for making requests to the server management API.""" + + def __init__( + self, + base_url: Optional[str] = None, + timeout: float = 30.0, + ): + """ + Initialize the API client. + + Args: + base_url: Base URL of the API. Defaults to environment variable or localhost. + timeout: Request timeout in seconds. + """ + self.base_url = base_url or os.getenv("SERVER_API_URL", "http://localhost:5001") + self.timeout = timeout + self.client = httpx.Client(base_url=self.base_url, timeout=timeout) + + def __enter__(self): + """Context manager entry.""" + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Context manager exit.""" + self.close() + + def close(self): + """Close the HTTP client.""" + self.client.close() + + def _handle_response(self, response: httpx.Response) -> dict[str, Any]: + """ + Handle API response and raise appropriate errors. + + Args: + response: HTTP response object. + + Returns: + Parsed JSON response. + + Raises: + httpx.HTTPStatusError: If the response contains an error status. + """ + try: + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + # Try to extract error detail from response + try: + error_data = e.response.json() + error_msg = error_data.get("detail", str(e)) + except Exception: + error_msg = str(e) + + console.print(f"[bold red]Error:[/bold red] {error_msg}") + raise + + def list_servers(self) -> list[dict[str, Any]]: + """ + List all servers. + + Returns: + List of server dictionaries. + """ + response = self.client.get("/servers") + data = self._handle_response(response) + return data.get("servers", []) + + def get_server(self, server_id: int) -> dict[str, Any]: + """ + Get a specific server by ID. + + Args: + server_id: The server ID. + + Returns: + Server dictionary. + """ + response = self.client.get(f"/servers/{server_id}") + return self._handle_response(response) + + def create_server( + self, + name: str, + ip_address: str, + hostname: str, + state: str = "active", + ) -> dict[str, Any]: + """ + Create a new server. + + Args: + name: Server name. + ip_address: Server IP address (IPv4 or IPv6). + hostname: Server hostname (must be unique). + state: Server state (active, offline, or retired). + + Returns: + Response dictionary with status and message. + """ + data = { + "name": name, + "ip_address": ip_address, + "hostname": hostname, + "state": state, + } + response = self.client.post("/servers", json=data) + return self._handle_response(response) + + def update_server( + self, + server_id: int, + name: Optional[str] = None, + ip_address: Optional[str] = None, + hostname: Optional[str] = None, + state: Optional[str] = None, + ) -> dict[str, Any]: + """ + Update an existing server. + + Args: + server_id: The server ID. + name: New server name (optional). + ip_address: New IP address (optional). + hostname: New hostname (optional). + state: New state (optional). + + Returns: + Response dictionary with status and message. + """ + data = {} + if name is not None: + data["name"] = name + if ip_address is not None: + data["ip_address"] = ip_address + if hostname is not None: + data["hostname"] = hostname + if state is not None: + data["state"] = state + + response = self.client.put(f"/servers/{server_id}", json=data) + return self._handle_response(response) + + def delete_server(self, server_id: int) -> dict[str, Any]: + """ + Delete a server. + + Args: + server_id: The server ID. + + Returns: + Response dictionary with status and message. + """ + response = self.client.delete(f"/servers/{server_id}") + return self._handle_response(response) + + def health_check(self) -> bool: + """ + Check if the API is reachable. + + Returns: + True if API is healthy, False otherwise. + """ + try: + response = self.client.get("/") + response.raise_for_status() + return True + except Exception: + return False diff --git a/cli/utils/config.py b/cli/utils/config.py new file mode 100644 index 0000000..77bc4dd --- /dev/null +++ b/cli/utils/config.py @@ -0,0 +1,162 @@ +import json +import os +from pathlib import Path +from typing import Any, Optional + +from rich.console import Console + +console = Console() + + +class Config: + """Configuration manager for the CLI.""" + + def __init__(self, config_file: Optional[str] = None): + """ + Initialize configuration manager. + + Args: + config_file: Path to configuration file. Defaults to ~/.server-cli/config.json + """ + if config_file: + self.config_path = Path(config_file) + else: + # Default config location + config_dir = Path.home() / ".server-cli" + config_dir.mkdir(exist_ok=True) + self.config_path = config_dir / "config.json" + + self._config: dict[str, Any] = {} + self.load() + + def load(self) -> None: + """Load configuration from file.""" + if self.config_path.exists(): + try: + with open(self.config_path, "r") as f: + self._config = json.load(f) + except Exception as e: + console.print(f"[yellow]Warning: Could not load config: {e}[/yellow]") + self._config = {} + else: + # Initialize with defaults + self._config = self._get_defaults() + self.save() + + def save(self) -> None: + """Save configuration to file.""" + try: + # Ensure directory exists + self.config_path.parent.mkdir(parents=True, exist_ok=True) + + with open(self.config_path, "w") as f: + json.dump(self._config, f, indent=2) + except Exception as e: + console.print(f"[red]Error: Could not save config: {e}[/red]") + + def _get_defaults(self) -> dict[str, Any]: + """ + Get default configuration values. + + Returns: + Dictionary of default configuration values. + """ + return { + "api_url": os.getenv("SERVER_API_URL", "http://localhost:5001"), + "timeout": 30.0, + "output_format": "table", + "color": True, + "confirm_delete": True, + } + + def get(self, key: str, default: Any = None) -> Any: + """ + Get configuration value. + + Args: + key: Configuration key (supports dot notation for nested keys). + default: Default value if key not found. + + Returns: + Configuration value. + """ + # Support dot notation for nested keys + keys = key.split(".") + value = self._config + + for k in keys: + if isinstance(value, dict) and k in value: + value = value[k] + else: + return default + + return value + + def set(self, key: str, value: Any) -> None: + """ + Set configuration value. + + Args: + key: Configuration key (supports dot notation for nested keys). + value: Configuration value. + """ + # Support dot notation for nested keys + keys = key.split(".") + config = self._config + + # Navigate to the nested location + for k in keys[:-1]: + if k not in config: + config[k] = {} + config = config[k] + + # Set the value + config[keys[-1]] = value + self.save() + + def get_all(self) -> dict[str, Any]: + """ + Get all configuration values. + + Returns: + Dictionary of all configuration values. + """ + return self._config.copy() + + def reset(self) -> None: + """Reset configuration to defaults.""" + self._config = self._get_defaults() + self.save() + + def delete(self, key: str) -> bool: + """ + Delete a configuration key. + + Args: + key: Configuration key to delete. + + Returns: + True if key was deleted, False if not found. + """ + if key in self._config: + del self._config[key] + self.save() + return True + return False + + +# Global config instance +_config_instance: Optional[Config] = None + + +def get_config() -> Config: + """ + Get the global configuration instance. + + Returns: + Config instance. + """ + global _config_instance + if _config_instance is None: + _config_instance = Config() + return _config_instance diff --git a/cli/utils/output.py b/cli/utils/output.py new file mode 100644 index 0000000..50755dd --- /dev/null +++ b/cli/utils/output.py @@ -0,0 +1,201 @@ +import json +from typing import Any + +from rich.console import Console +from rich.panel import Panel +from rich.prompt import Confirm, Prompt +from rich.syntax import Syntax +from rich.table import Table + +console = Console() + + +def print_success(message: str) -> None: + """ + Print a success message. + + Args: + message: The success message to display. + """ + console.print(f"[bold green]✓[/bold green] {message}") + + +def print_error(message: str) -> None: + """ + Print an error message. + + Args: + message: The error message to display. + """ + console.print(f"[bold red]✗[/bold red] {message}") + + +def print_warning(message: str) -> None: + """ + Print a warning message. + + Args: + message: The warning message to display. + """ + console.print(f"[bold yellow]⚠[/bold yellow] {message}") + + +def print_info(message: str) -> None: + """ + Print an info message. + + Args: + message: The info message to display. + """ + console.print(f"[bold blue]ℹ[/bold blue] {message}") + + +def print_json(data: Any) -> None: + """ + Print data as formatted JSON. + + Args: + data: The data to display as JSON. + """ + json_str = json.dumps(data, indent=2, sort_keys=True) + syntax = Syntax(json_str, "json", theme="monokai", line_numbers=False) + console.print(syntax) + + +def print_servers_table(servers: list[dict[str, Any]]) -> None: + """ + Print servers in a formatted table. + + Args: + servers: List of server dictionaries. + """ + if not servers: + print_warning("No servers found.") + return + + table = Table( + title="Servers", + show_header=True, + header_style="bold magenta", + border_style="blue", + ) + + # Add columns + table.add_column("ID", style="cyan", justify="right") + table.add_column("Name", style="green") + table.add_column("Hostname", style="yellow") + table.add_column("IP Address", style="white") + table.add_column("State", style="blue") + + # Add rows + for server in servers: + state = server.get("state", "unknown") + # Color code states + if state == "active": + state_display = f"[green]{state}[/green]" + elif state == "offline": + state_display = f"[red]{state}[/red]" + elif state == "retired": + state_display = f"[yellow]{state}[/yellow]" + else: + state_display = state + + table.add_row( + str(server.get("id", "")), + server.get("name", ""), + server.get("hostname", ""), + server.get("ip_address", ""), + state_display, + ) + + console.print(table) + console.print(f"\n[dim]Total: {len(servers)} server(s)[/dim]") + + +def print_server_detail(server: dict[str, Any]) -> None: + """ + Print detailed server information in a panel. + + Args: + server: Server dictionary. + """ + state = server.get("state", "unknown") + + # Color code state + if state == "active": + state_color = "green" + elif state == "offline": + state_color = "red" + elif state == "retired": + state_color = "yellow" + else: + state_color = "white" + + content = f"""[bold]ID:[/bold] {server.get("id", "N/A")} + [bold]Name:[/bold] {server.get("name", "N/A")} + [bold]Hostname:[/bold] {server.get("hostname", "N/A")} + [bold]IP Address:[/bold] {server.get("ip_address", "N/A")} + [bold]State:[/bold] [{state_color}]{state}[/{state_color}]""" + + panel = Panel( + content, + title="[bold blue]Server Details[/bold blue]", + border_style="blue", + expand=False, + ) + + console.print(panel) + + +def format_output(data: Any, output_format: str = "table") -> None: + """ + Format and print output based on the specified format. + + Args: + data: The data to display. + output_format: Output format ('table', 'json', 'detail'). + """ + if output_format == "json": + print_json(data) + elif output_format == "table": + if isinstance(data, list): + print_servers_table(data) + elif isinstance(data, dict) and "servers" in data: + print_servers_table(data["servers"]) + else: + print_server_detail(data) + elif output_format == "detail": + if isinstance(data, dict): + print_server_detail(data) + else: + print_json(data) + else: + # Default to JSON + print_json(data) + + +def confirm_action(message: str) -> bool: + """ + Ask for user confirmation. + + Args: + message: The confirmation message. + + Returns: + True if user confirms, False otherwise. + """ + return Confirm.ask(message) + + +def prompt_for_input(message: str, default: str = "") -> str: + """ + Prompt user for input. + + Args: + message: The prompt message. + default: Default value if no input provided. + + Returns: + User input string. + """ + return Prompt.ask(message, default=default) diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..65889cd --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,44 @@ +services: + db: + image: postgres:17 + restart: on-failure + ports: + - "5432:5432" + environment: &db-variables + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-password} + POSTGRES_DB: ${POSTGRES_DB:-fastapi} + POSTGRES_HOST: ${POSTGRES_HOST:-db} + DATABASE_PORT: ${DATABASE_PORT:-5432} + volumes: + - postgres-db:/var/lib/postgresql/data + networks: + - app_network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $POSTGRES_USER -d $POSTGRES_DB"] + interval: 10s + timeout: 5s + retries: 5 + + app: + build: + context: . + dockerfile: Dockerfile + restart: on-failure + container_name: fastapi_app + ports: + - "5001:5000" + environment: + <<: + - *db-variables + depends_on: + - db + networks: + - app_network + +volumes: + postgres-db: + +networks: + app_network: + driver: bridge diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1e0e94b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,17 @@ +[project] +name = "hiring-challenge-devops-python" +version = "0.1.0" +description = "Server Management API and CLI" +readme = "README.md" +requires-python = ">=3.14.2" +dependencies = [ + "fastapi>=0.124.4", + "httpx>=0.27.0", + "psycopg2-binary>=2.9.11", + "pydantic-settings>=2.12.0", + "pytest>=9.0.2", + "rich>=13.9.0", + "sqlalchemy>=2.0.45", + "typer>=0.15.0", + "uvicorn>=0.38.0", +] diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..5ee6477 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +testpaths = tests diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..d4839a6 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# Tests package diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..f39c297 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,191 @@ +""" +Pytest configuration and fixtures for testing. +""" + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from app.database import get_db +from app.routers import servers_router + +# Use in-memory SQLite database for testing +SQLALCHEMY_DATABASE_URL = "sqlite:///:memory:" + +engine = create_engine( + SQLALCHEMY_DATABASE_URL, + connect_args={"check_same_thread": False}, + poolclass=StaticPool, +) + +TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +def init_test_db(): + """Initialize test database with schema.""" + with TestingSessionLocal() as db: + db.execute( + text(""" + CREATE TABLE IF NOT EXISTS servers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR(255) NOT NULL, + hostname VARCHAR(255) NOT NULL UNIQUE, + ip_address VARCHAR(45) NOT NULL, + state VARCHAR(20) NOT NULL CHECK (state IN ('active', 'offline', 'retired')), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + ) + db.commit() + + +def override_get_db(): + """Override database dependency for testing.""" + db = TestingSessionLocal() + try: + yield db + finally: + db.close() + + +@pytest.fixture(scope="function") +def db_session(): + """Create a fresh database session for each test.""" + # Create tables + init_test_db() + + db = TestingSessionLocal() + try: + yield db + finally: + db.close() + # Drop all tables after test + with engine.begin() as conn: + conn.execute(text("DROP TABLE IF EXISTS servers")) + + +@pytest.fixture(scope="function") +def client(db_session): + """Create a test client with overridden database dependency.""" + # Create a new FastAPI app without lifespan for testing + app = FastAPI(title="FastAPI Starter", version="1.0.0") + + # Include routers + app.include_router(servers_router, tags=["servers"]) + + # Add root endpoint + @app.get("/") + def read_root() -> dict[str, str]: + return {"message": "Welcome to FastAPI Starter!"} + + # Override database dependency + app.dependency_overrides[get_db] = override_get_db + + with TestClient(app) as test_client: + yield test_client + + app.dependency_overrides.clear() + + +@pytest.fixture +def sample_server_data(): + """Sample server data for testing.""" + return { + "name": "Test Server", + "ip_address": "192.168.1.100", + "hostname": "test-server.local", + "state": "active", + } + + +@pytest.fixture +def sample_server_data_2(): + """Second sample server data for testing.""" + return { + "name": "Test Server 2", + "ip_address": "192.168.1.101", + "hostname": "test-server-2.local", + "state": "offline", + } + + +@pytest.fixture +def sample_server_data_3(): + """Third sample server data for testing.""" + return { + "name": "Test Server 3", + "ip_address": "10.0.0.50", + "hostname": "prod-server.local", + "state": "retired", + } + + +@pytest.fixture +def create_test_server(db_session): + """Factory fixture to create test servers in the database.""" + + def _create_server( + name="Test Server", + ip_address="192.168.1.100", + hostname="test-server.local", + state="active", + ): + db_session.execute( + text( + "INSERT INTO servers (name, ip_address, hostname, state) VALUES (:name, :ip_address, :hostname, :state)" + ), + { + "name": name, + "ip_address": ip_address, + "hostname": hostname, + "state": state, + }, + ) + db_session.commit() + + # Get the created server's ID + result = db_session.execute(text("SELECT last_insert_rowid()")) + server_id = result.scalar() + return server_id + + return _create_server + + +@pytest.fixture +def populated_db(db_session, create_test_server): + """Database pre-populated with test servers.""" + server_ids = [] + + # Create multiple test servers + server_ids.append( + create_test_server( + name="Web Server", + ip_address="192.168.1.10", + hostname="web-01.example.com", + state="active", + ) + ) + + server_ids.append( + create_test_server( + name="Database Server", + ip_address="192.168.1.20", + hostname="db-01.example.com", + state="active", + ) + ) + + server_ids.append( + create_test_server( + name="Old Server", + ip_address="192.168.1.99", + hostname="legacy-01.example.com", + state="retired", + ) + ) + + return {"session": db_session, "server_ids": server_ids} diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..d8822cb --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,587 @@ +""" +Integration tests for API endpoints. +""" + + +class TestRootEndpoint: + """Tests for root endpoint.""" + + def test_root_endpoint(self, client): + """Test root endpoint returns welcome message.""" + response = client.get("/") + assert response.status_code == 200 + assert response.json() == {"message": "Welcome to FastAPI Starter!"} + + +class TestListServersEndpoint: + """Tests for GET /servers endpoint.""" + + def test_list_servers_empty(self, client): + """Test listing servers when database is empty.""" + response = client.get("/servers") + assert response.status_code == 200 + data = response.json() + assert "servers" in data + assert data["servers"] == [] + + def test_list_servers_single(self, client, sample_server_data): + """Test listing servers with one server.""" + # Create a server first + client.post("/servers", json=sample_server_data) + + # List servers + response = client.get("/servers") + assert response.status_code == 200 + data = response.json() + assert len(data["servers"]) == 1 + assert data["servers"][0]["name"] == sample_server_data["name"] + assert data["servers"][0]["ip_address"] == sample_server_data["ip_address"] + assert data["servers"][0]["hostname"] == sample_server_data["hostname"] + assert data["servers"][0]["state"] == sample_server_data["state"] + + def test_list_servers_multiple( + self, client, sample_server_data, sample_server_data_2, sample_server_data_3 + ): + """Test listing multiple servers.""" + # Create multiple servers + client.post("/servers", json=sample_server_data) + client.post("/servers", json=sample_server_data_2) + client.post("/servers", json=sample_server_data_3) + + # List servers + response = client.get("/servers") + assert response.status_code == 200 + data = response.json() + assert len(data["servers"]) == 3 + + hostnames = [server["hostname"] for server in data["servers"]] + assert sample_server_data["hostname"] in hostnames + assert sample_server_data_2["hostname"] in hostnames + assert sample_server_data_3["hostname"] in hostnames + + def test_list_servers_response_structure(self, client, sample_server_data): + """Test that response has correct structure.""" + client.post("/servers", json=sample_server_data) + + response = client.get("/servers") + assert response.status_code == 200 + data = response.json() + + # Check structure + assert "servers" in data + assert isinstance(data["servers"], list) + assert len(data["servers"]) > 0 + + server = data["servers"][0] + assert "id" in server + assert "name" in server + assert "ip_address" in server + assert "hostname" in server + assert "state" in server + + +class TestGetServerEndpoint: + """Tests for GET /servers/{server_id} endpoint.""" + + def test_get_server_success(self, client, sample_server_data): + """Test getting an existing server.""" + # Create a server + create_response = client.post("/servers", json=sample_server_data) + assert create_response.status_code == 201 + + # Get the first server (ID should be 1) + response = client.get("/servers/1") + assert response.status_code == 200 + data = response.json() + assert data["id"] == 1 + assert data["name"] == sample_server_data["name"] + assert data["ip_address"] == sample_server_data["ip_address"] + assert data["hostname"] == sample_server_data["hostname"] + assert data["state"] == sample_server_data["state"] + + def test_get_server_not_found(self, client): + """Test getting a server that doesn't exist.""" + response = client.get("/servers/999") + assert response.status_code == 404 + assert "not found" in response.json()["detail"].lower() + + def test_get_server_invalid_id_type(self, client): + """Test getting a server with invalid ID type.""" + response = client.get("/servers/invalid") + assert response.status_code == 422 # Validation error + + def test_get_server_correct_one( + self, client, sample_server_data, sample_server_data_2 + ): + """Test that correct server is returned when multiple exist.""" + client.post("/servers", json=sample_server_data) + client.post("/servers", json=sample_server_data_2) + + # Get second server + response = client.get("/servers/2") + assert response.status_code == 200 + data = response.json() + assert data["id"] == 2 + assert data["hostname"] == sample_server_data_2["hostname"] + + +class TestCreateServerEndpoint: + """Tests for POST /servers endpoint.""" + + def test_create_server_success(self, client, sample_server_data): + """Test creating a server successfully.""" + response = client.post("/servers", json=sample_server_data) + assert response.status_code == 201 + data = response.json() + assert data["status"] == 201 + assert "created" in data["message"].lower() + + def test_create_server_default_state(self, client): + """Test creating a server with default state.""" + server_data = { + "name": "Default Server", + "ip_address": "192.168.1.1", + "hostname": "default.local", + } + response = client.post("/servers", json=server_data) + assert response.status_code == 201 + + # Verify default state is active + get_response = client.get("/servers/1") + assert get_response.json()["state"] == "active" + + def test_create_server_all_states(self, client): + """Test creating servers with all valid states.""" + states = ["active", "offline", "retired"] + + for i, state in enumerate(states, 1): + server_data = { + "name": f"Server {i}", + "ip_address": f"192.168.1.{i}", + "hostname": f"server{i}.local", + "state": state, + } + response = client.post("/servers", json=server_data) + assert response.status_code == 201 + + # Verify state + get_response = client.get(f"/servers/{i}") + assert get_response.json()["state"] == state + + def test_create_server_invalid_ip(self, client): + """Test creating a server with invalid IP address.""" + server_data = { + "name": "Invalid IP", + "ip_address": "999.999.999.999", + "hostname": "invalid.local", + "state": "active", + } + response = client.post("/servers", json=server_data) + assert response.status_code == 422 # Validation error + + def test_create_server_invalid_state(self, client): + """Test creating a server with invalid state.""" + server_data = { + "name": "Invalid State", + "ip_address": "192.168.1.1", + "hostname": "invalid.local", + "state": "unknown", + } + response = client.post("/servers", json=server_data) + assert response.status_code == 422 # Validation error + + def test_create_server_missing_required_fields(self, client): + """Test creating a server with missing required fields.""" + server_data = {"name": "Incomplete Server"} + response = client.post("/servers", json=server_data) + assert response.status_code == 422 # Validation error + + def test_create_server_duplicate_hostname(self, client, sample_server_data): + """Test creating a server with duplicate hostname.""" + # Create first server + response1 = client.post("/servers", json=sample_server_data) + assert response1.status_code == 201 + + # Try to create second server with same hostname + duplicate_data = sample_server_data.copy() + duplicate_data["name"] = "Duplicate" + duplicate_data["ip_address"] = "10.0.0.1" + + response2 = client.post("/servers", json=duplicate_data) + assert response2.status_code == 400 + assert "already exists" in response2.json()["detail"].lower() + + def test_create_server_ipv6(self, client): + """Test creating a server with IPv6 address.""" + server_data = { + "name": "IPv6 Server", + "ip_address": "2001:db8::1", + "hostname": "ipv6.local", + "state": "active", + } + response = client.post("/servers", json=server_data) + assert response.status_code == 201 + + def test_create_server_localhost(self, client): + """Test creating a server with localhost IP.""" + server_data = { + "name": "Localhost", + "ip_address": "127.0.0.1", + "hostname": "localhost.local", + "state": "active", + } + response = client.post("/servers", json=server_data) + assert response.status_code == 201 + + +class TestUpdateServerEndpoint: + """Tests for PUT /servers/{server_id} endpoint.""" + + def test_update_server_name(self, client, sample_server_data): + """Test updating server name.""" + # Create server + client.post("/servers", json=sample_server_data) + + # Update name + update_data = {"name": "Updated Name"} + response = client.put("/servers/1", json=update_data) + assert response.status_code == 200 + data = response.json() + assert data["status"] == 200 + assert "updated" in data["message"].lower() + + # Verify update + get_response = client.get("/servers/1") + assert get_response.json()["name"] == "Updated Name" + assert get_response.json()["hostname"] == sample_server_data["hostname"] + + def test_update_server_ip_address(self, client, sample_server_data): + """Test updating server IP address.""" + client.post("/servers", json=sample_server_data) + + update_data = {"ip_address": "10.0.0.100"} + response = client.put("/servers/1", json=update_data) + assert response.status_code == 200 + + # Verify update + get_response = client.get("/servers/1") + assert get_response.json()["ip_address"] == "10.0.0.100" + + def test_update_server_hostname(self, client, sample_server_data): + """Test updating server hostname.""" + client.post("/servers", json=sample_server_data) + + update_data = {"hostname": "updated.local"} + response = client.put("/servers/1", json=update_data) + assert response.status_code == 200 + + # Verify update + get_response = client.get("/servers/1") + assert get_response.json()["hostname"] == "updated.local" + + def test_update_server_state(self, client, sample_server_data): + """Test updating server state.""" + client.post("/servers", json=sample_server_data) + + update_data = {"state": "offline"} + response = client.put("/servers/1", json=update_data) + assert response.status_code == 200 + + # Verify update + get_response = client.get("/servers/1") + assert get_response.json()["state"] == "offline" + + def test_update_server_multiple_fields(self, client, sample_server_data): + """Test updating multiple fields at once.""" + client.post("/servers", json=sample_server_data) + + update_data = { + "name": "Multi Update", + "ip_address": "172.16.0.1", + "state": "retired", + } + response = client.put("/servers/1", json=update_data) + assert response.status_code == 200 + + # Verify updates + get_response = client.get("/servers/1") + data = get_response.json() + assert data["name"] == "Multi Update" + assert data["ip_address"] == "172.16.0.1" + assert data["state"] == "retired" + assert data["hostname"] == sample_server_data["hostname"] # Unchanged + + def test_update_server_all_fields(self, client, sample_server_data): + """Test updating all fields.""" + client.post("/servers", json=sample_server_data) + + update_data = { + "name": "Completely Updated", + "ip_address": "10.10.10.10", + "hostname": "newhost.local", + "state": "offline", + } + response = client.put("/servers/1", json=update_data) + assert response.status_code == 200 + + # Verify all updates + get_response = client.get("/servers/1") + data = get_response.json() + assert data["name"] == "Completely Updated" + assert data["ip_address"] == "10.10.10.10" + assert data["hostname"] == "newhost.local" + assert data["state"] == "offline" + + def test_update_server_not_found(self, client): + """Test updating a server that doesn't exist.""" + update_data = {"name": "Should Fail"} + response = client.put("/servers/999", json=update_data) + assert response.status_code == 404 + assert "not found" in response.json()["detail"].lower() + + def test_update_server_empty_body(self, client, sample_server_data): + """Test updating with empty body.""" + client.post("/servers", json=sample_server_data) + + response = client.put("/servers/1", json={}) + assert response.status_code == 200 # Should succeed with no changes + + def test_update_server_invalid_ip(self, client, sample_server_data): + """Test updating with invalid IP address.""" + client.post("/servers", json=sample_server_data) + + update_data = {"ip_address": "invalid-ip"} + response = client.put("/servers/1", json=update_data) + assert response.status_code == 422 # Validation error + + def test_update_server_invalid_state(self, client, sample_server_data): + """Test updating with invalid state.""" + client.post("/servers", json=sample_server_data) + + update_data = {"state": "invalid"} + response = client.put("/servers/1", json=update_data) + assert response.status_code == 422 # Validation error + + def test_update_server_duplicate_hostname( + self, client, sample_server_data, sample_server_data_2 + ): + """Test updating to a hostname that already exists.""" + client.post("/servers", json=sample_server_data) + client.post("/servers", json=sample_server_data_2) + + # Try to update second server to use first server's hostname + update_data = {"hostname": sample_server_data["hostname"]} + response = client.put("/servers/2", json=update_data) + assert response.status_code == 400 + assert "already exists" in response.json()["detail"].lower() + + +class TestDeleteServerEndpoint: + """Tests for DELETE /servers/{server_id} endpoint.""" + + def test_delete_server_success(self, client, sample_server_data): + """Test deleting a server successfully.""" + # Create server + client.post("/servers", json=sample_server_data) + + # Delete server + response = client.delete("/servers/1") + assert response.status_code == 200 + data = response.json() + assert data["status"] == 200 + assert "deleted" in data["message"].lower() + + # Verify server is deleted + get_response = client.get("/servers/1") + assert get_response.status_code == 404 + + def test_delete_server_not_found(self, client): + """Test deleting a server that doesn't exist.""" + response = client.delete("/servers/999") + assert response.status_code == 404 + assert "not found" in response.json()["detail"].lower() + + def test_delete_server_correct_one( + self, client, sample_server_data, sample_server_data_2, sample_server_data_3 + ): + """Test that only the specified server is deleted.""" + # Create three servers + client.post("/servers", json=sample_server_data) + client.post("/servers", json=sample_server_data_2) + client.post("/servers", json=sample_server_data_3) + + # Delete second server + response = client.delete("/servers/2") + assert response.status_code == 200 + + # Verify first server still exists + assert client.get("/servers/1").status_code == 200 + + # Verify second server is deleted + assert client.get("/servers/2").status_code == 404 + + # Verify third server still exists + assert client.get("/servers/3").status_code == 200 + + def test_delete_server_count_decreases(self, client, sample_server_data): + """Test that server count decreases after deletion.""" + # Create multiple servers + client.post("/servers", json=sample_server_data) + sample_server_data_2 = sample_server_data.copy() + sample_server_data_2["hostname"] = "server2.local" + client.post("/servers", json=sample_server_data_2) + + # Check count before + list_response = client.get("/servers") + count_before = len(list_response.json()["servers"]) + + # Delete one server + client.delete("/servers/1") + + # Check count after + list_response = client.get("/servers") + count_after = len(list_response.json()["servers"]) + + assert count_after == count_before - 1 + + def test_delete_server_invalid_id_type(self, client): + """Test deleting with invalid ID type.""" + response = client.delete("/servers/invalid") + assert response.status_code == 422 # Validation error + + +class TestEndToEndWorkflows: + """End-to-end workflow tests.""" + + def test_full_crud_workflow(self, client): + """Test complete CRUD workflow for a server.""" + # Create + create_data = { + "name": "Workflow Server", + "ip_address": "192.168.100.1", + "hostname": "workflow.local", + "state": "active", + } + create_response = client.post("/servers", json=create_data) + assert create_response.status_code == 201 + + # Read (Get) + get_response = client.get("/servers/1") + assert get_response.status_code == 200 + assert get_response.json()["name"] == "Workflow Server" + + # Update + update_data = {"state": "offline", "name": "Updated Workflow"} + update_response = client.put("/servers/1", json=update_data) + assert update_response.status_code == 200 + + # Read (Verify Update) + get_response = client.get("/servers/1") + assert get_response.json()["name"] == "Updated Workflow" + assert get_response.json()["state"] == "offline" + + # Delete + delete_response = client.delete("/servers/1") + assert delete_response.status_code == 200 + + # Verify Deletion + get_response = client.get("/servers/1") + assert get_response.status_code == 404 + + def test_multiple_servers_lifecycle(self, client): + """Test managing multiple servers through their lifecycle.""" + # Create three servers + for i in range(1, 4): + server_data = { + "name": f"Server {i}", + "ip_address": f"192.168.1.{i}", + "hostname": f"server{i}.local", + "state": "active", + } + response = client.post("/servers", json=server_data) + assert response.status_code == 201 + + # List all servers + list_response = client.get("/servers") + assert len(list_response.json()["servers"]) == 3 + + # Update states + client.put("/servers/1", json={"state": "offline"}) + client.put("/servers/3", json={"state": "retired"}) + + # Verify updates + assert client.get("/servers/1").json()["state"] == "offline" + assert client.get("/servers/2").json()["state"] == "active" + assert client.get("/servers/3").json()["state"] == "retired" + + # Delete one server + client.delete("/servers/2") + + # Verify remaining servers + list_response = client.get("/servers") + assert len(list_response.json()["servers"]) == 2 + + def test_state_transitions(self, client, sample_server_data): + """Test server state transitions.""" + # Create server with active state + client.post("/servers", json=sample_server_data) + + # Transition: active -> offline + client.put("/servers/1", json={"state": "offline"}) + assert client.get("/servers/1").json()["state"] == "offline" + + # Transition: offline -> retired + client.put("/servers/1", json={"state": "retired"}) + assert client.get("/servers/1").json()["state"] == "retired" + + # Transition: retired -> active (reactivation) + client.put("/servers/1", json={"state": "active"}) + assert client.get("/servers/1").json()["state"] == "active" + + +class TestEdgeCases: + """Tests for edge cases and boundary conditions.""" + + def test_special_characters_in_name(self, client): + """Test server name with special characters.""" + server_data = { + "name": "Server-Test_01 (Production)", + "ip_address": "192.168.1.1", + "hostname": "special.local", + "state": "active", + } + response = client.post("/servers", json=server_data) + assert response.status_code == 201 + + get_response = client.get("/servers/1") + assert get_response.json()["name"] == "Server-Test_01 (Production)" + + def test_very_long_server_name(self, client): + """Test server with very long name.""" + long_name = "A" * 250 + server_data = { + "name": long_name, + "ip_address": "192.168.1.1", + "hostname": "long.local", + "state": "active", + } + response = client.post("/servers", json=server_data) + assert response.status_code == 201 + + def test_ipv4_edge_cases(self, client): + """Test edge case IPv4 addresses.""" + edge_cases = [ + ("0.0.0.0", "zero.local"), + ("255.255.255.255", "broadcast.local"), + ("127.0.0.1", "localhost.local"), + ] + + for i, (ip, hostname) in enumerate(edge_cases, 1): + server_data = { + "name": f"Edge Case {i}", + "ip_address": ip, + "hostname": hostname, + "state": "active", + } + response = client.post("/servers", json=server_data) + assert response.status_code == 201 diff --git a/tests/test_controller.py b/tests/test_controller.py new file mode 100644 index 0000000..df9b63d --- /dev/null +++ b/tests/test_controller.py @@ -0,0 +1,474 @@ +""" +Tests for ServerController methods. +""" + +import pytest +from sqlalchemy import text + +from app.controller import ServerController +from app.schemas import ServerListResponse, ServerResponse + + +class TestListServers: + """Tests for ServerController.list_servers method.""" + + def test_list_servers_empty(self, db_session): + """Test listing servers when database is empty.""" + result = ServerController.list_servers(db_session) + assert isinstance(result, ServerListResponse) + assert len(result.servers) == 0 + assert result.servers == [] + + def test_list_servers_single(self, db_session, create_test_server): + """Test listing servers with one server in database.""" + create_test_server( + name="Test Server", + ip_address="192.168.1.100", + hostname="test.local", + state="active", + ) + + result = ServerController.list_servers(db_session) + assert isinstance(result, ServerListResponse) + assert len(result.servers) == 1 + assert result.servers[0].name == "Test Server" + assert result.servers[0].ip_address == "192.168.1.100" + assert result.servers[0].hostname == "test.local" + assert result.servers[0].state == "active" + + def test_list_servers_multiple(self, db_session, populated_db): + """Test listing servers with multiple servers in database.""" + result = ServerController.list_servers(db_session) + assert isinstance(result, ServerListResponse) + assert len(result.servers) == 3 + + # Verify all servers are returned + hostnames = [server.hostname for server in result.servers] + assert "web-01.example.com" in hostnames + assert "db-01.example.com" in hostnames + assert "legacy-01.example.com" in hostnames + + def test_list_servers_all_states(self, db_session, populated_db): + """Test that servers with all states are returned.""" + result = ServerController.list_servers(db_session) + states = [server.state for server in result.servers] + assert "active" in states + assert "retired" in states + + +class TestGetServer: + """Tests for ServerController.get_server method.""" + + def test_get_server_exists(self, db_session, create_test_server): + """Test getting a server that exists.""" + server_id = create_test_server( + name="Test Server", + ip_address="192.168.1.100", + hostname="test.local", + state="active", + ) + + result = ServerController.get_server(db_session, server_id) + assert result is not None + assert isinstance(result, ServerResponse) + assert result.id == server_id + assert result.name == "Test Server" + assert result.ip_address == "192.168.1.100" + assert result.hostname == "test.local" + assert result.state == "active" + + def test_get_server_not_exists(self, db_session): + """Test getting a server that doesn't exist.""" + result = ServerController.get_server(db_session, 999) + assert result is None + + def test_get_server_negative_id(self, db_session): + """Test getting a server with negative ID.""" + result = ServerController.get_server(db_session, -1) + assert result is None + + def test_get_server_zero_id(self, db_session): + """Test getting a server with zero ID.""" + result = ServerController.get_server(db_session, 0) + assert result is None + + def test_get_server_correct_one(self, db_session, populated_db): + """Test that get_server returns the correct server from multiple.""" + server_ids = populated_db["server_ids"] + + result = ServerController.get_server(db_session, server_ids[1]) + assert result is not None + assert result.hostname == "db-01.example.com" + + +class TestCreateServer: + """Tests for ServerController.create_server method.""" + + def test_create_server_success(self, db_session): + """Test creating a server successfully.""" + ServerController.create_server( + db_session, + name="New Server", + ip_address="10.0.0.1", + hostname="new.local", + state="active", + ) + + # Verify server was created + result = db_session.execute( + text("SELECT * FROM servers WHERE hostname = 'new.local'") + ) + server = result.fetchone() + assert server is not None + assert server.name == "New Server" + assert server.ip_address == "10.0.0.1" + assert server.hostname == "new.local" + assert server.state == "active" + + def test_create_server_default_state(self, db_session): + """Test creating a server with default state.""" + ServerController.create_server( + db_session, + name="Default State Server", + ip_address="10.0.0.2", + hostname="default.local", + ) + + result = db_session.execute( + text("SELECT * FROM servers WHERE hostname = 'default.local'") + ) + server = result.fetchone() + assert server is not None + assert server.state == "active" + + def test_create_server_offline_state(self, db_session): + """Test creating a server with offline state.""" + ServerController.create_server( + db_session, + name="Offline Server", + ip_address="10.0.0.3", + hostname="offline.local", + state="offline", + ) + + result = db_session.execute( + text("SELECT * FROM servers WHERE hostname = 'offline.local'") + ) + server = result.fetchone() + assert server.state == "offline" + + def test_create_server_retired_state(self, db_session): + """Test creating a server with retired state.""" + ServerController.create_server( + db_session, + name="Retired Server", + ip_address="10.0.0.4", + hostname="retired.local", + state="retired", + ) + + result = db_session.execute( + text("SELECT * FROM servers WHERE hostname = 'retired.local'") + ) + server = result.fetchone() + assert server.state == "retired" + + def test_create_server_duplicate_hostname(self, db_session, create_test_server): + """Test creating a server with duplicate hostname raises error.""" + create_test_server(hostname="duplicate.local") + + with pytest.raises(ValueError) as exc_info: + ServerController.create_server( + db_session, + name="Duplicate", + ip_address="10.0.0.5", + hostname="duplicate.local", + state="active", + ) + assert "duplicate.local" in str(exc_info.value) + assert "already exists" in str(exc_info.value) + + def test_create_server_rollback_on_error(self, db_session, create_test_server): + """Test that database is rolled back on error.""" + create_test_server(hostname="existing.local") + + # Count servers before failed insert + result = db_session.execute(text("SELECT COUNT(*) FROM servers")) + count_before = result.scalar() + + try: + ServerController.create_server( + db_session, + name="Should Fail", + ip_address="10.0.0.6", + hostname="existing.local", + state="active", + ) + except ValueError: + pass + + # Verify count is same after failed insert + result = db_session.execute(text("SELECT COUNT(*) FROM servers")) + count_after = result.scalar() + assert count_before == count_after + + +class TestUpdateServer: + """Tests for ServerController.update_server method.""" + + def test_update_server_name(self, db_session, create_test_server): + """Test updating only server name.""" + server_id = create_test_server(name="Old Name") + + ServerController.update_server(db_session, server_id, name="New Name") + + result = db_session.execute( + text("SELECT * FROM servers WHERE id = :id"), {"id": server_id} + ) + server = result.fetchone() + assert server.name == "New Name" + + def test_update_server_ip_address(self, db_session, create_test_server): + """Test updating only IP address.""" + server_id = create_test_server(ip_address="192.168.1.1") + + ServerController.update_server(db_session, server_id, ip_address="10.0.0.1") + + result = db_session.execute( + text("SELECT * FROM servers WHERE id = :id"), {"id": server_id} + ) + server = result.fetchone() + assert server.ip_address == "10.0.0.1" + + def test_update_server_hostname(self, db_session, create_test_server): + """Test updating only hostname.""" + server_id = create_test_server(hostname="old.local") + + ServerController.update_server(db_session, server_id, hostname="new.local") + + result = db_session.execute( + text("SELECT * FROM servers WHERE id = :id"), {"id": server_id} + ) + server = result.fetchone() + assert server.hostname == "new.local" + + def test_update_server_state(self, db_session, create_test_server): + """Test updating only server state.""" + server_id = create_test_server(state="active") + + ServerController.update_server(db_session, server_id, state="offline") + + result = db_session.execute( + text("SELECT * FROM servers WHERE id = :id"), {"id": server_id} + ) + server = result.fetchone() + assert server.state == "offline" + + def test_update_server_multiple_fields(self, db_session, create_test_server): + """Test updating multiple fields at once.""" + server_id = create_test_server( + name="Old", + ip_address="192.168.1.1", + hostname="old.local", + state="active", + ) + + ServerController.update_server( + db_session, + server_id, + name="New", + ip_address="10.0.0.1", + state="offline", + ) + + result = db_session.execute( + text("SELECT * FROM servers WHERE id = :id"), {"id": server_id} + ) + server = result.fetchone() + assert server.name == "New" + assert server.ip_address == "10.0.0.1" + assert server.hostname == "old.local" # Unchanged + assert server.state == "offline" + + def test_update_server_all_fields(self, db_session, create_test_server): + """Test updating all fields.""" + server_id = create_test_server() + + ServerController.update_server( + db_session, + server_id, + name="Fully Updated", + ip_address="172.16.0.1", + hostname="updated.local", + state="retired", + ) + + result = db_session.execute( + text("SELECT * FROM servers WHERE id = :id"), {"id": server_id} + ) + server = result.fetchone() + assert server.name == "Fully Updated" + assert server.ip_address == "172.16.0.1" + assert server.hostname == "updated.local" + assert server.state == "retired" + + def test_update_server_not_exists(self, db_session): + """Test updating a server that doesn't exist.""" + with pytest.raises(ValueError) as exc_info: + ServerController.update_server(db_session, 999, name="Should Fail") + assert "not found" in str(exc_info.value).lower() + + def test_update_server_no_fields(self, db_session, create_test_server): + """Test updating with no fields provided.""" + server_id = create_test_server(name="Unchanged") + + # Should not raise an error + ServerController.update_server(db_session, server_id) + + # Verify nothing changed + result = db_session.execute( + text("SELECT * FROM servers WHERE id = :id"), {"id": server_id} + ) + server = result.fetchone() + assert server.name == "Unchanged" + + def test_update_server_duplicate_hostname(self, db_session, create_test_server): + """Test updating to a hostname that already exists.""" + server_id_1 = create_test_server(hostname="first.local") + server_id_2 = create_test_server(hostname="second.local") + + with pytest.raises(ValueError) as exc_info: + ServerController.update_server( + db_session, server_id_2, hostname="first.local" + ) + assert "already exists" in str(exc_info.value).lower() + + def test_update_server_same_hostname(self, db_session, create_test_server): + """Test updating a server with its own hostname (should succeed).""" + server_id = create_test_server(hostname="same.local", name="Old Name") + + # This should not raise an error in SQLite (would in PostgreSQL with proper implementation) + ServerController.update_server( + db_session, + server_id, + name="New Name", + hostname="same.local", + ) + + result = db_session.execute( + text("SELECT * FROM servers WHERE id = :id"), {"id": server_id} + ) + server = result.fetchone() + assert server.name == "New Name" + + def test_update_server_rollback_on_error(self, db_session, create_test_server): + """Test that database is rolled back on update error.""" + server_id_1 = create_test_server(hostname="taken.local") + server_id_2 = create_test_server(hostname="target.local", name="Original") + + try: + ServerController.update_server( + db_session, server_id_2, hostname="taken.local" + ) + except ValueError: + pass + + # Verify original data is unchanged + result = db_session.execute( + text("SELECT * FROM servers WHERE id = :id"), {"id": server_id_2} + ) + server = result.fetchone() + assert server.hostname == "target.local" + assert server.name == "Original" + + +class TestDeleteServer: + """Tests for ServerController.delete_server method.""" + + def test_delete_server_success(self, db_session, create_test_server): + """Test deleting a server successfully.""" + server_id = create_test_server(hostname="to-delete.local") + + ServerController.delete_server(db_session, server_id) + + # Verify server was deleted + result = db_session.execute( + text("SELECT * FROM servers WHERE id = :id"), {"id": server_id} + ) + server = result.fetchone() + assert server is None + + def test_delete_server_not_exists(self, db_session): + """Test deleting a server that doesn't exist.""" + with pytest.raises(ValueError) as exc_info: + ServerController.delete_server(db_session, 999) + assert "not found" in str(exc_info.value).lower() + + def test_delete_server_correct_one(self, db_session, populated_db): + """Test that only the specified server is deleted.""" + server_ids = populated_db["server_ids"] + + # Delete the second server + ServerController.delete_server(db_session, server_ids[1]) + + # Verify first server still exists + result = db_session.execute( + text("SELECT * FROM servers WHERE id = :id"), {"id": server_ids[0]} + ) + assert result.fetchone() is not None + + # Verify second server is deleted + result = db_session.execute( + text("SELECT * FROM servers WHERE id = :id"), {"id": server_ids[1]} + ) + assert result.fetchone() is None + + # Verify third server still exists + result = db_session.execute( + text("SELECT * FROM servers WHERE id = :id"), {"id": server_ids[2]} + ) + assert result.fetchone() is not None + + def test_delete_server_count(self, db_session, populated_db): + """Test that server count decreases after deletion.""" + server_ids = populated_db["server_ids"] + + result = db_session.execute(text("SELECT COUNT(*) FROM servers")) + count_before = result.scalar() + + ServerController.delete_server(db_session, server_ids[0]) + + result = db_session.execute(text("SELECT COUNT(*) FROM servers")) + count_after = result.scalar() + + assert count_after == count_before - 1 + + def test_delete_all_servers(self, db_session, populated_db): + """Test deleting all servers one by one.""" + server_ids = populated_db["server_ids"] + + for server_id in server_ids: + ServerController.delete_server(db_session, server_id) + + result = db_session.execute(text("SELECT COUNT(*) FROM servers")) + count = result.scalar() + assert count == 0 + + def test_delete_server_rollback_on_error(self, db_session, create_test_server): + """Test that database is rolled back on delete error.""" + create_test_server() + + # Count before + result = db_session.execute(text("SELECT COUNT(*) FROM servers")) + count_before = result.scalar() + + try: + # Try to delete non-existent server + ServerController.delete_server(db_session, 999) + except ValueError: + pass + + # Verify count is unchanged + result = db_session.execute(text("SELECT COUNT(*) FROM servers")) + count_after = result.scalar() + assert count_before == count_after diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..31e2989 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,306 @@ +""" +Tests for main application endpoints and configuration. +""" + + +class TestMainApplication: + """Tests for main application setup and configuration.""" + + def test_app_title(self, client): + """Test that application has correct title.""" + response = client.get("/openapi.json") + assert response.status_code == 200 + openapi_schema = response.json() + assert openapi_schema["info"]["title"] == "FastAPI Starter" + + def test_app_version(self, client): + """Test that application has correct version.""" + response = client.get("/openapi.json") + assert response.status_code == 200 + openapi_schema = response.json() + assert openapi_schema["info"]["version"] == "1.0.0" + + def test_app_description(self, client): + """Test that application has description.""" + response = client.get("/openapi.json") + assert response.status_code == 200 + openapi_schema = response.json() + # Test app doesn't have custom description + assert "title" in openapi_schema["info"] + assert openapi_schema["info"]["title"] == "FastAPI Starter" + + +class TestRootEndpoint: + """Tests for root '/' endpoint.""" + + def test_root_returns_200(self, client): + """Test that root endpoint returns 200 status.""" + response = client.get("/") + assert response.status_code == 200 + + def test_root_returns_json(self, client): + """Test that root endpoint returns JSON.""" + response = client.get("/") + assert response.headers["content-type"] == "application/json" + + def test_root_message_content(self, client): + """Test root endpoint message content.""" + response = client.get("/") + data = response.json() + assert "message" in data + assert data["message"] == "Welcome to FastAPI Starter!" + + def test_root_response_structure(self, client): + """Test root endpoint response structure.""" + response = client.get("/") + data = response.json() + assert isinstance(data, dict) + assert len(data) == 1 + assert list(data.keys()) == ["message"] + + +class TestOpenAPIDocumentation: + """Tests for OpenAPI documentation endpoints.""" + + def test_openapi_json_accessible(self, client): + """Test that OpenAPI JSON is accessible.""" + response = client.get("/openapi.json") + assert response.status_code == 200 + + def test_openapi_json_is_valid(self, client): + """Test that OpenAPI JSON is valid JSON.""" + response = client.get("/openapi.json") + data = response.json() + assert isinstance(data, dict) + assert "openapi" in data + assert "info" in data + assert "paths" in data + + def test_openapi_contains_servers_endpoints(self, client): + """Test that OpenAPI schema contains servers endpoints.""" + response = client.get("/openapi.json") + data = response.json() + paths = data["paths"] + + # Check that all server endpoints are documented + assert "/servers" in paths + assert "/servers/{server_id}" in paths + + def test_openapi_servers_get_endpoint(self, client): + """Test OpenAPI documentation for GET /servers.""" + response = client.get("/openapi.json") + data = response.json() + + servers_path = data["paths"]["/servers"] + assert "get" in servers_path + assert servers_path["get"]["responses"]["200"] + + def test_openapi_servers_post_endpoint(self, client): + """Test OpenAPI documentation for POST /servers.""" + response = client.get("/openapi.json") + data = response.json() + + servers_path = data["paths"]["/servers"] + assert "post" in servers_path + assert servers_path["post"]["responses"]["201"] + + def test_openapi_server_get_by_id_endpoint(self, client): + """Test OpenAPI documentation for GET /servers/{server_id}.""" + response = client.get("/openapi.json") + data = response.json() + + server_path = data["paths"]["/servers/{server_id}"] + assert "get" in server_path + assert server_path["get"]["responses"]["200"] + + def test_openapi_server_put_endpoint(self, client): + """Test OpenAPI documentation for PUT /servers/{server_id}.""" + response = client.get("/openapi.json") + data = response.json() + + server_path = data["paths"]["/servers/{server_id}"] + assert "put" in server_path + assert server_path["put"]["responses"]["200"] + + def test_openapi_server_delete_endpoint(self, client): + """Test OpenAPI documentation for DELETE /servers/{server_id}.""" + response = client.get("/openapi.json") + data = response.json() + + server_path = data["paths"]["/servers/{server_id}"] + assert "delete" in server_path + assert server_path["delete"]["responses"]["200"] + + def test_openapi_has_schemas(self, client): + """Test that OpenAPI includes schema definitions.""" + response = client.get("/openapi.json") + data = response.json() + + assert "components" in data + assert "schemas" in data["components"] + schemas = data["components"]["schemas"] + + # Check for important schemas + assert "ServerResponse" in schemas + assert "ServerCreateRequest" in schemas + assert "ServerUpdateRequest" in schemas + assert "StatusResponse" in schemas + assert "ServerListResponse" in schemas + + def test_openapi_tags(self, client): + """Test that endpoints are properly tagged.""" + response = client.get("/openapi.json") + data = response.json() + + # Check servers endpoints have correct tags + assert "servers" in data["paths"]["/servers"]["get"]["tags"] + assert "servers" in data["paths"]["/servers"]["post"]["tags"] + + +class TestDocsEndpoint: + """Tests for Swagger UI documentation endpoint.""" + + def test_docs_accessible(self, client): + """Test that /docs endpoint is accessible.""" + response = client.get("/docs") + assert response.status_code == 200 + + def test_docs_returns_html(self, client): + """Test that /docs returns HTML.""" + response = client.get("/docs") + assert "text/html" in response.headers["content-type"] + + def test_docs_contains_swagger_ui(self, client): + """Test that /docs contains Swagger UI.""" + response = client.get("/docs") + assert b"swagger-ui" in response.content.lower() + + +class TestRedocEndpoint: + """Tests for ReDoc documentation endpoint.""" + + def test_redoc_accessible(self, client): + """Test that /redoc endpoint is accessible.""" + response = client.get("/redoc") + assert response.status_code == 200 + + def test_redoc_returns_html(self, client): + """Test that /redoc returns HTML.""" + response = client.get("/redoc") + assert "text/html" in response.headers["content-type"] + + def test_redoc_contains_redoc(self, client): + """Test that /redoc contains ReDoc.""" + response = client.get("/redoc") + assert b"redoc" in response.content.lower() + + +class TestInvalidEndpoints: + """Tests for invalid/non-existent endpoints.""" + + def test_invalid_endpoint_returns_404(self, client): + """Test that invalid endpoint returns 404.""" + response = client.get("/invalid-endpoint") + assert response.status_code == 404 + + def test_invalid_nested_endpoint(self, client): + """Test that invalid nested endpoint returns 404.""" + response = client.get("/api/v1/invalid") + assert response.status_code == 404 + + def test_trailing_slash_handling(self, client): + """Test handling of trailing slashes.""" + # FastAPI redirects trailing slashes + response = client.get("/servers/", follow_redirects=False) + # Should either work or redirect + assert response.status_code in [200, 307, 308] + + +class TestHTTPMethods: + """Tests for HTTP method handling.""" + + def test_root_only_allows_get(self, client): + """Test that root endpoint only allows GET.""" + # POST should not be allowed + response = client.post("/") + assert response.status_code == 405 # Method Not Allowed + + def test_servers_allows_get_and_post(self, client): + """Test that /servers allows GET and POST.""" + # GET should work + get_response = client.get("/servers") + assert get_response.status_code == 200 + + # POST should work (with valid data) + post_data = { + "name": "Test", + "ip_address": "192.168.1.1", + "hostname": "test.local", + "state": "active", + } + post_response = client.post("/servers", json=post_data) + assert post_response.status_code == 201 + + def test_servers_does_not_allow_put(self, client): + """Test that /servers does not allow PUT.""" + response = client.put("/servers", json={}) + assert response.status_code == 405 # Method Not Allowed + + def test_servers_does_not_allow_delete(self, client): + """Test that /servers does not allow DELETE.""" + response = client.delete("/servers") + assert response.status_code == 405 # Method Not Allowed + + def test_server_by_id_allows_get_put_delete(self, client, sample_server_data): + """Test that /servers/{id} allows GET, PUT, DELETE.""" + # Create a server first + client.post("/servers", json=sample_server_data) + + # GET should work + get_response = client.get("/servers/1") + assert get_response.status_code == 200 + + # PUT should work + put_response = client.put("/servers/1", json={"name": "Updated"}) + assert put_response.status_code == 200 + + # DELETE should work + delete_response = client.delete("/servers/1") + assert delete_response.status_code == 200 + + def test_server_by_id_does_not_allow_post(self, client): + """Test that /servers/{id} does not allow POST.""" + response = client.post("/servers/1", json={}) + assert response.status_code == 405 # Method Not Allowed + + +class TestCORS: + """Tests for CORS configuration (if applicable).""" + + def test_options_request(self, client): + """Test OPTIONS request handling.""" + response = client.options("/servers") + # Should return allowed methods or 200/204 + assert response.status_code in [200, 204, 405] + + +class TestContentNegotiation: + """Tests for content negotiation.""" + + def test_json_content_type_required(self, client): + """Test that JSON content type is expected for POST.""" + # Try to post with wrong content type + response = client.post( + "/servers", + data="not json", + headers={"Content-Type": "text/plain"}, + ) + assert response.status_code in [422, 400] + + def test_accepts_json_response(self, client): + """Test that responses are in JSON format.""" + response = client.get("/") + assert "application/json" in response.headers["content-type"] + + response = client.get("/servers") + assert "application/json" in response.headers["content-type"] diff --git a/tests/test_schemas.py b/tests/test_schemas.py new file mode 100644 index 0000000..7421008 --- /dev/null +++ b/tests/test_schemas.py @@ -0,0 +1,336 @@ +""" +Tests for Pydantic schemas and validation. +""" + +import pytest +from pydantic import ValidationError + +from app.schemas import ( + ServerCreateRequest, + ServerListResponse, + ServerResponse, + ServerUpdateRequest, + StatusResponse, +) + + +class TestServerResponse: + """Tests for ServerResponse schema.""" + + def test_server_response_valid(self): + """Test creating a valid ServerResponse.""" + server = ServerResponse( + id=1, + name="Test Server", + ip_address="192.168.1.100", + hostname="test-server.local", + state="active", + ) + assert server.id == 1 + assert server.name == "Test Server" + assert server.ip_address == "192.168.1.100" + assert server.hostname == "test-server.local" + assert server.state == "active" + + def test_server_response_missing_fields(self): + """Test ServerResponse with missing required fields.""" + with pytest.raises(ValidationError) as exc_info: + ServerResponse( + id=1, + name="Test Server", + ip_address="192.168.1.100", + # Missing hostname and state + ) + errors = exc_info.value.errors() + assert len(errors) == 2 + assert any(e["loc"] == ("hostname",) for e in errors) + assert any(e["loc"] == ("state",) for e in errors) + + +class TestServerListResponse: + """Tests for ServerListResponse schema.""" + + def test_server_list_response_empty(self): + """Test ServerListResponse with empty list.""" + response = ServerListResponse(servers=[]) + assert response.servers == [] + + def test_server_list_response_with_servers(self): + """Test ServerListResponse with multiple servers.""" + servers = [ + ServerResponse( + id=1, + name="Server 1", + ip_address="192.168.1.1", + hostname="server1.local", + state="active", + ), + ServerResponse( + id=2, + name="Server 2", + ip_address="192.168.1.2", + hostname="server2.local", + state="offline", + ), + ] + response = ServerListResponse(servers=servers) + assert len(response.servers) == 2 + assert response.servers[0].id == 1 + assert response.servers[1].id == 2 + + +class TestServerCreateRequest: + """Tests for ServerCreateRequest schema.""" + + def test_create_request_valid_ipv4(self): + """Test creating a server with valid IPv4 address.""" + request = ServerCreateRequest( + name="Test Server", + ip_address="192.168.1.100", + hostname="test.local", + state="active", + ) + assert request.name == "Test Server" + assert request.ip_address == "192.168.1.100" + assert request.hostname == "test.local" + assert request.state == "active" + + def test_create_request_valid_ipv6(self): + """Test creating a server with valid IPv6 address.""" + request = ServerCreateRequest( + name="Test Server", + ip_address="2001:0db8:85a3:0000:0000:8a2e:0370:7334", + hostname="test.local", + state="active", + ) + assert request.ip_address == "2001:0db8:85a3:0000:0000:8a2e:0370:7334" + + def test_create_request_valid_ipv6_compressed(self): + """Test creating a server with compressed IPv6 address.""" + request = ServerCreateRequest( + name="Test Server", + ip_address="2001:db8::1", + hostname="test.local", + state="active", + ) + assert request.ip_address == "2001:db8::1" + + def test_create_request_invalid_ip_address(self): + """Test creating a server with invalid IP address.""" + with pytest.raises(ValidationError) as exc_info: + ServerCreateRequest( + name="Test Server", + ip_address="999.999.999.999", + hostname="test.local", + state="active", + ) + errors = exc_info.value.errors() + assert len(errors) == 1 + assert "not a valid IP address" in str(errors[0]["ctx"]["error"]) + + def test_create_request_invalid_ip_format(self): + """Test creating a server with malformed IP address.""" + with pytest.raises(ValidationError) as exc_info: + ServerCreateRequest( + name="Test Server", + ip_address="not-an-ip", + hostname="test.local", + state="active", + ) + errors = exc_info.value.errors() + assert len(errors) == 1 + assert "not a valid IP address" in str(errors[0]["ctx"]["error"]) + + def test_create_request_default_state(self): + """Test that default state is 'active'.""" + request = ServerCreateRequest( + name="Test Server", + ip_address="192.168.1.100", + hostname="test.local", + ) + assert request.state == "active" + + def test_create_request_valid_states(self): + """Test all valid server states.""" + for state in ["active", "offline", "retired"]: + request = ServerCreateRequest( + name="Test Server", + ip_address="192.168.1.100", + hostname="test.local", + state=state, + ) + assert request.state == state + + def test_create_request_invalid_state(self): + """Test creating a server with invalid state.""" + with pytest.raises(ValidationError) as exc_info: + ServerCreateRequest( + name="Test Server", + ip_address="192.168.1.100", + hostname="test.local", + state="invalid_state", + ) + errors = exc_info.value.errors() + assert len(errors) == 1 + assert errors[0]["type"] == "literal_error" + + def test_create_request_missing_required_fields(self): + """Test creating a server with missing required fields.""" + with pytest.raises(ValidationError) as exc_info: + ServerCreateRequest(name="Test Server") + errors = exc_info.value.errors() + assert len(errors) == 2 + assert any(e["loc"] == ("ip_address",) for e in errors) + assert any(e["loc"] == ("hostname",) for e in errors) + + +class TestServerUpdateRequest: + """Tests for ServerUpdateRequest schema.""" + + def test_update_request_all_fields_none(self): + """Test update request with all fields as None.""" + request = ServerUpdateRequest() + assert request.name is None + assert request.ip_address is None + assert request.hostname is None + assert request.state is None + + def test_update_request_partial_update_name(self): + """Test update request with only name.""" + request = ServerUpdateRequest(name="New Name") + assert request.name == "New Name" + assert request.ip_address is None + assert request.hostname is None + assert request.state is None + + def test_update_request_partial_update_ip(self): + """Test update request with only IP address.""" + request = ServerUpdateRequest(ip_address="10.0.0.1") + assert request.ip_address == "10.0.0.1" + assert request.name is None + + def test_update_request_valid_ipv4(self): + """Test update with valid IPv4 address.""" + request = ServerUpdateRequest(ip_address="192.168.1.50") + assert request.ip_address == "192.168.1.50" + + def test_update_request_valid_ipv6(self): + """Test update with valid IPv6 address.""" + request = ServerUpdateRequest(ip_address="fe80::1") + assert request.ip_address == "fe80::1" + + def test_update_request_invalid_ip_address(self): + """Test update with invalid IP address.""" + with pytest.raises(ValidationError) as exc_info: + ServerUpdateRequest(ip_address="invalid-ip") + errors = exc_info.value.errors() + assert len(errors) == 1 + assert "not a valid IP address" in str(errors[0]["ctx"]["error"]) + + def test_update_request_all_fields(self): + """Test update request with all fields provided.""" + request = ServerUpdateRequest( + name="Updated Server", + ip_address="10.0.0.100", + hostname="updated.local", + state="offline", + ) + assert request.name == "Updated Server" + assert request.ip_address == "10.0.0.100" + assert request.hostname == "updated.local" + assert request.state == "offline" + + def test_update_request_invalid_state(self): + """Test update with invalid state.""" + with pytest.raises(ValidationError) as exc_info: + ServerUpdateRequest(state="unknown") + errors = exc_info.value.errors() + assert len(errors) == 1 + assert errors[0]["type"] == "literal_error" + + def test_update_request_none_ip_address(self): + """Test that None is allowed for ip_address.""" + request = ServerUpdateRequest(ip_address=None) + assert request.ip_address is None + + +class TestStatusResponse: + """Tests for StatusResponse schema.""" + + def test_status_response_with_message(self): + """Test StatusResponse with status and message.""" + response = StatusResponse(status=200, message="Success") + assert response.status == 200 + assert response.message == "Success" + + def test_status_response_without_message(self): + """Test StatusResponse with only status.""" + response = StatusResponse(status=201) + assert response.status == 201 + assert response.message is None + + def test_status_response_error(self): + """Test StatusResponse for error cases.""" + response = StatusResponse(status=404, message="Not found") + assert response.status == 404 + assert response.message == "Not found" + + +class TestIPAddressEdgeCases: + """Tests for edge cases in IP address validation.""" + + def test_ipv4_localhost(self): + """Test localhost IPv4 address.""" + request = ServerCreateRequest( + name="Localhost", + ip_address="127.0.0.1", + hostname="localhost", + state="active", + ) + assert request.ip_address == "127.0.0.1" + + def test_ipv6_localhost(self): + """Test localhost IPv6 address.""" + request = ServerCreateRequest( + name="Localhost IPv6", + ip_address="::1", + hostname="localhost", + state="active", + ) + assert request.ip_address == "::1" + + def test_ipv4_broadcast(self): + """Test broadcast IPv4 address.""" + request = ServerCreateRequest( + name="Broadcast", + ip_address="255.255.255.255", + hostname="broadcast", + state="active", + ) + assert request.ip_address == "255.255.255.255" + + def test_ipv4_zero(self): + """Test zero IPv4 address.""" + request = ServerCreateRequest( + name="Zero", + ip_address="0.0.0.0", + hostname="zero", + state="active", + ) + assert request.ip_address == "0.0.0.0" + + def test_private_network_addresses(self): + """Test various private network addresses.""" + private_ips = [ + "10.0.0.1", + "172.16.0.1", + "192.168.0.1", + ] + for ip in private_ips: + request = ServerCreateRequest( + name=f"Server-{ip}", + ip_address=ip, + hostname=f"host-{ip.replace('.', '-')}", + state="active", + ) + assert request.ip_address == ip diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..d15c6c6 --- /dev/null +++ b/uv.lock @@ -0,0 +1,449 @@ +version = 1 +revision = 3 +requires-python = ">=3.14.2" + +[[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.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/ce/8a777047513153587e5434fd752e89334ac33e379aa3497db860eeb60377/anyio-4.12.0.tar.gz", hash = "sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0", size = 228266, upload-time = "2025-11-28T23:37:38.911Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362, upload-time = "2025-11-28T23:36:57.897Z" }, +] + +[[package]] +name = "certifi" +version = "2025.11.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, +] + +[[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.124.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/21/ade3ff6745a82ea8ad88552b4139d27941549e4f19125879f848ac8f3c3d/fastapi-0.124.4.tar.gz", hash = "sha256:0e9422e8d6b797515f33f500309f6e1c98ee4e85563ba0f2debb282df6343763", size = 378460, upload-time = "2025-12-12T15:00:43.891Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/57/aa70121b5008f44031be645a61a7c4abc24e0e888ad3fc8fda916f4d188e/fastapi-0.124.4-py3-none-any.whl", hash = "sha256:6d1e703698443ccb89e50abe4893f3c84d9d6689c0cf1ca4fad6d3c15cf69f15", size = 113281, upload-time = "2025-12-12T15:00:42.44Z" }, +] + +[[package]] +name = "greenlet" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/e5/40dbda2736893e3e53d25838e0f19a2b417dfc122b9989c91918db30b5d3/greenlet-3.3.0.tar.gz", hash = "sha256:a82bb225a4e9e4d653dd2fb7b8b2d36e4fb25bc0165422a11e48b88e9e6f78fb", size = 190651, upload-time = "2025-12-04T14:49:44.05Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/7c/f0a6d0ede2c7bf092d00bc83ad5bafb7e6ec9b4aab2fbdfa6f134dc73327/greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f", size = 275671, upload-time = "2025-12-04T14:23:05.267Z" }, + { url = "https://files.pythonhosted.org/packages/44/06/dac639ae1a50f5969d82d2e3dd9767d30d6dbdbab0e1a54010c8fe90263c/greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365", size = 646360, upload-time = "2025-12-04T14:50:10.026Z" }, + { url = "https://files.pythonhosted.org/packages/e0/94/0fb76fe6c5369fba9bf98529ada6f4c3a1adf19e406a47332245ef0eb357/greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3", size = 658160, upload-time = "2025-12-04T14:57:45.41Z" }, + { url = "https://files.pythonhosted.org/packages/93/79/d2c70cae6e823fac36c3bbc9077962105052b7ef81db2f01ec3b9bf17e2b/greenlet-3.3.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dcd2bdbd444ff340e8d6bdf54d2f206ccddbb3ccfdcd3c25bf4afaa7b8f0cf45", size = 671388, upload-time = "2025-12-04T15:07:15.789Z" }, + { url = "https://files.pythonhosted.org/packages/b8/14/bab308fc2c1b5228c3224ec2bf928ce2e4d21d8046c161e44a2012b5203e/greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955", size = 660166, upload-time = "2025-12-04T14:26:05.099Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d2/91465d39164eaa0085177f61983d80ffe746c5a1860f009811d498e7259c/greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55", size = 1615193, upload-time = "2025-12-04T15:04:27.041Z" }, + { url = "https://files.pythonhosted.org/packages/42/1b/83d110a37044b92423084d52d5d5a3b3a73cafb51b547e6d7366ff62eff1/greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc", size = 1683653, upload-time = "2025-12-04T14:27:32.366Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/9030e6f9aa8fd7808e9c31ba4c38f87c4f8ec324ee67431d181fe396d705/greenlet-3.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:73f51dd0e0bdb596fb0417e475fa3c5e32d4c83638296e560086b8d7da7c4170", size = 305387, upload-time = "2025-12-04T14:26:51.063Z" }, + { url = "https://files.pythonhosted.org/packages/a0/66/bd6317bc5932accf351fc19f177ffba53712a202f9df10587da8df257c7e/greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931", size = 282638, upload-time = "2025-12-04T14:25:20.941Z" }, + { url = "https://files.pythonhosted.org/packages/30/cf/cc81cb030b40e738d6e69502ccbd0dd1bced0588e958f9e757945de24404/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388", size = 651145, upload-time = "2025-12-04T14:50:11.039Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ea/1020037b5ecfe95ca7df8d8549959baceb8186031da83d5ecceff8b08cd2/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3", size = 654236, upload-time = "2025-12-04T14:57:47.007Z" }, + { url = "https://files.pythonhosted.org/packages/69/cc/1e4bae2e45ca2fa55299f4e85854606a78ecc37fead20d69322f96000504/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2662433acbca297c9153a4023fe2161c8dcfdcc91f10433171cf7e7d94ba2221", size = 662506, upload-time = "2025-12-04T15:07:16.906Z" }, + { url = "https://files.pythonhosted.org/packages/57/b9/f8025d71a6085c441a7eaff0fd928bbb275a6633773667023d19179fe815/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b", size = 653783, upload-time = "2025-12-04T14:26:06.225Z" }, + { url = "https://files.pythonhosted.org/packages/f6/c7/876a8c7a7485d5d6b5c6821201d542ef28be645aa024cfe1145b35c120c1/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd", size = 1614857, upload-time = "2025-12-04T15:04:28.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/dc/041be1dff9f23dac5f48a43323cd0789cb798342011c19a248d9c9335536/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9", size = 1676034, upload-time = "2025-12-04T14:27:33.531Z" }, +] + +[[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 = "hiring-challenge-devops-python" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "fastapi" }, + { name = "httpx" }, + { name = "psycopg2-binary" }, + { name = "pydantic-settings" }, + { name = "pytest" }, + { name = "rich" }, + { name = "sqlalchemy" }, + { name = "typer" }, + { name = "uvicorn" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", specifier = ">=0.124.4" }, + { name = "httpx", specifier = ">=0.27.0" }, + { name = "psycopg2-binary", specifier = ">=2.9.11" }, + { name = "pydantic-settings", specifier = ">=2.12.0" }, + { name = "pytest", specifier = ">=9.0.2" }, + { name = "rich", specifier = ">=13.9.0" }, + { name = "sqlalchemy", specifier = ">=2.0.45" }, + { name = "typer", specifier = ">=0.15.0" }, + { name = "uvicorn", specifier = ">=0.38.0" }, +] + +[[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 = "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 = "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 = "psycopg2-binary" +version = "2.9.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/6c/8767aaa597ba424643dc87348c6f1754dd9f48e80fdc1b9f7ca5c3a7c213/psycopg2-binary-2.9.11.tar.gz", hash = "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c", size = 379620, upload-time = "2025-10-10T11:14:48.041Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/12/93ef0098590cf51d9732b4f139533732565704f45bdc1ffa741b7c95fb54/psycopg2_binary-2.9.11-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:92e3b669236327083a2e33ccfa0d320dd01b9803b3e14dd986a4fc54aa00f4e1", size = 3756567, upload-time = "2025-10-10T11:13:11.885Z" }, + { url = "https://files.pythonhosted.org/packages/7c/a9/9d55c614a891288f15ca4b5209b09f0f01e3124056924e17b81b9fa054cc/psycopg2_binary-2.9.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e0deeb03da539fa3577fcb0b3f2554a97f7e5477c246098dbb18091a4a01c16f", size = 3864755, upload-time = "2025-10-10T11:13:17.727Z" }, + { url = "https://files.pythonhosted.org/packages/13/1e/98874ce72fd29cbde93209977b196a2edae03f8490d1bd8158e7f1daf3a0/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b52a3f9bb540a3e4ec0f6ba6d31339727b2950c9772850d6545b7eae0b9d7c5", size = 4411646, upload-time = "2025-10-10T11:13:24.432Z" }, + { url = "https://files.pythonhosted.org/packages/5a/bd/a335ce6645334fb8d758cc358810defca14a1d19ffbc8a10bd38a2328565/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:db4fd476874ccfdbb630a54426964959e58da4c61c9feba73e6094d51303d7d8", size = 4468701, upload-time = "2025-10-10T11:13:29.266Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/c8b4f53f34e295e45709b7568bf9b9407a612ea30387d35eb9fa84f269b4/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47f212c1d3be608a12937cc131bd85502954398aaa1320cb4c14421a0ffccf4c", size = 4166293, upload-time = "2025-10-10T11:13:33.336Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e0/f8cc36eadd1b716ab36bb290618a3292e009867e5c97ce4aba908cb99644/psycopg2_binary-2.9.11-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e35b7abae2b0adab776add56111df1735ccc71406e56203515e228a8dc07089f", size = 3983184, upload-time = "2025-10-30T02:55:32.483Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/2a8fe18a4e61cfb3417da67b6318e12691772c0696d79434184a511906dc/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fcf21be3ce5f5659daefd2b3b3b6e4727b028221ddc94e6c1523425579664747", size = 3652650, upload-time = "2025-10-10T11:13:38.181Z" }, + { url = "https://files.pythonhosted.org/packages/76/36/03801461b31b29fe58d228c24388f999fe814dfc302856e0d17f97d7c54d/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9bd81e64e8de111237737b29d68039b9c813bdf520156af36d26819c9a979e5f", size = 3298663, upload-time = "2025-10-10T11:13:44.878Z" }, + { url = "https://files.pythonhosted.org/packages/97/77/21b0ea2e1a73aa5fa9222b2a6b8ba325c43c3a8d54272839c991f2345656/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:32770a4d666fbdafab017086655bcddab791d7cb260a16679cc5a7338b64343b", size = 3044737, upload-time = "2025-10-30T02:55:35.69Z" }, + { url = "https://files.pythonhosted.org/packages/67/69/f36abe5f118c1dca6d3726ceae164b9356985805480731ac6712a63f24f0/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3cb3a676873d7506825221045bd70e0427c905b9c8ee8d6acd70cfcbd6e576d", size = 3347643, upload-time = "2025-10-10T11:13:53.499Z" }, + { url = "https://files.pythonhosted.org/packages/e1/36/9c0c326fe3a4227953dfb29f5d0c8ae3b8eb8c1cd2967aa569f50cb3c61f/psycopg2_binary-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316", size = 2803913, upload-time = "2025-10-10T11:13:57.058Z" }, +] + +[[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/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, +] + +[[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 = "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 = "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 = "sqlalchemy" +version = "2.0.45" +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/be/f9/5e4491e5ccf42f5d9cfc663741d261b3e6e1683ae7812114e7636409fcc6/sqlalchemy-2.0.45.tar.gz", hash = "sha256:1632a4bda8d2d25703fdad6363058d882541bdaaee0e5e3ddfa0cd3229efce88", size = 9869912, upload-time = "2025-12-09T21:05:16.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/64/4e1913772646b060b025d3fc52ce91a58967fe58957df32b455de5a12b4f/sqlalchemy-2.0.45-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f46ec744e7f51275582e6a24326e10c49fbdd3fc99103e01376841213028774", size = 3272404, upload-time = "2025-12-09T22:11:09.662Z" }, + { url = "https://files.pythonhosted.org/packages/b3/27/caf606ee924282fe4747ee4fd454b335a72a6e018f97eab5ff7f28199e16/sqlalchemy-2.0.45-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:883c600c345123c033c2f6caca18def08f1f7f4c3ebeb591a63b6fceffc95cce", size = 3277057, upload-time = "2025-12-09T22:13:56.213Z" }, + { url = "https://files.pythonhosted.org/packages/85/d0/3d64218c9724e91f3d1574d12eb7ff8f19f937643815d8daf792046d88ab/sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2c0b74aa79e2deade948fe8593654c8ef4228c44ba862bb7c9585c8e0db90f33", size = 3222279, upload-time = "2025-12-09T22:11:11.1Z" }, + { url = "https://files.pythonhosted.org/packages/24/10/dd7688a81c5bc7690c2a3764d55a238c524cd1a5a19487928844cb247695/sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a420169cef179d4c9064365f42d779f1e5895ad26ca0c8b4c0233920973db74", size = 3244508, upload-time = "2025-12-09T22:13:57.932Z" }, + { url = "https://files.pythonhosted.org/packages/aa/41/db75756ca49f777e029968d9c9fee338c7907c563267740c6d310a8e3f60/sqlalchemy-2.0.45-cp314-cp314-win32.whl", hash = "sha256:e50dcb81a5dfe4b7b4a4aa8f338116d127cb209559124f3694c70d6cd072b68f", size = 2113204, upload-time = "2025-12-09T21:39:38.365Z" }, + { url = "https://files.pythonhosted.org/packages/89/a2/0e1590e9adb292b1d576dbcf67ff7df8cf55e56e78d2c927686d01080f4b/sqlalchemy-2.0.45-cp314-cp314-win_amd64.whl", hash = "sha256:4748601c8ea959e37e03d13dcda4a44837afcd1b21338e637f7c935b8da06177", size = 2138785, upload-time = "2025-12-09T21:39:39.503Z" }, + { url = "https://files.pythonhosted.org/packages/42/39/f05f0ed54d451156bbed0e23eb0516bcad7cbb9f18b3bf219c786371b3f0/sqlalchemy-2.0.45-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd337d3526ec5298f67d6a30bbbe4ed7e5e68862f0bf6dd21d289f8d37b7d60b", size = 3522029, upload-time = "2025-12-09T22:13:32.09Z" }, + { url = "https://files.pythonhosted.org/packages/54/0f/d15398b98b65c2bce288d5ee3f7d0a81f77ab89d9456994d5c7cc8b2a9db/sqlalchemy-2.0.45-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9a62b446b7d86a3909abbcd1cd3cc550a832f99c2bc37c5b22e1925438b9367b", size = 3475142, upload-time = "2025-12-09T22:13:33.739Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e1/3ccb13c643399d22289c6a9786c1a91e3dcbb68bce4beb44926ac2c557bf/sqlalchemy-2.0.45-py3-none-any.whl", hash = "sha256:5225a288e4c8cc2308dbdd874edad6e7d0fd38eac1e9e5f23503425c8eee20d0", size = 1936672, upload-time = "2025-12-09T21:54:52.608Z" }, +] + +[[package]] +name = "starlette" +version = "0.50.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +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 = "typer" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/28/7c85c8032b91dbe79725b6f17d2fffc595dff06a35c7a30a37bef73a1ab4/typer-0.20.0.tar.gz", hash = "sha256:1aaf6494031793e4876fb0bacfa6a912b551cf43c1e63c800df8b1a866720c37", size = 106492, upload-time = "2025-10-20T17:03:49.445Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/64/7713ffe4b5983314e9d436a90d5bd4f63b6054e2aca783a3cfc44cb95bbf/typer-0.20.0-py3-none-any.whl", hash = "sha256:5b463df6793ec1dca6213a3cf4c0f03bc6e322ac5e16e13ddd622a889489784a", size = 47028, upload-time = "2025-10-20T17:03:47.617Z" }, +] + +[[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.38.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" }, +]