Compare commits

..

No commits in common. "main" and "typosaurus/9-make-it-a-web-application" have entirely different histories.

18 changed files with 73 additions and 588 deletions

View File

@ -1,21 +0,0 @@
# retoor <retoor@molodetz.nl>
name: CI
on:
push:
branches: [main, master]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e .
- name: Run tests
run: make verify

View File

@ -1,4 +1,3 @@
# retoor <retoor@molodetz.nl>
# typosaurus-sandbox
A minimal Python calculator used to verify the Typosaurus agent system.
@ -8,30 +7,8 @@ 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
```
make verify
```
## CI
- Workflow file: `.gitea/workflows/ci.yml`
- Trigger: push to `main` or `master` branches
- Steps: checkout, Python 3.12 setup, dependency install, `make verify`

View File

@ -1,8 +1,5 @@
# retoor <retoor@molodetz.nl>
verify:
@PYTHONPATH=src python3 -m compileall -q src tests && PYTHONPATH=src python3 -m unittest discover -s tests -q && echo "verification passed"
@python3.13 -m compileall -q src tests app && python3.13 -m unittest discover -s tests -q && echo "verification passed"
run:
@PYTHONPATH=src python3 -m typosaurus_sandbox
FLASK_APP=app flask run

122
README.md
View File

@ -1,123 +1,11 @@
# retoor <retoor@molodetz.nl>
# typosaurus-sandbox
Sandbox for Typosaurus end-to-end verification.
Sandbox for Typosaurus end-to-end verification
A FastAPI application serving arithmetic operations over HTTP with JSON request/response bodies.
## Running the web app
## Configuration
The application uses a single `.env.json` file at the project root as its central point of truth
for configuration. Defaults are plug-and-play and require no setup:
```json
{
"host": "127.0.0.1",
"port": 8000
}
```bash
make run
```
When no `.env.json` is present, the application starts with these defaults. To customise, create
`.env.json` in the project root and populate only the keys that differ.
## Usage
### Start the server
```sh
python -m typosaurus_sandbox
```
The server listens on `http://127.0.0.1:8000` by default.
### Health check
```
GET /health
```
Response:
```json
{"status": "ok"}
```
## API endpoints
All calculator endpoints accept `POST` requests with a JSON body and return a JSON response.
### POST /api/v1/calculator/add
Add two integers.
Request:
```json
{"left": 3, "right": 5}
```
Response:
```json
{"result": 8}
```
### POST /api/v1/calculator/subtract
Subtract the right integer from the left.
Request:
```json
{"left": 10, "right": 3}
```
Response:
```json
{"result": 7}
```
### POST /api/v1/calculator/clamp
Clamp a value between a low and high bound.
Request:
```json
{"value": 15, "low": 0, "high": 10}
```
Response:
```json
{"result": 10}
```
Boundaries are inclusive. A `low > high` combination produces a 422 validation response.
### POST /api/v1/calculator/clamp-to-byte
Clamp an integer to the byte range [0, 255].
Request:
```json
{"value": 300}
```
Response:
```json
{"result": 255}
```
## Verification
```sh
make verify
```
Runs compile-all checks against all source and test files, then executes the full test suite.
Zero warnings are tolerated.
The development server starts at http://127.0.0.1:5000.

View File

@ -1,16 +0,0 @@
[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"]

View File

@ -1,8 +1,5 @@
# retoor <retoor@molodetz.nl>
from typing import Sequence, Union
def add(left: int, right: int) -> int:
return left + right
@ -23,34 +20,3 @@ def clamp(value: int, low: int, high: int) -> int:
def clamp_to_byte(value: int) -> int:
return max(0, min(255, value))
def average(values: list[int | float]) -> float:
if not values:
raise ValueError
return sum(values) / len(values)
def median(values: list[float]) -> float:
if not values:
raise ValueError
sorted_values = sorted(values)
n = len(sorted_values)
mid = n // 2
if n % 2 == 1:
return sorted_values[mid]
return (sorted_values[mid - 1] + sorted_values[mid]) / 2.0
def percentage(value: Union[int, float], total: Union[int, float]) -> float:
if total == 0:
raise ValueError
return (value / total) * 100
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)

