API #2

Merged
schobernoise merged 9 commits from API into main 2026-09-01 17:38:30 +00:00
Owner
No description provided.
Groundwork for the HTTP API. Each of these is a real defect today and each
improves the CLI on its own merits; the API only makes them matter more.

Distribution metadata (blocked containerising entirely). __version__.py and
core/config.py each opened pyproject.toml at import of `shamash` itself,
disagreeing on how far up to walk (parents[2] vs parents[3]), so
`import shamash` raised FileNotFoundError anywhere the source tree was
absent -- every wheel, every container. One resolution now lives in
project_metadata(): the checkout's pyproject when it is present and names
this distribution, else importlib.metadata, else placeholders. Never raises.
The checkout wins deliberately, because a development install's .dist-info
is a snapshot and had gone stale at 2026.0.2 against a pyproject saying
2026.0.3.1. Verified: a wheel extracted somewhere with no pyproject above it
runs `python -m shamash -p` to a world byte-identical to `make run`.

Export no longer crashes on absent optional fields. _dump_value checked
`units is None` but never `value is None`, so a world that declined to model
a planetary core -- documented as valid -- died in conversions.py with
`TypeError: unsupported operand for *: NoneType and float`. It now exports a
bare null. Over HTTP that was a 500 on an entirely reasonable request.

Unit validation at load. _resolve_value now takes the field it is filling
and rejects an unknown symbol (a bare ValueError used to escape, naming
neither entity nor field) and, more quietly, a real unit of the wrong
dimension: `mass: {value: 1.0, unit: km}` loaded as 1000.0 kg without a word.
One Enum class per dimension makes the check a one-liner. New
calculationerror.py holds InvalidWorldDataError/InvalidUnitError on the
already-reserved CALCULATION category.

AppError._log honours its level argument instead of hardcoding logger.error.
MissingModelDataError and NoOrbitRelationError now pass DEBUG: both document
themselves as normal conditions and iter_computed_properties provokes them as
control flow, so a coreless world logged 14 ERROR lines per dump. Now zero.

FileError accepts the error_code/status_code its own subclasses were already
passing -- it did not, so constructing either raised TypeError instead of the
error it meant to report. FileNotFoundError becomes WorldFileNotFoundError,
which stops it shadowing the builtin.

