diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff9ba82..189abbb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,10 +21,10 @@ jobs: runs-on: ${{ github.repository == 'stainless-sdks/miru-platform-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} if: (github.event_name == 'push' || github.event.pull_request.head.repo.fork) && (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata') steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 with: version: '0.10.2' @@ -43,10 +43,10 @@ jobs: id-token: write runs-on: ${{ github.repository == 'stainless-sdks/miru-platform-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 with: version: '0.10.2' @@ -61,7 +61,7 @@ jobs: github.repository == 'stainless-sdks/miru-platform-python' && !startsWith(github.ref, 'refs/heads/stl/') id: github-oidc - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: core.setOutput('github_token', await core.getIDToken()); @@ -81,10 +81,10 @@ jobs: runs-on: ${{ github.repository == 'stainless-sdks/miru-platform-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} if: github.event_name == 'push' || github.event.pull_request.head.repo.fork steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 with: version: '0.10.2' diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 6729ce6..146229b 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -17,10 +17,10 @@ jobs: id-token: write steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 with: version: '0.9.13' diff --git a/.github/workflows/release-doctor.yml b/.github/workflows/release-doctor.yml index c6f5c01..ce3154c 100644 --- a/.github/workflows/release-doctor.yml +++ b/.github/workflows/release-doctor.yml @@ -12,7 +12,7 @@ jobs: if: github.repository == 'mirurobotics/python-platform-sdk' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'release-please') || github.head_ref == 'next') steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Check release environment run: | diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 6d78745..091cfb1 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.9.0" + ".": "0.10.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index ddaaf63..cb5abbc 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 30 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/miru-ml%2Fmiru-platform-17499bf3c547af4a5621b6681e2bfd3affd3f723f5d093b06dd359e074e2ef11.yml -openapi_spec_hash: 422d94e0e3ad00df785039db896b320f -config_hash: 44dad6a95136246af502e80b91c194b9 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/miru-ml/miru-platform-8ef5c42a5e3e5f7c24cabc1a900a5f13ad5b6ff42734bd5dfefa48dec747f6b5.yml +openapi_spec_hash: a5b8a1a51bddbe5a31445253a6effb7b +config_hash: 8bf107cbb3d57e7a1704382e9991502c diff --git a/CHANGELOG.md b/CHANGELOG.md index 93d2bce..cc26f52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## 0.10.0 (2026-05-13) + +Full Changelog: [v0.9.0...v0.10.0](https://github.com/mirurobotics/python-platform-sdk/compare/v0.9.0...v0.10.0) + +### Features + +* **api:** upgrade to 2026-05-06.rainier-beta.3 ([2dbcc65](https://github.com/mirurobotics/python-platform-sdk/commit/2dbcc652ba6c201d997a87c6bbf9810cb8c5eabb)) +* **internal/types:** support eagerly validating pydantic iterators ([1aadc67](https://github.com/mirurobotics/python-platform-sdk/commit/1aadc67a16bd634bbf368f36c28334365e50c4eb)) + + +### Bug Fixes + +* **client:** add missing f-string prefix in file type error message ([b21baec](https://github.com/mirurobotics/python-platform-sdk/commit/b21baec899aae9a318d65a0acacb76f76f946c07)) + + +### Chores + +* **internal:** reformat pyproject.toml ([bd0e40e](https://github.com/mirurobotics/python-platform-sdk/commit/bd0e40e96fecdfa1a46e4cff6260aadcde3aaf0e)) + ## 0.9.0 (2026-04-28) Full Changelog: [v0.8.2...v0.9.0](https://github.com/mirurobotics/python-platform-sdk/compare/v0.8.2...v0.9.0) diff --git a/api.md b/api.md index 4ffced5..811ddd6 100644 --- a/api.md +++ b/api.md @@ -70,23 +70,29 @@ Methods: Types: ```python -from miru_platform_sdk.types import ( - Device, - DeviceList, - DeviceIssueActivationTokenResponse, - DevicePingResponse, -) +from miru_platform_sdk.types import Device, DeviceList, DevicePingResponse ``` Methods: - client.devices.create(\*\*params) -> Device -- client.devices.retrieve(device_id) -> Device +- client.devices.retrieve(device_id, \*\*params) -> Device - client.devices.update(device_id, \*\*params) -> Device - client.devices.list(\*\*params) -> DeviceList -- client.devices.issue_activation_token(device_id, \*\*params) -> DeviceIssueActivationTokenResponse - client.devices.ping(device_id, \*\*params) -> DevicePingResponse +# ProvisioningTokens + +Types: + +```python +from miru_platform_sdk.types import ProvisioningToken +``` + +Methods: + +- client.provisioning_tokens.create() -> ProvisioningToken + # GitCommits Types: diff --git a/pyproject.toml b/pyproject.toml index 177fc44..e154c05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "miru-platform-sdk" -version = "0.9.0" +version = "0.10.0" description = "The official Python library for the miru API" dynamic = ["readme"] license = "MIT" @@ -154,7 +154,7 @@ show_error_codes = true # # We also exclude our `tests` as mypy doesn't always infer # types correctly and Pyright will still catch any type errors. -exclude = ['src/miru_platform_sdk/_files.py', '_dev/.*.py', 'tests/.*'] +exclude = ["src/miru_platform_sdk/_files.py", "_dev/.*.py", "tests/.*"] strict_equality = true implicit_reexport = true diff --git a/src/miru_platform_sdk/_client.py b/src/miru_platform_sdk/_client.py index 95ccfb8..eee16f5 100644 --- a/src/miru_platform_sdk/_client.py +++ b/src/miru_platform_sdk/_client.py @@ -44,6 +44,7 @@ config_types, config_schemas, config_instances, + provisioning_tokens, ) from .resources.devices import DevicesResource, AsyncDevicesResource from .resources.releases import ReleasesResource, AsyncReleasesResource @@ -53,6 +54,7 @@ from .resources.config_types import ConfigTypesResource, AsyncConfigTypesResource from .resources.config_schemas import ConfigSchemasResource, AsyncConfigSchemasResource from .resources.config_instances import ConfigInstancesResource, AsyncConfigInstancesResource + from .resources.provisioning_tokens import ProvisioningTokensResource, AsyncProvisioningTokensResource __all__ = [ "ENVIRONMENTS", @@ -200,6 +202,12 @@ def devices(self) -> DevicesResource: return DevicesResource(self) + @cached_property + def provisioning_tokens(self) -> ProvisioningTokensResource: + from .resources.provisioning_tokens import ProvisioningTokensResource + + return ProvisioningTokensResource(self) + @cached_property def git_commits(self) -> GitCommitsResource: from .resources.git_commits import GitCommitsResource @@ -462,6 +470,12 @@ def devices(self) -> AsyncDevicesResource: return AsyncDevicesResource(self) + @cached_property + def provisioning_tokens(self) -> AsyncProvisioningTokensResource: + from .resources.provisioning_tokens import AsyncProvisioningTokensResource + + return AsyncProvisioningTokensResource(self) + @cached_property def git_commits(self) -> AsyncGitCommitsResource: from .resources.git_commits import AsyncGitCommitsResource @@ -634,6 +648,12 @@ def devices(self) -> devices.DevicesResourceWithRawResponse: return DevicesResourceWithRawResponse(self._client.devices) + @cached_property + def provisioning_tokens(self) -> provisioning_tokens.ProvisioningTokensResourceWithRawResponse: + from .resources.provisioning_tokens import ProvisioningTokensResourceWithRawResponse + + return ProvisioningTokensResourceWithRawResponse(self._client.provisioning_tokens) + @cached_property def git_commits(self) -> git_commits.GitCommitsResourceWithRawResponse: from .resources.git_commits import GitCommitsResourceWithRawResponse @@ -689,6 +709,12 @@ def devices(self) -> devices.AsyncDevicesResourceWithRawResponse: return AsyncDevicesResourceWithRawResponse(self._client.devices) + @cached_property + def provisioning_tokens(self) -> provisioning_tokens.AsyncProvisioningTokensResourceWithRawResponse: + from .resources.provisioning_tokens import AsyncProvisioningTokensResourceWithRawResponse + + return AsyncProvisioningTokensResourceWithRawResponse(self._client.provisioning_tokens) + @cached_property def git_commits(self) -> git_commits.AsyncGitCommitsResourceWithRawResponse: from .resources.git_commits import AsyncGitCommitsResourceWithRawResponse @@ -744,6 +770,12 @@ def devices(self) -> devices.DevicesResourceWithStreamingResponse: return DevicesResourceWithStreamingResponse(self._client.devices) + @cached_property + def provisioning_tokens(self) -> provisioning_tokens.ProvisioningTokensResourceWithStreamingResponse: + from .resources.provisioning_tokens import ProvisioningTokensResourceWithStreamingResponse + + return ProvisioningTokensResourceWithStreamingResponse(self._client.provisioning_tokens) + @cached_property def git_commits(self) -> git_commits.GitCommitsResourceWithStreamingResponse: from .resources.git_commits import GitCommitsResourceWithStreamingResponse @@ -799,6 +831,12 @@ def devices(self) -> devices.AsyncDevicesResourceWithStreamingResponse: return AsyncDevicesResourceWithStreamingResponse(self._client.devices) + @cached_property + def provisioning_tokens(self) -> provisioning_tokens.AsyncProvisioningTokensResourceWithStreamingResponse: + from .resources.provisioning_tokens import AsyncProvisioningTokensResourceWithStreamingResponse + + return AsyncProvisioningTokensResourceWithStreamingResponse(self._client.provisioning_tokens) + @cached_property def git_commits(self) -> git_commits.AsyncGitCommitsResourceWithStreamingResponse: from .resources.git_commits import AsyncGitCommitsResourceWithStreamingResponse diff --git a/src/miru_platform_sdk/_files.py b/src/miru_platform_sdk/_files.py index 0fdce17..76da9e0 100644 --- a/src/miru_platform_sdk/_files.py +++ b/src/miru_platform_sdk/_files.py @@ -99,7 +99,7 @@ async def async_to_httpx_files(files: RequestFiles | None) -> HttpxRequestFiles elif is_sequence_t(files): files = [(key, await _async_transform_file(file)) for key, file in files] else: - raise TypeError("Unexpected file type input {type(files)}, expected mapping or sequence") + raise TypeError(f"Unexpected file type input {type(files)}, expected mapping or sequence") return files diff --git a/src/miru_platform_sdk/_models.py b/src/miru_platform_sdk/_models.py index e3d6b9b..85670f4 100644 --- a/src/miru_platform_sdk/_models.py +++ b/src/miru_platform_sdk/_models.py @@ -25,7 +25,9 @@ ClassVar, Protocol, Required, + Annotated, ParamSpec, + TypeAlias, TypedDict, TypeGuard, final, @@ -79,7 +81,15 @@ from ._constants import RAW_RESPONSE_HEADER if TYPE_CHECKING: + from pydantic import GetCoreSchemaHandler, ValidatorFunctionWrapHandler + from pydantic_core import CoreSchema, core_schema from pydantic_core.core_schema import ModelField, ModelSchema, LiteralSchema, ModelFieldsSchema +else: + try: + from pydantic_core import CoreSchema, core_schema + except ImportError: + CoreSchema = None + core_schema = None __all__ = ["BaseModel", "GenericModel"] @@ -402,6 +412,76 @@ def model_dump_json( ) +class _EagerIterable(list[_T], Generic[_T]): + """ + Accepts any Iterable[T] input (including generators), consumes it + eagerly, and validates all items upfront. + + Validation preserves the original container type where possible + (e.g. a set[T] stays a set[T]). Serialization (model_dump / JSON) + always emits a list — round-tripping through model_dump() will not + restore the original container type. + """ + + @classmethod + def __get_pydantic_core_schema__( + cls, + source_type: Any, + handler: GetCoreSchemaHandler, + ) -> CoreSchema: + (item_type,) = get_args(source_type) or (Any,) + item_schema: CoreSchema = handler.generate_schema(item_type) + list_of_items_schema: CoreSchema = core_schema.list_schema(item_schema) + + return core_schema.no_info_wrap_validator_function( + cls._validate, + list_of_items_schema, + serialization=core_schema.plain_serializer_function_ser_schema( + cls._serialize, + info_arg=False, + ), + ) + + @staticmethod + def _validate(v: Iterable[_T], handler: "ValidatorFunctionWrapHandler") -> Any: + original_type: type[Any] = type(v) + + # Normalize to list so list_schema can validate each item + if isinstance(v, list): + items: list[_T] = v + else: + try: + items = list(v) + except TypeError as e: + raise TypeError("Value is not iterable") from e + + # Validate items against the inner schema + validated: list[_T] = handler(items) + + # Reconstruct original container type + if original_type is list: + return validated + # str(list) produces the list's repr, not a string built from items, + # so skip reconstruction for str and its subclasses. + if issubclass(original_type, str): + return validated + try: + return original_type(validated) + except (TypeError, ValueError): + # If the type cannot be reconstructed, just return the validated list + return validated + + @staticmethod + def _serialize(v: Iterable[_T]) -> list[_T]: + """Always serialize as a list so Pydantic's JSON encoder is happy.""" + if isinstance(v, list): + return v + return list(v) + + +EagerIterable: TypeAlias = Annotated[Iterable[_T], _EagerIterable] + + def _construct_field(value: object, field: FieldInfo, key: str) -> object: if value is None: return field_get_default(field) diff --git a/src/miru_platform_sdk/_version.py b/src/miru_platform_sdk/_version.py index 4b492c7..b4b9d6c 100644 --- a/src/miru_platform_sdk/_version.py +++ b/src/miru_platform_sdk/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "miru_platform_sdk" -__version__ = "0.9.0" # x-release-please-version +__version__ = "0.10.0" # x-release-please-version diff --git a/src/miru_platform_sdk/resources/__init__.py b/src/miru_platform_sdk/resources/__init__.py index 120ee3c..b027fcf 100644 --- a/src/miru_platform_sdk/resources/__init__.py +++ b/src/miru_platform_sdk/resources/__init__.py @@ -64,6 +64,14 @@ ConfigInstancesResourceWithStreamingResponse, AsyncConfigInstancesResourceWithStreamingResponse, ) +from .provisioning_tokens import ( + ProvisioningTokensResource, + AsyncProvisioningTokensResource, + ProvisioningTokensResourceWithRawResponse, + AsyncProvisioningTokensResourceWithRawResponse, + ProvisioningTokensResourceWithStreamingResponse, + AsyncProvisioningTokensResourceWithStreamingResponse, +) __all__ = [ "ConfigInstancesResource", @@ -96,6 +104,12 @@ "AsyncDevicesResourceWithRawResponse", "DevicesResourceWithStreamingResponse", "AsyncDevicesResourceWithStreamingResponse", + "ProvisioningTokensResource", + "AsyncProvisioningTokensResource", + "ProvisioningTokensResourceWithRawResponse", + "AsyncProvisioningTokensResourceWithRawResponse", + "ProvisioningTokensResourceWithStreamingResponse", + "AsyncProvisioningTokensResourceWithStreamingResponse", "GitCommitsResource", "AsyncGitCommitsResource", "GitCommitsResourceWithRawResponse", diff --git a/src/miru_platform_sdk/resources/config_schemas.py b/src/miru_platform_sdk/resources/config_schemas.py index 16ee51b..bd1c161 100644 --- a/src/miru_platform_sdk/resources/config_schemas.py +++ b/src/miru_platform_sdk/resources/config_schemas.py @@ -81,7 +81,7 @@ def create( git_commit: The git commit to link to this config schema. - instance_filepath: The file path for config instances created from this schema. + instance_filepath: The absolute file system path config instances for this schema are written to. extra_headers: Send extra headers @@ -269,7 +269,7 @@ async def create( git_commit: The git commit to link to this config schema. - instance_filepath: The file path for config instances created from this schema. + instance_filepath: The absolute file system path config instances for this schema are written to. extra_headers: Send extra headers diff --git a/src/miru_platform_sdk/resources/deployments.py b/src/miru_platform_sdk/resources/deployments.py index 1c95546..ecc4905 100644 --- a/src/miru_platform_sdk/resources/deployments.py +++ b/src/miru_platform_sdk/resources/deployments.py @@ -84,10 +84,11 @@ def create( target_status: Desired state of the deployment. - - Staged: ready for deployment. Deployments can only be staged if their release - is not the current release for the device. - - Deployed: deployed to the device. Deployments can only be deployed if their - release is the device's current release. + `staged` means the deployment is ready for deployment. Deployments can only be + staged if their release is not the device's current release. + + `deployed` means the deployment should be deployed to the device. Deployments + can only be deployed if their release is the device's current release. expand: Fields to expand on the deployment resource. @@ -168,7 +169,7 @@ def list( self, *, id: SequenceNotStr[str] | Omit = omit, - activity_status: List[Literal["drifted", "staged", "queued", "deployed", "archived"]] | Omit = omit, + activity_status: List[Literal["drifted", "staged", "queued", "deployed", "removing", "archived"]] | Omit = omit, device_id: SequenceNotStr[str] | Omit = omit, error_status: List[Literal["none", "failed", "retrying"]] | Omit = omit, expand: List[Literal["total_count", "device", "release", "config_instances"]] | Omit = omit, @@ -417,10 +418,11 @@ async def create( target_status: Desired state of the deployment. - - Staged: ready for deployment. Deployments can only be staged if their release - is not the current release for the device. - - Deployed: deployed to the device. Deployments can only be deployed if their - release is the device's current release. + `staged` means the deployment is ready for deployment. Deployments can only be + staged if their release is not the device's current release. + + `deployed` means the deployment should be deployed to the device. Deployments + can only be deployed if their release is the device's current release. expand: Fields to expand on the deployment resource. @@ -503,7 +505,7 @@ async def list( self, *, id: SequenceNotStr[str] | Omit = omit, - activity_status: List[Literal["drifted", "staged", "queued", "deployed", "archived"]] | Omit = omit, + activity_status: List[Literal["drifted", "staged", "queued", "deployed", "removing", "archived"]] | Omit = omit, device_id: SequenceNotStr[str] | Omit = omit, error_status: List[Literal["none", "failed", "retrying"]] | Omit = omit, expand: List[Literal["total_count", "device", "release", "config_instances"]] | Omit = omit, diff --git a/src/miru_platform_sdk/resources/devices.py b/src/miru_platform_sdk/resources/devices.py index 30d2174..ce9d543 100644 --- a/src/miru_platform_sdk/resources/devices.py +++ b/src/miru_platform_sdk/resources/devices.py @@ -12,7 +12,7 @@ device_ping_params, device_create_params, device_update_params, - device_issue_activation_token_params, + device_retrieve_params, ) from .._types import Body, Omit, Query, Headers, NotGiven, SequenceNotStr, omit, not_given from .._utils import path_template, maybe_transform, async_maybe_transform @@ -28,7 +28,6 @@ from ..types.device import Device from ..types.device_list import DeviceList from ..types.device_ping_response import DevicePingResponse -from ..types.device_issue_activation_token_response import DeviceIssueActivationTokenResponse __all__ = ["DevicesResource", "AsyncDevicesResource"] @@ -57,6 +56,7 @@ def create( self, *, name: str, + expand: List[Literal["current_deployment", "current_release"]] | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -70,6 +70,8 @@ def create( Args: name: The name of the device. + expand: Fields to expand on the device resource. + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -82,7 +84,11 @@ def create( "/devices", body=maybe_transform({"name": name}, device_create_params.DeviceCreateParams), options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"expand": expand}, device_create_params.DeviceCreateParams), ), cast_to=Device, ) @@ -91,6 +97,7 @@ def retrieve( self, device_id: str, *, + expand: List[Literal["current_deployment", "current_release"]] | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -102,6 +109,8 @@ def retrieve( Get a device by ID. Args: + expand: Fields to expand on the device resource. + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -115,7 +124,11 @@ def retrieve( return self._get( path_template("/devices/{device_id}", device_id=device_id), options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"expand": expand}, device_retrieve_params.DeviceRetrieveParams), ), cast_to=Device, ) @@ -124,6 +137,7 @@ def update( self, device_id: str, *, + expand: List[Literal["current_deployment", "current_release"]] | Omit = omit, name: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. @@ -132,12 +146,13 @@ def update( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> Device: - """Update a device by ID. + """ + Update a device by ID. Args: - name: The new name of the device. + expand: Fields to expand on the device resource. - If excluded from the request, the device name is not + name: The new name of the device. If excluded from the request, the device name is not updated. extra_headers: Send extra headers @@ -154,7 +169,11 @@ def update( path_template("/devices/{device_id}", device_id=device_id), body=maybe_transform({"name": name}, device_update_params.DeviceUpdateParams), options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"expand": expand}, device_update_params.DeviceUpdateParams), ), cast_to=Device, ) @@ -164,7 +183,8 @@ def list( *, id: SequenceNotStr[str] | Omit = omit, agent_version: SequenceNotStr[str] | Omit = omit, - expand: List[Literal["total_count"]] | Omit = omit, + current_release_id: SequenceNotStr[str] | Omit = omit, + expand: List[Literal["total_count", "current_deployment", "current_release"]] | Omit = omit, limit: int | Omit = omit, name: SequenceNotStr[str] | Omit = omit, offset: int | Omit = omit, @@ -184,6 +204,8 @@ def list( agent_version: The agent versions to filter by. + current_release_id: The release IDs to filter devices by their current release. + expand: Fields to expand on each device in the list. limit: The maximum number of items to return. A limit of 15 with an offset of 0 returns @@ -215,6 +237,7 @@ def list( { "id": id, "agent_version": agent_version, + "current_release_id": current_release_id, "expand": expand, "limit": limit, "name": name, @@ -227,47 +250,6 @@ def list( cast_to=DeviceList, ) - def issue_activation_token( - self, - device_id: str, - *, - allow_reactivation: bool | Omit = omit, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> DeviceIssueActivationTokenResponse: - """ - Create a new device activation token. - - Args: - allow_reactivation: Whether this token can reactivate already activated devices. If false, the token - is unable to activate devices which are already activated. - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - if not device_id: - raise ValueError(f"Expected a non-empty value for `device_id` but received {device_id!r}") - return self._post( - path_template("/devices/{device_id}/activation_token", device_id=device_id), - body=maybe_transform( - {"allow_reactivation": allow_reactivation}, - device_issue_activation_token_params.DeviceIssueActivationTokenParams, - ), - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=DeviceIssueActivationTokenResponse, - ) - def ping( self, device_id: str, @@ -331,6 +313,7 @@ async def create( self, *, name: str, + expand: List[Literal["current_deployment", "current_release"]] | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -344,6 +327,8 @@ async def create( Args: name: The name of the device. + expand: Fields to expand on the device resource. + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -356,7 +341,11 @@ async def create( "/devices", body=await async_maybe_transform({"name": name}, device_create_params.DeviceCreateParams), options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform({"expand": expand}, device_create_params.DeviceCreateParams), ), cast_to=Device, ) @@ -365,6 +354,7 @@ async def retrieve( self, device_id: str, *, + expand: List[Literal["current_deployment", "current_release"]] | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -376,6 +366,8 @@ async def retrieve( Get a device by ID. Args: + expand: Fields to expand on the device resource. + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -389,7 +381,11 @@ async def retrieve( return await self._get( path_template("/devices/{device_id}", device_id=device_id), options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform({"expand": expand}, device_retrieve_params.DeviceRetrieveParams), ), cast_to=Device, ) @@ -398,6 +394,7 @@ async def update( self, device_id: str, *, + expand: List[Literal["current_deployment", "current_release"]] | Omit = omit, name: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. @@ -406,12 +403,13 @@ async def update( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> Device: - """Update a device by ID. + """ + Update a device by ID. Args: - name: The new name of the device. + expand: Fields to expand on the device resource. - If excluded from the request, the device name is not + name: The new name of the device. If excluded from the request, the device name is not updated. extra_headers: Send extra headers @@ -428,7 +426,11 @@ async def update( path_template("/devices/{device_id}", device_id=device_id), body=await async_maybe_transform({"name": name}, device_update_params.DeviceUpdateParams), options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform({"expand": expand}, device_update_params.DeviceUpdateParams), ), cast_to=Device, ) @@ -438,7 +440,8 @@ async def list( *, id: SequenceNotStr[str] | Omit = omit, agent_version: SequenceNotStr[str] | Omit = omit, - expand: List[Literal["total_count"]] | Omit = omit, + current_release_id: SequenceNotStr[str] | Omit = omit, + expand: List[Literal["total_count", "current_deployment", "current_release"]] | Omit = omit, limit: int | Omit = omit, name: SequenceNotStr[str] | Omit = omit, offset: int | Omit = omit, @@ -458,6 +461,8 @@ async def list( agent_version: The agent versions to filter by. + current_release_id: The release IDs to filter devices by their current release. + expand: Fields to expand on each device in the list. limit: The maximum number of items to return. A limit of 15 with an offset of 0 returns @@ -489,6 +494,7 @@ async def list( { "id": id, "agent_version": agent_version, + "current_release_id": current_release_id, "expand": expand, "limit": limit, "name": name, @@ -501,47 +507,6 @@ async def list( cast_to=DeviceList, ) - async def issue_activation_token( - self, - device_id: str, - *, - allow_reactivation: bool | Omit = omit, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> DeviceIssueActivationTokenResponse: - """ - Create a new device activation token. - - Args: - allow_reactivation: Whether this token can reactivate already activated devices. If false, the token - is unable to activate devices which are already activated. - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - if not device_id: - raise ValueError(f"Expected a non-empty value for `device_id` but received {device_id!r}") - return await self._post( - path_template("/devices/{device_id}/activation_token", device_id=device_id), - body=await async_maybe_transform( - {"allow_reactivation": allow_reactivation}, - device_issue_activation_token_params.DeviceIssueActivationTokenParams, - ), - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=DeviceIssueActivationTokenResponse, - ) - async def ping( self, device_id: str, @@ -597,9 +562,6 @@ def __init__(self, devices: DevicesResource) -> None: self.list = to_raw_response_wrapper( devices.list, ) - self.issue_activation_token = to_raw_response_wrapper( - devices.issue_activation_token, - ) self.ping = to_raw_response_wrapper( devices.ping, ) @@ -621,9 +583,6 @@ def __init__(self, devices: AsyncDevicesResource) -> None: self.list = async_to_raw_response_wrapper( devices.list, ) - self.issue_activation_token = async_to_raw_response_wrapper( - devices.issue_activation_token, - ) self.ping = async_to_raw_response_wrapper( devices.ping, ) @@ -645,9 +604,6 @@ def __init__(self, devices: DevicesResource) -> None: self.list = to_streamed_response_wrapper( devices.list, ) - self.issue_activation_token = to_streamed_response_wrapper( - devices.issue_activation_token, - ) self.ping = to_streamed_response_wrapper( devices.ping, ) @@ -669,9 +625,6 @@ def __init__(self, devices: AsyncDevicesResource) -> None: self.list = async_to_streamed_response_wrapper( devices.list, ) - self.issue_activation_token = async_to_streamed_response_wrapper( - devices.issue_activation_token, - ) self.ping = async_to_streamed_response_wrapper( devices.ping, ) diff --git a/src/miru_platform_sdk/resources/provisioning_tokens.py b/src/miru_platform_sdk/resources/provisioning_tokens.py new file mode 100644 index 0000000..a27d49d --- /dev/null +++ b/src/miru_platform_sdk/resources/provisioning_tokens.py @@ -0,0 +1,135 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import httpx + +from .._types import Body, Query, Headers, NotGiven, not_given +from .._compat import cached_property +from .._resource import SyncAPIResource, AsyncAPIResource +from .._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from .._base_client import make_request_options +from ..types.provisioning_token import ProvisioningToken + +__all__ = ["ProvisioningTokensResource", "AsyncProvisioningTokensResource"] + + +class ProvisioningTokensResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> ProvisioningTokensResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/mirurobotics/python-platform-sdk#accessing-raw-response-data-eg-headers + """ + return ProvisioningTokensResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> ProvisioningTokensResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/mirurobotics/python-platform-sdk#with_streaming_response + """ + return ProvisioningTokensResourceWithStreamingResponse(self) + + def create( + self, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ProvisioningToken: + """Create a new provisioning token.""" + return self._post( + "/provisioning_tokens", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ProvisioningToken, + ) + + +class AsyncProvisioningTokensResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncProvisioningTokensResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/mirurobotics/python-platform-sdk#accessing-raw-response-data-eg-headers + """ + return AsyncProvisioningTokensResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncProvisioningTokensResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/mirurobotics/python-platform-sdk#with_streaming_response + """ + return AsyncProvisioningTokensResourceWithStreamingResponse(self) + + async def create( + self, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ProvisioningToken: + """Create a new provisioning token.""" + return await self._post( + "/provisioning_tokens", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ProvisioningToken, + ) + + +class ProvisioningTokensResourceWithRawResponse: + def __init__(self, provisioning_tokens: ProvisioningTokensResource) -> None: + self._provisioning_tokens = provisioning_tokens + + self.create = to_raw_response_wrapper( + provisioning_tokens.create, + ) + + +class AsyncProvisioningTokensResourceWithRawResponse: + def __init__(self, provisioning_tokens: AsyncProvisioningTokensResource) -> None: + self._provisioning_tokens = provisioning_tokens + + self.create = async_to_raw_response_wrapper( + provisioning_tokens.create, + ) + + +class ProvisioningTokensResourceWithStreamingResponse: + def __init__(self, provisioning_tokens: ProvisioningTokensResource) -> None: + self._provisioning_tokens = provisioning_tokens + + self.create = to_streamed_response_wrapper( + provisioning_tokens.create, + ) + + +class AsyncProvisioningTokensResourceWithStreamingResponse: + def __init__(self, provisioning_tokens: AsyncProvisioningTokensResource) -> None: + self._provisioning_tokens = provisioning_tokens + + self.create = async_to_streamed_response_wrapper( + provisioning_tokens.create, + ) diff --git a/src/miru_platform_sdk/types/__init__.py b/src/miru_platform_sdk/types/__init__.py index 2c52d49..6ca0508 100644 --- a/src/miru_platform_sdk/types/__init__.py +++ b/src/miru_platform_sdk/types/__init__.py @@ -2,6 +2,8 @@ from __future__ import annotations +from . import device, deployment +from .. import _compat from .device import Device as Device from .shared import PaginatedList as PaginatedList from .release import Release as Release @@ -21,6 +23,7 @@ from .config_schema_list import ConfigSchemaList as ConfigSchemaList from .device_list_params import DeviceListParams as DeviceListParams from .device_ping_params import DevicePingParams as DevicePingParams +from .provisioning_token import ProvisioningToken as ProvisioningToken from .release_list_params import ReleaseListParams as ReleaseListParams from .device_create_params import DeviceCreateParams as DeviceCreateParams from .device_ping_response import DevicePingResponse as DevicePingResponse @@ -28,6 +31,7 @@ from .git_commit_ref_param import GitCommitRefParam as GitCommitRefParam from .release_create_params import ReleaseCreateParams as ReleaseCreateParams from .deployment_list_params import DeploymentListParams as DeploymentListParams +from .device_retrieve_params import DeviceRetrieveParams as DeviceRetrieveParams from .git_commit_list_params import GitCommitListParams as GitCommitListParams from .instance_content_param import InstanceContentParam as InstanceContentParam from .config_type_list_params import ConfigTypeListParams as ConfigTypeListParams @@ -49,7 +53,14 @@ from .deployment_list_drifts_params import DeploymentListDriftsParams as DeploymentListDriftsParams from .config_instance_download_params import ConfigInstanceDownloadParams as ConfigInstanceDownloadParams from .config_instance_retrieve_params import ConfigInstanceRetrieveParams as ConfigInstanceRetrieveParams -from .device_issue_activation_token_params import DeviceIssueActivationTokenParams as DeviceIssueActivationTokenParams -from .device_issue_activation_token_response import ( - DeviceIssueActivationTokenResponse as DeviceIssueActivationTokenResponse, -) + +# Rebuild cyclical models only after all modules are imported. +# This ensures that, when building the deferred (due to cyclical references) model schema, +# Pydantic can resolve the necessary references. +# See: https://github.com/pydantic/pydantic/issues/11250 for more context. +if _compat.PYDANTIC_V1: + deployment.Deployment.update_forward_refs() # type: ignore + device.Device.update_forward_refs() # type: ignore +else: + deployment.Deployment.model_rebuild(_parent_namespace_depth=0) + device.Device.model_rebuild(_parent_namespace_depth=0) diff --git a/src/miru_platform_sdk/types/config_instance.py b/src/miru_platform_sdk/types/config_instance.py index c911de3..556394b 100644 --- a/src/miru_platform_sdk/types/config_instance.py +++ b/src/miru_platform_sdk/types/config_instance.py @@ -29,11 +29,7 @@ class ConfigInstance(BaseModel): """The timestamp of when the config instance was created.""" filepath: str - """ - The file path to deploy the config instance relative to - `/srv/miru/config_instances`. `v1/motion-control.json` would deploy to - `/srv/miru/config_instances/v1/motion-control.json`. - """ + """The absolute file system path where this config instance is written.""" object: Literal["config_instance"] """The object type, which is always `config_instance`.""" diff --git a/src/miru_platform_sdk/types/config_schema.py b/src/miru_platform_sdk/types/config_schema.py index 87fcd25..6fa2e34 100644 --- a/src/miru_platform_sdk/types/config_schema.py +++ b/src/miru_platform_sdk/types/config_schema.py @@ -32,9 +32,8 @@ class ConfigSchema(BaseModel): instance_filepath: str """ - The file path to deploy the config instance relative to - `/srv/miru/config_instances`. `v1/motion-control.json` would deploy to - `/srv/miru/config_instances/v1/motion-control.json`. + The absolute file system path where config instances for this schema are + written. """ language: SchemaLanguage diff --git a/src/miru_platform_sdk/types/config_schema_create_params.py b/src/miru_platform_sdk/types/config_schema_create_params.py index b801670..e4938f8 100644 --- a/src/miru_platform_sdk/types/config_schema_create_params.py +++ b/src/miru_platform_sdk/types/config_schema_create_params.py @@ -34,7 +34,7 @@ class ConfigSchemaCreateParams(TypedDict, total=False): """The git commit to link to this config schema.""" instance_filepath: str - """The file path for config instances created from this schema.""" + """The absolute file system path config instances for this schema are written to.""" class ConfigTypeRef(TypedDict, total=False): diff --git a/src/miru_platform_sdk/types/deployment.py b/src/miru_platform_sdk/types/deployment.py index aca53d2..807913b 100644 --- a/src/miru_platform_sdk/types/deployment.py +++ b/src/miru_platform_sdk/types/deployment.py @@ -1,10 +1,11 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. +from __future__ import annotations + from typing import List, Optional from datetime import datetime from typing_extensions import Literal -from .device import Device from .release import Release from .._models import BaseModel from .config_instance import ConfigInstance @@ -16,18 +17,25 @@ class Deployment(BaseModel): id: str """ID of the deployment.""" - activity_status: Literal["drifted", "staged", "queued", "deployed", "archived"] + activity_status: Literal["drifted", "staged", "queued", "deployed", "removing", "archived"] """Last known activity state of the deployment. - - Drifted: device's configurations have drifted since this deployment was - staged, and the deployment needs to be reviewed before it can be deployed - - Staged: is ready to be deployed - - Queued: the deployment's config instances are waiting to be received by the - device; will be deployed as soon as the device is online - - Deployed: the deployment's config instances are currently available for - consumption on the device - - Archived: the deployment is available for historical reference but cannot be - deployed and is not active on the device + `drifted` means the device's configurations have drifted since this deployment + was staged, and the deployment needs to be reviewed before it can be deployed. + + `staged` means the deployment is ready to be deployed. + + `queued` means the deployment's config instances are waiting to be received by + the device and will be deployed as soon as the device is online. + + `deployed` means the deployment's config instances are currently available for + consumption on the device. + + `removing` means the deployment's config instances are being removed from the + device. + + `archived` means the deployment is available for historical reference but cannot + be deployed and is not active on the device. """ created_at: datetime @@ -42,11 +50,13 @@ class Deployment(BaseModel): error_status: Literal["none", "failed", "retrying"] """Last known error state of the deployment. - - None: no errors - - Retrying: an error has been encountered and the agent is retrying to reach the - target status - - Failed: a fatal error has been encountered; the deployment is archived and (if - deployed) removed from the device + `none` means there are no errors. + + `retrying` means an error has been encountered and the agent is retrying to + reach the target status. + + `failed` means a fatal error has been encountered; the deployment is archived + and, if deployed, removed from the device. """ object: Literal["deployment"] @@ -55,7 +65,7 @@ class Deployment(BaseModel): release_id: str """ID of the release.""" - status: Literal["drifted", "staged", "queued", "deployed", "archived", "failed", "retrying"] + status: Literal["drifted", "staged", "queued", "deployed", "removing", "archived", "failed", "retrying"] """ This status merges the 'activity_status' and 'error_status' fields, with error states taking precedence over activity states when errors are present. For @@ -67,11 +77,13 @@ class Deployment(BaseModel): target_status: Literal["staged", "deployed", "archived"] """Desired state of the deployment. - - Staged: is ready to be deployed - - Deployed: all config instances part of the deployment are available for - consumption on the device - - Archived: the deployment is available for historical reference but cannot be - deployed and is not active on the device + `staged` means the deployment is ready to be deployed. + + `deployed` means all config instances in the deployment are available for + consumption on the device. + + `archived` means the deployment is available for historical reference but cannot + be deployed and is not active on the device. """ updated_at: datetime @@ -82,8 +94,11 @@ class Deployment(BaseModel): Expand the config instances using 'expand=config_instances' in the query string. """ - device: Optional[Device] = None + device: Optional["Device"] = None """Expand the device using 'expand=device' in the query string.""" release: Optional[Release] = None """Expand the release using 'expand=release' in the query string.""" + + +from .device import Device diff --git a/src/miru_platform_sdk/types/deployment_create_params.py b/src/miru_platform_sdk/types/deployment_create_params.py index 33f0bf6..c7c6f0e 100644 --- a/src/miru_platform_sdk/types/deployment_create_params.py +++ b/src/miru_platform_sdk/types/deployment_create_params.py @@ -30,10 +30,11 @@ class DeploymentCreateParams(TypedDict, total=False): target_status: Required[Literal["staged", "deployed"]] """Desired state of the deployment. - - Staged: ready for deployment. Deployments can only be staged if their release - is not the current release for the device. - - Deployed: deployed to the device. Deployments can only be deployed if their - release is the device's current release. + `staged` means the deployment is ready for deployment. Deployments can only be + staged if their release is not the device's current release. + + `deployed` means the deployment should be deployed to the device. Deployments + can only be deployed if their release is the device's current release. """ expand: List[Literal["device", "release", "config_instances"]] diff --git a/src/miru_platform_sdk/types/deployment_list.py b/src/miru_platform_sdk/types/deployment_list.py index 7c976c3..792656a 100644 --- a/src/miru_platform_sdk/types/deployment_list.py +++ b/src/miru_platform_sdk/types/deployment_list.py @@ -1,13 +1,17 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. +from __future__ import annotations + from typing import List -from .deployment import Deployment from .shared.paginated_list import PaginatedList __all__ = ["DeploymentList"] class DeploymentList(PaginatedList): - data: List[Deployment] + data: List["Deployment"] """The list of deployments.""" + + +from .deployment import Deployment diff --git a/src/miru_platform_sdk/types/deployment_list_params.py b/src/miru_platform_sdk/types/deployment_list_params.py index 48cea5d..4df7034 100644 --- a/src/miru_platform_sdk/types/deployment_list_params.py +++ b/src/miru_platform_sdk/types/deployment_list_params.py @@ -14,7 +14,7 @@ class DeploymentListParams(TypedDict, total=False): id: SequenceNotStr[str] """The deployment IDs to filter by.""" - activity_status: List[Literal["drifted", "staged", "queued", "deployed", "archived"]] + activity_status: List[Literal["drifted", "staged", "queued", "deployed", "removing", "archived"]] """The deployment activity statuses to filter by.""" device_id: SequenceNotStr[str] diff --git a/src/miru_platform_sdk/types/device.py b/src/miru_platform_sdk/types/device.py index a41be0f..62f6b32 100644 --- a/src/miru_platform_sdk/types/device.py +++ b/src/miru_platform_sdk/types/device.py @@ -1,9 +1,12 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. +from __future__ import annotations + from typing import Optional from datetime import datetime from typing_extensions import Literal +from .release import Release from .._models import BaseModel __all__ = ["Device"] @@ -51,3 +54,12 @@ class Device(BaseModel): updated_at: datetime """Timestamp of when the device was last updated.""" + + current_deployment: Optional["Deployment"] = None + """The current deployment for the device.""" + + current_release: Optional[Release] = None + """The current release for the device.""" + + +from .deployment import Deployment diff --git a/src/miru_platform_sdk/types/device_create_params.py b/src/miru_platform_sdk/types/device_create_params.py index 1f15b9a..76c72af 100644 --- a/src/miru_platform_sdk/types/device_create_params.py +++ b/src/miru_platform_sdk/types/device_create_params.py @@ -2,7 +2,8 @@ from __future__ import annotations -from typing_extensions import Required, TypedDict +from typing import List +from typing_extensions import Literal, Required, TypedDict __all__ = ["DeviceCreateParams"] @@ -10,3 +11,6 @@ class DeviceCreateParams(TypedDict, total=False): name: Required[str] """The name of the device.""" + + expand: List[Literal["current_deployment", "current_release"]] + """Fields to expand on the device resource.""" diff --git a/src/miru_platform_sdk/types/device_issue_activation_token_params.py b/src/miru_platform_sdk/types/device_issue_activation_token_params.py deleted file mode 100644 index ae72909..0000000 --- a/src/miru_platform_sdk/types/device_issue_activation_token_params.py +++ /dev/null @@ -1,15 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -from typing_extensions import TypedDict - -__all__ = ["DeviceIssueActivationTokenParams"] - - -class DeviceIssueActivationTokenParams(TypedDict, total=False): - allow_reactivation: bool - """Whether this token can reactivate already activated devices. - - If false, the token is unable to activate devices which are already activated. - """ diff --git a/src/miru_platform_sdk/types/device_list.py b/src/miru_platform_sdk/types/device_list.py index 36ac9ed..a0f11ce 100644 --- a/src/miru_platform_sdk/types/device_list.py +++ b/src/miru_platform_sdk/types/device_list.py @@ -1,13 +1,17 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. +from __future__ import annotations + from typing import List -from .device import Device from .shared.paginated_list import PaginatedList __all__ = ["DeviceList"] class DeviceList(PaginatedList): - data: List[Device] + data: List["Device"] """The list of devices.""" + + +from .device import Device diff --git a/src/miru_platform_sdk/types/device_list_params.py b/src/miru_platform_sdk/types/device_list_params.py index e172859..fb31fdb 100644 --- a/src/miru_platform_sdk/types/device_list_params.py +++ b/src/miru_platform_sdk/types/device_list_params.py @@ -17,7 +17,10 @@ class DeviceListParams(TypedDict, total=False): agent_version: SequenceNotStr[str] """The agent versions to filter by.""" - expand: List[Literal["total_count"]] + current_release_id: SequenceNotStr[str] + """The release IDs to filter devices by their current release.""" + + expand: List[Literal["total_count", "current_deployment", "current_release"]] """Fields to expand on each device in the list.""" limit: int diff --git a/src/miru_platform_sdk/types/device_retrieve_params.py b/src/miru_platform_sdk/types/device_retrieve_params.py new file mode 100644 index 0000000..da16691 --- /dev/null +++ b/src/miru_platform_sdk/types/device_retrieve_params.py @@ -0,0 +1,13 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import List +from typing_extensions import Literal, TypedDict + +__all__ = ["DeviceRetrieveParams"] + + +class DeviceRetrieveParams(TypedDict, total=False): + expand: List[Literal["current_deployment", "current_release"]] + """Fields to expand on the device resource.""" diff --git a/src/miru_platform_sdk/types/device_update_params.py b/src/miru_platform_sdk/types/device_update_params.py index edc897d..d014024 100644 --- a/src/miru_platform_sdk/types/device_update_params.py +++ b/src/miru_platform_sdk/types/device_update_params.py @@ -2,12 +2,16 @@ from __future__ import annotations -from typing_extensions import TypedDict +from typing import List +from typing_extensions import Literal, TypedDict __all__ = ["DeviceUpdateParams"] class DeviceUpdateParams(TypedDict, total=False): + expand: List[Literal["current_deployment", "current_release"]] + """Fields to expand on the device resource.""" + name: str """The new name of the device. diff --git a/src/miru_platform_sdk/types/instance_content.py b/src/miru_platform_sdk/types/instance_content.py index ffc1b2d..30f38ae 100644 --- a/src/miru_platform_sdk/types/instance_content.py +++ b/src/miru_platform_sdk/types/instance_content.py @@ -11,4 +11,4 @@ class InstanceContent(BaseModel): data: str """The configuration values associated with the config instance.""" - format: Literal["json"] + format: Literal["json", "yaml", "jsonc"] diff --git a/src/miru_platform_sdk/types/instance_content_param.py b/src/miru_platform_sdk/types/instance_content_param.py index aef148b..81259c8 100644 --- a/src/miru_platform_sdk/types/instance_content_param.py +++ b/src/miru_platform_sdk/types/instance_content_param.py @@ -11,4 +11,4 @@ class InstanceContentParam(TypedDict, total=False): data: Required[str] """The configuration values associated with the config instance.""" - format: Required[Literal["json"]] + format: Required[Literal["json", "yaml", "jsonc"]] diff --git a/src/miru_platform_sdk/types/device_issue_activation_token_response.py b/src/miru_platform_sdk/types/provisioning_token.py similarity index 61% rename from src/miru_platform_sdk/types/device_issue_activation_token_response.py rename to src/miru_platform_sdk/types/provisioning_token.py index b5cf47d..1593991 100644 --- a/src/miru_platform_sdk/types/device_issue_activation_token_response.py +++ b/src/miru_platform_sdk/types/provisioning_token.py @@ -4,12 +4,12 @@ from .._models import BaseModel -__all__ = ["DeviceIssueActivationTokenResponse"] +__all__ = ["ProvisioningToken"] -class DeviceIssueActivationTokenResponse(BaseModel): +class ProvisioningToken(BaseModel): token: str - """The token.""" + """The provisioning token. This value is only returned when the token is created.""" expires_at: datetime """The expiration date and time of the token.""" diff --git a/tests/api_resources/test_config_schemas.py b/tests/api_resources/test_config_schemas.py index e7e3916..795e78a 100644 --- a/tests/api_resources/test_config_schemas.py +++ b/tests/api_resources/test_config_schemas.py @@ -60,7 +60,7 @@ def test_method_create_with_all_params(self, client: Miru) -> None: }, "schema_filepaths": ["path/to/config/schema/robot1.cue", "path/to/config/schema/robot2.cue"], }, - instance_filepath="/v1/motion-control.json", + instance_filepath="/srv/miru/configs/v1/motion-control.json", ) assert_matches_type(ConfigSchema, config_schema, path=["response"]) @@ -244,7 +244,7 @@ async def test_method_create_with_all_params(self, async_client: AsyncMiru) -> N }, "schema_filepaths": ["path/to/config/schema/robot1.cue", "path/to/config/schema/robot2.cue"], }, - instance_filepath="/v1/motion-control.json", + instance_filepath="/srv/miru/configs/v1/motion-control.json", ) assert_matches_type(ConfigSchema, config_schema, path=["response"]) diff --git a/tests/api_resources/test_devices.py b/tests/api_resources/test_devices.py index 7b135d1..512eac8 100644 --- a/tests/api_resources/test_devices.py +++ b/tests/api_resources/test_devices.py @@ -13,7 +13,6 @@ Device, DeviceList, DevicePingResponse, - DeviceIssueActivationTokenResponse, ) base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") @@ -30,6 +29,15 @@ def test_method_create(self, client: Miru) -> None: ) assert_matches_type(Device, device, path=["response"]) + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_create_with_all_params(self, client: Miru) -> None: + device = client.devices.create( + name="Robot 1", + expand=["current_release"], + ) + assert_matches_type(Device, device, path=["response"]) + @pytest.mark.skip(reason="Mock server tests are disabled") @parametrize def test_raw_response_create(self, client: Miru) -> None: @@ -60,7 +68,16 @@ def test_streaming_response_create(self, client: Miru) -> None: @parametrize def test_method_retrieve(self, client: Miru) -> None: device = client.devices.retrieve( - "dvc_123", + device_id="dvc_123", + ) + assert_matches_type(Device, device, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_retrieve_with_all_params(self, client: Miru) -> None: + device = client.devices.retrieve( + device_id="dvc_123", + expand=["current_release"], ) assert_matches_type(Device, device, path=["response"]) @@ -68,7 +85,7 @@ def test_method_retrieve(self, client: Miru) -> None: @parametrize def test_raw_response_retrieve(self, client: Miru) -> None: response = client.devices.with_raw_response.retrieve( - "dvc_123", + device_id="dvc_123", ) assert response.is_closed is True @@ -80,7 +97,7 @@ def test_raw_response_retrieve(self, client: Miru) -> None: @parametrize def test_streaming_response_retrieve(self, client: Miru) -> None: with client.devices.with_streaming_response.retrieve( - "dvc_123", + device_id="dvc_123", ) as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -95,7 +112,7 @@ def test_streaming_response_retrieve(self, client: Miru) -> None: def test_path_params_retrieve(self, client: Miru) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `device_id` but received ''"): client.devices.with_raw_response.retrieve( - "", + device_id="", ) @pytest.mark.skip(reason="Mock server tests are disabled") @@ -111,6 +128,7 @@ def test_method_update(self, client: Miru) -> None: def test_method_update_with_all_params(self, client: Miru) -> None: device = client.devices.update( device_id="dvc_123", + expand=["current_release"], name="Robot 1", ) assert_matches_type(Device, device, path=["response"]) @@ -161,7 +179,8 @@ def test_method_list_with_all_params(self, client: Miru) -> None: device = client.devices.list( id=["dev_123"], agent_version=["v1.0.0"], - expand=["total_count"], + current_release_id=["rls_123"], + expand=["current_release"], limit=10, name=["My Device"], offset=0, @@ -191,57 +210,6 @@ def test_streaming_response_list(self, client: Miru) -> None: assert cast(Any, response.is_closed) is True - @pytest.mark.skip(reason="Mock server tests are disabled") - @parametrize - def test_method_issue_activation_token(self, client: Miru) -> None: - device = client.devices.issue_activation_token( - device_id="dvc_123", - ) - assert_matches_type(DeviceIssueActivationTokenResponse, device, path=["response"]) - - @pytest.mark.skip(reason="Mock server tests are disabled") - @parametrize - def test_method_issue_activation_token_with_all_params(self, client: Miru) -> None: - device = client.devices.issue_activation_token( - device_id="dvc_123", - allow_reactivation=False, - ) - assert_matches_type(DeviceIssueActivationTokenResponse, device, path=["response"]) - - @pytest.mark.skip(reason="Mock server tests are disabled") - @parametrize - def test_raw_response_issue_activation_token(self, client: Miru) -> None: - response = client.devices.with_raw_response.issue_activation_token( - device_id="dvc_123", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - device = response.parse() - assert_matches_type(DeviceIssueActivationTokenResponse, device, path=["response"]) - - @pytest.mark.skip(reason="Mock server tests are disabled") - @parametrize - def test_streaming_response_issue_activation_token(self, client: Miru) -> None: - with client.devices.with_streaming_response.issue_activation_token( - device_id="dvc_123", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - device = response.parse() - assert_matches_type(DeviceIssueActivationTokenResponse, device, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @pytest.mark.skip(reason="Mock server tests are disabled") - @parametrize - def test_path_params_issue_activation_token(self, client: Miru) -> None: - with pytest.raises(ValueError, match=r"Expected a non-empty value for `device_id` but received ''"): - client.devices.with_raw_response.issue_activation_token( - device_id="", - ) - @pytest.mark.skip(reason="Mock server tests are disabled") @parametrize def test_method_ping(self, client: Miru) -> None: @@ -302,6 +270,15 @@ async def test_method_create(self, async_client: AsyncMiru) -> None: ) assert_matches_type(Device, device, path=["response"]) + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_create_with_all_params(self, async_client: AsyncMiru) -> None: + device = await async_client.devices.create( + name="Robot 1", + expand=["current_release"], + ) + assert_matches_type(Device, device, path=["response"]) + @pytest.mark.skip(reason="Mock server tests are disabled") @parametrize async def test_raw_response_create(self, async_client: AsyncMiru) -> None: @@ -332,7 +309,16 @@ async def test_streaming_response_create(self, async_client: AsyncMiru) -> None: @parametrize async def test_method_retrieve(self, async_client: AsyncMiru) -> None: device = await async_client.devices.retrieve( - "dvc_123", + device_id="dvc_123", + ) + assert_matches_type(Device, device, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_retrieve_with_all_params(self, async_client: AsyncMiru) -> None: + device = await async_client.devices.retrieve( + device_id="dvc_123", + expand=["current_release"], ) assert_matches_type(Device, device, path=["response"]) @@ -340,7 +326,7 @@ async def test_method_retrieve(self, async_client: AsyncMiru) -> None: @parametrize async def test_raw_response_retrieve(self, async_client: AsyncMiru) -> None: response = await async_client.devices.with_raw_response.retrieve( - "dvc_123", + device_id="dvc_123", ) assert response.is_closed is True @@ -352,7 +338,7 @@ async def test_raw_response_retrieve(self, async_client: AsyncMiru) -> None: @parametrize async def test_streaming_response_retrieve(self, async_client: AsyncMiru) -> None: async with async_client.devices.with_streaming_response.retrieve( - "dvc_123", + device_id="dvc_123", ) as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -367,7 +353,7 @@ async def test_streaming_response_retrieve(self, async_client: AsyncMiru) -> Non async def test_path_params_retrieve(self, async_client: AsyncMiru) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `device_id` but received ''"): await async_client.devices.with_raw_response.retrieve( - "", + device_id="", ) @pytest.mark.skip(reason="Mock server tests are disabled") @@ -383,6 +369,7 @@ async def test_method_update(self, async_client: AsyncMiru) -> None: async def test_method_update_with_all_params(self, async_client: AsyncMiru) -> None: device = await async_client.devices.update( device_id="dvc_123", + expand=["current_release"], name="Robot 1", ) assert_matches_type(Device, device, path=["response"]) @@ -433,7 +420,8 @@ async def test_method_list_with_all_params(self, async_client: AsyncMiru) -> Non device = await async_client.devices.list( id=["dev_123"], agent_version=["v1.0.0"], - expand=["total_count"], + current_release_id=["rls_123"], + expand=["current_release"], limit=10, name=["My Device"], offset=0, @@ -463,57 +451,6 @@ async def test_streaming_response_list(self, async_client: AsyncMiru) -> None: assert cast(Any, response.is_closed) is True - @pytest.mark.skip(reason="Mock server tests are disabled") - @parametrize - async def test_method_issue_activation_token(self, async_client: AsyncMiru) -> None: - device = await async_client.devices.issue_activation_token( - device_id="dvc_123", - ) - assert_matches_type(DeviceIssueActivationTokenResponse, device, path=["response"]) - - @pytest.mark.skip(reason="Mock server tests are disabled") - @parametrize - async def test_method_issue_activation_token_with_all_params(self, async_client: AsyncMiru) -> None: - device = await async_client.devices.issue_activation_token( - device_id="dvc_123", - allow_reactivation=False, - ) - assert_matches_type(DeviceIssueActivationTokenResponse, device, path=["response"]) - - @pytest.mark.skip(reason="Mock server tests are disabled") - @parametrize - async def test_raw_response_issue_activation_token(self, async_client: AsyncMiru) -> None: - response = await async_client.devices.with_raw_response.issue_activation_token( - device_id="dvc_123", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - device = await response.parse() - assert_matches_type(DeviceIssueActivationTokenResponse, device, path=["response"]) - - @pytest.mark.skip(reason="Mock server tests are disabled") - @parametrize - async def test_streaming_response_issue_activation_token(self, async_client: AsyncMiru) -> None: - async with async_client.devices.with_streaming_response.issue_activation_token( - device_id="dvc_123", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - device = await response.parse() - assert_matches_type(DeviceIssueActivationTokenResponse, device, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @pytest.mark.skip(reason="Mock server tests are disabled") - @parametrize - async def test_path_params_issue_activation_token(self, async_client: AsyncMiru) -> None: - with pytest.raises(ValueError, match=r"Expected a non-empty value for `device_id` but received ''"): - await async_client.devices.with_raw_response.issue_activation_token( - device_id="", - ) - @pytest.mark.skip(reason="Mock server tests are disabled") @parametrize async def test_method_ping(self, async_client: AsyncMiru) -> None: diff --git a/tests/api_resources/test_provisioning_tokens.py b/tests/api_resources/test_provisioning_tokens.py new file mode 100644 index 0000000..6d60ef0 --- /dev/null +++ b/tests/api_resources/test_provisioning_tokens.py @@ -0,0 +1,80 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from tests.utils import assert_matches_type +from miru_platform_sdk import Miru, AsyncMiru +from miru_platform_sdk.types import ProvisioningToken + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestProvisioningTokens: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_create(self, client: Miru) -> None: + provisioning_token = client.provisioning_tokens.create() + assert_matches_type(ProvisioningToken, provisioning_token, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_create(self, client: Miru) -> None: + response = client.provisioning_tokens.with_raw_response.create() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + provisioning_token = response.parse() + assert_matches_type(ProvisioningToken, provisioning_token, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_create(self, client: Miru) -> None: + with client.provisioning_tokens.with_streaming_response.create() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + provisioning_token = response.parse() + assert_matches_type(ProvisioningToken, provisioning_token, path=["response"]) + + assert cast(Any, response.is_closed) is True + + +class TestAsyncProvisioningTokens: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_create(self, async_client: AsyncMiru) -> None: + provisioning_token = await async_client.provisioning_tokens.create() + assert_matches_type(ProvisioningToken, provisioning_token, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_create(self, async_client: AsyncMiru) -> None: + response = await async_client.provisioning_tokens.with_raw_response.create() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + provisioning_token = await response.parse() + assert_matches_type(ProvisioningToken, provisioning_token, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_create(self, async_client: AsyncMiru) -> None: + async with async_client.provisioning_tokens.with_streaming_response.create() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + provisioning_token = await response.parse() + assert_matches_type(ProvisioningToken, provisioning_token, path=["response"]) + + assert cast(Any, response.is_closed) is True diff --git a/tests/test_models.py b/tests/test_models.py index c506e2a..e94b7d5 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,7 +1,8 @@ import json -from typing import TYPE_CHECKING, Any, Dict, List, Union, Optional, cast +from typing import TYPE_CHECKING, Any, Dict, List, Union, Iterable, Optional, cast from datetime import datetime, timezone -from typing_extensions import Literal, Annotated, TypeAliasType +from collections import deque +from typing_extensions import Literal, Annotated, TypedDict, TypeAliasType import pytest import pydantic @@ -9,7 +10,7 @@ from miru_platform_sdk._utils import PropertyInfo from miru_platform_sdk._compat import PYDANTIC_V1, parse_obj, model_dump, model_json -from miru_platform_sdk._models import DISCRIMINATOR_CACHE, BaseModel, construct_type +from miru_platform_sdk._models import DISCRIMINATOR_CACHE, BaseModel, EagerIterable, construct_type class BasicModel(BaseModel): @@ -961,3 +962,56 @@ def __getattr__(self, attr: str) -> Item: ... assert model.a.prop == 1 assert isinstance(model.a, Item) assert model.other == "foo" + + +# NOTE: Workaround for Pydantic Iterable behavior. +# Iterable fields are replaced with a ValidatorIterator and may be consumed +# during serialization, which can cause subsequent dumps to return empty data. +# See: https://github.com/pydantic/pydantic/issues/9541 +@pytest.mark.parametrize( + "data, expected_validated", + [ + ([1, 2, 3], [1, 2, 3]), + ((1, 2, 3), (1, 2, 3)), + (set([1, 2, 3]), set([1, 2, 3])), + (iter([1, 2, 3]), [1, 2, 3]), + ([], []), + ((x for x in [1, 2, 3]), [1, 2, 3]), + (map(lambda x: x, [1, 2, 3]), [1, 2, 3]), + (frozenset([1, 2, 3]), frozenset([1, 2, 3])), + (deque([1, 2, 3]), deque([1, 2, 3])), + ], + ids=["list", "tuple", "set", "iterator", "empty", "generator", "map", "frozenset", "deque"], +) +@pytest.mark.skipif(PYDANTIC_V1, reason="this is only supported in pydantic v2") +def test_iterable_construction(data: Iterable[int], expected_validated: Iterable[int]) -> None: + class TypeWithIterable(TypedDict): + items: EagerIterable[int] + + class Model(BaseModel): + data: TypeWithIterable + + m = Model.model_validate({"data": {"items": data}}) + assert m.data["items"] == expected_validated + + # Verify repeated dumps don't lose data (the original bug) + assert m.model_dump()["data"]["items"] == list(expected_validated) + assert m.model_dump()["data"]["items"] == list(expected_validated) + + +@pytest.mark.skipif(PYDANTIC_V1, reason="this is only supported in pydantic v2") +def test_iterable_construction_str_falls_back_to_list() -> None: + # str is iterable (over chars), but str(list_of_chars) produces the list's repr + # rather than reconstructing a string from items. We special-case str to fall + # back to list instead of attempting reconstruction. + class TypeWithIterable(TypedDict): + items: EagerIterable[str] + + class Model(BaseModel): + data: TypeWithIterable + + m = Model.model_validate({"data": {"items": "hello"}}) + + # falls back to list of chars rather than calling str(["h", "e", "l", "l", "o"]) + assert m.data["items"] == ["h", "e", "l", "l", "o"] + assert m.model_dump()["data"]["items"] == ["h", "e", "l", "l", "o"]