Skip to content
Merged
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ docs/superpowers/plans/
docs/api/openapi.json
docs/api/files-key-rules.json
docs/api/error-codes.json
docs/api/output-rules.json
docs/examples/
site/

Expand Down
9 changes: 6 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ IMAGE := $(if ${REGISTRY},${REGISTRY}/,)${IMAGE_NAME}:${DOCKER_TAG}
LATEST_IMAGE := $(if ${REGISTRY},${REGISTRY}/,)${IMAGE_NAME}:latest
DOCS_PORT ?= 9876

.PHONY: version build buildx-build-publish push run serve test test-all test-integration test-docker lint lint-fix format type-check checks regenerate-examples docs-examples docs-openapi docs-constraints docs-error-codes docs-build docs-serve
.PHONY: version build buildx-build-publish push run serve test test-all test-integration test-docker lint lint-fix format type-check checks regenerate-examples docs-examples docs-openapi docs-constraints docs-error-codes docs-output-rules docs-build docs-serve

# Print the tag the next build would use, e.g. for CI or `docker run`.
version:
Expand Down Expand Up @@ -104,8 +104,11 @@ docs-constraints:
docs-error-codes:
@PRELUM_ENVIRONMENT=test uv run python -m scripts.export_error_codes

docs-build: docs-examples docs-openapi docs-constraints docs-error-codes
docs-output-rules:
@PRELUM_ENVIRONMENT=test uv run python -m scripts.export_output_rules

docs-build: docs-examples docs-openapi docs-constraints docs-error-codes docs-output-rules
@uv run --group docs zensical build --strict

docs-serve: docs-examples docs-openapi docs-constraints docs-error-codes
docs-serve: docs-examples docs-openapi docs-constraints docs-error-codes docs-output-rules
@uv run --group docs zensical serve -a 127.0.0.1:${DOCS_PORT}
5 changes: 3 additions & 2 deletions app/api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ def _inline_local_defs(schema: dict[str, Any]) -> dict[str, Any]:
}

_CONSTRAINTS_RESPONSES: dict[int | str, dict[str, Any]] = {
200: {"description": "Published files-key rules and this instance's limits"},
200: {"description": "Published files-key rules, output-option rules and this instance's limits"},
403: {
"description": "Problem response",
"content": {"application/problem+json": {"schema": _PROBLEM_SCHEMA}},
Expand Down Expand Up @@ -145,7 +145,8 @@ async def constraints(
_token: Annotated[None, Depends(require_api_token)],
) -> ConstraintsResponse:
"""
Publish the files-key rules and this instance's limits, so a caller validates before dispatch.
Publish the files-key and output-option rules with this instance's limits, so a caller can
validate before dispatch.

Authenticated because the limits describe the deployment's operational configuration. The
response is a pure function of published constants and Settings: no body is read, no permit is
Expand Down
5 changes: 5 additions & 0 deletions app/core/constraints.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from typing import Any

from app.core.config import Settings
from app.core.output_rules import output_rules
from app.render.templates import (
INLINE_FILE_KEY_PATTERN,
MAX_INLINE_FILE_KEYS,
Expand Down Expand Up @@ -156,9 +157,13 @@ def build_constraints(settings: Settings) -> dict[str, Any]:
``max_keys`` is structural and ``max_inline_files`` is configurable, so the effective cap is the
smaller of the two. It is stated outright because a caller that mirrors only one of them has the
contract wrong in one direction or the other.

The output rules are nested whole rather than merged, so their version counter stays their own;
everything a caller needs to validate a request before dispatch then arrives in one fetch.
"""
return {
**files_key_rules(),
"output_rules": output_rules(),
"limits": {
"effective_max_files": min(MAX_INLINE_FILE_KEYS, settings.max_inline_files),
"max_inline_files": settings.max_inline_files,
Expand Down
338 changes: 338 additions & 0 deletions app/core/output_rules.py

Large diffs are not rendered by default.

23 changes: 22 additions & 1 deletion app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,18 @@ async def handle_validation_error(request: Request, exc: RequestValidationError)
):
error_class = UnsupportedFormatError

# The one error `detail`, `code` and `rule` all describe. `code` follows the classified
# files-key error wherever it sits, so that error — not errors[0] — is what the other two
# are read from as well; with nothing classified, the first failure answers for all three.
# Picking the three from different errors is what produced "source: Field required" beside
# a rule naming the output option, and a client doing what the documentation says (branch
# on `rule`, not on `detail`) then reported the wrong cause. Today the two cannot even
# coexist — the files-key checks run in RenderRequest's model validators, which Pydantic
# skips once a field such as `output` has failed — but that is an accident of where the
# checks happen to live, and moving one into a field validator on `files` would bring the
# mismatch straight back. Reading all three from one error does not depend on it.
reported = classified[0] if classified is not None else (errors[0] if errors else None)

context: dict[str, object] = {"errors": published_errors}
if len(errors) > _MAX_PUBLISHED_ERRORS:
context["errors_total"] = len(errors)
Expand All @@ -320,11 +332,20 @@ async def handle_validation_error(request: Request, exc: RequestValidationError)
# "message" is the prose already in msg.
lifted = cast(dict[str, object], classified[0].get("ctx", {}))
context.update({key: value for key, value in lifted.items() if key != "message"})
# An output-option rule keeps the `value_error` type every other validator failure has, so
# `loc`, `type` and `code` are the same whichever of them fired: without the id, the only
# thing separating them is `msg`, which is prose a caller may not branch on. The id is
# therefore published, and past _MAX_PUBLISHED_ERRORS it is the only trace of the rule left
# — the named error need not appear in `errors` at all. A rule carried by some other error
# is dropped rather than reported: the caller has not been told about that failure yet.
rule = cast(dict[str, object], reported.get("ctx", {})).get("rule") if reported is not None else None
if isinstance(rule, str):
context["rule"] = rule

# Validation errors carry the raised exception in ctx; encode at any depth so the response
# body cannot fail to serialise.
error = error_class(
_validation_detail(errors[0]) if errors else "Request validation failed",
_validation_detail(reported) if reported is not None else "Request validation failed",
context=cast(dict[str, object], jsonable_encoder(context, custom_encoder={Exception: str})),
)
return error.to_response(request)
Expand Down
Loading
Loading