API #2
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "API"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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_013zVYK6aAA8eVFKHMGWshrzgod.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_013zVYK6aAA8eVFKHMGWshrzBoth 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_013zVYK6aAA8eVFKHMGWshrzAdds 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.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".