pyproject declares data/*.yml as package data. Note the predicted failure did
not reproduce: setuptools 68+ already shipped the seed world without it
(checked both ways, cache disabled). Kept as an explicit guarantee, since
that is implicit behaviour and BaseService.run resolves the path relative to
the installed package.

Seed world byte-identical; all documented anchors hold (Sol 1.000 RSol /
5770.8 K / G2.9 / #FFF1EA, Earth habitability 1.000, Moon 0.000, plant_color
#B3F49A, magnetopause 9.519 Re, moment 7.48e22).

Claude-Session: https://claude.ai/code/session_013zVYK6aAA8eVFKHMGWshrz
god.world becomes a property over a contextvars.ContextVar: inside
god.scope() it resolves to that scope's world, outside one to the single
process world. No call site changed -- roughly fifty model properties reach
sh.god from inside a @property body, where no registry argument can be
threaded through, so isolating a world has to work by changing what
god.world resolves to rather than by passing a registry around.

The ContextVar defaults to None rather than {}. None encodes "no scope
active", which is what keeps the CLI on the process world; an empty-dict
default would hand every thread its own blank world and break the CLI the
moment anything ran off the main thread.

The bug this fixes is invisible from the CLI and fatal over HTTP. Two
sequential Worldloader.load() calls returned *the same dict object*, so two
different worlds merged into one registry (15 entities from two 8-entity
worlds). Under concurrency it is worse than a merge: 20 computes over 8
threads raise RuntimeError: dictionary changed size during iteration. With
scope() the same 20 return one distinct result per input world, share no
entity objects, and leave the process world empty.

Also adds god.reset(), which empties the active world in place -- in place
because BaseService.run hands god.world to its caller.

Seed world byte-identical, 40 modules cold-start import, black clean.

Claude-Session: https://claude.ai/code/session_013zVYK6aAA8eVFKHMGWshrz
Both directions collapse to one dict-level core, so a file, a YAML string
and a JSON body are three ways into the same loader and two ways out of the
same serializer. load() keeps its signature and gains only the filesystem;
loads() takes YAML text, which is the seam an HTTP body uses; load_data()
takes an already-parsed mapping and is the real entry point. to_dict()
becomes the single source of the serialized form and dumps() a thin YAML
renderer over it.

to_dict is already JSON-serializable -- 7,762 bytes for the seed world --
because _to_plain normalises Enums and tuples for YAML's safe dumper, which
is exactly what JSON needs. Rendering JSON needs no second serializer.

Structural failures are now typed. Every one of these used to escape as a
bare KeyError or TypeError from somewhere inside the loop, naming nothing a
caller could act on: no 'world' mapping, no 'entities' list, an entry that
isn't a mapping, an entry with no 'type' or no 'id', unparseable YAML. They
raise MalformedWorldError naming the position in the payload
("world.entities[3]"), since an entry missing its id can be named no other
way; an unrecognised type raises UnknownEntityTypeError listing every
loadable type. load() maps OSError to WorldFileNotFoundError /
FilePermissionError / FileError, which is the first use of the file-error
classes fixed in Phase 0 -- a directory reports "Not a file" rather than
"File not found", because the path exists and is simply the wrong kind of
thing.

Verified: seed world byte-identical, -e and -p still agree byte for byte,
dump -> reload -> redump is stable, loads() and load_data(json) produce the
same dict, and 20 concurrent computes through the new seams stay isolated.

Claude-Session: https://claude.ai/code/session_013zVYK6aAA8eVFKHMGWshrz
Adds src/shamash/api/, starting with the piece the endpoints need before
they can exist: a decision about what each failure means to a caller.

AppError.status_code was never an HTTP status -- it holds an ErrorCategory
value that the log formatter correlates on, though the class docstring
claimed otherwise. Repurposing it would break log formatting and force
every raise site to think about HTTP, so the mapping lives in the API
layer, keyed by exception class and read down the MRO. A new subclass
inherits its parent's status without touching the table: the three
InvalidWorldDataError subclasses are 422 by descent alone.

The line is whose mistake it is. Everything about the submitted world is
422 -- it parsed, so the complaint is about what it says, not its syntax.
Everything about us is 500, the whole FileError branch included: the API
takes no path from anybody, so a file error means the deployment cannot
read its own seed world, and a 404 would blame the caller for that.
EntityDoesNotExistError is 422 for the mirror-image reason -- a dangling
parent_star is a bad reference inside the world, not a URL that failed.

error_body reuses AppError.to_dict() so one place accounts for what an
error exposes, renaming status_code to category and adding the real
status. A non-AppError gets a fixed body; its message may name a path or
somebody else's world.

Two fixes found by constructing every exception class to check it:

  - ConfigurationError did not accept the error_code/status_code that
    EnvironmentVariableError was already passing, so constructing one
    raised TypeError. Same defect FileError had, missed for the same
    reason -- nothing raises either yet.
  - _capture_stack_trace now calls inspect.stack(0). The default reads and
    caches every frame's source line off disk (0.115 ms vs 0.053 ms) for
    lines only co_qualname is taken from.

Seed world byte-identical, -e still matches -p, 41 modules cold-start.
The scaffolding the world endpoints need. /healthz is the only route --
3e adds the rest -- but everything around it is here and exercised: CORS,
the four exception handlers, JSON/YAML in and out, and an OpenAPI schema.

Handlers have to be async def, contradicting the plan. The obvious shape
-- a sync def, which FastAPI runs in a threadpool with the context copied
-- cannot own its request body. A `body: bytes = Body()` parameter works
for YAML and fails for JSON, the primary content type: FastAPI decodes
the JSON first, then rejects the resulting dict as "not valid bytes", so
a good request gets a 422 in FastAPI's error shape. Reading
`await request.body()` and handing the compute to run_in_threadpool gets
both properties: the ~117 ms stays off the event loop, and a ContextVar
set inside the worker does not leak back to the loop -- measured, and
exactly the isolation god.scope() needs.

payload.py decides notation and never meaning. The directions are
asymmetric on purpose: an undecodable request is a hard 415, because
guessing at a body we were told is something else computes the wrong
world silently, while an unmatchable Accept falls back to JSON rather
than 406 -- the usual cause is a client that never thought about it. Its
YAML branch calls the new Worldloader.render staticmethod instead of its
own safe_dump call, so a YAML response and `shamash -p` are byte-
identical by construction rather than by two call sites agreeing today.

Non-finite floats become null on the JSON path only. json.dumps writes
bare NaN/Infinity, which JSON.parse rejects outright, so one unbounded
property would hand the frontend a body it cannot read; YAML round-trips
.nan fine, which is why the CLI never noticed. Not done in the loader --
null is honest output but a silent data change on the way in.

schemas.py models the envelope strictly and entities loosely. Typing
entity fields would restate the dataclass metadata in a second place and
drift, which is what EXPORT_UNITS already demonstrated.

Two fixes on the way past:

  - settings.log_level now applies to the `shamash` logger, not the root.
    basicConfig's level is a root level, so DEBUG turned on DEBUG for
    every library in the process -- chatty in the CLI, a couple of
    asyncio/httpx lines per request under the API.
  - pyproject gains the dev extra `make install` has always asked for and
    never found. httpx2 is what starlette's TestClient imports.

Seed world byte-identical, -e still matches -p, 45 modules cold-start,
verified under real uvicorn as well as TestClient.
POST /v1/world:compute is the -w flag over HTTP. GET /v1/world/default
computes the packaged seed world so a frontend has something real to
render before anyone types a number. GET /v1/schema/entities derives the
per-type field/unit/required metadata from the models at runtime -- off
the class, constructing no entity and evaluating no property, so it can
list properties the export path can only discover by calling them.

The contract is byte-identity with the CLI, and it holds both ways:
posting default.yml as application/yaml returns exactly what `shamash -p`
writes, as does GET /v1/world/default, and a JSON response re-posted as
JSON re-computes to the identical document.

?diagnostics=true closes the silent-200 hazard. A property that raises is
omitted from the dump, which is right for a file -- a NotImplementedError
stub is not data -- but over HTTP it means a parseable-but-wrong world
comes back 200 with fields quietly missing and nothing saying which. On,
each entity carries an _unavailable map. Off by default, so dumps() stays
byte-identical and the -p/-e invariant holds; 8 lines on the seed world.

Load and serialization run in one god.scope() on the worker thread,
because to_dict reaches back into the registry through
orbit.orbital_distance_unit, outside _dump_entity's per-property guard. A
dangling parent_celestial_object therefore fails in the *dump* phase --
now verified to land as a 422 rather than a 500. Handlers return rendered
text from the worker, so nothing touches the registry after the scope
closes.

Isolation, the one thing the CLI can never exercise: 20 distinct worlds
posted 8-way concurrent, each response holding exactly its own 8 entities
and nothing else, process world empty afterwards.

Three supporting changes:

  - god.summons now names the id it could not find. The bare default
    message reads fine beside a traceback and is useless as a 422, where
    the traceback is exactly what the caller does not get.
  - BaseService.default_world_path() -- the seed path is needed in two
    places now, and a second copy of that parents[2] arithmetic would be
    a second thing to get wrong.
  - _dimension_name moves from world/service.py to utils as
    dimension_name, since the schema endpoint labels fields with it.

Typing Entity's extras is what puts Quantity into the OpenAPI components
at all; nothing else references it, and a generated client with no name
for {value, unit} would miss the shape on nearly every field. The union
is wide on purpose -- it documents, it does not validate.

Seed world byte-identical, -e matches -p, every documented anchor holds
(Sol 1.000 RSol / 5770.8 K / G2.9, Earth 1.000, Moon 0.000, #B3F49A,
9.5 REarth), 46 modules cold-start.
[project.scripts] gives shamash (cli:main) and shamash-api
(api.app:serve); there were none at all before. serve() grows
--host/--port/--reload defaulting to Settings, which reads
SHAMASH_API_HOST/PORT from the environment, so a container needs no flags
and a developer needs no environment.

--reload has to hand uvicorn the import string rather than the app
object: the reloader re-imports the module in a fresh process on every
change and cannot do that with an object it was given. Passing the object
with reload on is silently ignored -- nothing reloads and nothing says
why.

`make api` goes through the console script for the same class of reason.
Calling `python -m uvicorn` directly skips serve(), which is the only
thing that reads api_host/api_port, so it would quietly ignore both and
always listen on :8000. Caught by testing the target rather than the
function.

Two documented Makefile defects fixed while here, both of which I have
been working around by hand all along: `install` now resolves, because
the dev extra it asks for exists as of the previous commit, and `format`
passes $(wildcard tests) so it stops failing on a directory that was
deleted. The stale `test` entry is out of .PHONY.

Verified from a wheel with no source tree: entry_points.txt carries both
scripts, `python -m shamash -p` matches `make run` byte for byte, and the
API served out of that wheel returns a world byte-identical to the CLI.
Both scripts, the env-var path and the reload path all exercised against
a live server.

Left alone deliberately: `make venv` still says --python 3.13 while the
venv in use is 3.14.6. Both satisfy requires-python >=3.12 and picking
one is a call about the environment, not a fix; noted in CLAUDE.md.
The API item and its seven sub-items are all done, so the parent is
ticked. Getting started never mentioned the API at all and pointed at
src/data/default.yml, which is not where the seed world lives; it now
names both console scripts, /docs, and the fact that neither needs the
source tree.

Three things are added as open items rather than left as things only this
conversation knew:

  - The frontend decision, and why the API being OpenAPI-first is what
    keeps it a late one.
  - The missing test suite. Every check across this whole branch has been
    a one-off script; the seed world is the obvious fixture, and the
    anchors in CLAUDE.md already read like assertions.
  - What log level a caller's mistake deserves. AppError logs at ERROR on
    construction, so over HTTP every 422 -- the client's bad world, not a
    server incident -- lands in the error log, while the same exception
    from the CLI against a file you just edited is worth an ERROR. One
    class, two contexts.

Containerising moves from blocked to merely undone, and the 3.13/3.14
mismatch in `make venv` is written down rather than fixed, since which
interpreter to build on is a call about the environment.

Every claim the new Getting started makes was run: both scripts, /docs on
the default port, and GET /v1/world/default byte-identical to `make run`.
Six defects from a review of this branch, all reproduced before and after.

Three turned a bad request into a 500:

  - A dimensional field's *value* was never type-checked, only its unit.
    `mass: "1.26e9"` reached an arithmetic operation and died as a bare
    TypeError naming neither entity nor field. That is the YAML 1.1
    unsigned-exponent trap CLAUDE.md documents, which has already bitten
    this project's own seed world, so it is the likeliest malformed world
    anyone will send. Now InvalidValueError/422, and when the value looks
    like an unsigned exponent the message says to write 1.26e+9. Checked
    in both arrival shapes, since the seed world hit it in the bare one.
  - unit_from_symbol was guarded for KeyError/ValueError but not
    TypeError, so `unit: [kg]` raised "unhashable type" and escaped, while
    `unit: 5` was correctly a 422.
  - Worldloader.load caught three OSErrors, but read_text also raises
    UnicodeDecodeError -- a ValueError -- on a latin-1 world file, and
    ENOTDIR/ELOOP still escaped untyped.

One made every 500 undiagnosable: _handle_unexpected is sync, so Starlette
runs it in a threadpool where sys.exc_info() is empty, and
logger.exception wrote a literal "NoneType: None" where the traceback
belongs. exc_info=exc explicitly.

One took the CLI down for an API setting: int(os.getenv("SHAMASH_API_PORT"))
in a default_factory on the module-level settings, so SHAMASH_API_PORT= --
docker-compose's usual spelling of "unset" -- killed `shamash -p` with a
ValueError about a port it never wanted. Warn and fall back.

One made error bodies unreadable by the browser they are formatted for.
An Exception handler is installed in ServerErrorMiddleware, outside the
whole user stack, so 500s carried no Access-Control-Allow-Origin while
every other status did. Caught inside the stack now -- which needs the
middleware registered *before* CORS, since add_middleware inserts at the
front and the last one added ends up outermost. My first attempt got that
backwards and changed nothing; the traceback showing the handler outside
cors.py is what caught it.

Seed world byte-identical, -e matches -p, API YAML still byte-identical to
shamash -p, 20/20 concurrent worlds isolated, anchors hold, 46 modules
cold-start.

Left for you, as flagged: whether a caller's 422 should log at ERROR, and
whether SHAMASH_CORS_ORIGINS="" should mean "no origins" or "the default".
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: schobernoise/shamash#2
No description provided.