diff --git a/.github/workflows/contract-test-prod.yml b/.github/workflows/contract-test-prod.yml new file mode 100644 index 0000000..05db8fb --- /dev/null +++ b/.github/workflows/contract-test-prod.yml @@ -0,0 +1,26 @@ +name: contract-test (prod) + +# Runs the OpenAPI schema contract test against production. + +on: + push: + branches: + - main + - 'releases/**' + pull_request: + branches: + - main + - 'releases/**' + workflow_dispatch: + +# Must grant at least what the called workflow's job requests (contents: read); +# a called workflow cannot receive more token permission than its caller. +permissions: + contents: read + +jobs: + prod: + uses: ./.github/workflows/contract-test.yml + with: + url_api: https://pluto-api.trainy.ai + secrets: inherit diff --git a/.github/workflows/contract-test-staging.yml b/.github/workflows/contract-test-staging.yml new file mode 100644 index 0000000..59f2caf --- /dev/null +++ b/.github/workflows/contract-test-staging.yml @@ -0,0 +1,28 @@ +name: contract-test (staging) + +# Runs the OpenAPI schema contract test against staging/dev, where the server +# side typically deploys first — so client/server enum drift is caught before +# it reaches production. + +on: + push: + branches: + - main + - 'releases/**' + pull_request: + branches: + - main + - 'releases/**' + workflow_dispatch: + +# Must grant at least what the called workflow's job requests (contents: read); +# a called workflow cannot receive more token permission than its caller. +permissions: + contents: read + +jobs: + staging: + uses: ./.github/workflows/contract-test.yml + with: + url_api: https://pluto-api-dev.trainy.ai + secrets: inherit diff --git a/.github/workflows/contract-test.yml b/.github/workflows/contract-test.yml new file mode 100644 index 0000000..8da96c3 --- /dev/null +++ b/.github/workflows/contract-test.yml @@ -0,0 +1,57 @@ +name: contract-test + +# Reusable workflow: run the OpenAPI schema contract test (tests/test_contract.py) +# against a given Pluto API host. Callers (contract-test-prod.yml, +# contract-test-staging.yml) supply the base URL so the two environments share +# one identical job definition and can never drift. + +on: + workflow_call: + inputs: + url_api: + description: Pluto API base URL (e.g. https://pluto-api.trainy.ai) + required: true + type: string + +permissions: {} + +jobs: + contract: + # Reuse the "integration" environment so secrets.MLOP_API_TOKEN resolves. + environment: integration + permissions: + contents: read + runs-on: ubicloud-standard-4 + strategy: + matrix: + python-version: ["3.11"] + poetry-version: ["2.1.1"] + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: ${{ matrix.python-version }} + + - name: Install Poetry + run: pip install poetry==${{ matrix.poetry-version }} + + - name: Install deps + run: | + set -euox pipefail + # Clear stale cached venv that may be missing dev dependencies. + rm -rf .venv + # `--extras full` is required even for this light job: importing + # pluto.query pulls in pluto/__init__.py -> pluto/file.py -> soundfile. + poetry install --with dev --extras full + pip install -e ".[dev,full]" + + - name: Run contract test + env: + PLUTO_URL_API: ${{ inputs.url_api }} + PLUTO_API_KEY: ${{ secrets.MLOP_API_TOKEN }} + run: | + set -euox pipefail + poetry run pytest tests/test_contract.py -rs -v diff --git a/docs-api/query.mdx b/docs-api/query.mdx index 41a3d35..8f946d4 100644 --- a/docs-api/query.mdx +++ b/docs-api/query.mdx @@ -44,6 +44,9 @@ list_runs( search: Optional[str] = None, tags: Optional[List[str]] = None, limit: int = 50, + sort: Optional[str] = None, + offset: int = 0, + filters: Optional[Dict[str, Any]] = None, ) -> List[Dict[str, Any]] ``` @@ -55,9 +58,16 @@ List runs in a project. | `search` | `Optional[str]` | Full-text search on run name. | | `tags` | `Optional[List[str]]` | Filter by tags (AND logic). Only runs matching *all* specified tags are returned. | | `limit` | `int` | Maximum number of runs to return (max 200). | +| `sort` | `Optional[str]` | Server-side ordering as `[+\|-]` (`-` = descending). Accepts built-in columns (`createdAt`, `updatedAt`, `name`, `status` and their snake_case aliases), `config..value` / `systemMetadata..value`, `summary_metrics.` (LAST aggregation), and `heartbeat_at` (last logged data point). Defaults to `createdAt desc` server-side. | +| `offset` | `int` | Skip-based pagination offset (0–100,000). Combine with `limit` to page; advance `offset` until a short page is returned. | +| `filters` | `Optional[Dict[str, Any]]` | A wandb-compatible MongoDB-style query (the OR-capable filter surface). Boolean `$and`/`$or`/`$not` combine leaf terms; each leaf is `{field: value}` (equality) or `{field: {$op: value}}` with ops `$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$in`, `$nin`, `$regex`. Fields: `state`/`status`, `heartbeat_at`, `created_at`/`updated_at`, `name`, `tags`, `config.`, `summaryMetrics.`. `$or`/`$not` and `heartbeat_at`/`summaryMetrics.*` leaves require `project`. | **Returns:** `List[Dict[str, Any]]` — List of run dicts with keys: `id`, `name`, `displayId`, `status`, `tags`, `config`, `createdAt`, `updatedAt`, `url`. +**Raises:** + +- `ValueError` — If `filters` uses an unrecognised operator or field. + #### `Client.get_run` ```python @@ -275,6 +285,9 @@ list_runs( search: Optional[str] = None, tags: Optional[List[str]] = None, limit: int = 50, + sort: Optional[str] = None, + offset: int = 0, + filters: Optional[Dict[str, Any]] = None, ) -> List[Dict[str, Any]] ``` diff --git a/pluto/query.py b/pluto/query.py index 49277a2..c9a25df 100644 --- a/pluto/query.py +++ b/pluto/query.py @@ -18,6 +18,7 @@ runs = client.list_runs("my-project") """ +import json import logging import os import time @@ -45,6 +46,94 @@ def __init__(self, message: str, status_code: Optional[int] = None): super().__init__(message) +# wandb-compatible vocabulary for the ``filters`` query (the ``/api/runs/list`` +# ``filter`` param). The server defines this grammar canonically in +# ``lib/queries/run-filter-grammar.ts`` and publishes it as the +# ``RunFilterGrammar`` OpenAPI component; these constants are the client mirror, +# kept honest by ``tests/test_contract.py`` (drift fails CI). The server is the +# authority on detailed semantics; the client does a light structural check so +# obvious mistakes fail fast with a clear ``ValueError`` instead of an HTTP 400. +_FILTER_BOOL_OPS = {'$and', '$or', '$not'} +_FILTER_LEAF_OPS = { + '$eq', + '$ne', + '$gt', + '$gte', + '$lt', + '$lte', + '$in', + '$nin', + '$regex', +} +# Recognised leaf field names (exact) plus dotted-prefix families. +_FILTER_FIELDS = { + 'state', + 'status', + 'heartbeat_at', + 'heartbeatAt', + 'created_at', + 'createdAt', + 'updated_at', + 'updatedAt', + 'name', + 'displayName', + 'display_name', + 'tags', +} +_FILTER_FIELD_PREFIXES = ( + 'config.', + 'systemMetadata.', + 'summaryMetrics.', + 'summary_metrics.', +) + +# Server clamps offset to this range (MAX_JSON_SORT_OFFSET). +_MAX_OFFSET = 100_000 + + +def _validate_filters(node: Any, _depth: int = 0) -> None: + """Light structural validation of a wandb-style ``filters`` dict. + + Recursively checks boolean operators (``$and``/``$or``/``$not``), leaf + operators, and field names against the supported vocabulary, raising + ``ValueError`` on anything unrecognised. The server enforces full semantics. + """ + if _depth > 50: + raise ValueError('filters nested too deeply') + if not isinstance(node, dict): + raise ValueError(f'filters node must be a dict, got {type(node).__name__}') + for key, value in node.items(): + if key in ('$and', '$or'): + if not isinstance(value, list): + raise ValueError(f'{key} expects a list') + for child in value: + _validate_filters(child, _depth + 1) + elif key == '$not': + _validate_filters(value, _depth + 1) + elif key.startswith('$'): + raise ValueError(f'unknown boolean operator: {key!r}') + else: + _validate_filter_field(key) + if isinstance(value, dict) and any(k.startswith('$') for k in value): + for op in value: + if op not in _FILTER_LEAF_OPS: + raise ValueError( + f'unknown leaf operator {op!r} on field {key!r}; ' + f'expected one of {sorted(_FILTER_LEAF_OPS)}' + ) + + +def _validate_filter_field(field_name: str) -> None: + if field_name in _FILTER_FIELDS: + return + if any( + field_name.startswith(p) and len(field_name) > len(p) + for p in _FILTER_FIELD_PREFIXES + ): + return + raise ValueError(f'unknown filter field: {field_name!r}') + + class Client: """HTTP client for reading data from the Pluto server. @@ -115,6 +204,9 @@ def list_runs( search: Optional[str] = None, tags: Optional[List[str]] = None, limit: int = 50, + sort: Optional[str] = None, + offset: int = 0, + filters: Optional[Dict[str, Any]] = None, ) -> List[Dict[str, Any]]: """List runs in a project. @@ -124,17 +216,74 @@ def list_runs( tags: Filter by tags (AND logic). Only runs matching *all* specified tags are returned. limit: Maximum number of runs to return (max 200). + sort: Server-side ordering as ``[+|-]`` (``-`` = descending). + Accepts built-in columns (``createdAt``, ``updatedAt``, + ``name``, ``status`` and their snake_case aliases), + ``config..value`` / ``systemMetadata..value``, + ``summary_metrics.`` (LAST aggregation), and + ``heartbeat_at`` (last logged data point). Defaults to + ``createdAt desc`` server-side. + offset: Skip-based pagination offset (0–100,000). Combine with + ``limit`` to page; advance ``offset`` until a short page is + returned. + filters: A wandb-compatible MongoDB-style query (the OR-capable + filter surface). Boolean ``$and``/``$or``/``$not`` combine leaf + terms; each leaf is ``{field: value}`` (equality) or + ``{field: {$op: value}}`` with ops ``$eq``, ``$ne``, ``$gt``, + ``$gte``, ``$lt``, ``$lte``, ``$in``, ``$nin``, ``$regex``. + Fields: ``state``/``status``, ``heartbeat_at``, + ``created_at``/``updated_at``, ``name``, ``tags``, + ``config.``, ``summaryMetrics.``. + ``$or``/``$not`` and ``heartbeat_at``/``summaryMetrics.*`` leaves + require ``project``. + + ``filters`` AND-combines with ``search``/``tags``. To find interrupted + spot jobs to retry, find the *alive* set and exclude it — or directly + select non-completed runs that have gone stale:: + + # wandb-style "alive" set (running OR reported data in the last hour): + list_runs(project, filters={"$or": [ + {"state": "running"}, + {"heartbeat_at": {"$gte": cutoff_iso}}, + ]}) + + # retry candidates directly (not completed AND stale): + list_runs(project, filters={"$and": [ + {"status": {"$ne": "COMPLETED"}}, + {"heartbeat_at": {"$lt": cutoff_iso}}, + ]}) Returns: List of run dicts with keys: ``id``, ``name``, ``displayId``, ``status``, ``tags``, ``config``, ``createdAt``, ``updatedAt``, ``url``. + + Raises: + ValueError: If ``filters`` uses an unrecognised operator or field. """ params: Dict[str, Any] = {'projectName': project, 'limit': min(limit, 200)} if search is not None: params['search'] = search if tags is not None: params['tags'] = ','.join(tags) + if filters is not None: + _validate_filters(filters) + params['filter'] = json.dumps(filters) + if sort is not None: + params['sort'] = sort + 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, + ) + # A negative offset clamps to 0; don't send a redundant offset=0. + if clamped: + params['offset'] = clamped return self._get('/api/runs/list', params=params)['runs'] def get_run( @@ -549,9 +698,20 @@ def list_runs( search: Optional[str] = None, tags: Optional[List[str]] = None, limit: int = 50, + sort: Optional[str] = None, + offset: int = 0, + filters: Optional[Dict[str, Any]] = None, ) -> List[Dict[str, Any]]: """List runs in a project. See :meth:`Client.list_runs`.""" - return _get_client().list_runs(project, search=search, tags=tags, limit=limit) + return _get_client().list_runs( + project, + search=search, + tags=tags, + limit=limit, + sort=sort, + offset=offset, + filters=filters, + ) def get_run(project: str, run_id: Union[int, str]) -> Dict[str, Any]: diff --git a/tests/test_contract.py b/tests/test_contract.py new file mode 100644 index 0000000..f93affc --- /dev/null +++ b/tests/test_contract.py @@ -0,0 +1,120 @@ +"""Contract test: the client's ``filters`` surface has a server counterpart. + +The client sends `list_runs(filters=...)` as the `/api/runs/list` ``filter`` +query param. This test fetches the server's published OpenAPI document +(``{url_api}/api/openapi.json``) and asserts that endpoint actually documents a +``filter`` parameter — so a deployed server that dropped/renamed it (or a client +pointed at a server that predates the feature) fails CI loudly instead of +silently no-op-ing the filter. + +Until the server change is deployed to the target host, ``/api/runs/list`` has no +``filter`` param yet, so the test SKIPS rather than fails. The detailed operator +vocabulary is validated client-side (``tests/test_query.py``) and on the server; +a structured ``RunFilter`` schema for a full operator/field contract is a +fast-follow (see plan). + +Network test: hits the live API spec endpoint. Skips cleanly when unreachable, so +offline/hermetic runs are unaffected — matching the style of ``tests/test_e2e.py``. +""" + +import os + +import httpx +import pytest + +from pluto.query import ( + _FILTER_BOOL_OPS, + _FILTER_FIELD_PREFIXES, + _FILTER_FIELDS, + _FILTER_LEAF_OPS, + _resolve_url_api, +) + + +def _fetch_openapi() -> dict: + url = f'{_resolve_url_api(None)}/api/openapi.json' + # Prod serves the spec unauthenticated; staging/dev may gate it. Send the + # bearer token when present so the check actually runs there. Harmless on an + # unauthenticated endpoint. + headers = {} + token = os.environ.get('PLUTO_API_KEY') + if token: + headers['Authorization'] = f'Bearer {token}' + try: + resp = httpx.get(url, timeout=15, follow_redirects=True, headers=headers) + except httpx.HTTPError as exc: # pragma: no cover - network dependent + 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}') + try: + return resp.json() + except ValueError as exc: # invalid/non-JSON body (e.g. an HTML error page) + pytest.skip(f'OpenAPI spec at {url} returned non-JSON body: {exc}') + + +def _list_runs_params() -> dict: + spec = _fetch_openapi() + op = spec.get('paths', {}).get('/api/runs/list', {}).get('get', {}) + params = {p.get('name') for p in op.get('parameters', [])} + if 'filter' not in params: + pytest.skip( + 'GET /api/runs/list has no `filter` param yet; pending the ' + 'pluto-server filter-query change being deployed to this host.' + ) + return params + + +def test_filter_param_is_published(): + params = _list_runs_params() + assert 'filter' in params + + +def test_list_runs_still_documents_core_params(): + # Guard against an accidental contract regression on the surface the client + # relies on alongside `filter`. + params = _list_runs_params() + for p in ('projectName', 'limit', 'sort', 'offset'): + assert p in params, f'/api/runs/list missing documented param: {p}' + + +def _grammar() -> dict: + """The server's published RunFilterGrammar component (its `properties`).""" + spec = _fetch_openapi() + schemas = spec.get('components', {}).get('schemas', {}) + grammar = schemas.get('RunFilterGrammar') + if grammar is None: + pytest.skip( + 'OpenAPI spec has no RunFilterGrammar component yet; pending the ' + 'pluto-server grammar-publish change being deployed to this host.' + ) + return grammar.get('properties', {}) + + +def _published(props: dict, key: str) -> set: + enum = props.get(key, {}).get('items', {}).get('enum') + if enum is None: + pytest.skip(f'RunFilterGrammar.{key} has no items.enum in the spec') + return set(enum) + + +@pytest.mark.parametrize( + 'prop,client', + [ + ('booleanOperators', _FILTER_BOOL_OPS), + ('leafOperators', _FILTER_LEAF_OPS), + ('fields', _FILTER_FIELDS), + ('fieldPrefixes', set(_FILTER_FIELD_PREFIXES)), + ], +) +def test_client_filter_vocab_matches_server(prop, client): + """The client's filter vocabulary must equal the server's published grammar. + + Drift (a field/operator added on one side only) fails here with the exact + set difference, instead of surfacing as a runtime 400. + """ + server = _published(_grammar(), prop) + assert client == server, ( + f'{prop} drift between client and server.\n' + f' server-only: {sorted(server - client)}\n' + f' client-only: {sorted(client - server)}' + ) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 5ea1748..6cd9048 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -13,6 +13,7 @@ import importlib.util import io import time +import uuid from typing import Callable, List, TypeVar import httpx @@ -430,6 +431,83 @@ def test_e2e_list_runs_search(): assert run_id in found_ids, f'Run {run_id} (name={task_name}) not found via search' +def _two_tagged_runs() -> tuple: + """Create two finished runs (A older than B) sharing a unique tag. + + Returns ``(tag, id_a, id_b)`` once both are listable under the tag. The + unique tag scopes subsequent list queries to exactly these two runs, so + concurrent writes to the shared ``testing-ci`` project (parallel matrix + legs, ``-n auto`` workers) can't shift the sort/pagination window. A >1s + gap guarantees distinct ``createdAt`` even at second precision. + """ + tag = f'e2e-page-{uuid.uuid4().hex[:12]}' + run_a = pluto.init(project=TESTING_PROJECT_NAME, name=get_task_name(), tags=[tag]) + id_a = run_a.settings._op_id + run_a.finish() + time.sleep(1.1) + run_b = pluto.init(project=TESTING_PROJECT_NAME, name=get_task_name(), tags=[tag]) + id_b = run_b.settings._op_id + run_b.finish() + + # Tags sync asynchronously; poll until both runs are listable under the tag. + def _both_listed() -> bool: + ids = { + r['id'] for r in pq.list_runs(TESTING_PROJECT_NAME, tags=[tag], limit=200) + } + return {id_a, id_b} <= ids + + assert _poll( + fn=_both_listed, check=lambda ok: ok + ), f'tagged runs {id_a},{id_b} not both listable under {tag}' + return tag, id_a, id_b + + +def test_e2e_list_runs_sort_created_desc(): + """Verify sort='-createdAt' orders newest-first within a controlled set.""" + tag, id_a, id_b = _two_tagged_runs() + runs = pq.list_runs(TESTING_PROJECT_NAME, tags=[tag], sort='-createdAt', limit=200) + ids = [r['id'] for r in runs] + # Exactly our two runs match the tag; B (later) must come before A. + assert ids == [id_b, id_a], f'sort=-createdAt not newest-first: {ids}' + + +def test_e2e_list_runs_offset_pagination(): + """Verify offset advances the page within a controlled, stable set.""" + tag, id_a, id_b = _two_tagged_runs() + page1 = pq.list_runs(TESTING_PROJECT_NAME, tags=[tag], sort='-createdAt', limit=1) + page2 = pq.list_runs( + TESTING_PROJECT_NAME, tags=[tag], sort='-createdAt', limit=1, offset=1 + ) + assert [r['id'] for r in page1] == [id_b], 'page 1 should be the newest run' + assert [r['id'] for r in page2] == [id_a], 'offset=1 should skip to the older run' + + +def test_e2e_list_runs_filter(): + """Verify the wandb-style `filters` query filters by a config value.""" + marker = f'e2e-ff-{int(time.time())}' + run = pluto.init( + project=TESTING_PROJECT_NAME, + name=get_task_name(), + config={'e2e_filter_marker': marker}, + ) + run_id = run.settings._op_id + run.finish() + + def _query(): + runs = pq.list_runs( + TESTING_PROJECT_NAME, + filters={'config.e2e_filter_marker': marker}, + limit=200, + ) + ids = [r['id'] for r in runs] + return run_id in ids + + # Field values are indexed asynchronously; poll for eventual consistency. + assert _poll( + fn=_query, check=lambda found: found + ), f'Run {run_id} not found via filters on config.e2e_filter_marker' + + # --------------------------------------------------------------------------- # Histogram (structured data) # --------------------------------------------------------------------------- diff --git a/tests/test_query.py b/tests/test_query.py index dec315d..81b2a09 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -207,6 +207,91 @@ def test_limit_capped_at_200(self, client, mock_response): call_args = client._client.get.call_args assert call_args[1]['params']['limit'] == 200 + def test_sort(self, client, mock_response): + client._client.get.return_value = mock_response(200, {'runs': []}) + client.list_runs('proj', sort='-createdAt') + call_args = client._client.get.call_args + assert call_args[1]['params']['sort'] == '-createdAt' + + def test_offset(self, client, mock_response): + client._client.get.return_value = mock_response(200, {'runs': []}) + client.list_runs('proj', offset=100) + call_args = client._client.get.call_args + assert call_args[1]['params']['offset'] == 100 + + def test_offset_zero_omitted(self, client, mock_response): + client._client.get.return_value = mock_response(200, {'runs': []}) + client.list_runs('proj') + call_args = client._client.get.call_args + assert 'offset' not in call_args[1]['params'] + + def test_offset_clamped(self, client, mock_response): + client._client.get.return_value = mock_response(200, {'runs': []}) + client.list_runs('proj', offset=10**9) + call_args = client._client.get.call_args + assert call_args[1]['params']['offset'] == 100_000 + + def test_negative_offset_omitted(self, client, mock_response): + client._client.get.return_value = mock_response(200, {'runs': []}) + client.list_runs('proj', offset=-5) + call_args = client._client.get.call_args + assert 'offset' not in call_args[1]['params'] + + def test_filters_serialized_as_json(self, client, mock_response): + client._client.get.return_value = mock_response(200, {'runs': []}) + flt = { + '$or': [ + {'state': 'running'}, + {'heartbeat_at': {'$gte': '2026-06-22T00:00:00Z'}}, + ] + } + client.list_runs('proj', filters=flt) + params = client._client.get.call_args[1]['params'] + assert json.loads(params['filter']) == flt + + def test_filters_config_leaf(self, client, mock_response): + client._client.get.return_value = mock_response(200, {'runs': []}) + client.list_runs('proj', filters={'config.lr': {'$gt': 0.001}}) + params = client._client.get.call_args[1]['params'] + assert json.loads(params['filter']) == {'config.lr': {'$gt': 0.001}} + + def test_filters_unknown_field_raises(self, client, mock_response): + client._client.get.return_value = mock_response(200, {'runs': []}) + with pytest.raises(ValueError, match='unknown filter field'): + client.list_runs('proj', filters={'bogus': 1}) + + def test_filters_unknown_operator_raises(self, client, mock_response): + client._client.get.return_value = mock_response(200, {'runs': []}) + with pytest.raises(ValueError, match='unknown leaf operator'): + client.list_runs('proj', filters={'config.lr': {'$bogus': 1}}) + + def test_filters_unknown_boolean_op_raises(self, client, mock_response): + client._client.get.return_value = mock_response(200, {'runs': []}) + with pytest.raises(ValueError, match='unknown boolean operator'): + client.list_runs('proj', filters={'$xor': [{'state': 'running'}]}) + + +class TestValidateFilters: + def test_accepts_nested_boolean(self): + from pluto.query import _validate_filters + + _validate_filters( + { + '$and': [ + {'$or': [{'status': 'RUNNING'}, {'status': 'FAILED'}]}, + {'$not': {'config.lr': {'$lt': 0.1}}}, + {'tags': {'$in': ['a', 'b']}}, + {'summaryMetrics.loss': {'$lte': 0.5}}, + ] + } + ) + + def test_and_or_require_list(self): + from pluto.query import _validate_filters + + with pytest.raises(ValueError, match=r'\$or expects a list'): + _validate_filters({'$or': {'state': 'running'}}) + # --------------------------------------------------------------------------- # get_run @@ -520,7 +605,13 @@ def test_list_runs_creates_default_client(self, monkeypatch, mock_response): assert result == [{'id': 1}] mock_client_instance.list_runs.assert_called_once_with( - 'my-project', search=None, tags=None, limit=50 + 'my-project', + search=None, + tags=None, + limit=50, + sort=None, + offset=0, + filters=None, ) # Clean up