diff --git a/.spec-sha b/.spec-sha index eccd7b1..24650c2 100644 --- a/.spec-sha +++ b/.spec-sha @@ -1 +1 @@ -cddf1ca71c61a62e9e429206b74a6d064222b4ed +25958e9afda3dab55f5928054d2860c6f78d48ec diff --git a/src/onepin/.fern/metadata.json b/src/onepin/.fern/metadata.json index 6e675c6..9c687bf 100644 --- a/src/onepin/.fern/metadata.json +++ b/src/onepin/.fern/metadata.json @@ -30,8 +30,7 @@ } ] }, - "originGitCommit": "a3ee57d10e66cc74651c81add046845159caf420", - "originGitCommitIsDirty": false, - "invokedBy": "ci", - "ciProvider": "github" + "originGitCommit": "b2e32490c9795d004581bca295158bfa60cb8140", + "originGitCommitIsDirty": true, + "invokedBy": "manual" } \ No newline at end of file diff --git a/src/onepin/_cli/_spec.py b/src/onepin/_cli/_spec.py index 9b88476..9528906 100644 --- a/src/onepin/_cli/_spec.py +++ b/src/onepin/_cli/_spec.py @@ -107,6 +107,17 @@ def _list_opts(*extra: Opt) -> list[Opt]: # Workflow run status filter / terminal states (SDK exposes run status as raw str; no enum). _RUN_STATUS = ("draft", "running", "completed", "failed", "paused", "cancelled", "pending") +_NODE_TYPES = ( + "source_script", + "operator_translator", + "operator_normalizer", + "operator_generator", + "sink_preview", + "validator_error_rate", + "validator_naturalness", + "validator_noise", +) + # Column presets keyed by output model. _COLS_WORKFLOW = ["id", "name", "runs_count", "last_run_status", "updated_at"] _COLS_VOICE = ["id", "name", "provider", "gender", "category"] @@ -283,11 +294,16 @@ def _list_opts(*extra: Opt) -> list[Opt]: Cmd( "workflows", "steps", - "workflows.get_run_steps", + "workflows.runs.steps", "List the steps of a run.", subgroup="runs", args=[("workflow_id", "Workflow UUID."), ("run_id", "Run UUID.")], - options=[_JSON], + options=[ + Opt("--include-result", "bool", False, help="Include each step's full result payload."), + Opt("--node-type", _NODE_TYPES, None, help="Filter by node type."), + Opt("--node-id", "str", None, help="Filter by node ID."), + _JSON, + ], unwrap="list", columns=[], ), diff --git a/src/onepin/reference.md b/src/onepin/reference.md index d023b31..e341527 100644 --- a/src/onepin/reference.md +++ b/src/onepin/reference.md @@ -6867,112 +6867,6 @@ client.workflows.runs_summary( - - - - -
client.workflows.get_run_steps(...) -> ApiResponseListWorkflowRunStepOut -
-
- -#### 📝 Description - -
-
- -
-
- -List per-node execution steps for a workflow run. - -Returns one entry per node execution attempt, ordered by execution sequence. -Each step includes the node type, status, iteration number (for nodes that -are retried), start/completion timestamps, and the node's `result` output. - -For audio output nodes, `result` is hydrated with short-lived `playback_url` -values (valid for 15 minutes) so callers can stream audio directly without -a separate download step. - -`node_display_name` is resolved from the run's definition snapshot, so it -reflects the name the node had when the run executed. Repeated executions of -the same node share that name and are distinguished by `iteration`. - -For a higher-level view with aggregated metrics (pass rates, audio duration -by language), use `GET /runs/{run_id}/overview`. For paginated, grouped -script+audio rows suitable for a data table, use `GET /runs/{run_id}/data`. -
-
-
-
- -#### 🔌 Usage - -
-
- -
-
- -```python -from onepin import OnePinClient -from onepin.environment import OnePinClientEnvironment - -client = OnePinClient( - token="", - environment=OnePinClientEnvironment.PROD, -) - -client.workflows.get_run_steps( - workflow_id="workflow_id", - run_id="run_id", -) - -``` -
-
-
-
- -#### ⚙️ Parameters - -
-
- -
-
- -**workflow_id:** `str` - -
-
- -
-
- -**run_id:** `str` - -
-
- -
-
- -**workspace_id:** `typing.Optional[str]` - -
-
- -
-
- -**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. - -
-
-
-
- -
@@ -7101,7 +6995,8 @@ completes. It differs from the other run sub-resources as follows: - `GET /runs/{run_id}` — full run record including the raw definition snapshot. - `GET /runs/{run_id}/status` — volatile status fields only; for polling. -- `GET /runs/{run_id}/steps` — flat per-node step log with audio playback URLs. +- `GET /runs/{run_id}/steps` — lightweight per-node step log by default; + `include_result=true` includes results and audio playback URLs. - `GET /runs/{run_id}/outputs` — one logical result per snapshot sink node. - `GET /runs/{run_id}/data` — paginated script+audio rows for a data table. - `GET /runs/{run_id}/overview` (this endpoint) — pre-aggregated metrics and @@ -8101,7 +7996,8 @@ workflow definition has since been edited. This is the heaviest run endpoint. For progress polling, use the lighter `GET /runs/{run_id}/status` which omits the snapshot. For aggregated visual metrics, use `GET /runs/{run_id}/overview`. For the per-node step -log with audio playback URLs, use `GET /runs/{run_id}/steps`. +log, use `GET /runs/{run_id}/steps`; opt into full results and audio +playback URLs with `include_result=true`. @@ -8281,6 +8177,137 @@ client.workflows.runs.status( + + + + +
client.workflows.runs.steps(...) -> ApiResponseListWorkflowRunStepOut +
+
+ +#### 📝 Description + +
+
+ +
+
+ +List per-node execution steps for a workflow run. + +Returns one entry per node execution attempt, ordered by execution sequence. +By default, the response is lightweight: `result` is null, `has_result` +reports whether a stored result exists, and `active_ports` is projected from +the result without loading the full JSON payload. + +Set `include_result=true` to restore the full result payload. Audio results +are then hydrated with short-lived `playback_url` values (valid for 15 +minutes). `node_type` and `node_id` filters combine with AND semantics. + +`node_display_name` is resolved from the run's definition snapshot, so it +reflects the name the node had when the run executed. Repeated executions of +the same node share that name and are distinguished by `iteration`. + +For a higher-level view with aggregated metrics (pass rates, audio duration +by language), use `GET /runs/{run_id}/overview`. For paginated, grouped +script+audio rows suitable for a data table, use `GET /runs/{run_id}/data`. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from onepin import OnePinClient +from onepin.environment import OnePinClientEnvironment + +client = OnePinClient( + token="", + environment=OnePinClientEnvironment.PROD, +) + +client.workflows.runs.steps( + workflow_id="workflow_id", + run_id="run_id", +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**workflow_id:** `str` + +
+
+ +
+
+ +**run_id:** `str` + +
+
+ +
+
+ +**include_result:** `typing.Optional[bool]` — Include the full step result payload and hydrate audio playback URLs. + +
+
+ +
+
+ +**node_type:** `typing.Optional[NodeType]` — Filter steps by node type. + +
+
+ +
+
+ +**node_id:** `typing.Optional[str]` — Filter steps by node ID. + +
+
+ +
+
+ +**workspace_id:** `typing.Optional[str]` + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ +
@@ -8304,9 +8331,9 @@ In-flight work at the current node may be abandoned mid-execution. The operation is idempotent: cancelling an already-cancelled run returns the run unchanged without error. -Only runs in `pending`, `running`, or `paused` status can be cancelled. -Runs that have already reached a terminal state (`completed`, `failed`, -`cancelled`) return 409. +Runs already in a terminal state (`completed`, `failed`, or `cancelled`) +are returned unchanged. Concurrent terminalization is also treated as an +idempotent success; a still-active compare-and-swap loser returns 409. Unlike `pause`, cancel is permanent — a cancelled run cannot be resumed. Use `pause` if you intend to continue the run later. @@ -8386,4 +8413,3 @@ client.workflows.runs.cancel( - diff --git a/src/onepin/templates/client.py b/src/onepin/templates/client.py index 5e47f61..cd41b03 100644 --- a/src/onepin/templates/client.py +++ b/src/onepin/templates/client.py @@ -3,6 +3,7 @@ import typing from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ..core.pagination import AsyncPager, SyncPager from ..core.request_options import RequestOptions from ..types.api_list_response_template_out import ApiListResponseTemplateOut from ..types.api_response_dict import ApiResponseDict @@ -10,6 +11,7 @@ from ..types.api_response_template_out import ApiResponseTemplateOut from ..types.api_response_workflow_out import ApiResponseWorkflowOut from ..types.template_category import TemplateCategory +from ..types.template_out import TemplateOut from ..types.workflow_definition_input import WorkflowDefinitionInput from .raw_client import AsyncRawTemplatesClient, RawTemplatesClient from .types.list_templates_request_sort import ListTemplatesRequestSort @@ -43,7 +45,7 @@ def list( limit: typing.Optional[int] = None, favorites_only: typing.Optional[bool] = None, request_options: typing.Optional[RequestOptions] = None, - ) -> ApiListResponseTemplateOut: + ) -> SyncPager[TemplateOut, ApiListResponseTemplateOut]: """ Browse the public template gallery across all workspaces. @@ -82,7 +84,7 @@ def list( Returns ------- - ApiListResponseTemplateOut + SyncPager[TemplateOut, ApiListResponseTemplateOut] Successful Response Examples @@ -92,9 +94,14 @@ def list( client = OnePinClient( token="YOUR_TOKEN", ) - client.templates.list() + response = client.templates.list() + for item in response: + yield item + # alternatively, you can paginate page-by-page + for page in response.iter_pages(): + yield page """ - _response = self._raw_client.list( + return self._raw_client.list( category=category, search=search, sort=sort, @@ -103,7 +110,6 @@ def list( favorites_only=favorites_only, request_options=request_options, ) - return _response.data def create_template( self, @@ -569,7 +575,7 @@ async def list( limit: typing.Optional[int] = None, favorites_only: typing.Optional[bool] = None, request_options: typing.Optional[RequestOptions] = None, - ) -> ApiListResponseTemplateOut: + ) -> AsyncPager[TemplateOut, ApiListResponseTemplateOut]: """ Browse the public template gallery across all workspaces. @@ -608,7 +614,7 @@ async def list( Returns ------- - ApiListResponseTemplateOut + AsyncPager[TemplateOut, ApiListResponseTemplateOut] Successful Response Examples @@ -623,12 +629,18 @@ async def list( async def main() -> None: - await client.templates.list() + response = await client.templates.list() + async for item in response: + yield item + + # alternatively, you can paginate page-by-page + async for page in response.iter_pages(): + yield page asyncio.run(main()) """ - _response = await self._raw_client.list( + return await self._raw_client.list( category=category, search=search, sort=sort, @@ -637,7 +649,6 @@ async def main() -> None: favorites_only=favorites_only, request_options=request_options, ) - return _response.data async def create_template( self, diff --git a/src/onepin/templates/raw_client.py b/src/onepin/templates/raw_client.py index 7fce3d8..c5de796 100644 --- a/src/onepin/templates/raw_client.py +++ b/src/onepin/templates/raw_client.py @@ -7,6 +7,7 @@ from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper from ..core.http_response import AsyncHttpResponse, HttpResponse from ..core.jsonable_encoder import encode_path_param +from ..core.pagination import AsyncPager, SyncPager from ..core.parse_error import ParsingError from ..core.pydantic_utilities import parse_obj_as from ..core.request_options import RequestOptions @@ -18,6 +19,7 @@ from ..types.api_response_template_out import ApiResponseTemplateOut from ..types.api_response_workflow_out import ApiResponseWorkflowOut from ..types.template_category import TemplateCategory +from ..types.template_out import TemplateOut from ..types.workflow_definition_input import WorkflowDefinitionInput from .types.list_templates_request_sort import ListTemplatesRequestSort from pydantic import ValidationError @@ -40,7 +42,7 @@ def list( limit: typing.Optional[int] = None, favorites_only: typing.Optional[bool] = None, request_options: typing.Optional[RequestOptions] = None, - ) -> HttpResponse[ApiListResponseTemplateOut]: + ) -> SyncPager[TemplateOut, ApiListResponseTemplateOut]: """ Browse the public template gallery across all workspaces. @@ -79,9 +81,11 @@ def list( Returns ------- - HttpResponse[ApiListResponseTemplateOut] + SyncPager[TemplateOut, ApiListResponseTemplateOut] Successful Response """ + offset = offset if offset is not None else 0 + _response = self._client_wrapper.httpx_client.request( "api/v1/templates", method="GET", @@ -97,14 +101,25 @@ def list( ) try: if 200 <= _response.status_code < 300: - _data = typing.cast( + _parsed_response = typing.cast( ApiListResponseTemplateOut, parse_obj_as( type_=ApiListResponseTemplateOut, # type: ignore object_=_response.json(), ), ) - return HttpResponse(response=_response, data=_data) + _items = _parsed_response.data + _has_next = len(_items or []) > 0 + _get_next = lambda: self.list( + category=category, + search=search, + sort=sort, + offset=offset + len(_items or []), + limit=limit, + favorites_only=favorites_only, + request_options=request_options, + ) + return SyncPager(has_next=_has_next, items=_items, get_next=_get_next, response=_parsed_response) if _response.status_code == 422: raise UnprocessableEntityError( headers=dict(_response.headers), @@ -768,7 +783,7 @@ async def list( limit: typing.Optional[int] = None, favorites_only: typing.Optional[bool] = None, request_options: typing.Optional[RequestOptions] = None, - ) -> AsyncHttpResponse[ApiListResponseTemplateOut]: + ) -> AsyncPager[TemplateOut, ApiListResponseTemplateOut]: """ Browse the public template gallery across all workspaces. @@ -807,9 +822,11 @@ async def list( Returns ------- - AsyncHttpResponse[ApiListResponseTemplateOut] + AsyncPager[TemplateOut, ApiListResponseTemplateOut] Successful Response """ + offset = offset if offset is not None else 0 + _response = await self._client_wrapper.httpx_client.request( "api/v1/templates", method="GET", @@ -825,14 +842,28 @@ async def list( ) try: if 200 <= _response.status_code < 300: - _data = typing.cast( + _parsed_response = typing.cast( ApiListResponseTemplateOut, parse_obj_as( type_=ApiListResponseTemplateOut, # type: ignore object_=_response.json(), ), ) - return AsyncHttpResponse(response=_response, data=_data) + _items = _parsed_response.data + _has_next = len(_items or []) > 0 + + async def _get_next(): + return await self.list( + category=category, + search=search, + sort=sort, + offset=offset + len(_items or []), + limit=limit, + favorites_only=favorites_only, + request_options=request_options, + ) + + return AsyncPager(has_next=_has_next, items=_items, get_next=_get_next, response=_parsed_response) if _response.status_code == 422: raise UnprocessableEntityError( headers=dict(_response.headers), diff --git a/src/onepin/types/workflow_run_step_out.py b/src/onepin/types/workflow_run_step_out.py index 14da64c..19593a3 100644 --- a/src/onepin/types/workflow_run_step_out.py +++ b/src/onepin/types/workflow_run_step_out.py @@ -17,6 +17,8 @@ class WorkflowRunStepOut(UniversalBaseModel): started_at: typing.Optional[dt.datetime] = None completed_at: typing.Optional[dt.datetime] = None result: typing.Optional[typing.Dict[str, typing.Any]] = None + has_result: typing.Optional[bool] = None + active_ports: typing.Optional[typing.List[str]] = None error: typing.Optional[str] = None if IS_PYDANTIC_V2: diff --git a/src/onepin/voices/client.py b/src/onepin/voices/client.py index ed265d3..eaa566b 100644 --- a/src/onepin/voices/client.py +++ b/src/onepin/voices/client.py @@ -3,6 +3,7 @@ import typing from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ..core.pagination import AsyncPager, SyncPager from ..core.request_options import RequestOptions from ..types.api_counted_list_response_voice_out import ApiCountedListResponseVoiceOut from ..types.api_list_response_voice_similar_out import ApiListResponseVoiceSimilarOut @@ -13,6 +14,7 @@ from ..types.voice_age import VoiceAge from ..types.voice_category import VoiceCategory from ..types.voice_gender import VoiceGender +from ..types.voice_out import VoiceOut from .raw_client import AsyncRawVoicesClient, RawVoicesClient from .types.get_voice_facets_api_v1_voices_facets_get_request_source_item import ( GetVoiceFacetsApiV1VoicesFacetsGetRequestSourceItem, @@ -57,7 +59,7 @@ def list( language: typing.Optional[typing.Sequence[ListVoicesRequestLanguageItem]] = None, workspace_id: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None, - ) -> ApiCountedListResponseVoiceOut: + ) -> SyncPager[VoiceOut, ApiCountedListResponseVoiceOut]: """ List TTS voices available to the current workspace. @@ -130,7 +132,7 @@ def list( Returns ------- - ApiCountedListResponseVoiceOut + SyncPager[VoiceOut, ApiCountedListResponseVoiceOut] Successful Response Examples @@ -140,9 +142,14 @@ def list( client = OnePinClient( token="YOUR_TOKEN", ) - client.voices.list() + response = client.voices.list() + for item in response: + yield item + # alternatively, you can paginate page-by-page + for page in response.iter_pages(): + yield page """ - _response = self._raw_client.list( + return self._raw_client.list( offset=offset, limit=limit, favorites_only=favorites_only, @@ -160,7 +167,6 @@ def list( workspace_id=workspace_id, request_options=request_options, ) - return _response.data def get_voice_facets( self, @@ -497,7 +503,7 @@ async def list( language: typing.Optional[typing.Sequence[ListVoicesRequestLanguageItem]] = None, workspace_id: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None, - ) -> ApiCountedListResponseVoiceOut: + ) -> AsyncPager[VoiceOut, ApiCountedListResponseVoiceOut]: """ List TTS voices available to the current workspace. @@ -570,7 +576,7 @@ async def list( Returns ------- - ApiCountedListResponseVoiceOut + AsyncPager[VoiceOut, ApiCountedListResponseVoiceOut] Successful Response Examples @@ -585,12 +591,18 @@ async def list( async def main() -> None: - await client.voices.list() + response = await client.voices.list() + async for item in response: + yield item + + # alternatively, you can paginate page-by-page + async for page in response.iter_pages(): + yield page asyncio.run(main()) """ - _response = await self._raw_client.list( + return await self._raw_client.list( offset=offset, limit=limit, favorites_only=favorites_only, @@ -608,7 +620,6 @@ async def main() -> None: workspace_id=workspace_id, request_options=request_options, ) - return _response.data async def get_voice_facets( self, diff --git a/src/onepin/voices/raw_client.py b/src/onepin/voices/raw_client.py index 7cab1e1..8e18a6f 100644 --- a/src/onepin/voices/raw_client.py +++ b/src/onepin/voices/raw_client.py @@ -7,6 +7,7 @@ from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper from ..core.http_response import AsyncHttpResponse, HttpResponse from ..core.jsonable_encoder import encode_path_param +from ..core.pagination import AsyncPager, SyncPager from ..core.parse_error import ParsingError from ..core.pydantic_utilities import parse_obj_as from ..core.request_options import RequestOptions @@ -20,6 +21,7 @@ from ..types.voice_age import VoiceAge from ..types.voice_category import VoiceCategory from ..types.voice_gender import VoiceGender +from ..types.voice_out import VoiceOut from .types.get_voice_facets_api_v1_voices_facets_get_request_source_item import ( GetVoiceFacetsApiV1VoicesFacetsGetRequestSourceItem, ) @@ -53,7 +55,7 @@ def list( language: typing.Optional[typing.Sequence[ListVoicesRequestLanguageItem]] = None, workspace_id: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None, - ) -> HttpResponse[ApiCountedListResponseVoiceOut]: + ) -> SyncPager[VoiceOut, ApiCountedListResponseVoiceOut]: """ List TTS voices available to the current workspace. @@ -126,9 +128,11 @@ def list( Returns ------- - HttpResponse[ApiCountedListResponseVoiceOut] + SyncPager[VoiceOut, ApiCountedListResponseVoiceOut] Successful Response """ + offset = offset if offset is not None else 0 + _response = self._client_wrapper.httpx_client.request( "api/v1/voices", method="GET", @@ -155,14 +159,34 @@ def list( ) try: if 200 <= _response.status_code < 300: - _data = typing.cast( + _parsed_response = typing.cast( ApiCountedListResponseVoiceOut, parse_obj_as( type_=ApiCountedListResponseVoiceOut, # type: ignore object_=_response.json(), ), ) - return HttpResponse(response=_response, data=_data) + _items = _parsed_response.data + _has_next = len(_items or []) > 0 + _get_next = lambda: self.list( + offset=offset + len(_items or []), + limit=limit, + favorites_only=favorites_only, + source=source, + gender=gender, + age=age, + category=category, + accent=accent, + search=search, + sort=sort, + order=order, + provider=provider, + model=model, + language=language, + workspace_id=workspace_id, + request_options=request_options, + ) + return SyncPager(has_next=_has_next, items=_items, get_next=_get_next, response=_parsed_response) if _response.status_code == 422: raise UnprocessableEntityError( headers=dict(_response.headers), @@ -626,7 +650,7 @@ async def list( language: typing.Optional[typing.Sequence[ListVoicesRequestLanguageItem]] = None, workspace_id: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None, - ) -> AsyncHttpResponse[ApiCountedListResponseVoiceOut]: + ) -> AsyncPager[VoiceOut, ApiCountedListResponseVoiceOut]: """ List TTS voices available to the current workspace. @@ -699,9 +723,11 @@ async def list( Returns ------- - AsyncHttpResponse[ApiCountedListResponseVoiceOut] + AsyncPager[VoiceOut, ApiCountedListResponseVoiceOut] Successful Response """ + offset = offset if offset is not None else 0 + _response = await self._client_wrapper.httpx_client.request( "api/v1/voices", method="GET", @@ -728,14 +754,37 @@ async def list( ) try: if 200 <= _response.status_code < 300: - _data = typing.cast( + _parsed_response = typing.cast( ApiCountedListResponseVoiceOut, parse_obj_as( type_=ApiCountedListResponseVoiceOut, # type: ignore object_=_response.json(), ), ) - return AsyncHttpResponse(response=_response, data=_data) + _items = _parsed_response.data + _has_next = len(_items or []) > 0 + + async def _get_next(): + return await self.list( + offset=offset + len(_items or []), + limit=limit, + favorites_only=favorites_only, + source=source, + gender=gender, + age=age, + category=category, + accent=accent, + search=search, + sort=sort, + order=order, + provider=provider, + model=model, + language=language, + workspace_id=workspace_id, + request_options=request_options, + ) + + return AsyncPager(has_next=_has_next, items=_items, get_next=_get_next, response=_parsed_response) if _response.status_code == 422: raise UnprocessableEntityError( headers=dict(_response.headers), diff --git a/src/onepin/workflows/client.py b/src/onepin/workflows/client.py index 876fdb8..afb0607 100644 --- a/src/onepin/workflows/client.py +++ b/src/onepin/workflows/client.py @@ -6,13 +6,13 @@ import typing from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ..core.pagination import AsyncPager, SyncPager from ..core.request_options import RequestOptions from ..types.api_counted_list_response_workflow_list_item import ApiCountedListResponseWorkflowListItem from ..types.api_list_response_upload_out import ApiListResponseUploadOut from ..types.api_response_dict import ApiResponseDict from ..types.api_response_download_url_out import ApiResponseDownloadUrlOut from ..types.api_response_estimate_response import ApiResponseEstimateResponse -from ..types.api_response_list_workflow_run_step_out import ApiResponseListWorkflowRunStepOut from ..types.api_response_runs_summary_out import ApiResponseRunsSummaryOut from ..types.api_response_workflow_name_availability_out import ApiResponseWorkflowNameAvailabilityOut from ..types.api_response_workflow_out import ApiResponseWorkflowOut @@ -20,6 +20,7 @@ from ..types.api_response_workflow_run_outputs_out import ApiResponseWorkflowRunOutputsOut from ..types.api_response_workflow_run_overview_out import ApiResponseWorkflowRunOverviewOut from ..types.workflow_definition_input import WorkflowDefinitionInput +from ..types.workflow_list_item import WorkflowListItem from ..types.workflow_list_status import WorkflowListStatus from ..types.workflow_run_data_response import WorkflowRunDataResponse from .raw_client import AsyncRawWorkflowsClient, RawWorkflowsClient @@ -63,7 +64,7 @@ def list( limit: typing.Optional[int] = None, workspace_id: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None, - ) -> ApiCountedListResponseWorkflowListItem: + ) -> SyncPager[WorkflowListItem, ApiCountedListResponseWorkflowListItem]: """ List workflows in the current workspace. @@ -138,7 +139,7 @@ def list( Returns ------- - ApiCountedListResponseWorkflowListItem + SyncPager[WorkflowListItem, ApiCountedListResponseWorkflowListItem] Successful Response Examples @@ -148,9 +149,14 @@ def list( client = OnePinClient( token="YOUR_TOKEN", ) - client.workflows.list() + response = client.workflows.list() + for item in response: + yield item + # alternatively, you can paginate page-by-page + for page in response.iter_pages(): + yield page """ - _response = self._raw_client.list( + return self._raw_client.list( status=status, search=search, sort=sort, @@ -163,7 +169,6 @@ def list( workspace_id=workspace_id, request_options=request_options, ) - return _response.data def create_workflow( self, @@ -705,66 +710,6 @@ def runs_summary( ) return _response.data - def get_run_steps( - self, - workflow_id: str, - run_id: str, - *, - workspace_id: typing.Optional[str] = None, - request_options: typing.Optional[RequestOptions] = None, - ) -> ApiResponseListWorkflowRunStepOut: - """ - List per-node execution steps for a workflow run. - - Returns one entry per node execution attempt, ordered by execution sequence. - Each step includes the node type, status, iteration number (for nodes that - are retried), start/completion timestamps, and the node's `result` output. - - For audio output nodes, `result` is hydrated with short-lived `playback_url` - values (valid for 15 minutes) so callers can stream audio directly without - a separate download step. - - `node_display_name` is resolved from the run's definition snapshot, so it - reflects the name the node had when the run executed. Repeated executions of - the same node share that name and are distinguished by `iteration`. - - For a higher-level view with aggregated metrics (pass rates, audio duration - by language), use `GET /runs/{run_id}/overview`. For paginated, grouped - script+audio rows suitable for a data table, use `GET /runs/{run_id}/data`. - - Parameters - ---------- - workflow_id : str - - run_id : str - - workspace_id : typing.Optional[str] - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - ApiResponseListWorkflowRunStepOut - Successful Response - - Examples - -------- - from onepin import OnePinClient - - client = OnePinClient( - token="YOUR_TOKEN", - ) - client.workflows.get_run_steps( - workflow_id="workflow_id", - run_id="run_id", - ) - """ - _response = self._raw_client.get_run_steps( - workflow_id, run_id, workspace_id=workspace_id, request_options=request_options - ) - return _response.data - def get_run_outputs( self, workflow_id: str, @@ -840,7 +785,8 @@ def get_run_overview( - `GET /runs/{run_id}` — full run record including the raw definition snapshot. - `GET /runs/{run_id}/status` — volatile status fields only; for polling. - - `GET /runs/{run_id}/steps` — flat per-node step log with audio playback URLs. + - `GET /runs/{run_id}/steps` — lightweight per-node step log by default; + `include_result=true` includes results and audio playback URLs. - `GET /runs/{run_id}/outputs` — one logical result per snapshot sink node. - `GET /runs/{run_id}/data` — paginated script+audio rows for a data table. - `GET /runs/{run_id}/overview` (this endpoint) — pre-aggregated metrics and @@ -1276,7 +1222,7 @@ async def list( limit: typing.Optional[int] = None, workspace_id: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None, - ) -> ApiCountedListResponseWorkflowListItem: + ) -> AsyncPager[WorkflowListItem, ApiCountedListResponseWorkflowListItem]: """ List workflows in the current workspace. @@ -1351,7 +1297,7 @@ async def list( Returns ------- - ApiCountedListResponseWorkflowListItem + AsyncPager[WorkflowListItem, ApiCountedListResponseWorkflowListItem] Successful Response Examples @@ -1366,12 +1312,18 @@ async def list( async def main() -> None: - await client.workflows.list() + response = await client.workflows.list() + async for item in response: + yield item + + # alternatively, you can paginate page-by-page + async for page in response.iter_pages(): + yield page asyncio.run(main()) """ - _response = await self._raw_client.list( + return await self._raw_client.list( status=status, search=search, sort=sort, @@ -1384,7 +1336,6 @@ async def main() -> None: workspace_id=workspace_id, request_options=request_options, ) - return _response.data async def create_workflow( self, @@ -2006,74 +1957,6 @@ async def main() -> None: ) return _response.data - async def get_run_steps( - self, - workflow_id: str, - run_id: str, - *, - workspace_id: typing.Optional[str] = None, - request_options: typing.Optional[RequestOptions] = None, - ) -> ApiResponseListWorkflowRunStepOut: - """ - List per-node execution steps for a workflow run. - - Returns one entry per node execution attempt, ordered by execution sequence. - Each step includes the node type, status, iteration number (for nodes that - are retried), start/completion timestamps, and the node's `result` output. - - For audio output nodes, `result` is hydrated with short-lived `playback_url` - values (valid for 15 minutes) so callers can stream audio directly without - a separate download step. - - `node_display_name` is resolved from the run's definition snapshot, so it - reflects the name the node had when the run executed. Repeated executions of - the same node share that name and are distinguished by `iteration`. - - For a higher-level view with aggregated metrics (pass rates, audio duration - by language), use `GET /runs/{run_id}/overview`. For paginated, grouped - script+audio rows suitable for a data table, use `GET /runs/{run_id}/data`. - - Parameters - ---------- - workflow_id : str - - run_id : str - - workspace_id : typing.Optional[str] - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - ApiResponseListWorkflowRunStepOut - Successful Response - - Examples - -------- - import asyncio - - from onepin import AsyncOnePinClient - - client = AsyncOnePinClient( - token="YOUR_TOKEN", - ) - - - async def main() -> None: - await client.workflows.get_run_steps( - workflow_id="workflow_id", - run_id="run_id", - ) - - - asyncio.run(main()) - """ - _response = await self._raw_client.get_run_steps( - workflow_id, run_id, workspace_id=workspace_id, request_options=request_options - ) - return _response.data - async def get_run_outputs( self, workflow_id: str, @@ -2157,7 +2040,8 @@ async def get_run_overview( - `GET /runs/{run_id}` — full run record including the raw definition snapshot. - `GET /runs/{run_id}/status` — volatile status fields only; for polling. - - `GET /runs/{run_id}/steps` — flat per-node step log with audio playback URLs. + - `GET /runs/{run_id}/steps` — lightweight per-node step log by default; + `include_result=true` includes results and audio playback URLs. - `GET /runs/{run_id}/outputs` — one logical result per snapshot sink node. - `GET /runs/{run_id}/data` — paginated script+audio rows for a data table. - `GET /runs/{run_id}/overview` (this endpoint) — pre-aggregated metrics and diff --git a/src/onepin/workflows/raw_client.py b/src/onepin/workflows/raw_client.py index 22b75ba..9f4b019 100644 --- a/src/onepin/workflows/raw_client.py +++ b/src/onepin/workflows/raw_client.py @@ -9,6 +9,7 @@ from ..core.datetime_utils import serialize_datetime from ..core.http_response import AsyncHttpResponse, HttpResponse from ..core.jsonable_encoder import encode_path_param +from ..core.pagination import AsyncPager, SyncPager from ..core.parse_error import ParsingError from ..core.pydantic_utilities import parse_obj_as from ..core.request_options import RequestOptions @@ -21,7 +22,6 @@ from ..types.api_response_dict import ApiResponseDict from ..types.api_response_download_url_out import ApiResponseDownloadUrlOut from ..types.api_response_estimate_response import ApiResponseEstimateResponse -from ..types.api_response_list_workflow_run_step_out import ApiResponseListWorkflowRunStepOut from ..types.api_response_runs_summary_out import ApiResponseRunsSummaryOut from ..types.api_response_workflow_name_availability_out import ApiResponseWorkflowNameAvailabilityOut from ..types.api_response_workflow_out import ApiResponseWorkflowOut @@ -29,6 +29,7 @@ from ..types.api_response_workflow_run_outputs_out import ApiResponseWorkflowRunOutputsOut from ..types.api_response_workflow_run_overview_out import ApiResponseWorkflowRunOverviewOut from ..types.workflow_definition_input import WorkflowDefinitionInput +from ..types.workflow_list_item import WorkflowListItem from ..types.workflow_list_status import WorkflowListStatus from ..types.workflow_run_data_response import WorkflowRunDataResponse from .types.list_workflows_request_order_item import ListWorkflowsRequestOrderItem @@ -57,7 +58,7 @@ def list( limit: typing.Optional[int] = None, workspace_id: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None, - ) -> HttpResponse[ApiCountedListResponseWorkflowListItem]: + ) -> SyncPager[WorkflowListItem, ApiCountedListResponseWorkflowListItem]: """ List workflows in the current workspace. @@ -132,9 +133,11 @@ def list( Returns ------- - HttpResponse[ApiCountedListResponseWorkflowListItem] + SyncPager[WorkflowListItem, ApiCountedListResponseWorkflowListItem] Successful Response """ + offset = offset if offset is not None else 0 + _response = self._client_wrapper.httpx_client.request( "api/v1/workflows", method="GET", @@ -156,14 +159,29 @@ def list( ) try: if 200 <= _response.status_code < 300: - _data = typing.cast( + _parsed_response = typing.cast( ApiCountedListResponseWorkflowListItem, parse_obj_as( type_=ApiCountedListResponseWorkflowListItem, # type: ignore object_=_response.json(), ), ) - return HttpResponse(response=_response, data=_data) + _items = _parsed_response.data + _has_next = len(_items or []) > 0 + _get_next = lambda: self.list( + status=status, + search=search, + sort=sort, + order=order, + last_run_after=last_run_after, + last_run_before=last_run_before, + has_failed_run=has_failed_run, + offset=offset + len(_items or []), + limit=limit, + workspace_id=workspace_id, + request_options=request_options, + ) + return SyncPager(has_next=_has_next, items=_items, get_next=_get_next, response=_parsed_response) if _response.status_code == 422: raise UnprocessableEntityError( headers=dict(_response.headers), @@ -969,87 +987,6 @@ def runs_summary( ) raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) - def get_run_steps( - self, - workflow_id: str, - run_id: str, - *, - workspace_id: typing.Optional[str] = None, - request_options: typing.Optional[RequestOptions] = None, - ) -> HttpResponse[ApiResponseListWorkflowRunStepOut]: - """ - List per-node execution steps for a workflow run. - - Returns one entry per node execution attempt, ordered by execution sequence. - Each step includes the node type, status, iteration number (for nodes that - are retried), start/completion timestamps, and the node's `result` output. - - For audio output nodes, `result` is hydrated with short-lived `playback_url` - values (valid for 15 minutes) so callers can stream audio directly without - a separate download step. - - `node_display_name` is resolved from the run's definition snapshot, so it - reflects the name the node had when the run executed. Repeated executions of - the same node share that name and are distinguished by `iteration`. - - For a higher-level view with aggregated metrics (pass rates, audio duration - by language), use `GET /runs/{run_id}/overview`. For paginated, grouped - script+audio rows suitable for a data table, use `GET /runs/{run_id}/data`. - - Parameters - ---------- - workflow_id : str - - run_id : str - - workspace_id : typing.Optional[str] - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - HttpResponse[ApiResponseListWorkflowRunStepOut] - Successful Response - """ - _response = self._client_wrapper.httpx_client.request( - f"api/v1/workflows/{encode_path_param(workflow_id)}/runs/{encode_path_param(run_id)}/steps", - method="GET", - headers={ - "X-Workspace-Id": str(workspace_id) if workspace_id is not None else None, - }, - request_options=request_options, - ) - try: - if 200 <= _response.status_code < 300: - _data = typing.cast( - ApiResponseListWorkflowRunStepOut, - parse_obj_as( - type_=ApiResponseListWorkflowRunStepOut, # type: ignore - object_=_response.json(), - ), - ) - return HttpResponse(response=_response, data=_data) - if _response.status_code == 422: - raise UnprocessableEntityError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - _response_json = _response.json() - except JSONDecodeError: - raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) - except ValidationError as e: - raise ParsingError( - status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e - ) - raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) - def get_run_outputs( self, workflow_id: str, @@ -1146,7 +1083,8 @@ def get_run_overview( - `GET /runs/{run_id}` — full run record including the raw definition snapshot. - `GET /runs/{run_id}/status` — volatile status fields only; for polling. - - `GET /runs/{run_id}/steps` — flat per-node step log with audio playback URLs. + - `GET /runs/{run_id}/steps` — lightweight per-node step log by default; + `include_result=true` includes results and audio playback URLs. - `GET /runs/{run_id}/outputs` — one logical result per snapshot sink node. - `GET /runs/{run_id}/data` — paginated script+audio rows for a data table. - `GET /runs/{run_id}/overview` (this endpoint) — pre-aggregated metrics and @@ -1751,7 +1689,7 @@ async def list( limit: typing.Optional[int] = None, workspace_id: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None, - ) -> AsyncHttpResponse[ApiCountedListResponseWorkflowListItem]: + ) -> AsyncPager[WorkflowListItem, ApiCountedListResponseWorkflowListItem]: """ List workflows in the current workspace. @@ -1826,9 +1764,11 @@ async def list( Returns ------- - AsyncHttpResponse[ApiCountedListResponseWorkflowListItem] + AsyncPager[WorkflowListItem, ApiCountedListResponseWorkflowListItem] Successful Response """ + offset = offset if offset is not None else 0 + _response = await self._client_wrapper.httpx_client.request( "api/v1/workflows", method="GET", @@ -1850,14 +1790,32 @@ async def list( ) try: if 200 <= _response.status_code < 300: - _data = typing.cast( + _parsed_response = typing.cast( ApiCountedListResponseWorkflowListItem, parse_obj_as( type_=ApiCountedListResponseWorkflowListItem, # type: ignore object_=_response.json(), ), ) - return AsyncHttpResponse(response=_response, data=_data) + _items = _parsed_response.data + _has_next = len(_items or []) > 0 + + async def _get_next(): + return await self.list( + status=status, + search=search, + sort=sort, + order=order, + last_run_after=last_run_after, + last_run_before=last_run_before, + has_failed_run=has_failed_run, + offset=offset + len(_items or []), + limit=limit, + workspace_id=workspace_id, + request_options=request_options, + ) + + return AsyncPager(has_next=_has_next, items=_items, get_next=_get_next, response=_parsed_response) if _response.status_code == 422: raise UnprocessableEntityError( headers=dict(_response.headers), @@ -2663,87 +2621,6 @@ async def runs_summary( ) raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) - async def get_run_steps( - self, - workflow_id: str, - run_id: str, - *, - workspace_id: typing.Optional[str] = None, - request_options: typing.Optional[RequestOptions] = None, - ) -> AsyncHttpResponse[ApiResponseListWorkflowRunStepOut]: - """ - List per-node execution steps for a workflow run. - - Returns one entry per node execution attempt, ordered by execution sequence. - Each step includes the node type, status, iteration number (for nodes that - are retried), start/completion timestamps, and the node's `result` output. - - For audio output nodes, `result` is hydrated with short-lived `playback_url` - values (valid for 15 minutes) so callers can stream audio directly without - a separate download step. - - `node_display_name` is resolved from the run's definition snapshot, so it - reflects the name the node had when the run executed. Repeated executions of - the same node share that name and are distinguished by `iteration`. - - For a higher-level view with aggregated metrics (pass rates, audio duration - by language), use `GET /runs/{run_id}/overview`. For paginated, grouped - script+audio rows suitable for a data table, use `GET /runs/{run_id}/data`. - - Parameters - ---------- - workflow_id : str - - run_id : str - - workspace_id : typing.Optional[str] - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - AsyncHttpResponse[ApiResponseListWorkflowRunStepOut] - Successful Response - """ - _response = await self._client_wrapper.httpx_client.request( - f"api/v1/workflows/{encode_path_param(workflow_id)}/runs/{encode_path_param(run_id)}/steps", - method="GET", - headers={ - "X-Workspace-Id": str(workspace_id) if workspace_id is not None else None, - }, - request_options=request_options, - ) - try: - if 200 <= _response.status_code < 300: - _data = typing.cast( - ApiResponseListWorkflowRunStepOut, - parse_obj_as( - type_=ApiResponseListWorkflowRunStepOut, # type: ignore - object_=_response.json(), - ), - ) - return AsyncHttpResponse(response=_response, data=_data) - if _response.status_code == 422: - raise UnprocessableEntityError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - _response_json = _response.json() - except JSONDecodeError: - raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) - except ValidationError as e: - raise ParsingError( - status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e - ) - raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) - async def get_run_outputs( self, workflow_id: str, @@ -2840,7 +2717,8 @@ async def get_run_overview( - `GET /runs/{run_id}` — full run record including the raw definition snapshot. - `GET /runs/{run_id}/status` — volatile status fields only; for polling. - - `GET /runs/{run_id}/steps` — flat per-node step log with audio playback URLs. + - `GET /runs/{run_id}/steps` — lightweight per-node step log by default; + `include_result=true` includes results and audio playback URLs. - `GET /runs/{run_id}/outputs` — one logical result per snapshot sink node. - `GET /runs/{run_id}/data` — paginated script+audio rows for a data table. - `GET /runs/{run_id}/overview` (this endpoint) — pre-aggregated metrics and diff --git a/src/onepin/workflows/runs/client.py b/src/onepin/workflows/runs/client.py index 4e36b4f..465bf85 100644 --- a/src/onepin/workflows/runs/client.py +++ b/src/onepin/workflows/runs/client.py @@ -3,11 +3,15 @@ import typing from ...core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ...core.pagination import AsyncPager, SyncPager from ...core.request_options import RequestOptions from ...types.api_counted_list_response_workflow_run_list_item import ApiCountedListResponseWorkflowRunListItem +from ...types.api_response_list_workflow_run_step_out import ApiResponseListWorkflowRunStepOut from ...types.api_response_workflow_run_detail_out import ApiResponseWorkflowRunDetailOut from ...types.api_response_workflow_run_out import ApiResponseWorkflowRunOut from ...types.api_response_workflow_run_status_out import ApiResponseWorkflowRunStatusOut +from ...types.node_type import NodeType +from ...types.workflow_run_list_item import WorkflowRunListItem from ...types.workflow_run_start_in import WorkflowRunStartIn from .raw_client import AsyncRawRunsClient, RawRunsClient from .types.list_runs_request_order import ListRunsRequestOrder @@ -44,7 +48,7 @@ def list( order: typing.Optional[ListRunsRequestOrder] = None, workspace_id: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None, - ) -> ApiCountedListResponseWorkflowRunListItem: + ) -> SyncPager[WorkflowRunListItem, ApiCountedListResponseWorkflowRunListItem]: """ List runs for a workflow, newest first by default. @@ -91,7 +95,7 @@ def list( Returns ------- - ApiCountedListResponseWorkflowRunListItem + SyncPager[WorkflowRunListItem, ApiCountedListResponseWorkflowRunListItem] Successful Response Examples @@ -101,11 +105,16 @@ def list( client = OnePinClient( token="YOUR_TOKEN", ) - client.workflows.runs.list( + response = client.workflows.runs.list( workflow_id="workflow_id", ) + for item in response: + yield item + # alternatively, you can paginate page-by-page + for page in response.iter_pages(): + yield page """ - _response = self._raw_client.list( + return self._raw_client.list( workflow_id, offset=offset, limit=limit, @@ -116,7 +125,6 @@ def list( workspace_id=workspace_id, request_options=request_options, ) - return _response.data def start( self, @@ -201,7 +209,8 @@ def get( This is the heaviest run endpoint. For progress polling, use the lighter `GET /runs/{run_id}/status` which omits the snapshot. For aggregated visual metrics, use `GET /runs/{run_id}/overview`. For the per-node step - log with audio playback URLs, use `GET /runs/{run_id}/steps`. + log, use `GET /runs/{run_id}/steps`; opt into full results and audio + playback URLs with `include_result=true`. Parameters ---------- @@ -296,6 +305,85 @@ def status( ) return _response.data + def steps( + self, + workflow_id: str, + run_id: str, + *, + include_result: typing.Optional[bool] = None, + node_type: typing.Optional[NodeType] = None, + node_id: typing.Optional[str] = None, + workspace_id: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> ApiResponseListWorkflowRunStepOut: + """ + List per-node execution steps for a workflow run. + + Returns one entry per node execution attempt, ordered by execution sequence. + By default, the response is lightweight: `result` is null, `has_result` + reports whether a stored result exists, and `active_ports` is projected from + the result without loading the full JSON payload. + + Set `include_result=true` to restore the full result payload. Audio results + are then hydrated with short-lived `playback_url` values (valid for 15 + minutes). `node_type` and `node_id` filters combine with AND semantics. + + `node_display_name` is resolved from the run's definition snapshot, so it + reflects the name the node had when the run executed. Repeated executions of + the same node share that name and are distinguished by `iteration`. + + For a higher-level view with aggregated metrics (pass rates, audio duration + by language), use `GET /runs/{run_id}/overview`. For paginated, grouped + script+audio rows suitable for a data table, use `GET /runs/{run_id}/data`. + + Parameters + ---------- + workflow_id : str + + run_id : str + + include_result : typing.Optional[bool] + Include the full step result payload and hydrate audio playback URLs. + + node_type : typing.Optional[NodeType] + Filter steps by node type. + + node_id : typing.Optional[str] + Filter steps by node ID. + + workspace_id : typing.Optional[str] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ApiResponseListWorkflowRunStepOut + Successful Response + + Examples + -------- + from onepin import OnePinClient + + client = OnePinClient( + token="YOUR_TOKEN", + ) + client.workflows.runs.steps( + workflow_id="workflow_id", + run_id="run_id", + ) + """ + _response = self._raw_client.steps( + workflow_id, + run_id, + include_result=include_result, + node_type=node_type, + node_id=node_id, + workspace_id=workspace_id, + request_options=request_options, + ) + return _response.data + def cancel( self, workflow_id: str, @@ -312,9 +400,9 @@ def cancel( operation is idempotent: cancelling an already-cancelled run returns the run unchanged without error. - Only runs in `pending`, `running`, or `paused` status can be cancelled. - Runs that have already reached a terminal state (`completed`, `failed`, - `cancelled`) return 409. + Runs already in a terminal state (`completed`, `failed`, or `cancelled`) + are returned unchanged. Concurrent terminalization is also treated as an + idempotent success; a still-active compare-and-swap loser returns 409. Unlike `pause`, cancel is permanent — a cancelled run cannot be resumed. Use `pause` if you intend to continue the run later. @@ -380,7 +468,7 @@ async def list( order: typing.Optional[ListRunsRequestOrder] = None, workspace_id: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None, - ) -> ApiCountedListResponseWorkflowRunListItem: + ) -> AsyncPager[WorkflowRunListItem, ApiCountedListResponseWorkflowRunListItem]: """ List runs for a workflow, newest first by default. @@ -427,7 +515,7 @@ async def list( Returns ------- - ApiCountedListResponseWorkflowRunListItem + AsyncPager[WorkflowRunListItem, ApiCountedListResponseWorkflowRunListItem] Successful Response Examples @@ -442,14 +530,20 @@ async def list( async def main() -> None: - await client.workflows.runs.list( + response = await client.workflows.runs.list( workflow_id="workflow_id", ) + async for item in response: + yield item + + # alternatively, you can paginate page-by-page + async for page in response.iter_pages(): + yield page asyncio.run(main()) """ - _response = await self._raw_client.list( + return await self._raw_client.list( workflow_id, offset=offset, limit=limit, @@ -460,7 +554,6 @@ async def main() -> None: workspace_id=workspace_id, request_options=request_options, ) - return _response.data async def start( self, @@ -553,7 +646,8 @@ async def get( This is the heaviest run endpoint. For progress polling, use the lighter `GET /runs/{run_id}/status` which omits the snapshot. For aggregated visual metrics, use `GET /runs/{run_id}/overview`. For the per-node step - log with audio playback URLs, use `GET /runs/{run_id}/steps`. + log, use `GET /runs/{run_id}/steps`; opt into full results and audio + playback URLs with `include_result=true`. Parameters ---------- @@ -664,6 +758,93 @@ async def main() -> None: ) return _response.data + async def steps( + self, + workflow_id: str, + run_id: str, + *, + include_result: typing.Optional[bool] = None, + node_type: typing.Optional[NodeType] = None, + node_id: typing.Optional[str] = None, + workspace_id: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> ApiResponseListWorkflowRunStepOut: + """ + List per-node execution steps for a workflow run. + + Returns one entry per node execution attempt, ordered by execution sequence. + By default, the response is lightweight: `result` is null, `has_result` + reports whether a stored result exists, and `active_ports` is projected from + the result without loading the full JSON payload. + + Set `include_result=true` to restore the full result payload. Audio results + are then hydrated with short-lived `playback_url` values (valid for 15 + minutes). `node_type` and `node_id` filters combine with AND semantics. + + `node_display_name` is resolved from the run's definition snapshot, so it + reflects the name the node had when the run executed. Repeated executions of + the same node share that name and are distinguished by `iteration`. + + For a higher-level view with aggregated metrics (pass rates, audio duration + by language), use `GET /runs/{run_id}/overview`. For paginated, grouped + script+audio rows suitable for a data table, use `GET /runs/{run_id}/data`. + + Parameters + ---------- + workflow_id : str + + run_id : str + + include_result : typing.Optional[bool] + Include the full step result payload and hydrate audio playback URLs. + + node_type : typing.Optional[NodeType] + Filter steps by node type. + + node_id : typing.Optional[str] + Filter steps by node ID. + + workspace_id : typing.Optional[str] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ApiResponseListWorkflowRunStepOut + Successful Response + + Examples + -------- + import asyncio + + from onepin import AsyncOnePinClient + + client = AsyncOnePinClient( + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.workflows.runs.steps( + workflow_id="workflow_id", + run_id="run_id", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.steps( + workflow_id, + run_id, + include_result=include_result, + node_type=node_type, + node_id=node_id, + workspace_id=workspace_id, + request_options=request_options, + ) + return _response.data + async def cancel( self, workflow_id: str, @@ -680,9 +861,9 @@ async def cancel( operation is idempotent: cancelling an already-cancelled run returns the run unchanged without error. - Only runs in `pending`, `running`, or `paused` status can be cancelled. - Runs that have already reached a terminal state (`completed`, `failed`, - `cancelled`) return 409. + Runs already in a terminal state (`completed`, `failed`, or `cancelled`) + are returned unchanged. Concurrent terminalization is also treated as an + idempotent success; a still-active compare-and-swap loser returns 409. Unlike `pause`, cancel is permanent — a cancelled run cannot be resumed. Use `pause` if you intend to continue the run later. diff --git a/src/onepin/workflows/runs/raw_client.py b/src/onepin/workflows/runs/raw_client.py index b848485..2b104ae 100644 --- a/src/onepin/workflows/runs/raw_client.py +++ b/src/onepin/workflows/runs/raw_client.py @@ -7,15 +7,19 @@ from ...core.client_wrapper import AsyncClientWrapper, SyncClientWrapper from ...core.http_response import AsyncHttpResponse, HttpResponse from ...core.jsonable_encoder import encode_path_param +from ...core.pagination import AsyncPager, SyncPager from ...core.parse_error import ParsingError from ...core.pydantic_utilities import parse_obj_as from ...core.request_options import RequestOptions from ...core.serialization import convert_and_respect_annotation_metadata from ...errors.unprocessable_entity_error import UnprocessableEntityError from ...types.api_counted_list_response_workflow_run_list_item import ApiCountedListResponseWorkflowRunListItem +from ...types.api_response_list_workflow_run_step_out import ApiResponseListWorkflowRunStepOut from ...types.api_response_workflow_run_detail_out import ApiResponseWorkflowRunDetailOut from ...types.api_response_workflow_run_out import ApiResponseWorkflowRunOut from ...types.api_response_workflow_run_status_out import ApiResponseWorkflowRunStatusOut +from ...types.node_type import NodeType +from ...types.workflow_run_list_item import WorkflowRunListItem from ...types.workflow_run_start_in import WorkflowRunStartIn from .types.list_runs_request_order import ListRunsRequestOrder from .types.list_runs_request_sort import ListRunsRequestSort @@ -41,7 +45,7 @@ def list( order: typing.Optional[ListRunsRequestOrder] = None, workspace_id: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None, - ) -> HttpResponse[ApiCountedListResponseWorkflowRunListItem]: + ) -> SyncPager[WorkflowRunListItem, ApiCountedListResponseWorkflowRunListItem]: """ List runs for a workflow, newest first by default. @@ -88,9 +92,11 @@ def list( Returns ------- - HttpResponse[ApiCountedListResponseWorkflowRunListItem] + SyncPager[WorkflowRunListItem, ApiCountedListResponseWorkflowRunListItem] Successful Response """ + offset = offset if offset is not None else 0 + _response = self._client_wrapper.httpx_client.request( f"api/v1/workflows/{encode_path_param(workflow_id)}/runs", method="GET", @@ -109,14 +115,27 @@ def list( ) try: if 200 <= _response.status_code < 300: - _data = typing.cast( + _parsed_response = typing.cast( ApiCountedListResponseWorkflowRunListItem, parse_obj_as( type_=ApiCountedListResponseWorkflowRunListItem, # type: ignore object_=_response.json(), ), ) - return HttpResponse(response=_response, data=_data) + _items = _parsed_response.data + _has_next = len(_items or []) > 0 + _get_next = lambda: self.list( + workflow_id, + offset=offset + len(_items or []), + limit=limit, + status=status, + search=search, + sort=sort, + order=order, + workspace_id=workspace_id, + request_options=request_options, + ) + return SyncPager(has_next=_has_next, items=_items, get_next=_get_next, response=_parsed_response) if _response.status_code == 422: raise UnprocessableEntityError( headers=dict(_response.headers), @@ -246,7 +265,8 @@ def get( This is the heaviest run endpoint. For progress polling, use the lighter `GET /runs/{run_id}/status` which omits the snapshot. For aggregated visual metrics, use `GET /runs/{run_id}/overview`. For the per-node step - log with audio playback URLs, use `GET /runs/{run_id}/steps`. + log, use `GET /runs/{run_id}/steps`; opt into full results and audio + playback URLs with `include_result=true`. Parameters ---------- @@ -383,6 +403,105 @@ def status( ) raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + def steps( + self, + workflow_id: str, + run_id: str, + *, + include_result: typing.Optional[bool] = None, + node_type: typing.Optional[NodeType] = None, + node_id: typing.Optional[str] = None, + workspace_id: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[ApiResponseListWorkflowRunStepOut]: + """ + List per-node execution steps for a workflow run. + + Returns one entry per node execution attempt, ordered by execution sequence. + By default, the response is lightweight: `result` is null, `has_result` + reports whether a stored result exists, and `active_ports` is projected from + the result without loading the full JSON payload. + + Set `include_result=true` to restore the full result payload. Audio results + are then hydrated with short-lived `playback_url` values (valid for 15 + minutes). `node_type` and `node_id` filters combine with AND semantics. + + `node_display_name` is resolved from the run's definition snapshot, so it + reflects the name the node had when the run executed. Repeated executions of + the same node share that name and are distinguished by `iteration`. + + For a higher-level view with aggregated metrics (pass rates, audio duration + by language), use `GET /runs/{run_id}/overview`. For paginated, grouped + script+audio rows suitable for a data table, use `GET /runs/{run_id}/data`. + + Parameters + ---------- + workflow_id : str + + run_id : str + + include_result : typing.Optional[bool] + Include the full step result payload and hydrate audio playback URLs. + + node_type : typing.Optional[NodeType] + Filter steps by node type. + + node_id : typing.Optional[str] + Filter steps by node ID. + + workspace_id : typing.Optional[str] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[ApiResponseListWorkflowRunStepOut] + Successful Response + """ + _response = self._client_wrapper.httpx_client.request( + f"api/v1/workflows/{encode_path_param(workflow_id)}/runs/{encode_path_param(run_id)}/steps", + method="GET", + params={ + "include_result": include_result, + "node_type": node_type, + "node_id": node_id, + }, + headers={ + "X-Workspace-Id": str(workspace_id) if workspace_id is not None else None, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ApiResponseListWorkflowRunStepOut, + parse_obj_as( + type_=ApiResponseListWorkflowRunStepOut, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + def cancel( self, workflow_id: str, @@ -399,9 +518,9 @@ def cancel( operation is idempotent: cancelling an already-cancelled run returns the run unchanged without error. - Only runs in `pending`, `running`, or `paused` status can be cancelled. - Runs that have already reached a terminal state (`completed`, `failed`, - `cancelled`) return 409. + Runs already in a terminal state (`completed`, `failed`, or `cancelled`) + are returned unchanged. Concurrent terminalization is also treated as an + idempotent success; a still-active compare-and-swap loser returns 409. Unlike `pause`, cancel is permanent — a cancelled run cannot be resumed. Use `pause` if you intend to continue the run later. @@ -477,7 +596,7 @@ async def list( order: typing.Optional[ListRunsRequestOrder] = None, workspace_id: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None, - ) -> AsyncHttpResponse[ApiCountedListResponseWorkflowRunListItem]: + ) -> AsyncPager[WorkflowRunListItem, ApiCountedListResponseWorkflowRunListItem]: """ List runs for a workflow, newest first by default. @@ -524,9 +643,11 @@ async def list( Returns ------- - AsyncHttpResponse[ApiCountedListResponseWorkflowRunListItem] + AsyncPager[WorkflowRunListItem, ApiCountedListResponseWorkflowRunListItem] Successful Response """ + offset = offset if offset is not None else 0 + _response = await self._client_wrapper.httpx_client.request( f"api/v1/workflows/{encode_path_param(workflow_id)}/runs", method="GET", @@ -545,14 +666,30 @@ async def list( ) try: if 200 <= _response.status_code < 300: - _data = typing.cast( + _parsed_response = typing.cast( ApiCountedListResponseWorkflowRunListItem, parse_obj_as( type_=ApiCountedListResponseWorkflowRunListItem, # type: ignore object_=_response.json(), ), ) - return AsyncHttpResponse(response=_response, data=_data) + _items = _parsed_response.data + _has_next = len(_items or []) > 0 + + async def _get_next(): + return await self.list( + workflow_id, + offset=offset + len(_items or []), + limit=limit, + status=status, + search=search, + sort=sort, + order=order, + workspace_id=workspace_id, + request_options=request_options, + ) + + return AsyncPager(has_next=_has_next, items=_items, get_next=_get_next, response=_parsed_response) if _response.status_code == 422: raise UnprocessableEntityError( headers=dict(_response.headers), @@ -682,7 +819,8 @@ async def get( This is the heaviest run endpoint. For progress polling, use the lighter `GET /runs/{run_id}/status` which omits the snapshot. For aggregated visual metrics, use `GET /runs/{run_id}/overview`. For the per-node step - log with audio playback URLs, use `GET /runs/{run_id}/steps`. + log, use `GET /runs/{run_id}/steps`; opt into full results and audio + playback URLs with `include_result=true`. Parameters ---------- @@ -819,6 +957,105 @@ async def status( ) raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + async def steps( + self, + workflow_id: str, + run_id: str, + *, + include_result: typing.Optional[bool] = None, + node_type: typing.Optional[NodeType] = None, + node_id: typing.Optional[str] = None, + workspace_id: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[ApiResponseListWorkflowRunStepOut]: + """ + List per-node execution steps for a workflow run. + + Returns one entry per node execution attempt, ordered by execution sequence. + By default, the response is lightweight: `result` is null, `has_result` + reports whether a stored result exists, and `active_ports` is projected from + the result without loading the full JSON payload. + + Set `include_result=true` to restore the full result payload. Audio results + are then hydrated with short-lived `playback_url` values (valid for 15 + minutes). `node_type` and `node_id` filters combine with AND semantics. + + `node_display_name` is resolved from the run's definition snapshot, so it + reflects the name the node had when the run executed. Repeated executions of + the same node share that name and are distinguished by `iteration`. + + For a higher-level view with aggregated metrics (pass rates, audio duration + by language), use `GET /runs/{run_id}/overview`. For paginated, grouped + script+audio rows suitable for a data table, use `GET /runs/{run_id}/data`. + + Parameters + ---------- + workflow_id : str + + run_id : str + + include_result : typing.Optional[bool] + Include the full step result payload and hydrate audio playback URLs. + + node_type : typing.Optional[NodeType] + Filter steps by node type. + + node_id : typing.Optional[str] + Filter steps by node ID. + + workspace_id : typing.Optional[str] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[ApiResponseListWorkflowRunStepOut] + Successful Response + """ + _response = await self._client_wrapper.httpx_client.request( + f"api/v1/workflows/{encode_path_param(workflow_id)}/runs/{encode_path_param(run_id)}/steps", + method="GET", + params={ + "include_result": include_result, + "node_type": node_type, + "node_id": node_id, + }, + headers={ + "X-Workspace-Id": str(workspace_id) if workspace_id is not None else None, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ApiResponseListWorkflowRunStepOut, + parse_obj_as( + type_=ApiResponseListWorkflowRunStepOut, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + async def cancel( self, workflow_id: str, @@ -835,9 +1072,9 @@ async def cancel( operation is idempotent: cancelling an already-cancelled run returns the run unchanged without error. - Only runs in `pending`, `running`, or `paused` status can be cancelled. - Runs that have already reached a terminal state (`completed`, `failed`, - `cancelled`) return 409. + Runs already in a terminal state (`completed`, `failed`, or `cancelled`) + are returned unchanged. Concurrent terminalization is also treated as an + idempotent success; a still-active compare-and-swap loser returns 409. Unlike `pause`, cancel is permanent — a cancelled run cannot be resumed. Use `pause` if you intend to continue the run later. diff --git a/tests/build/test_sdk_contract.py b/tests/build/test_sdk_contract.py index 84241e9..6993c03 100644 --- a/tests/build/test_sdk_contract.py +++ b/tests/build/test_sdk_contract.py @@ -14,11 +14,13 @@ from __future__ import annotations import inspect +from typing import get_args import pytest from onepin._cli._spec import TABLE, Cmd from onepin.client import OnePinClient +from onepin.types import NodeType # Options whose dest is consumed by the CLI itself, not forwarded to the SDK. _LOCAL_DESTS = {"json_output_local", "reveal", "yes"} @@ -33,6 +35,16 @@ def _resolve_method(dotted: str): return obj +def test_run_steps_node_type_choices_match_public_type() -> None: + cmd = next(cmd for cmd in TABLE if cmd.path == ("workflows", "runs", "steps")) + option = next(option for option in cmd.options if option.flag == "--node-type") + public_values = tuple( + value for branch in get_args(NodeType) for value in get_args(branch) if isinstance(value, str) + ) + + assert option.type == public_values + + @pytest.mark.parametrize("cmd", TABLE, ids=lambda c: ".".join(c.path)) def test_method_resolves(cmd: Cmd) -> None: method = _resolve_method(cmd.method) diff --git a/tests/build/test_workflow_run_steps_contract.py b/tests/build/test_workflow_run_steps_contract.py new file mode 100644 index 0000000..f4c9ba0 --- /dev/null +++ b/tests/build/test_workflow_run_steps_contract.py @@ -0,0 +1,120 @@ +"""Generated SDK contract coverage for lightweight workflow run steps.""" + +from __future__ import annotations + +import inspect + +import httpx +import pytest +import respx + +from onepin.client import AsyncOnePinClient, OnePinClient +from onepin.types import WorkflowRunStepOut +from onepin.workflows.runs.client import AsyncRunsClient, RunsClient +from onepin.workflows.runs.raw_client import AsyncRawRunsClient, RawRunsClient + +_BASE = "https://api.onepin.ai" +_STEPS_URL = f"{_BASE}/api/v1/workflows/wf-1/runs/run-1/steps" +_META = {"request_id": "req-1", "timestamp": "2025-01-01T00:00:00Z"} +_STEP = { + "id": "step-1", + "node_id": "node-1", + "node_type": "validator_error_rate", + "node_display_name": "Error Rate", + "status": "completed", + "iteration": 0, + "started_at": "2025-01-01T00:00:00Z", + "completed_at": "2025-01-01T00:00:01Z", + "result": None, + "has_result": True, + "active_ports": ["output"], + "error": None, +} +_FILTER_CASES = [ + ({}, {}), + ({"include_result": True}, {"include_result": "true"}), + ({"node_type": "validator_error_rate"}, {"node_type": "validator_error_rate"}), + ({"node_id": "node-1"}, {"node_id": "node-1"}), + ( + {"include_result": True, "node_type": "validator_error_rate", "node_id": "node-1"}, + {"include_result": "true", "node_type": "validator_error_rate", "node_id": "node-1"}, + ), +] + + +@pytest.mark.parametrize("client_type", [RunsClient, AsyncRunsClient, RawRunsClient, AsyncRawRunsClient]) +def test_all_generated_surfaces_expose_optional_step_filters(client_type: type[object]) -> None: + signature = inspect.signature(client_type.steps) + + assert tuple(signature.parameters) == ( + "self", + "workflow_id", + "run_id", + "include_result", + "node_type", + "node_id", + "workspace_id", + "request_options", + ) + for name in ("include_result", "node_type", "node_id"): + assert signature.parameters[name].default is None + + +def test_generated_step_model_exposes_lightweight_result_metadata() -> None: + step = WorkflowRunStepOut.model_validate(_STEP) + + assert step.result is None + assert step.has_result is True + assert step.active_ports == ["output"] + + +def test_generated_step_model_accepts_older_backend_payload() -> None: + legacy_payload = {key: value for key, value in _STEP.items() if key not in {"has_result", "active_ports"}} + legacy_payload["result"] = {"output": "legacy full result"} + + step = WorkflowRunStepOut.model_validate(legacy_payload) + + assert step.result == {"output": "legacy full result"} + assert step.has_result is None + assert step.active_ports is None + + +@pytest.mark.parametrize("raw", [False, True], ids=["standard", "raw"]) +@pytest.mark.parametrize(("kwargs", "expected_query"), _FILTER_CASES) +@respx.mock +def test_generated_sync_clients_serialize_step_filters( + raw: bool, kwargs: dict[str, object], expected_query: dict[str, str] +) -> None: + route = respx.get(_STEPS_URL).mock(return_value=httpx.Response(200, json={"data": [_STEP], "meta": _META})) + http_client = httpx.Client() + client = OnePinClient(token="op_live_test", httpx_client=http_client) + try: + runs = client.workflows.runs.with_raw_response if raw else client.workflows.runs + response = runs.steps("wf-1", "run-1", **kwargs) + finally: + http_client.close() + + data = response.data.data if raw else response.data + assert data[0].has_result is True + assert dict(route.calls[0].request.url.params) == expected_query + + +@pytest.mark.asyncio +@pytest.mark.parametrize("raw", [False, True], ids=["standard", "raw"]) +@pytest.mark.parametrize(("kwargs", "expected_query"), _FILTER_CASES) +@respx.mock +async def test_generated_async_clients_serialize_step_filters( + raw: bool, kwargs: dict[str, object], expected_query: dict[str, str] +) -> None: + route = respx.get(_STEPS_URL).mock(return_value=httpx.Response(200, json={"data": [_STEP], "meta": _META})) + http_client = httpx.AsyncClient() + client = AsyncOnePinClient(token="op_live_test", httpx_client=http_client) + try: + runs = client.workflows.runs.with_raw_response if raw else client.workflows.runs + response = await runs.steps("wf-1", "run-1", **kwargs) + finally: + await http_client.aclose() + + data = response.data.data if raw else response.data + assert data[0].active_ports == ["output"] + assert dict(route.calls[0].request.url.params) == expected_query diff --git a/tests/cli/_manifest_snapshot.json b/tests/cli/_manifest_snapshot.json index 8bf8af6..5860d46 100644 --- a/tests/cli/_manifest_snapshot.json +++ b/tests/cli/_manifest_snapshot.json @@ -1488,6 +1488,27 @@ "group": "workflows", "name": "steps", "options": [ + { + "default": false, + "flag": "--include-result", + "help": "Include each step's full result payload.", + "required": false, + "type": "bool" + }, + { + "default": null, + "flag": "--node-type", + "help": "Filter by node type.", + "required": false, + "type": "choice[source_script,operator_translator,operator_normalizer,operator_generator,sink_preview,validator_error_rate,validator_naturalness,validator_noise]" + }, + { + "default": null, + "flag": "--node-id", + "help": "Filter by node ID.", + "required": false, + "type": "text" + }, { "default": false, "flag": "--json", diff --git a/tests/cli/test_cli_dispatch.py b/tests/cli/test_cli_dispatch.py index 9dfa383..cd1804a 100644 --- a/tests/cli/test_cli_dispatch.py +++ b/tests/cli/test_cli_dispatch.py @@ -74,11 +74,18 @@ def _meta(): class FakeRuns: raise_404 = False + def __init__(self) -> None: + self.step_calls: list[dict[str, object]] = [] + def cancel(self, workflow_id, run_id, **kw): if self.raise_404: raise NotFoundError(body={"error": {"code": "NOT_FOUND", "message": "gone"}}) return _dict_response(cancelled=True, id=run_id) + def steps(self, workflow_id, run_id, **kw): + self.step_calls.append({"workflow_id": workflow_id, "run_id": run_id, **kw}) + return [{"id": "step-1", "node_id": "node-1", "status": "completed"}] + class FakeWorkflows: raise_404 = False @@ -176,6 +183,71 @@ def test_cancel_404_under_yes_surfaces_error(self, fake_client: FakeClient, tmp_ assert "cancelled" not in result.output.lower() +class TestWorkflowRunSteps: + def test_omits_optional_filters_by_default(self, fake_client: FakeClient, tmp_home) -> None: + result = _invoke(["workflows", "runs", "steps", "wf-1", "run-1", "--json"]) + + assert result.exit_code == 0, result.output + assert fake_client.workflows.runs.step_calls == [{"workflow_id": "wf-1", "run_id": "run-1"}] + assert '"id": "step-1"' in result.output + + @pytest.mark.parametrize( + ("flag", "value", "expected"), + [ + ("--include-result", None, {"include_result": True}), + ("--node-type", "operator_generator", {"node_type": "operator_generator"}), + ("--node-id", "node-7", {"node_id": "node-7"}), + ], + ) + def test_forwards_each_optional_filter( + self, + fake_client: FakeClient, + tmp_home, + flag: str, + value: str | None, + expected: dict[str, object], + ) -> None: + option = [flag] if value is None else [flag, value] + result = _invoke(["workflows", "runs", "steps", "wf-1", "run-1", *option, "--json"]) + + assert result.exit_code == 0, result.output + assert fake_client.workflows.runs.step_calls == [{"workflow_id": "wf-1", "run_id": "run-1", **expected}] + + def test_forwards_combined_filters(self, fake_client: FakeClient, tmp_home) -> None: + result = _invoke( + [ + "workflows", + "runs", + "steps", + "wf-1", + "run-1", + "--include-result", + "--node-type", + "validator_error_rate", + "--node-id", + "node-7", + "--json", + ] + ) + + assert result.exit_code == 0, result.output + assert fake_client.workflows.runs.step_calls == [ + { + "workflow_id": "wf-1", + "run_id": "run-1", + "include_result": True, + "node_type": "validator_error_rate", + "node_id": "node-7", + } + ] + + def test_node_type_rejects_unknown_value(self, fake_client: FakeClient, tmp_home) -> None: + result = _invoke(["workflows", "runs", "steps", "wf-1", "run-1", "--node-type", "not-a-node", "--json"]) + + assert result.exit_code == 2 + assert fake_client.workflows.runs.step_calls == [] + + class TestNotAuthenticated: def test_no_creds_exit_1(self, tmp_home) -> None: # No flag, no env, no file -> get_client raises OnePinAuthError.