diff --git a/Dockerfile b/Dockerfile index 64800218..ab056b38 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,7 +30,7 @@ FROM builder AS builder-slim RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=bind,source=uv.lock,target=uv.lock \ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ - uv sync --frozen --no-build --no-install-project --no-dev --no-editable + uv sync --frozen --no-install-project --no-dev --no-editable # Then, add the rest of the project source code and install it # Installing separately from its dependencies allows optimal layer caching @@ -59,7 +59,7 @@ FROM builder AS builder-all RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=bind,source=uv.lock,target=uv.lock \ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ - uv sync --frozen --no-build --no-install-project --all-extras --no-dev --no-editable + uv sync --frozen --no-install-project --all-extras --no-dev --no-editable # Then, add the rest of the project source code and install it # Installing separately from its dependencies allows optimal layer caching diff --git a/specifications/SPEC_PLATFORM_SERVICE.md b/specifications/SPEC_PLATFORM_SERVICE.md index bde11c0e..1c8e5dd8 100644 --- a/specifications/SPEC_PLATFORM_SERVICE.md +++ b/specifications/SPEC_PLATFORM_SERVICE.md @@ -462,9 +462,20 @@ class ApplicationRun: self, nocache: bool = False, item_ids: list[str] | None = None, + *, external_ids: list[str] | None = None, + state: ItemState | None = None, + termination_reason: ItemTerminationReason | None = None, + custom_metadata: str | None = None, ) -> Iterator[ItemResultData]: - """Retrieves the results of items in the run, optionally filtered by item or external IDs.""" + """Retrieves the results of items in the run, optionally filtered server-side. + + Filters: ``item_ids`` / ``external_ids`` (identifier-based), ``state`` / + ``termination_reason`` (lifecycle), and ``custom_metadata`` (JSONPath + expression evaluated against the item's custom metadata). All filters + are forwarded to ``GET /v1/runs/{run_id}/items``; ``None`` parameters + are omitted from the request. + """ def item_status(self) -> dict[str, ItemStatus]: """Retrieves the status of all items in the run.""" diff --git a/src/aignostics/platform/CLAUDE.md b/src/aignostics/platform/CLAUDE.md index 6cb69dd7..a2aa36a6 100644 --- a/src/aignostics/platform/CLAUDE.md +++ b/src/aignostics/platform/CLAUDE.md @@ -809,7 +809,7 @@ def delete(self) -> None: - ✅ `Applications.list()` - Application list (5 min TTL) - ✅ `Applications.details()` - Application details (5 min TTL) - ✅ `Runs.details()` - Run details (15 sec TTL) -- ✅ `Runs.results()` - Run results (15 sec TTL), supports `item_ids` and `external_ids` filters +- ✅ `Runs.results()` - Run results (15 sec TTL), supports `item_ids`, `external_ids`, `state`, `termination_reason`, and `custom_metadata` filters - ✅ `Runs.list()` - Run list (15 sec TTL) **Cache Bypass (NEW):** diff --git a/src/aignostics/platform/resources/runs.py b/src/aignostics/platform/resources/runs.py index ecb1a3fa..72c0f3ce 100644 --- a/src/aignostics/platform/resources/runs.py +++ b/src/aignostics/platform/resources/runs.py @@ -23,6 +23,7 @@ ItemOutput, ItemResultReadResponse, ItemState, + ItemTerminationReason, RunCreationRequest, RunCreationResponse, RunState, @@ -375,11 +376,15 @@ def delete(self) -> None: ) operation_cache_clear() # Clear all caches since we added a new run - def results( + def results( # noqa: PLR0913 self, nocache: bool = False, item_ids: list[str] | None = None, external_ids: list[str] | None = None, + *, + state: ItemState | None = None, + termination_reason: ItemTerminationReason | None = None, + custom_metadata: str | None = None, ) -> t.Iterator[ItemResultData]: """Retrieves the results of all items in the run. @@ -390,6 +395,11 @@ def results( The fresh result will still be cached for subsequent calls. Defaults to False. item_ids (list[str] | None): Optional list of item IDs to filter results by. external_ids (list[str] | None): Optional list of external IDs to filter results by. + state (ItemState | None): Optional filter by item state (server-side). + termination_reason (ItemTerminationReason | None): Optional filter by termination reason + (server-side, only applies to TERMINATED items). + custom_metadata (str | None): Optional JSONPath expression to filter items by their + custom_metadata (server-side). Returns: Iterator[ItemResultData]: An iterator over item results. @@ -422,6 +432,12 @@ def results_with_retry(run_id: str, **kwargs: object) -> list[ItemResultData]: filter_kwargs["item_id__in"] = item_ids if external_ids: filter_kwargs["external_id__in"] = external_ids + if state is not None: + filter_kwargs["state"] = state + if termination_reason is not None: + filter_kwargs["termination_reason"] = termination_reason + if custom_metadata is not None: + filter_kwargs["custom_metadata"] = custom_metadata return paginate(lambda **kwargs: results_with_retry(self.run_id, nocache=nocache, **filter_kwargs, **kwargs)) diff --git a/tests/aignostics/platform/resources/runs_test.py b/tests/aignostics/platform/resources/runs_test.py index 1de38ebb..0e69b1d8 100644 --- a/tests/aignostics/platform/resources/runs_test.py +++ b/tests/aignostics/platform/resources/runs_test.py @@ -1243,3 +1243,79 @@ def test_ensure_artifacts_downloaded_is_instance_method_not_static(app_run, tmp_ bound = app_run.ensure_artifacts_downloaded # On a method, __self__ is the instance; on a staticmethod, __self__ doesn't exist. assert getattr(bound, "__self__", None) is app_run + + +# --------------------------------------------------------------------------- +# Run.results() — server-side state / termination_reason / custom_metadata filters +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_results_passes_state_filter(app_run, mock_api) -> None: + """state= is forwarded to the API call on every page.""" + from aignx.codegen.models import ItemState + + mock_api.list_run_items_v1_runs_run_id_items_get.return_value = [] + + list(app_run.results(state=ItemState.TERMINATED)) + + call_kwargs = mock_api.list_run_items_v1_runs_run_id_items_get.call_args[1] + assert call_kwargs["state"] == ItemState.TERMINATED + + +@pytest.mark.unit +def test_results_passes_termination_reason_filter(app_run, mock_api) -> None: + """termination_reason= is forwarded to the API call on every page.""" + from aignx.codegen.models import ItemTerminationReason + + mock_api.list_run_items_v1_runs_run_id_items_get.return_value = [] + + list(app_run.results(termination_reason=ItemTerminationReason.SUCCEEDED)) + + call_kwargs = mock_api.list_run_items_v1_runs_run_id_items_get.call_args[1] + assert call_kwargs["termination_reason"] == ItemTerminationReason.SUCCEEDED + + +@pytest.mark.unit +def test_results_passes_custom_metadata_filter(app_run, mock_api) -> None: + """custom_metadata= is forwarded to the API call on every page.""" + mock_api.list_run_items_v1_runs_run_id_items_get.return_value = [] + + list(app_run.results(custom_metadata="$.key")) + + call_kwargs = mock_api.list_run_items_v1_runs_run_id_items_get.call_args[1] + assert call_kwargs["custom_metadata"] == "$.key" + + +@pytest.mark.unit +def test_results_combines_all_filters(app_run, mock_api) -> None: + """All three new filters are forwarded together when all are provided.""" + from aignx.codegen.models import ItemState, ItemTerminationReason + + mock_api.list_run_items_v1_runs_run_id_items_get.return_value = [] + + list( + app_run.results( + state=ItemState.TERMINATED, + termination_reason=ItemTerminationReason.SUCCEEDED, + custom_metadata="$.batch_id=='x'", + ) + ) + + call_kwargs = mock_api.list_run_items_v1_runs_run_id_items_get.call_args[1] + assert call_kwargs["state"] == ItemState.TERMINATED + assert call_kwargs["termination_reason"] == ItemTerminationReason.SUCCEEDED + assert call_kwargs["custom_metadata"] == "$.batch_id=='x'" + + +@pytest.mark.unit +def test_results_omits_none_filters(app_run, mock_api) -> None: + """When state/termination_reason/custom_metadata are not provided they must not appear in API call.""" + mock_api.list_run_items_v1_runs_run_id_items_get.return_value = [] + + list(app_run.results()) + + call_kwargs = mock_api.list_run_items_v1_runs_run_id_items_get.call_args[1] + assert "state" not in call_kwargs + assert "termination_reason" not in call_kwargs + assert "custom_metadata" not in call_kwargs