Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
*.pyc
*.db
.coverage
.opencode
.opencode
*.log
4 changes: 4 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
{
"cSpell.words": [
"Charizard",
"forcelist",
"Grimp",
"Holo",
"IGAPI",
"importlinter",
"lastrowid",
"opencode",
"polyclean",
"polylith",
"unposted"
Expand Down
3 changes: 3 additions & 0 deletions bases/polyclean/pokemon_collection_api/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .main import create_app

__all__ = ["create_app"]
32 changes: 32 additions & 0 deletions bases/polyclean/pokemon_collection_api/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from pathlib import Path

from pydantic_settings import BaseSettings, SettingsConfigDict


class AppSettings(BaseSettings):
"""Application settings - loads from environment variables."""

model_config = SettingsConfigDict(
env_prefix="POKEMON_",
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)

# Database settings
db_path: Path = Path("pokemon_cards.db")
db_echo: bool = False

# API settings
api_title: str = "Pokemon Collection API"
api_version: str = "1.0.0"

# Logging settings
log_level: str = "INFO"
log_file: Path | None = (
None # Set to path like "logs/app.log" to enable file logging
)


# Singleton instance
settings = AppSettings()
52 changes: 52 additions & 0 deletions bases/polyclean/pokemon_collection_api/logging_.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import sys
from pathlib import Path
from typing import TYPE_CHECKING

from loguru import logger

if TYPE_CHECKING:
from loguru import Logger


def setup_logging(
log_level: str = "INFO",
log_file: Path | str | None = None,
) -> None:
"""Configure Loguru for the application.

Args:
log_level: Minimum log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
log_file: Optional path to log file. If None, only stdout is used.
Supports rotation keywords: "500 MB", "1 week", etc.

"""
logger.remove()

# Console: stdout with colored output
logger.add(
sys.stdout,
format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan> - <level>{message}</level>",
level=log_level,
colorize=True,
)

# File: optional, with rotation and compression
if log_file:
log_path = Path(log_file)
log_path.parent.mkdir(parents=True, exist_ok=True)

logger.add(
str(log_path),
level=log_level,
rotation="500 MB",
retention="7 days",
compression="zip",
enqueue=True,
serialize=False,
format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function} - {message}",
)


def get_logger() -> "Logger":
"""Return the configured logger instance."""
return logger
157 changes: 157 additions & 0 deletions bases/polyclean/pokemon_collection_api/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
from contextlib import asynccontextmanager
from typing import AsyncIterator

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from loguru import logger
from polyclean.add_pokemon_card_flow import AddPokemonCardFlow
from polyclean.list_pokemon_cards_flow import ListPokemonCardsFlow
from polyclean.pokemon_card_contract import CardCondition, PokemonCardStoragePort
from polyclean.remove_pokemon_card_flow import RemovePokemonCardFlow
from polyclean.sqlite_pokemon_adapter import SQLitePokemonAdapter
from pydantic import BaseModel, Field, field_validator

from .config import settings
from .logging_ import setup_logging
from .responses import (
ApiResponse,
CardCreatedResponse,
CardListResponse,
CardRemovedResponse,
PokemonCardResponse,
)


class AddCardRequest(BaseModel):
name: str = Field(
..., min_length=1, max_length=100, description="Pokemon card name"
)
card_type: str = Field(
..., min_length=1, description="Card type (e.g., Fire, Water)"
)
hp: int = Field(..., ge=0, le=1000, description="Hit points")
set_name: str = Field(..., min_length=1, description="Set name (e.g., Base Set)")
set_number: int = Field(..., ge=1, le=1000, description="Card number in set")
rarity: str = Field(..., min_length=1, description="Rarity (e.g., Rare Holo)")
condition: CardCondition = Field(..., description="Card condition")
notes: str | None = Field(
default=None, max_length=1000, description="Optional notes"
)

@field_validator("name")
@classmethod
def name_must_be_valid(cls, v: str) -> str:
if not v or v.strip() == "":
raise ValueError("Name cannot be empty or whitespace")
# Normalize to title case for consistency
return v.strip().title()


def create_app(storage: PokemonCardStoragePort) -> FastAPI:
add_card_flow = AddPokemonCardFlow(storage)
list_cards_flow = ListPokemonCardsFlow(storage)
remove_card_flow = RemovePokemonCardFlow(storage)

@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
# Configure logging on startup
log_level = settings.log_level.upper() if settings.log_level else "INFO"
if settings.db_echo:
log_level = "DEBUG"
setup_logging(log_level=log_level, log_file=settings.log_file)
logger.info(f"Starting {settings.api_title} v{settings.api_version}")

initialize = getattr(storage, "initialize", None)
if initialize is not None:
await initialize()
logger.info("Database initialized")

yield

close = getattr(storage, "close", None)
if close is not None:
await close()
logger.info("Database connection closed")

logger.info("Application shutdown complete")

app = FastAPI(title="Pokemon Collection API", lifespan=lifespan)

app.add_middleware(
CORSMiddleware, # type: ignore[arg-type]
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)

@app.post("/cards", response_model=ApiResponse[CardCreatedResponse])
async def add_card(req: AddCardRequest) -> ApiResponse[CardCreatedResponse]:
logger.debug(f"Adding card: {req.name}")
result = await add_card_flow.flow(
name=req.name,
card_type=req.card_type,
hp=req.hp,
set_name=req.set_name,
set_number=req.set_number,
rarity=req.rarity,
condition=req.condition.value,
notes=req.notes,
)
if not result["success"]:
logger.warning(f"Failed to add card: {result.get('message')}")
return ApiResponse(
success=False,
error=result.get("message", "Failed to add card"),
)
logger.info(f"Card added successfully: {req.name} (ID: {result['card_id']})")
return ApiResponse(
success=True,
data=CardCreatedResponse(card_id=result["card_id"]),
)

@app.get("/cards", response_model=ApiResponse[CardListResponse])
async def list_cards() -> ApiResponse[CardListResponse]:
result = await list_cards_flow.flow()
cards = [
PokemonCardResponse(
id=card["id"],
name=card["name"],
card_type=card["card_type"],
hp=card["hp"],
set_name=card["set_name"],
set_number=card["set_number"],
rarity=card["rarity"],
condition=card["condition"],
acquired_at=card["acquired_at"],
notes=card.get("notes"),
)
for card in result.get("cards", [])
]
return ApiResponse(
success=True,
data=CardListResponse(cards=cards, count=len(cards)),
)

@app.delete("/cards/{card_id}", response_model=ApiResponse[CardRemovedResponse])
async def remove_card(card_id: int) -> ApiResponse[CardRemovedResponse]:
logger.debug(f"Removing card ID: {card_id}")
result = await remove_card_flow.flow(card_id)
if not result["success"]:
logger.warning(f"Failed to remove card {card_id}: {result.get('message')}")
return ApiResponse(
success=False,
error=result.get("message", "Card not found"),
)
logger.info(f"Card removed successfully: ID {card_id}")
return ApiResponse(
success=True,
data=CardRemovedResponse(removed=True, card_id=card_id),
)

return app


storage = SQLitePokemonAdapter(db_path=settings.db_path)

app = create_app(storage)
50 changes: 50 additions & 0 deletions bases/polyclean/pokemon_collection_api/responses.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
from __future__ import annotations

from typing import Generic, Optional, TypeVar

from pydantic import BaseModel

T = TypeVar("T")


class ApiResponse(BaseModel, Generic[T]):
"""Generic API response wrapper."""

success: bool
data: Optional[T] = None
error: Optional[str] = None


class PokemonCardResponse(BaseModel):
"""Single Pokemon card in responses."""

id: int
name: str
card_type: str
hp: int
set_name: str
set_number: int
rarity: str
condition: str
acquired_at: str
notes: Optional[str] = None


class CardCreatedResponse(BaseModel):
"""Response when a card is created."""

card_id: int


class CardListResponse(BaseModel):
"""Response containing list of cards."""

cards: list[PokemonCardResponse]
count: int


class CardRemovedResponse(BaseModel):
"""Response when a card is removed."""

removed: bool
card_id: int
3 changes: 3 additions & 0 deletions components/polyclean/add_pokemon_card_flow/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .flow import AddPokemonCardFlow

__all__ = ["AddPokemonCardFlow"]
56 changes: 56 additions & 0 deletions components/polyclean/add_pokemon_card_flow/flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
from __future__ import annotations

from datetime import datetime, timezone

from polyclean.pokemon_card_contract import (
CardCondition,
PokemonCard,
PokemonCardStoragePort,
)


class AddPokemonCardFlow:
def __init__(self, storage: PokemonCardStoragePort) -> None:
self._storage = storage

async def flow(
self,
name: str,
card_type: str,
hp: int,
set_name: str,
set_number: int,
rarity: str,
condition: str,
notes: str | None = None,
) -> dict:
if not name or not card_type or hp < 0:
return {"success": False, "message": "Invalid input"}

valid_conditions = [c.value for c in CardCondition]
try:
CardCondition(condition)
except ValueError:
return {
"success": False,
"message": f"Invalid condition. Must be one of: {valid_conditions}",
}

card = PokemonCard(
id=None,
name=name,
card_type=card_type,
hp=hp,
set_name=set_name,
set_number=set_number,
rarity=rarity,
condition=condition,
acquired_at=datetime.now(timezone.utc),
notes=notes,
)

if not card.validate():
return {"success": False, "message": "Card validation failed"}

saved = await self._storage.save(card)
return {"success": True, "card_id": saved.id}
3 changes: 3 additions & 0 deletions components/polyclean/list_pokemon_cards_flow/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .flow import ListPokemonCardsFlow

__all__ = ["ListPokemonCardsFlow"]
Loading
Loading