View File

@ -1,5 +0,0 @@
# retoor <retoor@molodetz.nl>
from typosaurus_sandbox.app import App
__all__ = ["App"]

View File

@ -1,7 +0,0 @@
# retoor <retoor@molodetz.nl>
import uvicorn
from typosaurus_sandbox.app import App
uvicorn.run(App, host="127.0.0.1", port=8000, log_level="info")

View File

@ -1,15 +0,0 @@
# retoor <retoor@molodetz.nl>
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)

View File

@ -1 +0,0 @@
# retoor <retoor@molodetz.nl>

View File

@ -1,6 +0,0 @@
# retoor <retoor@molodetz.nl>
from typosaurus_sandbox.domain.calculator.operations import add, average, clamp, clamp_to_byte, median, percentage, subtract, variance
__all__ = ["add", "average", "clamp", "clamp_to_byte", "median", "percentage", "subtract", "variance"]

View File

@ -1,56 +0,0 @@
# retoor <retoor@molodetz.nl>
from typing import Sequence, Union
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 average(values: list[int | float]) -> float:
if not values:
raise ValueError
return sum(values) / len(values)
def median(values: list[float]) -> float:
if not values:
raise ValueError
sorted_values = sorted(values)
n = len(sorted_values)
mid = n // 2
if n % 2 == 1:
return sorted_values[mid]
return (sorted_values[mid - 1] + sorted_values[mid]) / 2.0
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)
def percentage(value: Union[int, float], total: Union[int, float]) -> float:
if total == 0:
raise ValueError
return (value / total) * 100

View File

@ -1 +0,0 @@
# retoor <retoor@molodetz.nl>

View File

@ -1 +0,0 @@
# retoor <retoor@molodetz.nl>

View File

@ -1 +0,0 @@
# retoor <retoor@molodetz.nl>

View File

@ -1,60 +0,0 @@
# retoor <retoor@molodetz.nl>
from fastapi import APIRouter, HTTPException
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:
try:
result = clamp(body.value, body.low, body.high)
except ValueError:
raise HTTPException(status_code=422, detail="low must not exceed high")
return FloatResult(result=result)
@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))

View File

@ -1,120 +1,87 @@
# retoor <retoor@molodetz.nl>
import json
import unittest
from fastapi.testclient import TestClient
from typosaurus_sandbox.app import App
client = TestClient(App)
from app import app
class TestHealthEndpoint(unittest.TestCase):
class TestCalculatorAPI(unittest.TestCase):
def test_health_returns_ok(self) -> None:
response = client.get("/health")
def setUp(self) -> None:
self.client = app.test_client()
def test_add_success(self) -> None:
response = self.client.get('/add?left=3&right=5')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"status": "ok"})
self.assertEqual(response.json, {'result': 8})
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)
class TestCalculatorAddEndpoint(unittest.TestCase):
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)
def test_add_positive_integers(self) -> None:
response = client.post("/api/v1/calculator/add", json={"left": 3, "right": 5})
def test_subtract_success(self) -> None:
response = self.client.get('/subtract?left=10&right=3')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"result": 8})
self.assertEqual(response.json, {'result': 7})
def test_add_negative_integers(self) -> None:
response = client.post("/api/v1/calculator/add", json={"left": -3, "right": -5})
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')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"result": -8})
self.assertEqual(response.json, {'result': 5})
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})
def test_clamp_value_below_low_returns_low(self) -> None:
response = self.client.get('/clamp?value=-5&low=0&high=10')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"result": 7})
self.assertEqual(response.json, {'result': 0})
def test_subtract_negative_result(self) -> None:
response = client.post("/api/v1/calculator/subtract", json={"left": 3, "right": 10})
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')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"result": -7})
self.assertEqual(response.json, {'result': 100})
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)
def test_subtract_missing_left_returns_422(self) -> None:
response = client.post("/api/v1/calculator/subtract", json={"right": 3})
self.assertEqual(response.status_code, 422)
def test_subtract_missing_right_returns_422(self) -> None:
response = client.post("/api/v1/calculator/subtract", json={"left": 10})
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})
def test_clamp_to_byte_above_255_returns_255(self) -> None:
response = self.client.get('/clamp_to_byte?value=300')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"result": 0})
self.assertEqual(response.json, {'result': 255})
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)
def test_clamp_low_greater_than_high_returns_422(self) -> None:
response = client.post("/api/v1/calculator/clamp", json={"value": 5, "low": 10, "high": 0})
self.assertEqual(response.status_code, 422)
def test_clamp_missing_low_returns_422(self) -> None:
response = client.post("/api/v1/calculator/clamp", json={"value": 5, "high": 10})
self.assertEqual(response.status_code, 422)
def test_clamp_missing_high_returns_422(self) -> None:
response = client.post("/api/v1/calculator/clamp", json={"value": 5, "low": 0})
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_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_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)

