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 c2c7f11..787a7c2 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,8 @@ +# retoor + verify: - @python3.13 -m compileall -q src tests app && python3.13 -m unittest discover -s tests -q && echo "verification passed" + @PYTHONPATH=src python3 -m compileall -q src tests && PYTHONPATH=src python3 -m unittest discover -s tests -q && echo "verification passed" run: - FLASK_APP=app flask run + @PYTHONPATH=src python3 -m typosaurus_sandbox + 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..368ace9 --- /dev/null +++ b/src/typosaurus_sandbox/domain/calculator/__init__.py @@ -0,0 +1,6 @@ +# retoor + +from typosaurus_sandbox.domain.calculator.operations import add, clamp, clamp_to_byte, subtract, variance + +__all__ = ["add", "subtract", "clamp", "clamp_to_byte", "variance"] + diff --git a/src/typosaurus_sandbox/domain/calculator/operations.py b/src/typosaurus_sandbox/domain/calculator/operations.py new file mode 100644 index 0000000..173fdab --- /dev/null +++ b/src/typosaurus_sandbox/domain/calculator/operations.py @@ -0,0 +1,33 @@ +# retoor + +from typing import Sequence + + +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)) + + +def variance(values: Sequence[float]) -> float: + if not values: + raise ValueError + mean = sum(values) / len(values) + return sum((x - mean) ** 2 for x in values) / len(values) + 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 index b1502cd..ee63beb 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,87 +1,100 @@ # retoor -import json import unittest -from app import app +from fastapi.testclient import TestClient + +from typosaurus_sandbox.app import App + +client = TestClient(App) -class TestCalculatorAPI(unittest.TestCase): +class TestHealthEndpoint(unittest.TestCase): - def setUp(self) -> None: - self.client = app.test_client() - - def test_add_success(self) -> None: - response = self.client.get('/add?left=3&right=5') + def test_health_returns_ok(self) -> None: + response = client.get("/health") self.assertEqual(response.status_code, 200) - self.assertEqual(response.json, {'result': 8}) + self.assertEqual(response.json(), {"status": "ok"}) - def test_add_missing_param_returns_400(self) -> None: - response = self.client.get('/add?left=3') - self.assertEqual(response.status_code, 400) - self.assertIn('error', response.json) - def test_add_invalid_type_returns_400(self) -> None: - response = self.client.get('/add?left=abc&right=5') - self.assertEqual(response.status_code, 400) - self.assertIn('error', response.json) +class TestCalculatorAddEndpoint(unittest.TestCase): - def test_subtract_success(self) -> None: - response = self.client.get('/subtract?left=10&right=3') + 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': 7}) + self.assertEqual(response.json(), {"result": 8}) - def test_subtract_missing_param_returns_400(self) -> None: - response = self.client.get('/subtract?right=3') - self.assertEqual(response.status_code, 400) - self.assertIn('error', response.json) - - def test_subtract_invalid_type_returns_400(self) -> None: - response = self.client.get('/subtract?left=10&right=xyz') - self.assertEqual(response.status_code, 400) - self.assertIn('error', response.json) - - def test_clamp_success(self) -> None: - response = self.client.get('/clamp?value=5&low=0&high=10') + 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': 5}) + self.assertEqual(response.json(), {"result": -8}) - def test_clamp_value_below_low_returns_low(self) -> None: - response = self.client.get('/clamp?value=-5&low=0&high=10') + 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': 0}) + self.assertEqual(response.json(), {"result": 7}) - def test_clamp_missing_param_returns_400(self) -> None: - response = self.client.get('/clamp?value=5&low=0') - self.assertEqual(response.status_code, 400) - self.assertIn('error', response.json) - - def test_clamp_invalid_type_returns_400(self) -> None: - response = self.client.get('/clamp?value=abc&low=0&high=10') - self.assertEqual(response.status_code, 400) - self.assertIn('error', response.json) - - def test_clamp_low_greater_than_high_returns_400(self) -> None: - response = self.client.get('/clamp?value=5&low=10&high=0') - self.assertEqual(response.status_code, 400) - self.assertIn('error', response.json) - - def test_clamp_to_byte_success(self) -> None: - response = self.client.get('/clamp_to_byte?value=100') + 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': 100}) + self.assertEqual(response.json(), {"result": -7}) - def test_clamp_to_byte_above_255_returns_255(self) -> None: - response = self.client.get('/clamp_to_byte?value=300') + 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': 255}) + self.assertEqual(response.json(), {"result": 0}) - def test_clamp_to_byte_missing_param_returns_400(self) -> None: - response = self.client.get('/clamp_to_byte') - self.assertEqual(response.status_code, 400) - self.assertIn('error', response.json) + 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) - def test_clamp_to_byte_invalid_type_returns_400(self) -> None: - response = self.client.get('/clamp_to_byte?value=abc') - self.assertEqual(response.status_code, 400) - self.assertIn('error', response.json) diff --git a/tests/test_calculator.py b/tests/test_calculator.py index 86d3387..d367009 100644 --- a/tests/test_calculator.py +++ b/tests/test_calculator.py @@ -3,7 +3,47 @@ import math import unittest -from src.calculator import clamp, percentage, variance +from typosaurus_sandbox.domain.calculator import add, clamp, clamp_to_byte, subtract, variance + + +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): @@ -63,31 +103,6 @@ class TestClampFunction(unittest.TestCase): self.assertTrue(math.isnan(result)) -class TestPercentageFunction(unittest.TestCase): - - def test_valid_percentage(self) -> None: - self.assertEqual(percentage(50, 200), 25.0) - - def test_zero_value(self) -> None: - self.assertEqual(percentage(0, 100), 0.0) - - def test_full_value(self) -> None: - self.assertEqual(percentage(200, 100), 200.0) - - def test_fractional(self) -> None: - self.assertAlmostEqual(percentage(1, 3), 33.333333333333336) - - def test_total_zero(self) -> None: - with self.assertRaises(ValueError): - percentage(50, 0) - - def test_float_arguments(self) -> None: - self.assertEqual(percentage(25.5, 100.0), 25.5) - - def test_negative_value(self) -> None: - self.assertEqual(percentage(-50, 200), -25.0) - - class TestVarianceFunction(unittest.TestCase): def test_empty_list_raises_value_error(self) -> None: @@ -118,5 +133,3 @@ class TestVarianceFunction(unittest.TestCase): def test_tuple_input_returns_variance(self) -> None: self.assertEqual(variance((1, 2, 3, 4, 5)), 2.0) - -