Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/api_reference/work_item.rst
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ nisystemlink.clients.work_item
.. automethod:: update_work_items
.. automethod:: schedule_work_items
.. automethod:: delete_work_items
.. automethod:: execute_work_item
.. automethod:: create_work_item_templates
.. automethod:: query_work_item_templates
.. automethod:: update_work_item_templates
Expand Down
4 changes: 2 additions & 2 deletions docs/getting_started.rst
Original file line number Diff line number Diff line change
Expand Up @@ -456,13 +456,13 @@ default connection. The default connection depends on your environment.

With a :class:`.WorkItemClient` object, you can:

* Create, query, get, update, schedule and delete work items
* Create, query, get, update, schedule, delete and execute work items
* Create, query, update and delete work item templates

Examples
~~~~~~~~

Create, query, get, update, schedule and delete work items
Create, query, get, update, schedule, delete and execute work items

.. literalinclude:: ../examples/work_item/work_items.py
:language: python
Expand Down
34 changes: 34 additions & 0 deletions examples/work_item/work_items.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@
dashboard=Dashboard(
id="DashboardId", variables={"product": "PXIe-4080", "location": "Lab1"}
),
workflow_id="example-workflow-id",
execution_actions=[
ManualExecution(action="boot", type="MANUAL"),
JobExecution(
Expand Down Expand Up @@ -202,6 +203,39 @@
f"Scheduled work item with ID: {schedule_work_items_response.scheduled_work_items[0].id}"
)

# Execute work item action
if created_work_item_id is not None:
try:
execute_response = client.execute_work_item(
work_item_id=created_work_item_id, action="START"
)
if execute_response.error is not None:
print(f"Execution failed: {execute_response.error.message}")
elif execute_response.result is not None:
print(f"Executed action successfully. Type: {execute_response.result.type}")

# Check for result-level error
if execute_response.result.error is not None:
print(f"Execution error: {execute_response.result.error.message}")
else:
# Handle type-specific fields
if execute_response.result.type == "NOTEBOOK":
if execute_response.result.execution_id:
print(
f"Notebook execution ID: {execute_response.result.execution_id}"
)
elif execute_response.result.type == "JOB":
if execute_response.result.job_ids:
print(f"Job IDs: {', '.join(execute_response.result.job_ids)}")
elif execute_response.result.type == "MANUAL":
print("Manual execution completed")
elif execute_response.result.type == "SCHEDULE":
print("Work item scheduled")
elif execute_response.result.type == "UNSCHEDULE":
print("Work item unscheduled")
except Exception as e:
print(f"Could not execute action: {e}")

# Delete work item
if created_work_item_id is not None:
client.delete_work_items(ids=[created_work_item_id])
Expand Down
23 changes: 22 additions & 1 deletion nisystemlink/clients/work_item/_work_item_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from nisystemlink.clients.core._uplink._base_client import BaseClient
from nisystemlink.clients.core._uplink._methods import get, post
from nisystemlink.clients.work_item import models
from uplink import Field, retry
from uplink import Field, Path, retry


@retry(
Expand Down Expand Up @@ -132,6 +132,27 @@ def delete_work_items(
"""
...

@post(
"workitems/{workItemId}/execute",
args=[Path(name="workItemId"), Field("action")],
)
def execute_work_item(
self, work_item_id: str, action: str
) -> models.ExecuteWorkItemResponse:
"""Executes the specified action for the work item.

Args:
work_item_id: The ID of the work item the action will be performed on.
action: The action to execute on the work item.

Returns:
The response containing the execution result or error information.

Raises:
ApiException: if unable to communicate with the `/niworkitem` service or provided invalid arguments.
"""
...

@post("workitem-templates", args=[Field("workItemTemplates")])
def create_work_item_templates(
self, work_item_templates: List[models.CreateWorkItemTemplateRequest]
Expand Down
5 changes: 5 additions & 0 deletions nisystemlink/clients/work_item/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@
)
from ._update_work_items_request import UpdateWorkItemsRequest

from ._execute_work_item_response import (
ExecuteWorkItemResponse,
ExecutionResult,
)

from ._create_work_item_template_request import CreateWorkItemTemplateRequest
from ._create_work_item_templates_partial_success_response import (
CreateWorkItemTemplatesPartialSuccessResponse,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from typing import List, Literal

from nisystemlink.clients.core._api_error import ApiError
from nisystemlink.clients.core._uplink._json_model import JsonModel


class ExecutionResult(JsonModel):
"""Result of executing a work item action."""

type: Literal["NONE", "MANUAL", "NOTEBOOK", "JOB", "SCHEDULE", "UNSCHEDULE"]
"""Type of execution."""

error: ApiError | None = None
"""Error information if the execution encountered an error."""

execution_id: str | None = None
"""The notebook execution ID. Only populated when type is NOTEBOOK."""

job_ids: List[str] | None = None
"""The list of job IDs. Only populated when type is JOB."""
Comment on lines +16 to +20
Copy link
Collaborator

Choose a reason for hiding this comment

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

If these are only used for their corresponding job types then we should make different model objects for these rather than relying on comments.

Ideally this would be defined as a discriminated union.

class ExecutionResultBase(JsonModel):
    """Result of executing a work item action."""

    type: Literal["NONE", "MANUAL", "NOTEBOOK", "JOB", "SCHEDULE", "UNSCHEDULE"]
    """Type of execution."""

class JobExecutionResult(ExecutionResultBase):
    type: Literal["JOB"]
    """Type of execution."""
    
    job_ids: List[str] | None = None
    """The list of job IDs."""

class NotebookExecutionResult(ExecutionResultBase):
    type: Literal["NOTEBOOK"]
    """Type of execution."""
    
    execution_id: str | None = None
    """The notebook execution ID."""

...

ExecutionResult = Annotated[
    Union[
        NoneExecutionResult,
        ManualExecutionResult,
        NotebookExecutionResult,
        JobExecutionResult,
        ScheduleExecutionResult,
        UnscheduleExecutionResult
    ],
    Field(discriminator='type')
]
"""Result of executing a work item action."""

class ExecuteWorkItemResponse(JsonModel):
    """Response for executing a work item action."""

    result: ExecutionResult
    """Result of the action execution."""



class ExecuteWorkItemResponse(JsonModel):
"""Response for executing a work item action."""

error: ApiError | None = None
"""Error information if the action failed."""

result: ExecutionResult | None = None
"""Result of the action execution."""
Copy link
Collaborator

Choose a reason for hiding this comment

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

I want to know if we can clean up this response model or if its useful to have these extra layers. Best I can tell the error will only be set when the response is an error status code, which we handle in the base client to convert into an ApiError already, so this part of the model will never be used. That leaves just the result, so can we elevate that and return it directly from the client instead?

Conversely, if we do need to return both the error and the execution result together in the error case then we need to override the base client's status code handler so it doesn't throw so we can have the behavior we'd want.

@darrenbiel or @kaviarasu-ni can you comment on the way we intend the API to behave?

Choose a reason for hiding this comment

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

The service may return both an error and job IDs in a partial failure case. It will cancel the jobs it successfully queued and return their IDs along with the error. The error is duplicated at the base response and in the Result.Error.

I think we would need to override the base client as you suggested.

117 changes: 117 additions & 0 deletions tests/integration/work_item/test_work_item_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
from typing import List

import pytest
import responses
from nisystemlink.clients.core import ApiException
from nisystemlink.clients.core._http_configuration import HttpConfiguration
from nisystemlink.clients.work_item import WorkItemClient
from nisystemlink.clients.work_item.models import (
Expand Down Expand Up @@ -41,6 +43,8 @@
WorkItemTemplateField,
)

BASE_URL = "https://test-api.lifecyclesolutions.ni.com"


@pytest.fixture(scope="class")
def client(enterprise_config: HttpConfiguration) -> WorkItemClient:
Expand Down Expand Up @@ -420,6 +424,119 @@ def test__delete_work_item(self, client: WorkItemClient, create_work_items):
)
assert len(query_deleted_work_item_response.work_items) == 0

@pytest.mark.parametrize(
"execution_type,action,extra_fields,expected_values",
[
("MANUAL", "START", {}, {}),
(
"NOTEBOOK",
"RUN_NOTEBOOK",
{"execution_id": "notebook-execution-123"},
{"execution_id": "notebook-execution-123"},
),
(
"JOB",
"RUN_JOBS",
{"job_ids": ["job-1", "job-2", "job-3"]},
{"job_ids": ["job-1", "job-2", "job-3"]},
),
("SCHEDULE", "SCHEDULE", {}, {}),
("UNSCHEDULE", "UNSCHEDULE", {}, {}),
],
)
@responses.activate
def test__execute_work_item_with_action__returns_execution_result(
self,
client: WorkItemClient,
execution_type: str,
action: str,
extra_fields: dict,
expected_values: dict,
):
work_item_id = "test-work-item-id"

return_value = {
"result": {
"type": execution_type,
"error": None,
**extra_fields,
},
"error": None,
}

responses.add(
responses.POST,
f"{BASE_URL}/niworkitem/v1/workitems/{work_item_id}/execute",
json=return_value,
status=200,
)

execute_response = client.execute_work_item(
work_item_id=work_item_id, action=action
)

assert execute_response is not None
assert execute_response.error is None
assert execute_response.result is not None
assert execute_response.result.type == execution_type
assert execute_response.result.error is None

# Verify type-specific fields
for field, expected_value in expected_values.items():
assert getattr(execute_response.result, field) == expected_value

def test__execute_work_item_with_invalid_id__raises_ApiException_NotFound(
self, client: WorkItemClient
):
invalid_work_item_id = "invalid-work-item-id"

with pytest.raises(ApiException) as exception_info:
client.execute_work_item(work_item_id=invalid_work_item_id, action="START")

assert exception_info.value.http_status_code == 404

@responses.activate
def test__execute_work_item_with_result_level_error__returns_result_with_error(
self, client: WorkItemClient
):
work_item_id = "test-work-item-id"

return_value = {
"result": {
"type": "NOTEBOOK",
"execution_id": None,
"error": {
"name": "Skyline.ExecutionFailed",
"code": -251050,
"message": "Notebook execution failed due to invalid parameters.",
"args": [],
"innerErrors": [],
},
},
"error": None,
}

responses.add(
responses.POST,
f"{BASE_URL}/niworkitem/v1/workitems/{work_item_id}/execute",
json=return_value,
status=200,
)

execute_response = client.execute_work_item(
work_item_id=work_item_id, action="RUN_NOTEBOOK"
)

assert execute_response is not None
assert execute_response.error is None
assert execute_response.result is not None
assert execute_response.result.type == "NOTEBOOK"
assert execute_response.result.execution_id is None
assert execute_response.result.error is not None
assert execute_response.result.error.code == -251050
assert execute_response.result.error.message is not None
assert "execution failed" in execute_response.result.error.message

def test__create_work_item_template__returns_created_work_item_template(
self, create_work_item_templates
):
Expand Down