View File

@ -3,69 +3,7 @@
import math
import unittest
from typosaurus_sandbox.domain.calculator import add, average, clamp, clamp_to_byte, median, 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 TestAverageFunction(unittest.TestCase):
def test_empty_sequence_raises_value_error(self) -> None:
with self.assertRaises(ValueError):
average([])
def test_single_element(self) -> None:
self.assertEqual(average([5]), 5.0)
def test_positive_values(self) -> None:
self.assertEqual(average([1, 2, 3, 4, 5]), 3.0)
def test_negative_values(self) -> None:
self.assertEqual(average([-10, -20, -30]), -20.0)
def test_mixed_positive_and_negative(self) -> None:
self.assertEqual(average([-5, 0, 5]), 0.0)
def test_float_values(self) -> None:
self.assertEqual(average([1.5, 2.5, 3.0]), 7.0 / 3.0)
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)
from src.calculator import clamp
class TestClampFunction(unittest.TestCase):
@ -123,61 +61,3 @@ class TestClampFunction(unittest.TestCase):
def test_nan_returns_nan(self) -> None:
result = clamp(math.nan, 0, 10)
self.assertTrue(math.isnan(result))
class TestMedianFunction(unittest.TestCase):
def test_odd_length_returns_middle_element(self) -> None:
self.assertEqual(median([1, 3, 5]), 3)
def test_even_length_returns_float_average_of_two_middle_values(self) -> None:
result = median([1, 2, 3, 4])
self.assertIsInstance(result, float)
self.assertEqual(result, 2.5)
def test_single_element_returns_that_element(self) -> None:
self.assertEqual(median([7]), 7)
def test_empty_list_raises_value_error(self) -> None:
with self.assertRaises(ValueError):
median([])
def test_unsorted_input_sorts_correctly(self) -> None:
self.assertEqual(median([3, 1, 2]), 2)
def test_unsorted_even_length_returns_float_average(self) -> None:
result = median([10, 30, 20, 40])
self.assertIsInstance(result, float)
self.assertEqual(result, 25.0)
class TestVarianceFunction(unittest.TestCase):
def test_empty_list_raises_value_error(self) -> None:
with self.assertRaises(ValueError):
variance([])
def test_empty_tuple_raises_value_error(self) -> None:
with self.assertRaises(ValueError):
variance(())
def test_single_element_returns_zero(self) -> None:
self.assertEqual(variance([42.0]), 0.0)
def test_constant_values_return_zero_variance(self) -> None:
self.assertEqual(variance([1.0, 1.0, 1.0]), 0.0)
def test_population_variance_of_known_set(self) -> None:
self.assertEqual(variance([1, 2, 3, 4, 5]), 2.0)
def test_two_element_variance(self) -> None:
self.assertEqual(variance([0, 2]), 1.0)
def test_integer_inputs_return_float(self) -> None:
result = variance([10, 20, 30])
self.assertIsInstance(result, float)
self.assertEqual(result, 200.0 / 3.0)
def test_tuple_input_returns_variance(self) -> None:
self.assertEqual(variance((1, 2, 3, 4, 5)), 2.0)