query: add fieldFilters/sort/offset to list_runs#122
Conversation
Companion client change to pluto-server #490, which added server-side
filtering, ordering, and offset pagination to GET /api/runs/list.
- New FieldFilter dataclass ({source, key, dataType, operator, values})
with validation against the server's operator/source/dataType
vocabulary; accepts FieldFilter objects or raw dicts.
- Client.list_runs and the module-level list_runs gain field_filters
(JSON-encoded, max 50 terms), sort (pass-through string), and offset
(clamped to 0-100,000). Return type stays List[dict].
- Unit tests for the new params and FieldFilter validation; e2e tests
for sort/offset/fieldFilters; a contract test that diffs the client's
filter enums against the live OpenAPI FieldFilterTerm component (skips
until the companion server change publishes that component).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request adds server-side filtering, sorting, and offset-based pagination to the list_runs method in the Pluto query client, supported by a new FieldFilter dataclass and comprehensive contract and end-to-end tests. The review feedback suggests improving type-checker compatibility for scalar values in FieldFilter, adding explicit type validation for input filters, avoiding redundant offset=0 parameters when negative offsets are clamped, and ensuring contract tests skip gracefully if the OpenAPI spec endpoint returns invalid JSON.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| key: str | ||
| dataType: str | ||
| operator: str | ||
| values: List[Any] = field(default_factory=list) |
There was a problem hiding this comment.
The values parameter is annotated as List[Any], but the docstring, examples, and __post_init__ coercion logic explicitly support passing a scalar value (which is then coerced into a single-element list). This mismatch causes static type checkers like mypy to raise errors when a scalar is passed (as shown in the docstring example FieldFilter("config", "model", "text", "contains", "gpt")).
Updating the type annotation to Union[List[Any], Any] resolves this discrepancy and ensures type-checker compatibility.
| values: List[Any] = field(default_factory=list) | |
| values: Union[List[Any], Any] = field(default_factory=list) |
| terms = [ | ||
| f.to_dict() if isinstance(f, FieldFilter) else f for f in field_filters | ||
| ] |
There was a problem hiding this comment.
The list comprehension assumes that any item in field_filters that is not a FieldFilter is a dictionary and passes it through. If a user accidentally passes an invalid type (e.g., a string or nested list), it will be serialized and sent to the server, resulting in an opaque HTTP error.
Adding an explicit type check ensures fail-fast behavior with a clear error message on the client side.
terms = []
for f in field_filters:
if isinstance(f, FieldFilter):
terms.append(f.to_dict())
elif isinstance(f, dict):
terms.append(f)
else:
raise TypeError(
f'field_filters must contain only FieldFilter instances or dicts, got {type(f).__name__}'
)| if offset: | ||
| clamped = max(0, min(offset, _MAX_OFFSET)) | ||
| if clamped != offset: | ||
| logger.debug( | ||
| '%s: offset %d clamped to %d (max %d)', | ||
| tag, | ||
| offset, | ||
| clamped, | ||
| _MAX_OFFSET, | ||
| ) | ||
| params['offset'] = clamped |
There was a problem hiding this comment.
If offset is negative (e.g., -5), it is clamped to 0. However, because offset is truthy, params['offset'] = 0 is added to the request parameters. In contrast, if offset is initially 0, it is omitted from the request parameters entirely.
To ensure consistent behavior and avoid sending redundant offset=0 parameters to the server, only include the offset parameter in the request if the clamped value is strictly greater than 0.
| if offset: | |
| clamped = max(0, min(offset, _MAX_OFFSET)) | |
| if clamped != offset: | |
| logger.debug( | |
| '%s: offset %d clamped to %d (max %d)', | |
| tag, | |
| offset, | |
| clamped, | |
| _MAX_OFFSET, | |
| ) | |
| params['offset'] = clamped | |
| if offset: | |
| clamped = max(0, min(offset, _MAX_OFFSET)) | |
| if clamped != offset: | |
| logger.debug( | |
| '%s: offset %d clamped to %d (max %d)', | |
| tag, | |
| offset, | |
| clamped, | |
| _MAX_OFFSET, | |
| ) | |
| if clamped > 0: | |
| params['offset'] = clamped |
| pytest.skip(f'Could not reach OpenAPI spec at {url}: {exc}') | ||
| if resp.status_code != 200: | ||
| pytest.skip(f'OpenAPI spec at {url} returned HTTP {resp.status_code}') | ||
| return resp.json() |
There was a problem hiding this comment.
If the OpenAPI spec endpoint is reachable but returns invalid JSON (e.g., a proxy, captive portal, or CDN error page returning a 200 OK with HTML content), resp.json() will raise a ValueError / JSONDecodeError. This will cause the contract test to fail and disrupt CI, rather than skipping gracefully as intended for network-dependent tests.
Wrapping resp.json() in a try-except block to catch ValueError ensures the test skips cleanly under these conditions.
| return resp.json() | |
| try: | |
| return resp.json() | |
| except ValueError as exc: | |
| pytest.skip(f'Failed to parse OpenAPI spec JSON from {url}: {exc}') |
… test on non-JSON spec - list_runs: a negative offset clamps to 0 and is now omitted rather than sent as a redundant offset=0 param. - test_contract: skip gracefully if /api/openapi.json returns a non-JSON body. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a reusable contract-test workflow (workflow_call, parameterised by API base URL) with two thin callers — prod (pluto-api.trainy.ai) and staging (pluto-api-dev.trainy.ai) — so the OpenAPI FieldFilterTerm schema check runs against both on every PR/push. Staging deploys first, so client/server enum drift is caught before it reaches prod. Also send Authorization: Bearer $PLUTO_API_KEY when fetching the spec, so the check still runs if staging gates /api/openapi.json behind auth (harmless on the unauthenticated prod endpoint). Both jobs pass-with-skips until the server publishes the FieldFilterTerm component; they self-activate once it appears. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reusable-workflow callers with permissions:{} caused startup_failure — a called
workflow cannot receive more GITHUB_TOKEN permission than its caller, and the
reusable job requests contents:read. Grant it on the callers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Companion to server PR #512. Lets clients filter runs by lifecycle status and
last-activity (heartbeat) time. The Linum spot-retry query becomes one call:
list_runs(project,
status=['RUNNING','FAILED','TERMINATED','CANCELLED'],
heartbeat_before=cutoff_iso) # stale, non-completed => retry
- status: list[str] validated against the 5 RunStatus values (ValueError on bad),
forwarded as the comma-separated `status` param.
- heartbeat_after / heartbeat_before: ISO-8601, forwarded as heartbeatAfter/
heartbeatBefore (server requires projectName for these).
- Docstring documents the retry recipe; regenerated docs-api/query.mdx.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Consolidates list_runs filtering behind a single OR-capable `filters` argument
(wandb's api.runs(filters=...) shape) instead of one kwarg per dimension. Removes
the field_filters/status/heartbeat_* kwargs and the public FieldFilter dataclass
added earlier on this branch (unmerged), in favor of:
list_runs(project, filters={"$or": [
{"state": "running"},
{"heartbeat_at": {"$gte": cutoff}},
]})
- list_runs(project, search, tags, limit, sort, offset, filters): search/tags/
sort/offset kept; filters is a MongoDB-style dict sent as the `filter` param.
- _validate_filters: light client-side structural check of boolean/leaf
operators + field names (clear ValueError; server enforces full semantics).
- test_contract.py reframed: assert the deployed server documents the `filter`
param (skips until deployed) instead of the now-internal FieldFilterTerm enum.
- Tests + e2e updated to the filters surface; docs regenerated.
Companion to server #512 (run-filter compiler).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The filter grammar is now defined canonically on the server (run-filter-grammar.ts) and published as the RunFilterGrammar OpenAPI component. These client constants are the mirror; assert they match the published grammar so drift fails CI instead of surfacing as a runtime 400. - add _FILTER_BOOL_OPS so all four vocab sets (bool/leaf ops, fields, prefixes) are explicit and contract-testable. - test_contract.py: assert each client set equals RunFilterGrammar's published enums (skips until the server change is deployed to the target host). Companion to server #512 (RunFilterGrammar). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Context
Companion client change to pluto-server
969b97f("Patch #490"), which broughtGET /api/runs/listto parity with the tRPCruns.listinterface by adding server-side field filtering, ordering, and offset pagination. The Python client (pluto/query.py) is the REST consumer of that endpoint but only forwardedprojectName/search/tags/limit— none of the new capabilities were reachable. This PR exposes them.Changes
pluto/query.pyFieldFilterdataclass ({source, key, dataType, operator, values}) with validation against the server'soperator/source/dataTypevocabulary, scalar→list coercion, andto_dict().Client.list_runs+ module-levellist_runsgain:field_filters— list ofFieldFilterobjects or raw dicts; JSON-encoded to thefieldFiltersparam; >50 terms raisesValueError.sort— pass-through[+|-]<field>string (built-in columns,config.<key>.value,summary_metrics.<name>,heartbeat_at).offset— clamped to 0–100,000, omitted when 0.List[dict](backward-compatible;totalnot surfaced).Tests
tests/test_query.py— unit tests for the new params +FieldFiltervalidation/coercion; updated the existing forwarding assertion.tests/test_e2e.py— sort-newest-first, offset pagination, and aconfigfieldFiltersround-trip.tests/test_contract.py(new) — fetches the live/api/openapi.jsonand diffs theFieldFilterTermcomponent enums against the client's filter constants, so client/server drift fails CI.Required follow-up (separate
pluto-serverPR)The contract test skips until the server surfaces its existing inner field-filter zod schema as
components.schemas.FieldFilterTerm(realenums foroperator/source/dataType) in the OpenAPI doc. Today the operators live only in thefieldFiltersparam description prose. Once that component is published, the contract test enforces automatically — no client change needed.Verification
ruff check pluto mlop tests✓ ·ruff format --check✓ ·mypy pluto✓ (33 files)pytest tests/test_query.py→ 59 passedpytest tests/test_contract.py→ 3 skipped (reaches live spec; component pending)🤖 Generated with Claude Code
Note
Low Risk
Backward-compatible query API extensions with client-side validation; no auth or data-write paths changed.
Overview
Exposes server-side field filtering, sorting, and offset pagination on
Client.list_runsand the module-levellist_runs, matching the expandedGET /api/runs/listAPI.Adds a
FieldFilterdataclass with client-side validation against the server’s filter vocabulary (source,dataType,operator), scalar→list coercion, and JSON serialization to thefieldFiltersquery param. More than 50 filter terms raisesValueError;offsetis clamped to 0–100,000 and omitted when zero.sortis passed through as[+|-]<field>.tests/test_contract.pycompares client filter enums to live OpenAPIFieldFilterTerm(skips until the server publishes that component). Unit and e2e tests cover the new parameters and a config field-filter round-trip.Reviewed by Cursor Bugbot for commit 0e69efa. Configure here.