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.