diff --git a/maestro_worker_python/serve.py b/maestro_worker_python/serve.py index eb6f656..67744f1 100644 --- a/maestro_worker_python/serve.py +++ b/maestro_worker_python/serve.py @@ -144,11 +144,23 @@ async def validation_error_handler(request: Request, exc: ValidationError): return JSONResponse(status_code=400, content=jsonable_encoder({"error": exc.reason})) +def _describe_validation_errors(errors: list) -> str: + """One line a caller can read, e.g. ``stems: Input should be a valid integer; outputs.midi: Field required``.""" + described = [] + for error in errors: + # A model-level validator reports an empty location. + field = ".".join(str(part) for part in error["loc"]) or "request" + described.append(f"{field}: {error['msg']}") + return "; ".join(described) + + @app.exception_handler(pydantic.ValidationError) async def pydantic_validation_exception_handler(request: Request, exc: pydantic.ValidationError): + errors = exc.errors() + # `error` carries the reason a caller reads, as it does for ValidationError. return JSONResponse( status_code=400, - content=jsonable_encoder({"detail": exc.errors()}), + content=jsonable_encoder({"error": _describe_validation_errors(errors), "detail": errors}), ) diff --git a/pyproject.toml b/pyproject.toml index 136a0f2..5bfe7f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "maestro-worker-python" -version = "5.2.1" +version = "5.2.2" description = "Utility to run workers on Moises/Maestro" readme = "README.md" requires-python = ">=3.10,<3.14" diff --git a/tests/test_serve.py b/tests/test_serve.py index 13277aa..447c6ed 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -345,6 +345,20 @@ def test_a_recoverable_failure_keeps_the_process_serving(tmp_path, monkeypatch, assert terminations == [] +def test_an_invalid_request_is_refused_with_a_reason_naming_the_field(tmp_path, monkeypatch): + _, client, _ = _serve_failing_worker( + tmp_path, + monkeypatch, + "import pydantic; type('Request', (pydantic.BaseModel,), {'__annotations__': {'stems': int}})(stems='x')", + ) + + refused = client.post("/inference", json={}) + + assert refused.status_code == 400 + assert refused.json()["error"].startswith("stems: ") + assert refused.json()["detail"][0]["loc"] == ["stems"] + + def _start_with_json_logging(tmp_path, then: str = "") -> subprocess.CompletedProcess: """Import serve.py in a fresh interpreter: json_logging configures process-wide state once.""" worker_path = tmp_path / "worker.py" diff --git a/uv.lock b/uv.lock index fa005b1..3611116 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.10, <3.14" [[package]] @@ -220,7 +220,7 @@ wheels = [ [[package]] name = "maestro-worker-python" -version = "5.2.1" +version = "5.2.2" source = { editable = "." } dependencies = [ { name = "fastapi" },