Skip to content

query: add fieldFilters/sort/offset to list_runs#122

Merged
asaiacai merged 11 commits into
mainfrom
worktree-list-runs-filter-sort-offset
Jun 27, 2026
Merged

query: add fieldFilters/sort/offset to list_runs#122
asaiacai merged 11 commits into
mainfrom
worktree-list-runs-filter-sort-offset

Conversation

@asaiacai

@asaiacai asaiacai commented Jun 16, 2026

Copy link
Copy Markdown

Context

Companion client change to pluto-server 969b97f ("Patch #490"), which brought GET /api/runs/list to parity with the tRPC runs.list interface 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 forwarded projectName/search/tags/limit — none of the new capabilities were reachable. This PR exposes them.

Changes

pluto/query.py

  • New FieldFilter dataclass ({source, key, dataType, operator, values}) with validation against the server's operator/source/dataType vocabulary, scalar→list coercion, and to_dict().
  • Client.list_runs + module-level list_runs gain:
    • field_filters — list of FieldFilter objects or raw dicts; JSON-encoded to the fieldFilters param; >50 terms raises ValueError.
    • 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.
  • Return type stays List[dict] (backward-compatible; total not surfaced).

Tests

  • tests/test_query.py — unit tests for the new params + FieldFilter validation/coercion; updated the existing forwarding assertion.
  • tests/test_e2e.py — sort-newest-first, offset pagination, and a config fieldFilters round-trip.
  • tests/test_contract.py (new) — fetches the live /api/openapi.json and diffs the FieldFilterTerm component enums against the client's filter constants, so client/server drift fails CI.

Required follow-up (separate pluto-server PR)

The contract test skips until the server surfaces its existing inner field-filter zod schema as components.schemas.FieldFilterTerm (real enums for operator/source/dataType) in the OpenAPI doc. Today the operators live only in the fieldFilters param 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 passed
  • pytest 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_runs and the module-level list_runs, matching the expanded GET /api/runs/list API.

Adds a FieldFilter dataclass with client-side validation against the server’s filter vocabulary (source, dataType, operator), scalar→list coercion, and JSON serialization to the fieldFilters query param. More than 50 filter terms raises ValueError; offset is clamped to 0–100,000 and omitted when zero. sort is passed through as [+|-]<field>.

tests/test_contract.py compares client filter enums to live OpenAPI FieldFilterTerm (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.

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>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread pluto/query.py Outdated
key: str
dataType: str
operator: str
values: List[Any] = field(default_factory=list)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
values: List[Any] = field(default_factory=list)
values: Union[List[Any], Any] = field(default_factory=list)

Comment thread pluto/query.py Outdated
Comment on lines +261 to +263
terms = [
f.to_dict() if isinstance(f, FieldFilter) else f for f in field_filters
]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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__}'
                    )

Comment thread pluto/query.py Outdated
Comment on lines +267 to +277
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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

Comment thread tests/test_contract.py Outdated
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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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>

@ryanhayame ryanhayame left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants