diff --git a/CLAUDE.md b/CLAUDE.md index 718b3bd..1edc515 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,6 +7,20 @@ A minimal Python calculator used to verify the Typosaurus agent system. - Full type annotations on every function signature. - No comments or docstrings in source files. +## Python backend + +- Package manifest: `pyproject.toml` +- Entry module: `src/typosaurus_sandbox/__main__.py` +- Framework: FastAPI +- Serve frontend: no + +## Architecture + +- Backend serves frontend: no +- Module root: `src/typosaurus_sandbox/` +- Calculator business logic: `src/typosaurus_sandbox/domain/calculator/operations.py` +- HTTP API layer: `src/typosaurus_sandbox/presentation/api/v1/calculator.py` + ## Verification ``` diff --git a/Makefile b/Makefile index 0ddaaf7..3328a96 100644 --- a/Makefile +++ b/Makefile @@ -1,2 +1,2 @@ verify: - @python3 -m compileall -q src tests && python3 -m unittest discover -s tests -q && echo "verification passed" + @python3.13 -m compileall -q src tests && python3.13 -m unittest discover -s tests -q && echo "verification passed" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6ae8758 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "typosaurus-sandbox" +version = "0.1.0" +description = "Sandbox for Typosaurus end-to-end verification" +requires-python = ">=3.12" +dependencies = [ + "fastapi", + "uvicorn[standard]", +] + +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/src/typosaurus_sandbox/__init__.py b/src/typosaurus_sandbox/__init__.py new file mode 100644 index 0000000..3bc5c04 --- /dev/null +++ b/src/typosaurus_sandbox/__init__.py @@ -0,0 +1,5 @@ +# retoor + +from typosaurus_sandbox.app import App + +__all__ = ["App"] diff --git a/src/typosaurus_sandbox/__main__.py b/src/typosaurus_sandbox/__main__.py new file mode 100644 index 0000000..9eea1bc --- /dev/null +++ b/src/typosaurus_sandbox/__main__.py @@ -0,0 +1,7 @@ +# retoor + +import uvicorn + +from typosaurus_sandbox.app import App + +uvicorn.run(App, host="127.0.0.1", port=8000, log_level="info") diff --git a/src/typosaurus_sandbox/app.py b/src/typosaurus_sandbox/app.py new file mode 100644 index 0000000..853e7ec --- /dev/null +++ b/src/typosaurus_sandbox/app.py @@ -0,0 +1,15 @@ +# retoor + +from fastapi import FastAPI + +from typosaurus_sandbox.presentation.api.v1.calculator import calculator_router + +App = FastAPI(title="typosaurus-sandbox") + + +@App.get("/health") +def health() -> dict[str, str]: + return {"status": "ok"} + + +App.include_router(calculator_router) diff --git a/src/typosaurus_sandbox/domain/__init__.py b/src/typosaurus_sandbox/domain/__init__.py new file mode 100644 index 0000000..95ee7c3 --- /dev/null +++ b/src/typosaurus_sandbox/domain/__init__.py @@ -0,0 +1 @@ +# retoor diff --git a/src/typosaurus_sandbox/domain/calculator/__init__.py b/src/typosaurus_sandbox/domain/calculator/__init__.py new file mode 100644 index 0000000..7ca0d8c --- /dev/null +++ b/src/typosaurus_sandbox/domain/calculator/__init__.py @@ -0,0 +1,5 @@ +# retoor + +from typosaurus_sandbox.domain.calculator.operations import add, clamp, clamp_to_byte, subtract + +__all__ = ["add", "subtract", "clamp", "clamp_to_byte"] diff --git a/src/typosaurus_sandbox/domain/calculator/operations.py b/src/typosaurus_sandbox/domain/calculator/operations.py new file mode 100644 index 0000000..e4e6afd --- /dev/null +++ b/src/typosaurus_sandbox/domain/calculator/operations.py @@ -0,0 +1,22 @@ +# retoor + +def add(left: int, right: int) -> int: + return left + right + + +def subtract(left: int, right: int) -> int: + return left - right + + +def clamp(value: int, low: int, high: int) -> int: + if low > high: + raise ValueError + if value < low: + return low + if value > high: + return high + return value + + +def clamp_to_byte(value: int) -> int: + return max(0, min(255, value)) diff --git a/src/typosaurus_sandbox/presentation/__init__.py b/src/typosaurus_sandbox/presentation/__init__.py new file mode 100644 index 0000000..95ee7c3 --- /dev/null +++ b/src/typosaurus_sandbox/presentation/__init__.py @@ -0,0 +1 @@ +# retoor diff --git a/src/typosaurus_sandbox/presentation/api/__init__.py b/src/typosaurus_sandbox/presentation/api/__init__.py new file mode 100644 index 0000000..95ee7c3 --- /dev/null +++ b/src/typosaurus_sandbox/presentation/api/__init__.py @@ -0,0 +1 @@ +# retoor diff --git a/src/typosaurus_sandbox/presentation/api/v1/__init__.py b/src/typosaurus_sandbox/presentation/api/v1/__init__.py new file mode 100644 index 0000000..95ee7c3 --- /dev/null +++ b/src/typosaurus_sandbox/presentation/api/v1/__init__.py @@ -0,0 +1 @@ +# retoor diff --git a/src/typosaurus_sandbox/presentation/api/v1/calculator.py b/src/typosaurus_sandbox/presentation/api/v1/calculator.py new file mode 100644 index 0000000..e8937f5 --- /dev/null +++ b/src/typosaurus_sandbox/presentation/api/v1/calculator.py @@ -0,0 +1,56 @@ +# retoor + +from fastapi import APIRouter +from pydantic import BaseModel, Field + +from typosaurus_sandbox.domain.calculator import add, clamp, clamp_to_byte, subtract + +calculator_router = APIRouter(prefix="/api/v1/calculator") + + +class AddRequest(BaseModel): + left: int + right: int + + +class SubtractRequest(BaseModel): + left: int + right: int + + +class ClampRequest(BaseModel): + value: float + low: float + high: float + + +class ClampToByteRequest(BaseModel): + value: int = Field(ge=-2147483648, le=2147483647) + + +class IntResult(BaseModel): + result: int + + +class FloatResult(BaseModel): + result: float + + +@calculator_router.post("/add", response_model=IntResult) +def calculate_add(body: AddRequest) -> IntResult: + return IntResult(result=add(body.left, body.right)) + + +@calculator_router.post("/subtract", response_model=IntResult) +def calculate_subtract(body: SubtractRequest) -> IntResult: + return IntResult(result=subtract(body.left, body.right)) + + +@calculator_router.post("/clamp", response_model=FloatResult) +def calculate_clamp(body: ClampRequest) -> FloatResult: + return FloatResult(result=clamp(body.value, body.low, body.high)) + + +@calculator_router.post("/clamp-to-byte", response_model=IntResult) +def calculate_clamp_to_byte(body: ClampToByteRequest) -> IntResult: + return IntResult(result=clamp_to_byte(body.value)) diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..0b44d70 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,99 @@ +# retoor + +import unittest + +from fastapi.testclient import TestClient + +from typosaurus_sandbox.app import App + +client = TestClient(App) + + +class TestHealthEndpoint(unittest.TestCase): + + def test_health_returns_ok(self) -> None: + response = client.get("/health") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"status": "ok"}) + + +class TestCalculatorAddEndpoint(unittest.TestCase): + + def test_add_positive_integers(self) -> None: + response = client.post("/api/v1/calculator/add", json={"left": 3, "right": 5}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"result": 8}) + + def test_add_negative_integers(self) -> None: + response = client.post("/api/v1/calculator/add", json={"left": -3, "right": -5}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"result": -8}) + + def test_add_invalid_input_returns_422(self) -> None: + response = client.post("/api/v1/calculator/add", json={"left": "abc", "right": 5}) + self.assertEqual(response.status_code, 422) + + def test_add_missing_field_returns_422(self) -> None: + response = client.post("/api/v1/calculator/add", json={"left": 3}) + self.assertEqual(response.status_code, 422) + + +class TestCalculatorSubtractEndpoint(unittest.TestCase): + + def test_subtract_positive(self) -> None: + response = client.post("/api/v1/calculator/subtract", json={"left": 10, "right": 3}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"result": 7}) + + def test_subtract_negative_result(self) -> None: + response = client.post("/api/v1/calculator/subtract", json={"left": 3, "right": 10}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"result": -7}) + + def test_subtract_invalid_input_returns_422(self) -> None: + response = client.post("/api/v1/calculator/subtract", json={"left": 10, "right": None}) + self.assertEqual(response.status_code, 422) + + +class TestCalculatorClampEndpoint(unittest.TestCase): + + def test_clamp_value_below_low(self) -> None: + response = client.post("/api/v1/calculator/clamp", json={"value": -5, "low": 0, "high": 10}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"result": 0}) + + def test_clamp_value_above_high(self) -> None: + response = client.post("/api/v1/calculator/clamp", json={"value": 15, "low": 0, "high": 10}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"result": 10}) + + def test_clamp_value_in_range(self) -> None: + response = client.post("/api/v1/calculator/clamp", json={"value": 5, "low": 0, "high": 10}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"result": 5.0}) + + def test_clamp_invalid_input_returns_422(self) -> None: + response = client.post("/api/v1/calculator/clamp", json={"value": "x", "low": 0, "high": 10}) + self.assertEqual(response.status_code, 422) + + +class TestCalculatorClampToByteEndpoint(unittest.TestCase): + + def test_clamp_to_byte_within_range(self) -> None: + response = client.post("/api/v1/calculator/clamp-to-byte", json={"value": 128}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"result": 128}) + + def test_clamp_to_byte_below_zero(self) -> None: + response = client.post("/api/v1/calculator/clamp-to-byte", json={"value": -10}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"result": 0}) + + def test_clamp_to_byte_above_255(self) -> None: + response = client.post("/api/v1/calculator/clamp-to-byte", json={"value": 300}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"result": 255}) + + def test_clamp_to_byte_invalid_input_returns_422(self) -> None: + response = client.post("/api/v1/calculator/clamp-to-byte", json={"value": "abc"}) + self.assertEqual(response.status_code, 422) diff --git a/tests/test_calculator.py b/tests/test_calculator.py index 48d48d4..9ddacfa 100644 --- a/tests/test_calculator.py +++ b/tests/test_calculator.py @@ -3,7 +3,47 @@ import math import unittest -from src.calculator import clamp +from typosaurus_sandbox.domain.calculator import add, clamp, clamp_to_byte, subtract + + +class TestAddFunction(unittest.TestCase): + + def test_add_positive_integers(self) -> None: + self.assertEqual(add(3, 5), 8) + + def test_add_negative_integers(self) -> None: + self.assertEqual(add(-3, -5), -8) + + def test_add_mixed_sign(self) -> None: + self.assertEqual(add(-3, 5), 2) + + +class TestSubtractFunction(unittest.TestCase): + + def test_subtract_positive(self) -> None: + self.assertEqual(subtract(10, 3), 7) + + def test_subtract_negative_result(self) -> None: + self.assertEqual(subtract(3, 10), -7) + + def test_subtract_negative_numbers(self) -> None: + self.assertEqual(subtract(-5, -3), -2) + + +class TestClampToByteFunction(unittest.TestCase): + + def test_clamp_to_byte_within_range(self) -> None: + self.assertEqual(clamp_to_byte(128), 128) + + def test_clamp_to_byte_below_zero(self) -> None: + self.assertEqual(clamp_to_byte(-10), 0) + + def test_clamp_to_byte_above_255(self) -> None: + self.assertEqual(clamp_to_byte(300), 255) + + def test_clamp_to_byte_boundaries(self) -> None: + self.assertEqual(clamp_to_byte(0), 0) + self.assertEqual(clamp_to_byte(255), 255) class TestClampFunction(unittest.TestCase):