From 998db974fc336546a2b023e5c89ac5ae4e28abc3 Mon Sep 17 00:00:00 2001 From: Fred Visser <1458528+fredvisser@users.noreply.github.com> Date: Tue, 26 May 2026 17:01:03 -0500 Subject: [PATCH 1/5] Make alias handling consistent and clean up lint findings --- .../clients/assetmanagement/models/_asset.py | 20 +++- .../assetmanagement/models/_asset_location.py | 22 ++-- .../models/_create_asset_request.py | 12 +- .../clients/core/_uplink/_json_model.py | 15 ++- .../clients/file/models/_file_metadata.py | 9 +- .../file/models/_file_query_response.py | 9 +- .../models/_upload_session_start_response.py | 7 +- .../clients/notebook/models/_execution.py | 34 ++++-- .../product/models/_query_products_request.py | 27 +++-- .../models/_query_results_request.py | 43 +++++-- tests/core/test_json_model.py | 107 ++++++++++++++++++ .../assetmanagement/test_asset_management.py | 17 +-- 12 files changed, 261 insertions(+), 61 deletions(-) create mode 100644 tests/core/test_json_model.py diff --git a/nisystemlink/clients/assetmanagement/models/_asset.py b/nisystemlink/clients/assetmanagement/models/_asset.py index 690b3fcc..9275bb1e 100644 --- a/nisystemlink/clients/assetmanagement/models/_asset.py +++ b/nisystemlink/clients/assetmanagement/models/_asset.py @@ -2,7 +2,7 @@ from typing import Dict, List from nisystemlink.clients.core._uplink._json_model import JsonModel -from pydantic import Field +from pydantic import AliasChoices, Field from ._asset_calibration import ( CalibrationStatus, @@ -68,17 +68,25 @@ class Asset(JsonModel): self_calibration: SelfCalibration | None = None """Gets or sets the last self-calibration of the asset.""" - is_NI_asset: bool | None = Field(alias="isNIAsset", default=None) + is_ni_asset: bool | None = Field( + default=None, + validation_alias=AliasChoices("is_ni_asset", "is_NI_asset", "isNIAsset"), + serialization_alias="isNIAsset", + ) """Gets or sets whether this asset is an NI asset (true) or a third-party asset (false).""" id: str | None = None """Gets or sets unique identifier of the asset.""" location: AssetLocation | None = None - """Model for information about the asset location, presence and the connection status of the system""" + """Model for information about the asset location, presence, + and the connection status of the system. + """ calibration_status: CalibrationStatus | None = None - """Gets or sets the calibration category the asset belongs to based on the next due calibration date.""" + """Gets or sets the calibration category the asset belongs to + based on the next due calibration date. + """ is_system_controller: bool | None = None """Gets or sets whether this asset represents a System Controller.""" @@ -96,7 +104,9 @@ class Asset(JsonModel): """Gets or sets words or phrases associated with an asset.""" last_updated_timestamp: datetime | None = None - """Gets or sets ISO-8601 formatted timestamp specifying the last date that the asset has had a property update.""" + """Gets or sets an ISO-8601 timestamp for the last date + that the asset had a property update. + """ file_ids: List[str] | None = None """Gets or sets all files linked to the asset.""" diff --git a/nisystemlink/clients/assetmanagement/models/_asset_location.py b/nisystemlink/clients/assetmanagement/models/_asset_location.py index bb35ccb9..67f7c22b 100644 --- a/nisystemlink/clients/assetmanagement/models/_asset_location.py +++ b/nisystemlink/clients/assetmanagement/models/_asset_location.py @@ -13,10 +13,12 @@ class AssetPresenceStatus(Enum): class SystemConnection(Enum): - """Whether or not the minion is connected to the server and has updated the server with its data. - To maintain compatibility with previous versions of SystemLink, the values - [APPROVED, UNSUPPORTED, ACTIVATED] are considered equivalent to DISCONNECTED and - [CONNECTED_UPDATE_PENDING, CONNECTED_UPDATE_SUCCESSFUL, CONNECTED_UPDATE_FAILED] are equivalent to CONNECTED. + """Whether the minion is connected to the server. + + For backward compatibility, APPROVED, UNSUPPORTED, and ACTIVATED are + treated as DISCONNECTED. CONNECTED_UPDATE_PENDING, + CONNECTED_UPDATE_SUCCESSFUL, and CONNECTED_UPDATE_FAILED are treated + as CONNECTED. """ APPROVED = "APPROVED" @@ -30,13 +32,15 @@ class SystemConnection(Enum): class AssetPresenceWithSystemConnection(JsonModel): - """Model for the presence of an asset and the connection of the system in which it resides.""" + """Asset presence and system connection information.""" asset_presence: AssetPresenceStatus """Gets or sets the status of an asset's presence in a system.""" system_connection: SystemConnection | None = None - """Gets or sets whether or not the minion is connected to the server and has updated the server with its data.""" + """Gets or sets whether the minion is connected to the server + and has updated it with its data. + """ class AssetPresence(JsonModel): @@ -47,7 +51,7 @@ class AssetPresence(JsonModel): class _AssetLocation(JsonModel): - """local model for information about the asset location, presence and the connection status of the system.""" + """Local model for asset location and presence information.""" minion_id: str | None = None """Gets or sets identifier of the minion where the asset is located.""" @@ -66,14 +70,14 @@ class _AssetLocation(JsonModel): class AssetLocation(_AssetLocation): - """Model for information about the asset location, presence and the connection status of the system.""" + """Asset location and system connection information.""" state: AssetPresenceWithSystemConnection """Presence of an asset and the connection of the system in which it resides.""" class AssetLocationForCreate(_AssetLocation): - """Model for information about the asset presence status of the system, used while create""" + """Asset presence information used during create.""" state: AssetPresence """Model for the presence of an asset.""" diff --git a/nisystemlink/clients/assetmanagement/models/_create_asset_request.py b/nisystemlink/clients/assetmanagement/models/_create_asset_request.py index b8a29357..325d67ab 100644 --- a/nisystemlink/clients/assetmanagement/models/_create_asset_request.py +++ b/nisystemlink/clients/assetmanagement/models/_create_asset_request.py @@ -1,7 +1,7 @@ from typing import Dict, List from nisystemlink.clients.core._uplink._json_model import JsonModel -from pydantic import Field +from pydantic import AliasChoices, Field from ._asset import ( AssetBusType, @@ -65,14 +65,20 @@ class CreateAssetRequest(JsonModel): self_calibration: SelfCalibration | None = None """Gets or sets the last self-calibration of the asset.""" - is_NI_asset: bool | None = Field(alias="isNIAsset", default=None) + is_ni_asset: bool | None = Field( + default=None, + validation_alias=AliasChoices("is_ni_asset", "is_NI_asset", "isNIAsset"), + serialization_alias="isNIAsset", + ) """Gets or sets whether this asset is an NI asset (true) or a third-party asset (false).""" workspace: str | None = None """Gets or sets the ID of the workspace.""" location: AssetLocationForCreate - """Model for information about the asset location, presence and the connection status of the system""" + """Model for information about the asset location, presence, + and the connection status of the system. + """ external_calibration: ExternalCalibration | None = None """Gets or sets the last external calibration of the asset.""" diff --git a/nisystemlink/clients/core/_uplink/_json_model.py b/nisystemlink/clients/core/_uplink/_json_model.py index e1643c43..80ed7f7d 100644 --- a/nisystemlink/clients/core/_uplink/_json_model.py +++ b/nisystemlink/clients/core/_uplink/_json_model.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel, ConfigDict +from pydantic import AliasChoices, AliasGenerator, BaseModel, ConfigDict def _camelcase(s: str) -> str: @@ -7,11 +7,22 @@ def _camelcase(s: str) -> str: return next(parts) + "".join(i.title() for i in parts) +def _validation_aliases(s: str) -> str | AliasChoices: + """Accept both Python snake_case and wire-format camelCase inputs.""" + camelcase = _camelcase(s) + if camelcase == s: + return s + return AliasChoices(s, camelcase) + + class JsonModel(BaseModel): """Base class for models that are serialized to and from JSON.""" model_config = ConfigDict( - alias_generator=_camelcase, + alias_generator=AliasGenerator( + validation_alias=_validation_aliases, + serialization_alias=_camelcase, + ), validate_by_name=True, validate_by_alias=True, extra="ignore", diff --git a/nisystemlink/clients/file/models/_file_metadata.py b/nisystemlink/clients/file/models/_file_metadata.py index 680572a6..04583286 100644 --- a/nisystemlink/clients/file/models/_file_metadata.py +++ b/nisystemlink/clients/file/models/_file_metadata.py @@ -2,7 +2,7 @@ from typing import Dict from nisystemlink.clients.core._uplink._json_model import JsonModel -from pydantic import Field +from pydantic import AliasChoices, Field from ._link import Link @@ -53,8 +53,13 @@ class BaseFileMetadata(JsonModel): class FileMetadata(BaseFileMetadata): + """Metadata for a file.""" - field_links: Dict[str, Link] | None = Field(None, alias="_links") + field_links: Dict[str, Link] | None = Field( + None, + validation_alias=AliasChoices("field_links", "_links"), + serialization_alias="_links", + ) """ The links to access and manipulate the file: - data: Link to download the file using a GET request diff --git a/nisystemlink/clients/file/models/_file_query_response.py b/nisystemlink/clients/file/models/_file_query_response.py index f52e520c..f2bf79c3 100644 --- a/nisystemlink/clients/file/models/_file_query_response.py +++ b/nisystemlink/clients/file/models/_file_query_response.py @@ -3,16 +3,19 @@ from typing import Dict, List from nisystemlink.clients.core._uplink._json_model import JsonModel -from pydantic import Field +from pydantic import AliasChoices, Field from ._file_metadata import FileMetadata from ._link import Link class FileQueryResponse(JsonModel): - """The result of a file query""" + """The result of a file query.""" - field_links: Dict[str, Link] = Field(alias="_links") + field_links: Dict[str, Link] = Field( + validation_alias=AliasChoices("field_links", "_links"), + serialization_alias="_links", + ) """The links that apply to the collection of files for a service group: - deleteFiles: Link to delete multiple files from the service group using a POST - query: Link to query for available files in the service group using a POST diff --git a/nisystemlink/clients/file/models/_upload_session_start_response.py b/nisystemlink/clients/file/models/_upload_session_start_response.py index c87357c7..0f87e47d 100644 --- a/nisystemlink/clients/file/models/_upload_session_start_response.py +++ b/nisystemlink/clients/file/models/_upload_session_start_response.py @@ -1,13 +1,16 @@ from datetime import datetime from nisystemlink.clients.core._uplink._json_model import JsonModel -from pydantic import Field +from pydantic import AliasChoices, Field class UploadSessionStartResponse(JsonModel): """Response model for starting an upload session.""" - session_id: str = Field(alias="id") + session_id: str = Field( + validation_alias=AliasChoices("session_id", "id"), + serialization_alias="id", + ) """ The id created for the upload session. """ diff --git a/nisystemlink/clients/notebook/models/_execution.py b/nisystemlink/clients/notebook/models/_execution.py index 6ee90677..c49d967a 100644 --- a/nisystemlink/clients/notebook/models/_execution.py +++ b/nisystemlink/clients/notebook/models/_execution.py @@ -3,11 +3,11 @@ from typing import Any, Dict from nisystemlink.clients.core._uplink._json_model import JsonModel -from pydantic import Field +from pydantic import AliasChoices, Field class SourceType(str, Enum): - """Source type of an execution""" + """Source type of an execution.""" MANUAL = "MANUAL" @@ -15,7 +15,7 @@ class SourceType(str, Enum): class Source(JsonModel): - """An object that defines properties set by routine service""" + """An object that defines properties set by routine service.""" type: SourceType """Source type of an execution""" @@ -38,13 +38,13 @@ class ReportType(str, Enum): class ReportSettings(JsonModel): - """A class that defines settings of the Report""" + """A class that defines settings of the Report.""" format: ReportType """Type for the report that is going to be generated.""" exclude_code: bool - """Boolean parameter that will define if the source code should be included in the report or not.""" + """Whether the source code should be included in the report.""" class ExecutionPriority(str, Enum): @@ -58,6 +58,7 @@ class ExecutionPriority(str, Enum): class ExecutionResourceProfile(str, Enum): + """Resource profile of the execution. Can be one of Low, Medium, High or Default.""" DEFAULT = "DEFAULT" @@ -109,7 +110,10 @@ class ExecutionErrorCode(str, Enum): class Execution(JsonModel): - """Information about an execution of a Jupyter notebook that has the cachedResult field added.""" + """Jupyter notebook execution information. + + Includes the cachedResult field. + """ id: str | None = None """The ID of the execution.""" @@ -117,22 +121,30 @@ class Execution(JsonModel): notebook_id: str | None = None """The ID of the executed notebook.""" - organization_id: str | None = Field(None, alias="orgId") + organization_id: str | None = Field( + None, + validation_alias=AliasChoices("organization_id", "orgId"), + serialization_alias="orgId", + ) """The org ID of the user creating the request.""" user_id: str | None = None """The user ID of the user creating the request.""" parameters: Dict[str, Any] | None = None - """The input parameters for this execution of the notebook. The keys are strings and the values can be of any - valid JSON type.""" + """The input parameters for this execution. + + The keys are strings and the values can be any valid JSON type. + """ workspace_id: str | None = None """The ID of the workspace this execution belongs to.""" timeout: int | None = None - """The number of seconds the execution runs before it aborts if uncompleted. The timer starts once status is - IN_PROGRESS. 0 means infinite.""" + """The number of seconds the execution runs before it aborts. + + The timer starts once status is IN_PROGRESS. 0 means infinite. + """ status: ExecutionStatus | None = None """Status of an execution.""" diff --git a/nisystemlink/clients/product/models/_query_products_request.py b/nisystemlink/clients/product/models/_query_products_request.py index 9f119ec8..d1ca795d 100644 --- a/nisystemlink/clients/product/models/_query_products_request.py +++ b/nisystemlink/clients/product/models/_query_products_request.py @@ -2,7 +2,7 @@ from typing import List from nisystemlink.clients.core._uplink._json_model import JsonModel -from pydantic import Field +from pydantic import AliasChoices, Field class ProductOrderBy(str, Enum): @@ -26,8 +26,9 @@ class ProductField(str, Enum): class ProductProjection(str, Enum): - """An enumeration of all fields in a Product. These are used to project the required fields - from the API response. + """An enumeration of all fields in a Product. + + These are used to project the required fields from the API response. """ ID = "ID" @@ -42,6 +43,8 @@ class ProductProjection(str, Enum): class QueryProductsBase(JsonModel): + """Base class for product query requests.""" + filter: str | None = None """ The product query filter in Dynamic Linq format. @@ -56,8 +59,8 @@ class QueryProductsBase(JsonModel): - `properties`: A dictionary of additional string to string properties - `fileIds`: A list of string ids for files stored in the file service (`/nifile`) - See [Dynamic Linq](https://github.com/ni/systemlink-OpenAPI-documents/wiki/Dynamic-Linq-Query-Language) - documentation for more details. + See the Dynamic Linq query language documentation: + https://github.com/ni/systemlink-OpenAPI-documents/wiki/Dynamic-Linq-Query-Language `"@0"`, `"@1"` etc. can be used in conjunction with the `substitutions` parameter to keep this query string more simple and reusable. @@ -75,8 +78,13 @@ class QueryProductsBase(JsonModel): class QueryProductsRequest(QueryProductsBase): + """Request model for querying products.""" - order_by: ProductOrderBy | None = Field(None, alias="orderBy") + order_by: ProductOrderBy | None = Field( + None, + validation_alias=AliasChoices("order_by", "orderBy"), + serialization_alias="orderBy", + ) """Specifies the fields to use to sort the products. By default, products are sorted by `id` @@ -91,8 +99,9 @@ class QueryProductsRequest(QueryProductsBase): projection: List[ProductProjection] | None = None """Specifies the product fields to project. - When a field value is given here, the corresponding field will be present in all returned products, - and all unspecified fields will be excluded. If no projection is specified, all product fields + When a field value is given here, the corresponding field will be + present in all returned products, and all unspecified fields will + be excluded. If no projection is specified, all product fields will be returned. """ @@ -120,6 +129,8 @@ class QueryProductsRequest(QueryProductsBase): class QueryProductValuesRequest(QueryProductsBase): + """Request model for querying product values.""" + field: ProductField | None = None """The product field to return for this query.""" diff --git a/nisystemlink/clients/testmonitor/models/_query_results_request.py b/nisystemlink/clients/testmonitor/models/_query_results_request.py index eb4c4b8e..ed14177a 100644 --- a/nisystemlink/clients/testmonitor/models/_query_results_request.py +++ b/nisystemlink/clients/testmonitor/models/_query_results_request.py @@ -3,7 +3,7 @@ from nisystemlink.clients.core._uplink._json_model import JsonModel from nisystemlink.clients.core._uplink._with_paging import WithPaging -from pydantic import Field +from pydantic import AliasChoices, Field class ResultField(str, Enum): @@ -74,6 +74,8 @@ class ResultProjection(str, Enum): class QueryResultsBase(JsonModel): + """Base class for result query requests.""" + filter: str | None = None """ The result query filter in Dynamic Linq format. @@ -94,9 +96,12 @@ class QueryResultsBase(JsonModel): - `keywords`: A list of keyword strings - `properties`: A dictionary of additional string to string properties - `fileIds`: A list of string ids for files stored in the file service (`/nifile`) - - `dataTableIds`: A list of string ids for data tables stored in the data frame service (`/nidataframe`) + - `dataTableIds`: A list of string ids for data tables stored in the + data frame service (`/nidataframe`) - `workspaceId`: String for the workspace identifier of the result - See [Dynamic Linq](https://github.com/ni/systemlink-OpenAPI-documents/wiki/Dynamic-Linq-Query-Language) + + See the Dynamic Linq query language documentation: + https://github.com/ni/systemlink-OpenAPI-documents/wiki/Dynamic-Linq-Query-Language documentation for more details. `"@0"`, `"@1"` etc. can be used in conjunction with the `substitutions` parameter to keep this query string more simple and reusable. @@ -113,6 +118,8 @@ class QueryResultsBase(JsonModel): class QueryProductsBase(JsonModel): + """Base class for product query requests.""" + product_filter: str | None = None """ The product query filter in Dynamic Linq format. @@ -125,7 +132,9 @@ class QueryProductsBase(JsonModel): - `keywords`: A list of keyword strings - `properties`: A dictionary of additional string to string properties - `fileIds`: A list of string ids for files stored in the file service (`/nifile`) - See [Dynamic Linq](https://github.com/ni/systemlink-OpenAPI-documents/wiki/Dynamic-Linq-Query-Language) + + See the Dynamic Linq query language documentation: + https://github.com/ni/systemlink-OpenAPI-documents/wiki/Dynamic-Linq-Query-Language documentation for more details. `"@0"`, `"@1"` etc. can be used in conjunction with the `substitutions` parameter to keep this query string more simple and reusable. @@ -142,20 +151,36 @@ class QueryProductsBase(JsonModel): class QueryResultsRequest(QueryResultsBase, QueryProductsBase, WithPaging): + """Request model for querying results.""" - order_by: ResultOrderByField | None = Field(None, alias="orderBy") + order_by: ResultOrderByField | None = Field( + None, + validation_alias=AliasChoices("order_by", "orderBy"), + serialization_alias="orderBy", + ) """Specifies the fields to use to sort the results. By default, results are sorted by `id` """ - order_by_key: str | None = Field(None, alias="orderByKey") + order_by_key: str | None = Field( + None, + validation_alias=AliasChoices("order_by_key", "orderByKey"), + serialization_alias="orderByKey", + ) """Specifies the property to use to sort the results when ordering by PROPERTIES. Results that do not contain the orderByKey will be considered the smallest value. """ order_by_comparison_type: ComparisonType | None = Field( - None, alias="orderByComparisonType" + None, + validation_alias=AliasChoices( + "order_by_comparison_type", + "orderByComparisonType", + ), + serialization_alias="orderByComparisonType", ) """An enumeration of comparison types that can be used for ordered queries. - For non-DEFAULT comparisons, values that cannot be converted will be considered the smallest value. + + For non-DEFAULT comparisons, values that cannot be converted + will be considered the smallest value. """ descending: bool | None = None """Specifies whether to return the results in descending order. @@ -177,6 +202,8 @@ class QueryResultsRequest(QueryResultsBase, QueryProductsBase, WithPaging): class QueryResultValuesRequest(QueryResultsBase): + """Request model for querying result values.""" + field: ResultField | None = None """The result field to return for this query.""" diff --git a/tests/core/test_json_model.py b/tests/core/test_json_model.py new file mode 100644 index 00000000..7e380401 --- /dev/null +++ b/tests/core/test_json_model.py @@ -0,0 +1,107 @@ +"""Tests for JsonModel alias handling.""" + +from inspect import signature +from typing import Annotated, cast, Literal + +from nisystemlink.clients.core._uplink._json_model import JsonModel +from pydantic import AliasChoices, Field, TypeAdapter + + +class ExampleJsonModel(JsonModel): + """Simple model used to verify JsonModel alias behavior.""" + + program_name: str + + +class ExampleExplicitAliasJsonModel(JsonModel): + """Simple model used to verify explicit alias behavior.""" + + program_name: str + session_id: str = Field( + validation_alias=AliasChoices("session_id", "id"), + serialization_alias="id", + ) + + +class TestJsonModel: + """Test cases for JsonModel alias behavior.""" + + def test__signature__uses_snake_case_field_names(self): + """Test that constructor signatures expose Pythonic snake_case names.""" + assert str(signature(ExampleJsonModel)) == "(*, program_name: str) -> None" + + def test__snake_case_input__deserializes_and_serializes_by_alias(self): + """Test that snake_case input remains valid and serializes to camelCase.""" + model = ExampleJsonModel(program_name="My Program") + + assert model.program_name == "My Program" + assert model.model_dump() == {"program_name": "My Program"} + assert model.model_dump(by_alias=True) == {"programName": "My Program"} + + def test__camel_case_input__remains_backward_compatible(self): + """Test that legacy camelCase input remains valid.""" + model = ExampleJsonModel.model_validate({"programName": "My Program"}) + + assert model.program_name == "My Program" + + def test__snake_case_input__wins_when_both_names_are_provided(self): + """Test that Python field names remain the primary input form.""" + model = ExampleJsonModel.model_validate( + { + "program_name": "Preferred Program", + "programName": "Legacy Program", + } + ) + + assert model.program_name == "Preferred Program" + + def test__explicit_alias_signature__uses_snake_case_field_names(self): + """Test that explicit aliases preserve snake_case constructor signatures.""" + assert str(signature(ExampleExplicitAliasJsonModel)) == ( + "(*, program_name: str, session_id: str) -> None" + ) + assert ExampleExplicitAliasJsonModel.model_fields["session_id"].alias is None + + def test__explicit_alias__accepts_both_input_names_and_serializes_wire_name(self): + """Test that explicit aliases accept Python and wire names and serialize wire names.""" + snake_case_model = ExampleExplicitAliasJsonModel( + program_name="My Program", + session_id="session-1", + ) + camel_case_model = ExampleExplicitAliasJsonModel.model_validate( + {"program_name": "My Program", "id": "session-2"} + ) + + assert snake_case_model.session_id == "session-1" + assert snake_case_model.model_dump(by_alias=True) == { + "programName": "My Program", + "id": "session-1", + } + assert camel_case_model.session_id == "session-2" + + def test__identical_wire_and_python_names__do_not_create_duplicate_aliases(self): + """Test that identical Python and wire names remain valid for discriminated unions.""" + + class NotebookExecution(JsonModel): + type: Literal["NOTEBOOK"] = Field(default="NOTEBOOK") + + class JobExecution(JsonModel): + type: Literal["JOB"] = Field(default="JOB") + + execution_type = Annotated[ + NotebookExecution | JobExecution, + Field(discriminator="type"), + ] + adapter = TypeAdapter(list[execution_type]) + + parsed = cast( + list[NotebookExecution | JobExecution], + adapter.validate_python( + [ + {"type": "NOTEBOOK"}, + {"type": "JOB"}, + ] + ), + ) + + assert [item.type for item in parsed] == ["NOTEBOOK", "JOB"] diff --git a/tests/integration/assetmanagement/test_asset_management.py b/tests/integration/assetmanagement/test_asset_management.py index db8fc0f8..99645fc6 100644 --- a/tests/integration/assetmanagement/test_asset_management.py +++ b/tests/integration/assetmanagement/test_asset_management.py @@ -62,13 +62,13 @@ def _create_assets( @pytest.fixture(scope="class") def client(enterprise_config: HttpConfiguration) -> AssetManagementClient: - """Fixture to create a AssetManagementClient instance""" + """Fixture to create an AssetManagementClient instance.""" return AssetManagementClient(enterprise_config) @pytest.fixture def unique_identifier() -> str: - """Fixture to generate a unique identifier using UUID""" + """Fixture to generate a unique identifier using UUID.""" return str(uuid4()) @@ -123,9 +123,10 @@ def _start_utilization( @pytest.mark.integration @pytest.mark.enterprise class TestAssetManagement: + """Integration tests for the asset management client.""" + _workspace = "2300760d-38c4-48a1-9acb-800260812337" - """Used the main-test default workspace since the client - for creating a workspace has not been added yet""" + """Use the main-test default workspace until workspace creation is supported.""" _create_assets_request = [ CreateAssetRequest( @@ -148,7 +149,7 @@ class TestAssetManagement: is_limited=False, date=datetime(2022, 6, 7, 18, 58, 5, tzinfo=timezone.utc), ), - is_NI_asset=True, + is_ni_asset=True, workspace=_workspace, location=AssetLocationForCreate( state=AssetPresence(asset_presence=AssetPresenceStatus.PRESENT) @@ -312,7 +313,7 @@ def test_query_assets_with_projections__returns_the_assets_with_projected_proper and asset.file_ids is None and asset.firmware_version is None and asset.hardware_version is None - and asset.is_NI_asset is None + and asset.is_ni_asset is None and asset.is_system_controller is None and asset.keywords is None and asset.last_updated_timestamp is None @@ -531,7 +532,7 @@ def test__query_asset_utilization_history__returns_response( assert isinstance(utilization.end_timestamp, datetime) assert isinstance(utilization.heartbeat_timestamp, datetime) - def test__start_utilization_with_nonexistent_asset__raises_ApiException( + def test__start_utilization_with_nonexistent_asset__raises_api_exception( self, client: AssetManagementClient, unique_identifier: str ): start_request = StartUtilizationRequest( @@ -576,7 +577,7 @@ def test__end_utilization_with_nonexistent_utilization_id__returns_empty_list( assert response.updated_utilization_ids is not None assert len(response.updated_utilization_ids) == 0 - def test__query_utilization_history_with_invalid_filter__raises_ApiException( + def test__query_utilization_history_with_invalid_filter__raises_api_exception( self, client: AssetManagementClient ): query_request = QueryAssetUtilizationHistoryRequest( From cdbb23387f3802ef9237f6141498c1f8a418676a Mon Sep 17 00:00:00 2001 From: Fred Visser <1458528+fredvisser@users.noreply.github.com> Date: Thu, 28 May 2026 13:37:02 -0500 Subject: [PATCH 2/5] Add single-item convenience wrappers for bulk partial-success APIs Introduce singular convenience methods (create_result, create_step, update_result, update_step, create_product, update_product, create_spec, update_spec, create_test_plan, update_test_plan, schedule_test_plan, create_test_plan_template, create_asset, create_work_item, update_work_item, schedule_work_item, create_work_item_template, update_work_item_template) on their respective client classes. Each method wraps the existing bulk API with a one-element list, then uses the shared unwrap_single_item_partial_success() helper to either return the created/updated item or raise ApiException with the server error details. This eliminates repetitive boilerplate for the common single-item use case while keeping the bulk methods available for batch operations. --- .../_asset_management_client.py | 25 ++ .../clients/core/helpers/_partial_success.py | 41 ++ .../clients/product/_product_client.py | 52 +++ nisystemlink/clients/spec/_spec_client.py | 53 +++ .../clients/test_plan/_test_plan_client.py | 114 +++++ .../testmonitor/_test_monitor_client.py | 122 +++++ .../clients/work_item/_work_item_client.py | 150 +++++++ tests/core/test_partial_success.py | 79 ++++ ..._single_item_client_convenience_methods.py | 420 ++++++++++++++++++ tests/testmonitor/test_testmonitor_client.py | 88 ++++ 10 files changed, 1144 insertions(+) create mode 100644 nisystemlink/clients/core/helpers/_partial_success.py create mode 100644 tests/core/test_partial_success.py create mode 100644 tests/test_single_item_client_convenience_methods.py create mode 100644 tests/testmonitor/test_testmonitor_client.py diff --git a/nisystemlink/clients/assetmanagement/_asset_management_client.py b/nisystemlink/clients/assetmanagement/_asset_management_client.py index 1ea9b61c..a3f0e707 100644 --- a/nisystemlink/clients/assetmanagement/_asset_management_client.py +++ b/nisystemlink/clients/assetmanagement/_asset_management_client.py @@ -13,6 +13,7 @@ from uplink import Field, Path, retry from . import models +from ..core.helpers._partial_success import unwrap_single_item_partial_success @retry( @@ -56,6 +57,30 @@ def create_assets( """ ... + def create_asset(self, asset: models.CreateAssetRequest) -> models.Asset: + """Create a single asset. + + Args: + asset: The asset to create. + + Returns: + The created asset. + + Raises: + ApiException: if the asset could not be created or the service returns an + unexpected partial-success payload. + """ + response = self.create_assets([asset]) + + return unwrap_single_item_partial_success( + response=response, + items=response.assets, + failed=response.failed, + error=response.error, + failure_message="Failed to create asset.", + empty_message="Server returned no created assets.", + ) + @post("query-assets") def __query_assets( self, query: models._QueryAssetsRequest diff --git a/nisystemlink/clients/core/helpers/_partial_success.py b/nisystemlink/clients/core/helpers/_partial_success.py new file mode 100644 index 00000000..326e50c8 --- /dev/null +++ b/nisystemlink/clients/core/helpers/_partial_success.py @@ -0,0 +1,41 @@ +from typing import Any, Sequence, TypeVar + +from nisystemlink.clients import core + +_ItemT = TypeVar("_ItemT") + + +def unwrap_single_item_partial_success( + *, + response: Any | None, + items: Sequence[_ItemT] | None, + failed: Sequence[Any] | None, + error: core.ApiError | None, + failure_message: str, + empty_message: str, +) -> _ItemT: + """Return the first successful item from a partial-success response. + + Raises: + ApiException: if the response reports a failure or contains no successful item. + """ + response_data = ( + response.model_dump(mode="json", by_alias=True) + if response is not None + else None + ) + + if failed or error: + raise core.ApiException( + failure_message, + error=error, + response_data=response_data, + ) + + if not items: + raise core.ApiException( + empty_message, + response_data=response_data, + ) + + return items[0] diff --git a/nisystemlink/clients/product/_product_client.py b/nisystemlink/clients/product/_product_client.py index 453b2c69..89346543 100644 --- a/nisystemlink/clients/product/_product_client.py +++ b/nisystemlink/clients/product/_product_client.py @@ -8,6 +8,7 @@ from uplink import Field, Query, retry, returns from . import models +from ..core.helpers._partial_success import unwrap_single_item_partial_success @retry( @@ -50,6 +51,30 @@ def create_products( """ ... + def create_product(self, product: models.CreateProductRequest) -> models.Product: + """Creates a single product. + + Args: + product: The product to create. + + Returns: + The created product. + + Raises: + ApiException: if the product could not be created or the service returns an + unexpected partial-success payload. + """ + response = self.create_products([product]) + + return unwrap_single_item_partial_success( + response=response, + items=response.products, + failed=response.failed, + error=response.error, + failure_message="Failed to create product.", + empty_message="Server returned no created products.", + ) + @get( "products", args=[Query("continuationToken"), Query("take"), Query("returnCount")], @@ -153,6 +178,33 @@ def update_products( """ ... + def update_product( + self, product: models.UpdateProductRequest, replace: bool = False + ) -> models.Product: + """Updates a single product. + + Args: + product: The product to update. + replace: Replace the existing fields instead of merging them. + + Returns: + The updated product. + + Raises: + ApiException: if the product could not be updated or the service returns an + unexpected partial-success payload. + """ + response = self.update_products([product], replace=replace) + + return unwrap_single_item_partial_success( + response=response, + items=response.products, + failed=response.failed, + error=response.error, + failure_message="Failed to update product.", + empty_message="Server returned no updated products.", + ) + @delete("products/{id}") def delete_product(self, id: str) -> None: """Deletes a single product by id. diff --git a/nisystemlink/clients/spec/_spec_client.py b/nisystemlink/clients/spec/_spec_client.py index d7d8912a..95b24a40 100644 --- a/nisystemlink/clients/spec/_spec_client.py +++ b/nisystemlink/clients/spec/_spec_client.py @@ -8,6 +8,7 @@ from uplink import Field, retry from . import models +from ..core.helpers._partial_success import unwrap_single_item_partial_success @retry( @@ -63,6 +64,32 @@ def create_specs( """ ... + def create_spec( + self, spec: models.CreateSpecificationsRequestObject + ) -> models.CreatedSpecification: + """Creates a single specification. + + Args: + spec: The specification to create. + + Returns: + The created specification. + + Raises: + ApiException: if the specification could not be created or the service returns an + unexpected partial-success payload. + """ + response = self.create_specs(models.CreateSpecificationsRequest(specs=[spec])) + + return unwrap_single_item_partial_success( + response=response, + items=response.created_specs, + failed=response.failed_specs, + error=response.error, + failure_message="Failed to create spec.", + empty_message="Server returned no created specs.", + ) + @post("delete-specs", args=[Field("ids")]) def delete_specs( self, ids: List[str] @@ -129,3 +156,29 @@ def update_specs( with error messages for updates that failed. """ ... + + def update_spec( + self, spec: models.UpdateSpecificationsRequestObject + ) -> models.UpdatedSpecification: + """Updates a single specification. + + Args: + spec: The specification to update. + + Returns: + The updated specification. + + Raises: + ApiException: if the specification could not be updated or the service returns an + unexpected partial-success payload. + """ + response = self.update_specs(models.UpdateSpecificationsRequest(specs=[spec])) + + return unwrap_single_item_partial_success( + response=response, + items=response.updated_specs if response is not None else None, + failed=response.failed_specs if response is not None else None, + error=response.error if response is not None else None, + failure_message="Failed to update spec.", + empty_message="Server returned no updated specs.", + ) diff --git a/nisystemlink/clients/test_plan/_test_plan_client.py b/nisystemlink/clients/test_plan/_test_plan_client.py index a730d9e5..33f97ecb 100644 --- a/nisystemlink/clients/test_plan/_test_plan_client.py +++ b/nisystemlink/clients/test_plan/_test_plan_client.py @@ -8,6 +8,8 @@ from nisystemlink.clients.test_plan import models from uplink import Field, retry +from ..core.helpers._partial_success import unwrap_single_item_partial_success + @retry( when=retry.when.status(408, 429, 502, 503, 504), @@ -66,6 +68,32 @@ def create_test_plans( """ ... + def create_test_plan( + self, test_plan: models.CreateTestPlanRequest + ) -> models.TestPlan: + """Create a single test plan. + + Args: + test_plan: The test plan to create. + + Returns: + The created test plan. + + Raises: + ApiException: if the test plan could not be created or the service returns an + unexpected partial-success payload. + """ + response = self.create_test_plans([test_plan]) + + return unwrap_single_item_partial_success( + response=response, + items=response.created_test_plans, + failed=response.failed_test_plans, + error=response.error, + failure_message="Failed to create test plan.", + empty_message="Server returned no created test plans.", + ) + @post("delete-testplans", args=[Field("ids")]) def delete_test_plans(self, ids: List[str]) -> None: """Delete test plans by IDs. @@ -106,6 +134,37 @@ def schedule_test_plans( """ ... + def schedule_test_plan( + self, + test_plan: models.ScheduleTestPlanRequest, + replace: bool | None = None, + ) -> models.TestPlan: + """Schedule a single test plan. + + Args: + test_plan: The test plan schedule request. + replace: Whether to replace the existing scheduled test plan. + + Returns: + The scheduled test plan. + + Raises: + ApiException: if the test plan could not be scheduled or the service returns an + unexpected partial-success payload. + """ + response = self.schedule_test_plans( + models.ScheduleTestPlansRequest(test_plans=[test_plan], replace=replace) + ) + + return unwrap_single_item_partial_success( + response=response, + items=response.scheduled_test_plans, + failed=response.failed_test_plans, + error=response.error, + failure_message="Failed to schedule test plan.", + empty_message="Server returned no scheduled test plans.", + ) + @post("update-testplans") def update_test_plans( self, update_request: models.UpdateTestPlansRequest @@ -120,6 +179,35 @@ def update_test_plans( """ ... + def update_test_plan( + self, test_plan: models.UpdateTestPlanRequest, replace: bool | None = None + ) -> models.TestPlan: + """Update a single test plan. + + Args: + test_plan: The test plan to update. + replace: Whether to replace the existing test plan instead of merging updates. + + Returns: + The updated test plan. + + Raises: + ApiException: if the test plan could not be updated or the service returns an + unexpected partial-success payload. + """ + response = self.update_test_plans( + models.UpdateTestPlansRequest(test_plans=[test_plan], replace=replace) + ) + + return unwrap_single_item_partial_success( + response=response, + items=response.updated_test_plans, + failed=response.failed_test_plans, + error=response.error, + failure_message="Failed to update test plan.", + empty_message="Server returned no updated test plans.", + ) + @post("testplan-templates", args=[Field("testPlanTemplates")]) def create_test_plan_templates( self, test_plan_templates: List[models.CreateTestPlanTemplateRequest] @@ -137,6 +225,32 @@ def create_test_plan_templates( """ ... + def create_test_plan_template( + self, test_plan_template: models.CreateTestPlanTemplateRequest + ) -> models.TestPlanTemplate: + """Creates a single test plan template. + + Args: + test_plan_template: The test plan template to create. + + Returns: + The created test plan template. + + Raises: + ApiException: if the test plan template could not be created or the service returns an + unexpected partial-success payload. + """ + response = self.create_test_plan_templates([test_plan_template]) + + return unwrap_single_item_partial_success( + response=response, + items=response.created_test_plan_templates, + failed=response.failed_test_plan_templates, + error=response.error, + failure_message="Failed to create test plan template.", + empty_message="Server returned no created test plan templates.", + ) + @post("query-testplan-templates") def query_test_plan_templates( self, query_test_plan_templates: models.QueryTestPlanTemplatesRequest diff --git a/nisystemlink/clients/testmonitor/_test_monitor_client.py b/nisystemlink/clients/testmonitor/_test_monitor_client.py index 416d1a93..c64eba99 100644 --- a/nisystemlink/clients/testmonitor/_test_monitor_client.py +++ b/nisystemlink/clients/testmonitor/_test_monitor_client.py @@ -12,6 +12,7 @@ from uplink import Field, Path, Query, retry, returns from . import models +from ..core.helpers._partial_success import unwrap_single_item_partial_success @retry( @@ -69,6 +70,30 @@ def create_results( """ ... + def create_result(self, result: CreateResultRequest) -> models.Result: + """Creates a single result. + + Args: + result: The result to create. + + Returns: + The created result. + + Raises: + ApiException: if the result could not be created or the service returns an + unexpected partial-success payload. + """ + response = self.create_results([result]) + + return unwrap_single_item_partial_success( + response=response, + items=response.results, + failed=response.failed, + error=response.error, + failure_message="Failed to create result.", + empty_message="Server returned no created results.", + ) + @get( "results", args=[Query("continuationToken"), Query("take"), Query("returnCount")], @@ -168,6 +193,33 @@ def update_results( """ ... + def update_result( + self, result: UpdateResultRequest, replace: bool = False + ) -> models.Result: + """Updates a single result. + + Args: + result: The result to update. + replace: Replace the existing fields instead of merging them. + + Returns: + The updated result. + + Raises: + ApiException: if the result could not be updated or the service returns an + unexpected partial-success payload. + """ + response = self.update_results([result], replace=replace) + + return unwrap_single_item_partial_success( + response=response, + items=response.results, + failed=response.failed, + error=response.error, + failure_message="Failed to update result.", + empty_message="Server returned no updated results.", + ) + @delete("results/{id}") def delete_result(self, id: str) -> None: """Deletes a single result by id. @@ -225,6 +277,38 @@ def create_steps( """ ... + def create_step( + self, + step: models.CreateStepRequest, + update_result_total_time: bool = False, + ) -> models.Step: + """Creates a single step. + + Args: + step: The step to create. + update_result_total_time: Determine test result total time from the step total times. + + Returns: + The created step. + + Raises: + ApiException: if the step could not be created or the service returns an + unexpected partial-success payload. + """ + response = self.create_steps( + [step], + update_result_total_time=update_result_total_time, + ) + + return unwrap_single_item_partial_success( + response=response, + items=response.steps, + failed=response.failed, + error=response.error, + failure_message="Failed to create step.", + empty_message="Server returned no created steps.", + ) + @post("delete-steps", args=[Field("steps")]) def delete_steps( self, steps: List[models.StepIdResultIdPair] @@ -327,6 +411,44 @@ def update_steps( """ ... + def update_step( + self, + step: models.UpdateStepRequest, + update_result_total_time: bool = False, + replace_keywords: bool = False, + replace_properties: bool = False, + ) -> models.Step: + """Updates a single step. + + Args: + step: The step to update. + update_result_total_time: Determine test result total time from the step total times. + replace_keywords: Replace existing keywords instead of merging them. + replace_properties: Replace existing properties instead of merging them. + + Returns: + The updated step. + + Raises: + ApiException: if the step could not be updated or the service returns an + unexpected partial-success payload. + """ + response = self.update_steps( + [step], + update_result_total_time=update_result_total_time, + replace_keywords=replace_keywords, + replace_properties=replace_properties, + ) + + return unwrap_single_item_partial_success( + response=response, + items=response.steps, + failed=response.failed, + error=response.error, + failure_message="Failed to update step.", + empty_message="Server returned no updated steps.", + ) + @get( "steps", args=[Query("continuationToken"), Query("take"), Query("returnCount")], diff --git a/nisystemlink/clients/work_item/_work_item_client.py b/nisystemlink/clients/work_item/_work_item_client.py index 4c1c54ba..376b754c 100644 --- a/nisystemlink/clients/work_item/_work_item_client.py +++ b/nisystemlink/clients/work_item/_work_item_client.py @@ -7,6 +7,8 @@ from nisystemlink.clients.work_item import models from uplink import Field, Path, retry +from ..core.helpers._partial_success import unwrap_single_item_partial_success + class WorkItemExecuteApiException(core.ApiException): """Raised when the execute work item API returns an error with a structured response body. @@ -87,6 +89,32 @@ def create_work_items( """ ... + def create_work_item( + self, work_item: models.CreateWorkItemRequest + ) -> models.WorkItem: + """Creates a single work item. + + Args: + work_item: The work item to create. + + Returns: + The created work item. + + Raises: + ApiException: if the work item could not be created or the service returns an + unexpected partial-success payload. + """ + response = self.create_work_items([work_item]) + + return unwrap_single_item_partial_success( + response=response, + items=response.created_work_items, + failed=response.failed_work_items, + error=response.error, + failure_message="Failed to create work item.", + empty_message="Server returned no created work items.", + ) + @post("query-workitems") def query_work_items( self, query_work_items: models.QueryWorkItemsRequest @@ -121,6 +149,37 @@ def schedule_work_items( """ ... + def schedule_work_item( + self, + work_item: models.ScheduleWorkItemRequest, + replace: bool | None = None, + ) -> models.WorkItem: + """Schedules a single work item. + + Args: + work_item: The work item schedule request. + replace: When true, existing array fields are replaced instead of merged. + + Returns: + The scheduled work item. + + Raises: + ApiException: if the work item could not be scheduled or the service returns an + unexpected partial-success payload. + """ + response = self.schedule_work_items( + models.ScheduleWorkItemsRequest(work_items=[work_item], replace=replace) + ) + + return unwrap_single_item_partial_success( + response=response, + items=response.scheduled_work_items, + failed=response.failed_work_items, + error=response.error, + failure_message="Failed to schedule work item.", + empty_message="Server returned no scheduled work items.", + ) + @post("update-workitems") def update_work_items( self, update_work_items: models.UpdateWorkItemsRequest @@ -138,6 +197,37 @@ def update_work_items( """ ... + def update_work_item( + self, + work_item: models.UpdateWorkItemRequest, + replace: bool | None = None, + ) -> models.WorkItem: + """Updates a single work item. + + Args: + work_item: The work item to update. + replace: When true, existing array and key-value pair fields are replaced instead of merged. + + Returns: + The updated work item. + + Raises: + ApiException: if the work item could not be updated or the service returns an + unexpected partial-success payload. + """ + response = self.update_work_items( + models.UpdateWorkItemsRequest(work_items=[work_item], replace=replace) + ) + + return unwrap_single_item_partial_success( + response=response, + items=response.updated_work_items, + failed=response.failed_work_items, + error=response.error, + failure_message="Failed to update work item.", + empty_message="Server returned no updated work items.", + ) + @post("delete-workitems", args=[Field("ids")]) def delete_work_items( self, ids: List[str] @@ -221,6 +311,32 @@ def create_work_item_templates( """ ... + def create_work_item_template( + self, work_item_template: models.CreateWorkItemTemplateRequest + ) -> models.WorkItemTemplate: + """Creates a single work item template. + + Args: + work_item_template: The work item template to create. + + Returns: + The created work item template. + + Raises: + ApiException: if the work item template could not be created or the service returns an + unexpected partial-success payload. + """ + response = self.create_work_item_templates([work_item_template]) + + return unwrap_single_item_partial_success( + response=response, + items=response.created_work_item_templates, + failed=response.failed_work_item_templates, + error=response.error, + failure_message="Failed to create work item template.", + empty_message="Server returned no created work item templates.", + ) + @post("query-workitem-templates") def query_work_item_templates( self, query_work_item_templates: models.QueryWorkItemTemplatesRequest @@ -255,6 +371,40 @@ def update_work_item_templates( """ ... + def update_work_item_template( + self, + work_item_template: models.UpdateWorkItemTemplateRequest, + replace: bool | None = None, + ) -> models.WorkItemTemplate: + """Updates a single work item template. + + Args: + work_item_template: The work item template to update. + replace: When true, existing key-value pair fields are replaced instead of merged. + + Returns: + The updated work item template. + + Raises: + ApiException: if the work item template could not be updated or the service returns an + unexpected partial-success payload. + """ + response = self.update_work_item_templates( + models.UpdateWorkItemTemplatesRequest( + work_item_templates=[work_item_template], + replace=replace, + ) + ) + + return unwrap_single_item_partial_success( + response=response, + items=response.updated_work_item_templates, + failed=response.failed_work_item_templates, + error=response.error, + failure_message="Failed to update work item template.", + empty_message="Server returned no updated work item templates.", + ) + @post("delete-workitem-templates", args=[Field("ids")]) def delete_work_item_templates( self, ids: List[str] diff --git a/tests/core/test_partial_success.py b/tests/core/test_partial_success.py new file mode 100644 index 00000000..6d8deba7 --- /dev/null +++ b/tests/core/test_partial_success.py @@ -0,0 +1,79 @@ +import pytest +from nisystemlink.clients.core import ApiError, ApiException +from nisystemlink.clients.core.helpers._partial_success import ( + unwrap_single_item_partial_success, +) + + +class _FakeResponse: + def __init__(self, payload): + self._payload = payload + + def model_dump(self, *, mode, by_alias): + assert mode == "json" + assert by_alias is True + return self._payload + + +def test__unwrap_single_item_partial_success__returns_first_item(): + """Return the first item when the partial-success response is successful.""" + response = _FakeResponse({"items": ["created-item"]}) + + created_item = unwrap_single_item_partial_success( + response=response, + items=["created-item"], + failed=None, + error=None, + failure_message="Failed to create item.", + empty_message="Server returned no created items.", + ) + + assert created_item == "created-item" + + +def test__unwrap_single_item_partial_success__raises_on_partial_failure(): + """Raise ApiException when the response reports a structured partial failure.""" + response = _FakeResponse( + { + "items": [], + "failed": [{"id": "request-id"}], + "error": {"message": "Create failed"}, + } + ) + error = ApiError(message="Create failed") + + with pytest.raises(ApiException) as exc_info: + unwrap_single_item_partial_success( + response=response, + items=[], + failed=[{"id": "request-id"}], + error=error, + failure_message="Failed to create item.", + empty_message="Server returned no created items.", + ) + + assert exc_info.value.error == error + assert exc_info.value.response_data == response.model_dump( + mode="json", by_alias=True + ) + + +def test__unwrap_single_item_partial_success__raises_on_empty_success_payload(): + """Raise ApiException when the response succeeds but contains no created item.""" + response = _FakeResponse({"items": []}) + + with pytest.raises( + ApiException, match="Server returned no created items" + ) as exc_info: + unwrap_single_item_partial_success( + response=response, + items=[], + failed=None, + error=None, + failure_message="Failed to create item.", + empty_message="Server returned no created items.", + ) + + assert exc_info.value.response_data == response.model_dump( + mode="json", by_alias=True + ) diff --git a/tests/test_single_item_client_convenience_methods.py b/tests/test_single_item_client_convenience_methods.py new file mode 100644 index 00000000..0765365b --- /dev/null +++ b/tests/test_single_item_client_convenience_methods.py @@ -0,0 +1,420 @@ +import pytest +from nisystemlink.clients.assetmanagement import AssetManagementClient +from nisystemlink.clients.assetmanagement import models as asset_models +from nisystemlink.clients.core import ApiException +from nisystemlink.clients.product import models as product_models +from nisystemlink.clients.product import ProductClient +from nisystemlink.clients.spec import models as spec_models +from nisystemlink.clients.spec import SpecClient +from nisystemlink.clients.test_plan import models as test_plan_models +from nisystemlink.clients.test_plan import TestPlanClient +from nisystemlink.clients.testmonitor import models as testmonitor_models +from nisystemlink.clients.testmonitor import TestMonitorClient +from nisystemlink.clients.work_item import models as work_item_models +from nisystemlink.clients.work_item import WorkItemClient + + +class TestProductClientSingleItemConvenience: + def test__create_product__wraps_bulk_create(self): + request = product_models.CreateProductRequest(part_number="part-number") + created_product = product_models.Product.model_construct(id="product-id") + captured_products = [] + client = object.__new__(ProductClient) + + def fake_create_products(products): + captured_products.append(products) + return product_models.CreateProductsPartialSuccess( + products=[created_product] + ) + + setattr(client, "create_products", fake_create_products) + + response = client.create_product(request) + + assert response == created_product + assert captured_products == [[request]] + + def test__update_product__wraps_bulk_update(self): + request = product_models.UpdateProductRequest(id="product-id") + updated_product = product_models.Product.model_construct(id="product-id") + captured_calls = [] + client = object.__new__(ProductClient) + + def fake_update_products(products, replace=False): + captured_calls.append((products, replace)) + return product_models.CreateProductsPartialSuccess( + products=[updated_product] + ) + + setattr(client, "update_products", fake_update_products) + + response = client.update_product(request, replace=True) + + assert response == updated_product + assert captured_calls == [([request], True)] + + +class TestSpecClientSingleItemConvenience: + def test__create_spec__wraps_bulk_create(self): + request = spec_models.CreateSpecificationsRequestObject.model_construct( + product_id="product-id", + spec_id="spec-id", + ) + created_spec = spec_models.CreatedSpecification.model_construct(id="spec-id") + captured_requests = [] + client = object.__new__(SpecClient) + + def fake_create_specs(specs): + captured_requests.append(specs) + return spec_models.CreateSpecificationsPartialSuccess( + created_specs=[created_spec] + ) + + setattr(client, "create_specs", fake_create_specs) + + response = client.create_spec(request) + + assert response == created_spec + assert captured_requests[0].specs == [request] + + def test__update_spec__wraps_bulk_update(self): + request = spec_models.UpdateSpecificationsRequestObject.model_construct( + id="spec-id", + product_id="product-id", + spec_id="spec-id", + ) + updated_spec = spec_models.UpdatedSpecification.model_construct(id="spec-id") + captured_requests = [] + client = object.__new__(SpecClient) + + def fake_update_specs(specs): + captured_requests.append(specs) + return spec_models.UpdateSpecificationsPartialSuccess( + updated_specs=[updated_spec] + ) + + setattr(client, "update_specs", fake_update_specs) + + response = client.update_spec(request) + + assert response == updated_spec + assert captured_requests[0].specs == [request] + + def test__update_spec__raises_on_missing_success_payload(self): + request = spec_models.UpdateSpecificationsRequestObject.model_construct( + id="spec-id", + product_id="product-id", + spec_id="spec-id", + ) + client = object.__new__(SpecClient) + + def fake_update_specs(_specs): + return None + + setattr(client, "update_specs", fake_update_specs) + + with pytest.raises( + ApiException, match="Server returned no updated specs" + ) as exc_info: + client.update_spec(request) + + assert exc_info.value.response_data is None + + +class TestAssetManagementClientSingleItemConvenience: + def test__create_asset__wraps_bulk_create(self): + request = asset_models.CreateAssetRequest.model_construct( + location=asset_models.AssetLocationForCreate.model_construct() + ) + created_asset = asset_models.Asset.model_construct(id="asset-id") + captured_assets = [] + client = object.__new__(AssetManagementClient) + + def fake_create_assets(assets): + captured_assets.append(assets) + return asset_models.CreateAssetsPartialSuccessResponse( + assets=[created_asset] + ) + + setattr(client, "create_assets", fake_create_assets) + + response = client.create_asset(request) + + assert response == created_asset + assert captured_assets == [[request]] + + +class TestTestPlanClientSingleItemConvenience: + def test__create_test_plan__wraps_bulk_create(self): + request = test_plan_models.CreateTestPlanRequest(name="test-plan") + created_test_plan = test_plan_models.TestPlan.model_construct(id="test-plan-id") + captured_test_plans = [] + client = object.__new__(TestPlanClient) + + def fake_create_test_plans(test_plans): + captured_test_plans.append(test_plans) + return test_plan_models.CreateTestPlansPartialSuccessResponse( + created_test_plans=[created_test_plan] + ) + + setattr(client, "create_test_plans", fake_create_test_plans) + + response = client.create_test_plan(request) + + assert response == created_test_plan + assert captured_test_plans == [[request]] + + def test__update_test_plan__wraps_bulk_update(self): + request = test_plan_models.UpdateTestPlanRequest(id="test-plan-id") + updated_test_plan = test_plan_models.TestPlan.model_construct(id="test-plan-id") + captured_requests = [] + client = object.__new__(TestPlanClient) + + def fake_update_test_plans(update_request): + captured_requests.append(update_request) + return test_plan_models.UpdateTestPlansResponse( + updated_test_plans=[updated_test_plan] + ) + + setattr(client, "update_test_plans", fake_update_test_plans) + + response = client.update_test_plan(request, replace=True) + + assert response == updated_test_plan + assert captured_requests[0].test_plans == [request] + assert captured_requests[0].replace is True + + def test__schedule_test_plan__wraps_bulk_schedule(self): + request = test_plan_models.ScheduleTestPlanRequest(id="test-plan-id") + scheduled_test_plan = test_plan_models.TestPlan.model_construct( + id="test-plan-id" + ) + captured_requests = [] + client = object.__new__(TestPlanClient) + + def fake_schedule_test_plans(schedule_request): + captured_requests.append(schedule_request) + return test_plan_models.ScheduleTestPlansResponse( + scheduled_test_plans=[scheduled_test_plan] + ) + + setattr(client, "schedule_test_plans", fake_schedule_test_plans) + + response = client.schedule_test_plan(request, replace=True) + + assert response == scheduled_test_plan + assert captured_requests[0].test_plans == [request] + assert captured_requests[0].replace is True + + def test__create_test_plan_template__wraps_bulk_create(self): + request = test_plan_models.CreateTestPlanTemplateRequest.model_construct( + name="template", + template_group="group", + ) + created_template = test_plan_models.TestPlanTemplate.model_construct( + id="template-id" + ) + captured_templates = [] + client = object.__new__(TestPlanClient) + + def fake_create_test_plan_templates(test_plan_templates): + captured_templates.append(test_plan_templates) + return test_plan_models.CreateTestPlanTemplatePartialSuccessResponse( + created_test_plan_templates=[created_template] + ) + + setattr(client, "create_test_plan_templates", fake_create_test_plan_templates) + + response = client.create_test_plan_template(request) + + assert response == created_template + assert captured_templates == [[request]] + + +class TestWorkItemClientSingleItemConvenience: + def test__create_work_item__wraps_bulk_create(self): + request = work_item_models.CreateWorkItemRequest(name="work-item") + created_work_item = work_item_models.WorkItem.model_construct(id="work-item-id") + captured_work_items = [] + client = object.__new__(WorkItemClient) + + def fake_create_work_items(work_items): + captured_work_items.append(work_items) + return work_item_models.CreateWorkItemsPartialSuccessResponse( + created_work_items=[created_work_item] + ) + + setattr(client, "create_work_items", fake_create_work_items) + + response = client.create_work_item(request) + + assert response == created_work_item + assert captured_work_items == [[request]] + + def test__update_work_item__wraps_bulk_update(self): + request = work_item_models.UpdateWorkItemRequest(id="work-item-id") + updated_work_item = work_item_models.WorkItem.model_construct(id="work-item-id") + captured_requests = [] + client = object.__new__(WorkItemClient) + + def fake_update_work_items(update_work_items): + captured_requests.append(update_work_items) + return work_item_models.UpdateWorkItemsPartialSuccessResponse( + updated_work_items=[updated_work_item] + ) + + setattr(client, "update_work_items", fake_update_work_items) + + response = client.update_work_item(request, replace=True) + + assert response == updated_work_item + assert captured_requests[0].work_items == [request] + assert captured_requests[0].replace is True + + def test__schedule_work_item__wraps_bulk_schedule(self): + request = work_item_models.ScheduleWorkItemRequest(id="work-item-id") + scheduled_work_item = work_item_models.WorkItem.model_construct( + id="work-item-id" + ) + captured_requests = [] + client = object.__new__(WorkItemClient) + + def fake_schedule_work_items(schedule_work_items): + captured_requests.append(schedule_work_items) + return work_item_models.ScheduleWorkItemsPartialSuccessResponse( + scheduled_work_items=[scheduled_work_item] + ) + + setattr(client, "schedule_work_items", fake_schedule_work_items) + + response = client.schedule_work_item(request, replace=True) + + assert response == scheduled_work_item + assert captured_requests[0].work_items == [request] + assert captured_requests[0].replace is True + + def test__create_work_item_template__wraps_bulk_create(self): + request = work_item_models.CreateWorkItemTemplateRequest( + name="template", + template_group="group", + type="type", + ) + created_template = work_item_models.WorkItemTemplate.model_construct( + id="template-id" + ) + captured_templates = [] + client = object.__new__(WorkItemClient) + + def fake_create_work_item_templates(work_item_templates): + captured_templates.append(work_item_templates) + return work_item_models.CreateWorkItemTemplatesPartialSuccessResponse( + created_work_item_templates=[created_template] + ) + + setattr(client, "create_work_item_templates", fake_create_work_item_templates) + + response = client.create_work_item_template(request) + + assert response == created_template + assert captured_templates == [[request]] + + def test__update_work_item_template__wraps_bulk_update(self): + request = work_item_models.UpdateWorkItemTemplateRequest(id="template-id") + updated_template = work_item_models.WorkItemTemplate.model_construct( + id="template-id" + ) + captured_requests = [] + client = object.__new__(WorkItemClient) + + def fake_update_work_item_templates(update_work_item_templates): + captured_requests.append(update_work_item_templates) + return work_item_models.UpdateWorkItemTemplatesPartialSuccessResponse( + updated_work_item_templates=[updated_template] + ) + + setattr(client, "update_work_item_templates", fake_update_work_item_templates) + + response = client.update_work_item_template(request, replace=True) + + assert response == updated_template + assert captured_requests[0].work_item_templates == [request] + assert captured_requests[0].replace is True + + +class TestTestMonitorClientSingleItemConvenience: + def test__update_result__wraps_bulk_update(self): + request = testmonitor_models.UpdateResultRequest(id="result-id") + updated_result = testmonitor_models.Result.model_construct(id="result-id") + captured_calls = [] + client = object.__new__(TestMonitorClient) + + def fake_update_results(results, replace=False): + captured_calls.append((results, replace)) + return testmonitor_models.UpdateResultsPartialSuccess( + results=[updated_result] + ) + + setattr(client, "update_results", fake_update_results) + + response = client.update_result(request, replace=True) + + assert response == updated_result + assert captured_calls == [([request], True)] + + def test__create_step__wraps_bulk_create(self): + request = testmonitor_models.CreateStepRequest( + name="step", + result_id="result-id", + step_id="step-id", + ) + created_step = testmonitor_models.Step.model_construct(id="step-id") + captured_calls = [] + client = object.__new__(TestMonitorClient) + + def fake_create_steps(steps, update_result_total_time=False): + captured_calls.append((steps, update_result_total_time)) + return testmonitor_models.CreateStepsPartialSuccess(steps=[created_step]) + + setattr(client, "create_steps", fake_create_steps) + + response = client.create_step(request, update_result_total_time=True) + + assert response == created_step + assert captured_calls == [([request], True)] + + def test__update_step__wraps_bulk_update(self): + request = testmonitor_models.UpdateStepRequest( + result_id="result-id", + step_id="step-id", + ) + updated_step = testmonitor_models.Step.model_construct(id="step-id") + captured_calls = [] + client = object.__new__(TestMonitorClient) + + def fake_update_steps( + steps, + update_result_total_time=False, + replace_keywords=False, + replace_properties=False, + ): + captured_calls.append( + ( + steps, + update_result_total_time, + replace_keywords, + replace_properties, + ) + ) + return testmonitor_models.UpdateStepsPartialSuccess(steps=[updated_step]) + + setattr(client, "update_steps", fake_update_steps) + + response = client.update_step( + request, + update_result_total_time=True, + replace_keywords=True, + replace_properties=True, + ) + + assert response == updated_step + assert captured_calls == [([request], True, True, True)] diff --git a/tests/testmonitor/test_testmonitor_client.py b/tests/testmonitor/test_testmonitor_client.py new file mode 100644 index 00000000..cc490326 --- /dev/null +++ b/tests/testmonitor/test_testmonitor_client.py @@ -0,0 +1,88 @@ +"""Tests for TestMonitorClient convenience methods.""" + +import pytest +from nisystemlink.clients.core import ApiError, ApiException +from nisystemlink.clients.testmonitor import TestMonitorClient +from nisystemlink.clients.testmonitor.models import ( + CreateResultRequest, + CreateResultsPartialSuccess, + Result, + Status, +) + + +class TestTestMonitorClient: + """Test cases for TestMonitorClient convenience methods.""" + + def test__create_result__returns_created_result(self): + """Test that create_result returns the created result on success.""" + request = CreateResultRequest( + part_number="Part Number", + program_name="Program Name", + status=Status.PASSED(), + ) + created_result = Result( + id="result-id", + part_number=request.part_number, + program_name=request.program_name, + status=request.status, + ) + captured_results = [] + client = object.__new__(TestMonitorClient) + + def fake_create_results(results): + captured_results.append(results) + return CreateResultsPartialSuccess(results=[created_result]) + + client.create_results = fake_create_results # type: ignore[method-assign] + + response = client.create_result(request) + + assert response == created_result + assert captured_results == [[request]] + + def test__create_result__raises_api_exception_on_partial_failure(self): + """Test that create_result raises with the structured error on failure.""" + request = CreateResultRequest( + part_number="Part Number", + program_name="Program Name", + status=Status.PASSED(), + ) + error = ApiError(message="Create failed") + client = object.__new__(TestMonitorClient) + + def fake_create_results(results): + return CreateResultsPartialSuccess( + results=[], + failed=results, + error=error, + ) + + client.create_results = fake_create_results # type: ignore[method-assign] + + with pytest.raises(ApiException) as exc_info: + client.create_result(request) + + assert exc_info.value.error == error + assert exc_info.value.response_data == { + "results": [], + "failed": [request.model_dump(mode="json", by_alias=True)], + "error": error.model_dump(mode="json", by_alias=True), + } + + def test__create_result__raises_on_missing_created_result(self): + """Test that create_result rejects an unexpected empty success payload.""" + request = CreateResultRequest( + part_number="Part Number", + program_name="Program Name", + status=Status.PASSED(), + ) + client = object.__new__(TestMonitorClient) + + def fake_create_results(_results): + return CreateResultsPartialSuccess(results=[]) + + client.create_results = fake_create_results # type: ignore[method-assign] + + with pytest.raises(ApiException, match="Server returned no created results"): + client.create_result(request) From eb2073014d3e03d7629ee5cf40aeda32e6b31a71 Mon Sep 17 00:00:00 2001 From: Fred Visser <1458528+fredvisser@users.noreply.github.com> Date: Thu, 28 May 2026 14:05:38 -0500 Subject: [PATCH 3/5] Fix notebook multipart metadata serialization --- nisystemlink/clients/notebook/_notebook_client.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/nisystemlink/clients/notebook/_notebook_client.py b/nisystemlink/clients/notebook/_notebook_client.py index 4bbc2233..448b3a00 100644 --- a/nisystemlink/clients/notebook/_notebook_client.py +++ b/nisystemlink/clients/notebook/_notebook_client.py @@ -97,14 +97,13 @@ def update_notebook( ApiException: if unable to communicate with the ``/ninotebook`` service or provided invalid arguments. """ - metadata_io = None + metadata_str = None if metadata is not None: metadata_str = metadata.model_dump_json(by_alias=True, exclude_unset=True) - metadata_io = io.BytesIO(metadata_str.encode("utf-8")) return self.__update_notebook( id=id, - metadata=metadata_io, + metadata=metadata_str, content=content, ) @@ -160,11 +159,9 @@ def create_notebook( ApiException: if unable to communicate with the ``/ninotebook`` service or provided invalid arguments. """ - metadata_str = metadata.model_dump_json() - - metadata_io = io.BytesIO(metadata_str.encode("utf-8")) + metadata_str = metadata.model_dump_json(by_alias=True, exclude_unset=True) return self.__create_notebook( - metadata=metadata_io, + metadata=metadata_str, content=content, ) From 80afc27aa56280d278552c8abf65105e42034f52 Mon Sep 17 00:00:00 2001 From: Fred Visser <1458528+fredvisser@users.noreply.github.com> Date: Thu, 28 May 2026 15:34:11 -0500 Subject: [PATCH 4/5] Revert "Fix notebook multipart metadata serialization" This reverts commit eb2073014d3e03d7629ee5cf40aeda32e6b31a71. --- nisystemlink/clients/notebook/_notebook_client.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/nisystemlink/clients/notebook/_notebook_client.py b/nisystemlink/clients/notebook/_notebook_client.py index 448b3a00..4bbc2233 100644 --- a/nisystemlink/clients/notebook/_notebook_client.py +++ b/nisystemlink/clients/notebook/_notebook_client.py @@ -97,13 +97,14 @@ def update_notebook( ApiException: if unable to communicate with the ``/ninotebook`` service or provided invalid arguments. """ - metadata_str = None + metadata_io = None if metadata is not None: metadata_str = metadata.model_dump_json(by_alias=True, exclude_unset=True) + metadata_io = io.BytesIO(metadata_str.encode("utf-8")) return self.__update_notebook( id=id, - metadata=metadata_str, + metadata=metadata_io, content=content, ) @@ -159,9 +160,11 @@ def create_notebook( ApiException: if unable to communicate with the ``/ninotebook`` service or provided invalid arguments. """ - metadata_str = metadata.model_dump_json(by_alias=True, exclude_unset=True) + metadata_str = metadata.model_dump_json() + + metadata_io = io.BytesIO(metadata_str.encode("utf-8")) return self.__create_notebook( - metadata=metadata_str, + metadata=metadata_io, content=content, ) From 4ca9b25e63b528d72d94e76e47d354d6063fbe43 Mon Sep 17 00:00:00 2001 From: Fred Visser <1458528+fredvisser@users.noreply.github.com> Date: Mon, 1 Jun 2026 15:53:27 -0500 Subject: [PATCH 5/5] fix: enforce single-item partial-success contract --- .../clients/core/helpers/_partial_success.py | 6 ++++++ tests/core/test_partial_success.py | 21 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/nisystemlink/clients/core/helpers/_partial_success.py b/nisystemlink/clients/core/helpers/_partial_success.py index 326e50c8..ee39aa3f 100644 --- a/nisystemlink/clients/core/helpers/_partial_success.py +++ b/nisystemlink/clients/core/helpers/_partial_success.py @@ -38,4 +38,10 @@ def unwrap_single_item_partial_success( response_data=response_data, ) + if len(items) != 1: + raise core.ApiException( + f"Expected exactly one successful item but received {len(items)}.", + response_data=response_data, + ) + return items[0] diff --git a/tests/core/test_partial_success.py b/tests/core/test_partial_success.py index 6d8deba7..7e64da62 100644 --- a/tests/core/test_partial_success.py +++ b/tests/core/test_partial_success.py @@ -77,3 +77,24 @@ def test__unwrap_single_item_partial_success__raises_on_empty_success_payload(): assert exc_info.value.response_data == response.model_dump( mode="json", by_alias=True ) + + +def test__unwrap_single_item_partial_success__raises_on_multiple_success_items(): + """Raise ApiException when the response unexpectedly contains multiple items.""" + response = _FakeResponse({"items": ["created-item", "extra-item"]}) + + with pytest.raises( + ApiException, match="Expected exactly one successful item but received 2" + ) as exc_info: + unwrap_single_item_partial_success( + response=response, + items=["created-item", "extra-item"], + failed=None, + error=None, + failure_message="Failed to create item.", + empty_message="Server returned no created items.", + ) + + assert exc_info.value.response_data == response.model_dump( + mode="json", by_alias=True + )